mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: full CoinMarketCap-style crypto portfolio (buy/sell log, 1h/24h/7d%, P&L)
Replaces the plain quantity field with a CryptoTransaction log (same
"record events, derive the total" shape as Goal/GoalContribution): quantity
and average buy price are now computed via weighted-average cost — a buy
blends into the running average, a sell reduces quantity but leaves the
average cost of what's still held unchanged.
Switches price fetching from /simple/price to CoinGecko's /coins/markets
(still one batched call for every coin), which also returns 1h/24h/7d %
change in the same request. Old PATCH /crypto/holdings/{id} is replaced by
POST .../transactions (buy/sell, rejects selling more than held), GET
.../transactions (history), and DELETE /crypto/transactions/{id}.
Frontend: CryptoHoldingsTable replaces the simple list with a real table
(Name/Price/1h%/24h%/7d%/Holdings/Avg Buy Price/Profit-Loss/Actions),
horizontally scrollable on mobile rather than dropping columns. New
CryptoTransactionModal (buy/sell) and CryptoTransactionHistoryModal.
Backend: 79/79 tests passing (13 in test_crypto.py, up from 9). Also
verified live end-to-end against the real CoinGecko API with the user's own
key — create, buy, sell, oversell rejection, history, manual refresh, all
against real BTC/ETH prices in EUR — test data cleaned up after.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
67
DOCS.md
67
DOCS.md
@@ -505,14 +505,21 @@ personal vehicle)
|
||||
|
||||
## Crypto
|
||||
|
||||
Live-priced crypto holdings — a coin plus a quantity, kept current against
|
||||
[CoinGecko](https://www.coingecko.com/en/api/pricing)'s Demo API. Under the hood each holding **is** an
|
||||
Asset (`asset_class=crypto`, see [Assets & Net Worth](#assets--net-worth)) — deleting one is `DELETE
|
||||
/assets/{asset_id}`, not a separate endpoint, and it shows up in `/net-worth/summary` like any other
|
||||
asset automatically.
|
||||
Live-priced crypto holdings with a full buy/sell history — a CoinMarketCap-style portfolio view, kept
|
||||
current against [CoinGecko](https://www.coingecko.com/en/api/pricing)'s Demo API. Under the hood each
|
||||
holding **is** an Asset (`asset_class=crypto`, see [Assets & Net Worth](#assets--net-worth)) — deleting
|
||||
one is `DELETE /assets/{asset_id}`, not a separate endpoint, and it shows up in `/net-worth/summary`
|
||||
like any other asset automatically.
|
||||
|
||||
**Quantity and average buy price are never sent directly — they're derived** from a log of buy/sell
|
||||
transactions, using the weighted-average-cost method: a buy blends into the running average cost; a
|
||||
sell reduces quantity but leaves the average cost of what's still held unchanged. This is the same
|
||||
"record events, derive the total" shape as [Goals](#goals)' contribution log.
|
||||
|
||||
Requires `AURUM_COINGECKO_API_KEY` in `.env` (a free Demo key, no card required). Endpoints that need
|
||||
CoinGecko return `400` with a message telling you so if it's unset.
|
||||
CoinGecko (`/crypto/holdings` on creation, `/crypto/refresh`, `/crypto/search`) return `400` with a
|
||||
message telling you so if it's unset — adding a transaction to an existing holding never needs it at
|
||||
all (see below).
|
||||
|
||||
Prices are **never** fetched in the background — there's no scheduler in this stack. Two triggers
|
||||
only: `POST /crypto/refresh` (a manual "refresh now"), and a lazy check on every `GET
|
||||
@@ -522,13 +529,15 @@ of the whole request failing.
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/crypto/holdings` | List holdings with their live value. Also runs the lazy once-a-day auto-refresh. |
|
||||
| `GET` | `/crypto/holdings` | List holdings with live price, 1h/24h/7d % change, computed quantity/avg buy price/P&L. Also runs the lazy once-a-day auto-refresh. |
|
||||
| `POST` | `/crypto/refresh` | Force a price refresh right now, bypassing the 24h window. |
|
||||
| `POST` | `/crypto/holdings` | Add a holding — fetches today's price immediately so it isn't `null` until the next sync. |
|
||||
| `PATCH` | `/crypto/holdings/{asset_id}` | Change `quantity`. Recomputes value from the last known price — does **not** call CoinGecko. |
|
||||
| `POST` | `/crypto/holdings` | Add a new holding — its first buy transaction, inline. Fetches today's price immediately so it isn't `null` until the next sync. |
|
||||
| `POST` | `/crypto/holdings/{asset_id}/transactions` | Buy more of, or sell some of, a coin already tracked. Never calls CoinGecko — value is recomputed from the last cached price. `400` if a sell would exceed what's currently held. |
|
||||
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
|
||||
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
|
||||
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
|
||||
|
||||
**Create body:**
|
||||
**Create body** (`POST /crypto/holdings`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -536,13 +545,25 @@ of the whole request failing.
|
||||
"symbol": "btc",
|
||||
"name": "Bitcoin",
|
||||
"thumb_url": "https://...",
|
||||
"quantity": "0.05"
|
||||
"quantity": "0.05",
|
||||
"price_per_unit": "55000",
|
||||
"date": "2026-08-01",
|
||||
"note": null
|
||||
}
|
||||
```
|
||||
|
||||
`coingecko_id` is CoinGecko's own stable id (not the ticker — tickers collide across unrelated
|
||||
coins) — get it from `/crypto/search` rather than guessing. `quantity` supports up to 18 decimal
|
||||
places (wei-level token amounts).
|
||||
coins) — get it from `/crypto/search` rather than guessing. `quantity`/`price_per_unit` support up to
|
||||
18 decimal places (wei-level token amounts). `price_per_unit` is what you actually paid, in the app's
|
||||
display currency — it's stored as-is, never re-derived from market data later.
|
||||
|
||||
**Add a transaction** (`POST /crypto/holdings/{asset_id}/transactions`):
|
||||
|
||||
```json
|
||||
{ "type": "sell", "quantity": "0.02", "price_per_unit": "61000", "date": "2026-08-30", "note": null }
|
||||
```
|
||||
|
||||
`type` is `"buy"` or `"sell"`.
|
||||
|
||||
**`GET /crypto/holdings` / `POST /crypto/refresh` response:**
|
||||
|
||||
@@ -559,17 +580,25 @@ places (wei-level token amounts).
|
||||
"name": "Bitcoin",
|
||||
"thumb_url": "https://...",
|
||||
"quantity": "0.05",
|
||||
"value": "3000.00",
|
||||
"unit_price": "60000.00",
|
||||
"as_of_date": "2026-08-30"
|
||||
"avg_buy_price": "55000.00",
|
||||
"current_price": "61000.00",
|
||||
"price_change_1h": "0.12",
|
||||
"price_change_24h": "2.30",
|
||||
"price_change_7d": "-1.80",
|
||||
"value": "3050.00",
|
||||
"cost_basis": "2750.00",
|
||||
"profit_loss": "300.00",
|
||||
"profit_loss_percent": 10.91
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`value`/`unit_price` are `null` only for a holding whose very first price fetch failed (CoinGecko was
|
||||
down right when it was added) — distinct from a real `0`. `error_key` is `"unreachable"` when this
|
||||
sync attempt couldn't reach CoinGecko (existing values are kept as-is), or `null` otherwise.
|
||||
`current_price`/`value` are `null` only for a holding whose very first price fetch failed (CoinGecko
|
||||
was down right when it was added) — distinct from a real `0`. `avg_buy_price`/`cost_basis`/
|
||||
`profit_loss`/`profit_loss_percent` are `null` once a holding's quantity has been fully sold down to
|
||||
zero (nothing left to have a cost basis). `error_key` is `"unreachable"` when a sync attempt couldn't
|
||||
reach CoinGecko (existing values are kept as-is), or `null` otherwise.
|
||||
|
||||
## Dashboard, Cash Flow & Reports
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Log income, expenses, and transfers across as many accounts as you want, organiz
|
||||
A live net worth timeline (30 days to all-time) aggregating cash and every manually tracked asset — investments, crypto, real estate, vehicles, precious metals — into one number, with a full breakdown by asset class, by how each asset behaves (income / neutral / drain), and by risk level.
|
||||
|
||||
### 🪙 Crypto Tracker
|
||||
A dedicated tab for the coins you actually hold — add a token and a quantity, and Aurum keeps its value current against live CoinGecko prices, no separate portfolio tracker needed. Refresh on demand with one button, or let it auto-refresh once a day; either way it plugs straight into the Net Worth engine above as just another tracked asset.
|
||||
A CoinMarketCap-style portfolio tab for the coins you actually hold: live price plus 1h/24h/7d change, your holdings value, average buy price, and profit/loss — computed from a full buy/sell history, no separate portfolio tracker needed. Refresh on demand with one button, or let it auto-refresh once a day; either way it plugs straight into the Net Worth engine above as just another tracked asset.
|
||||
|
||||
### 🎯 Budgets, Goals & Recurring Payments
|
||||
Set a monthly limit per category and watch progress bars fill up. Track savings goals with a running contribution log. Register recurring bills and post them with one click when they're due — nothing runs automatically in the background.
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models import ( # noqa: F401 — registers metadata
|
||||
Category,
|
||||
CryptoHolding,
|
||||
CryptoSyncState,
|
||||
CryptoTransaction,
|
||||
Goal,
|
||||
GoalContribution,
|
||||
RecurringTransaction,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""add crypto holdings and sync state
|
||||
"""add crypto holdings, transactions, and sync state
|
||||
|
||||
Revision ID: 3e3503b61b20
|
||||
Revises: f6a4d8b2e017
|
||||
@@ -26,12 +26,36 @@ def upgrade() -> None:
|
||||
sa.Column('symbol', sa.String(length=20), nullable=False),
|
||||
sa.Column('name', sa.String(length=150), nullable=False),
|
||||
sa.Column('thumb_url', sa.String(length=500), nullable=True),
|
||||
sa.Column('quantity', sa.Numeric(precision=38, scale=18), nullable=False),
|
||||
# Cached from the last successful CoinGecko sync — quantity and
|
||||
# average buy price are computed from crypto_transactions instead
|
||||
# of stored here (see models/crypto.py).
|
||||
sa.Column('last_price', sa.Numeric(precision=38, scale=18), nullable=True),
|
||||
sa.Column('price_change_1h', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('price_change_24h', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('price_change_7d', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('asset_id'),
|
||||
)
|
||||
op.create_table(
|
||||
'crypto_transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('asset_id', sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
'type',
|
||||
sa.Enum('BUY', 'SELL', name='crypto_transaction_type', native_enum=False, length=10),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column('quantity', sa.Numeric(precision=38, scale=18), nullable=False),
|
||||
sa.Column('price_per_unit', sa.Numeric(precision=38, scale=18), nullable=False),
|
||||
sa.Column('date', sa.Date(), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['asset_id'], ['crypto_holdings.asset_id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table(
|
||||
'crypto_sync_state',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
@@ -42,4 +66,5 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('crypto_sync_state')
|
||||
op.drop_table('crypto_transactions')
|
||||
op.drop_table('crypto_holdings')
|
||||
|
||||
@@ -2,8 +2,22 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_session
|
||||
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoHoldingUpdate, CryptoSearchResult, CryptoSyncResult
|
||||
from app.services.crypto_service import create_holding, refresh_prices, search_coins, update_holding_quantity
|
||||
from app.schemas.crypto import (
|
||||
CryptoHoldingCreate,
|
||||
CryptoHoldingRead,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
CryptoTransactionCreate,
|
||||
CryptoTransactionRead,
|
||||
)
|
||||
from app.services.crypto_service import (
|
||||
add_transaction,
|
||||
create_holding,
|
||||
delete_transaction,
|
||||
list_transactions,
|
||||
refresh_prices,
|
||||
search_coins,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/crypto", tags=["crypto"])
|
||||
|
||||
@@ -31,11 +45,24 @@ async def create_holding_route(
|
||||
return await create_holding(session, payload)
|
||||
|
||||
|
||||
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead)
|
||||
async def update_holding_route(
|
||||
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session)
|
||||
@router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201)
|
||||
async def add_transaction_route(
|
||||
asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session)
|
||||
) -> CryptoHoldingRead:
|
||||
return await update_holding_quantity(session, asset_id, payload.quantity)
|
||||
"""Buy more of, or sell some of, a coin already being tracked."""
|
||||
return await add_transaction(session, asset_id, payload)
|
||||
|
||||
|
||||
@router.get("/holdings/{asset_id}/transactions", response_model=list[CryptoTransactionRead])
|
||||
async def list_transactions_route(
|
||||
asset_id: int, session: AsyncSession = Depends(get_session)
|
||||
) -> list[CryptoTransactionRead]:
|
||||
return await list_transactions(session, asset_id)
|
||||
|
||||
|
||||
@router.delete("/transactions/{transaction_id}", status_code=204)
|
||||
async def delete_transaction_route(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await delete_transaction(session, transaction_id)
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[CryptoSearchResult])
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.crypto import CryptoHolding, CryptoSyncState
|
||||
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
|
||||
from app.models.goal import Goal, GoalContribution
|
||||
from app.models.recurring import RecurringTransaction
|
||||
from app.models.settings import AppSettings
|
||||
@@ -18,6 +18,7 @@ __all__ = [
|
||||
"Category",
|
||||
"CryptoHolding",
|
||||
"CryptoSyncState",
|
||||
"CryptoTransaction",
|
||||
"Goal",
|
||||
"GoalContribution",
|
||||
"RecurringTransaction",
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
"""Crypto holdings: a thin layer on top of the existing Asset/AssetValuation
|
||||
engine (see models/asset.py) rather than a parallel system. A CryptoHolding
|
||||
only records identity (which coin) and quantity (how much) — the actual
|
||||
value lives as ordinary AssetValuation rows, kept current by
|
||||
services/crypto_service.py, so Net Worth requires zero changes to treat a
|
||||
crypto holding like any other manually tracked asset.
|
||||
engine (see models/asset.py) rather than a parallel system. Net worth's
|
||||
number for a holding still lives as ordinary AssetValuation rows, kept
|
||||
current by services/crypto_service.py — Net Worth requires zero changes to
|
||||
treat a crypto holding like any other manually tracked asset.
|
||||
|
||||
Quantity and average buy price are NOT stored — they're computed from the
|
||||
CryptoTransaction log (same "you record it, we derive it" shape as
|
||||
Goal/GoalContribution), because a sell doesn't just subtract quantity, it
|
||||
also has to leave the average cost basis of what's still held unchanged
|
||||
(see services/crypto_service.py's _compute_position).
|
||||
"""
|
||||
from datetime import date as date_
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, String
|
||||
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.asset import Asset
|
||||
from app.models.enums import CryptoTransactionType
|
||||
from app.models.mixins import TimestampMixin
|
||||
|
||||
|
||||
@@ -20,7 +27,8 @@ class CryptoHolding(Base, TimestampMixin):
|
||||
this table's own primary key rather than a separate id+unique
|
||||
constraint, so the relationship can never drift into a 1:many by
|
||||
accident. Deleting the Asset (the existing DELETE /assets/{id}) cascades
|
||||
here too — there's no separate delete endpoint for a holding."""
|
||||
here too, and cascades again to every CryptoTransaction — there's no
|
||||
separate "delete the whole holding" endpoint."""
|
||||
|
||||
__tablename__ = "crypto_holdings"
|
||||
|
||||
@@ -36,12 +44,43 @@ class CryptoHolding(Base, TimestampMixin):
|
||||
name: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Bitcoin has 8 decimal places; several tokens go to 18 (wei-level
|
||||
# amounts). Deliberately NOT Numeric(14, 2) like every money field in
|
||||
# this app — this is a coin count, not a currency amount.
|
||||
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
|
||||
# Cached from the last successful sync (services/crypto_service.py) —
|
||||
# in the app's display currency (see AppSettings.currency). Null until
|
||||
# the first sync completes.
|
||||
last_price: Mapped[Numeric | None] = mapped_column(Numeric(38, 18), nullable=True)
|
||||
price_change_1h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
|
||||
price_change_24h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
|
||||
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
|
||||
|
||||
asset: Mapped["Asset"] = relationship()
|
||||
transactions: Mapped[list["CryptoTransaction"]] = relationship(
|
||||
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
|
||||
)
|
||||
|
||||
|
||||
class CryptoTransaction(Base, TimestampMixin):
|
||||
"""One buy or sell against a holding. Quantity held and average buy
|
||||
price are both derived by replaying this log in date order — see
|
||||
services/crypto_service.py's _compute_position for why a sell can't
|
||||
just be "negative quantity" the way it can for a Goal contribution: it
|
||||
must leave the remaining coins' average cost basis alone."""
|
||||
|
||||
__tablename__ = "crypto_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
asset_id: Mapped[int] = mapped_column(ForeignKey("crypto_holdings.asset_id", ondelete="CASCADE"), nullable=False)
|
||||
type: Mapped[CryptoTransactionType] = mapped_column(
|
||||
Enum(CryptoTransactionType, name="crypto_transaction_type", native_enum=False, length=10), nullable=False
|
||||
)
|
||||
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
|
||||
# Price paid (buy) or received (sell) per unit, in the app's display
|
||||
# currency at the time of the transaction — not re-derived later, since
|
||||
# what you actually paid doesn't change with today's market price.
|
||||
price_per_unit: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
|
||||
date: Mapped[date_] = mapped_column(Date, nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
holding: Mapped["CryptoHolding"] = relationship(back_populates="transactions")
|
||||
|
||||
|
||||
class CryptoSyncState(Base):
|
||||
|
||||
@@ -51,6 +51,11 @@ class RecurringFrequency(str, enum.Enum):
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
class CryptoTransactionType(str, enum.Enum):
|
||||
BUY = "buy"
|
||||
SELL = "sell"
|
||||
|
||||
|
||||
class RiskLevel(str, enum.Enum):
|
||||
"""Risk of loss, not asset class — set by the user, not inferred: real
|
||||
estate can be a paid-off primary home (LOW) or a leveraged rental
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.models.enums import (
|
||||
AssetClass,
|
||||
CapitalRole,
|
||||
CategoryKind,
|
||||
CryptoTransactionType,
|
||||
RecurringFrequency,
|
||||
RiskLevel,
|
||||
TransactionType,
|
||||
@@ -99,13 +100,30 @@ class CryptoHoldingBackup(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
# asset_id doubles as this row's own primary key (see
|
||||
# models/crypto.py's CryptoHolding) — there's no separate `id`.
|
||||
# models/crypto.py's CryptoHolding) — there's no separate `id`. No
|
||||
# quantity here — it's derived from crypto_transactions below, not
|
||||
# stored.
|
||||
asset_id: int
|
||||
coingecko_id: str
|
||||
symbol: str
|
||||
name: str
|
||||
thumb_url: str | None
|
||||
last_price: Decimal | None = None
|
||||
price_change_1h: Decimal | None = None
|
||||
price_change_24h: Decimal | None = None
|
||||
price_change_7d: Decimal | None = None
|
||||
|
||||
|
||||
class CryptoTransactionBackup(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
asset_id: int
|
||||
type: CryptoTransactionType
|
||||
quantity: Decimal
|
||||
price_per_unit: Decimal
|
||||
date: date_
|
||||
note: str | None
|
||||
|
||||
|
||||
class BudgetBackup(BaseModel):
|
||||
@@ -189,6 +207,7 @@ class BackupPayload(BaseModel):
|
||||
# Defaulted so a backup exported before crypto holdings existed still
|
||||
# imports cleanly under the same format version.
|
||||
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
|
||||
crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
|
||||
# Defaulted so a backup exported before budgets existed still imports
|
||||
# cleanly under the same format version.
|
||||
budgets: list[BudgetBackup] = Field(default_factory=list)
|
||||
|
||||
@@ -1,39 +1,74 @@
|
||||
from datetime import date as date_
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.enums import CryptoTransactionType
|
||||
|
||||
|
||||
class CryptoTransactionCreate(BaseModel):
|
||||
type: CryptoTransactionType
|
||||
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
|
||||
# Price paid (buy) / received (sell) per unit, in the app's display
|
||||
# currency — not fetched from CoinGecko, this is what the user actually
|
||||
# paid, which the market price today has nothing to do with.
|
||||
price_per_unit: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
|
||||
date: date_
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class CryptoTransactionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
asset_id: int
|
||||
type: CryptoTransactionType
|
||||
quantity: Decimal
|
||||
price_per_unit: Decimal
|
||||
date: date_
|
||||
note: str | None
|
||||
|
||||
|
||||
class CryptoHoldingCreate(BaseModel):
|
||||
coingecko_id: str = Field(min_length=1, max_length=100)
|
||||
symbol: str = Field(min_length=1, max_length=20)
|
||||
name: str = Field(min_length=1, max_length=150)
|
||||
thumb_url: str | None = Field(default=None, max_length=500)
|
||||
# 18 decimal places covers wei-level token amounts; still gt=0 since a
|
||||
# zero/negative holding isn't a holding.
|
||||
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
|
||||
|
||||
|
||||
class CryptoHoldingUpdate(BaseModel):
|
||||
# A holding always starts with a buy — there's nothing to "start
|
||||
# tracking" with a sell.
|
||||
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
|
||||
price_per_unit: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
|
||||
date: date_ = Field(default_factory=date_.today)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class CryptoHoldingRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
asset_id: int
|
||||
coingecko_id: str
|
||||
symbol: str
|
||||
name: str
|
||||
thumb_url: str | None
|
||||
|
||||
# Quantity and avg_buy_price are derived from the transaction log (see
|
||||
# services/crypto_service.py's _compute_position) — never stored.
|
||||
quantity: Decimal
|
||||
# Both derived from the linked Asset's latest AssetValuation — null
|
||||
# value/unit_price means "added but never priced yet" (CoinGecko was
|
||||
# unreachable at creation time), not zero.
|
||||
avg_buy_price: Decimal | None
|
||||
|
||||
# Cached from the last successful CoinGecko sync — null until the
|
||||
# first one completes.
|
||||
current_price: Decimal | None
|
||||
price_change_1h: Decimal | None
|
||||
price_change_24h: Decimal | None
|
||||
price_change_7d: Decimal | None
|
||||
|
||||
# All derived from the above: value = current_price * quantity,
|
||||
# cost_basis = avg_buy_price * quantity, profit_loss = value - cost_basis.
|
||||
value: Decimal | None
|
||||
unit_price: Decimal | None
|
||||
as_of_date: str | None
|
||||
cost_basis: Decimal | None
|
||||
profit_loss: Decimal | None
|
||||
profit_loss_percent: float | None
|
||||
|
||||
|
||||
class CryptoSyncResult(BaseModel):
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.budget import Budget
|
||||
from app.models.category import Category
|
||||
from app.models.crypto import CryptoHolding
|
||||
from app.models.crypto import CryptoHolding, CryptoTransaction
|
||||
from app.models.goal import Goal, GoalContribution
|
||||
from app.models.recurring import RecurringTransaction
|
||||
from app.models.settings import AppSettings
|
||||
@@ -36,6 +36,7 @@ from app.schemas.backup import (
|
||||
BudgetBackup,
|
||||
CategoryBackup,
|
||||
CryptoHoldingBackup,
|
||||
CryptoTransactionBackup,
|
||||
GoalBackup,
|
||||
GoalContributionBackup,
|
||||
RecurringTransactionBackup,
|
||||
@@ -54,6 +55,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||
assets = (await session.execute(select(Asset))).scalars().all()
|
||||
valuations = (await session.execute(select(AssetValuation))).scalars().all()
|
||||
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
|
||||
crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all()
|
||||
budgets = (await session.execute(select(Budget))).scalars().all()
|
||||
goals = (await session.execute(select(Goal))).scalars().all()
|
||||
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
|
||||
@@ -76,6 +78,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
|
||||
assets=[AssetBackup.model_validate(row) for row in assets],
|
||||
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
|
||||
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
|
||||
crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
|
||||
budgets=[BudgetBackup.model_validate(row) for row in budgets],
|
||||
goals=[GoalBackup.model_validate(row) for row in goals],
|
||||
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
|
||||
@@ -111,10 +114,15 @@ def _validate_references(payload: BackupPayload) -> None:
|
||||
if v.asset_id not in asset_ids:
|
||||
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
|
||||
|
||||
crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings}
|
||||
for h in payload.crypto_holdings:
|
||||
if h.asset_id not in asset_ids:
|
||||
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
|
||||
|
||||
for tx in payload.crypto_transactions:
|
||||
if tx.asset_id not in crypto_holding_asset_ids:
|
||||
raise HTTPException(400, f"Crypto transaction {tx.id} references unknown asset_id {tx.asset_id}")
|
||||
|
||||
for b in payload.budgets:
|
||||
if b.category_id not in category_ids:
|
||||
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
|
||||
@@ -162,6 +170,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
||||
try:
|
||||
# Children before parents.
|
||||
await session.execute(delete(AssetValuation))
|
||||
await session.execute(delete(CryptoTransaction))
|
||||
await session.execute(delete(CryptoHolding))
|
||||
await session.execute(delete(Budget))
|
||||
await session.execute(delete(GoalContribution))
|
||||
@@ -202,6 +211,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
||||
|
||||
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
|
||||
session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions)
|
||||
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
|
||||
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
|
||||
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)
|
||||
@@ -220,6 +230,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
||||
await _reset_sequence(session, "assets", payload.assets)
|
||||
await _reset_sequence(session, "transactions", payload.transactions)
|
||||
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
|
||||
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
|
||||
await _reset_sequence(session, "budgets", payload.budgets)
|
||||
await _reset_sequence(session, "goals", payload.goals)
|
||||
await _reset_sequence(session, "goal_contributions", payload.goal_contributions)
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"""Crypto price sync: CoinGecko Demo API calls, written into the *existing*
|
||||
Asset/AssetValuation engine (see models/asset.py) rather than a parallel
|
||||
value-tracking system. A CryptoHolding only ever records identity (which
|
||||
coin) and quantity (how much) — the actual value is an ordinary
|
||||
AssetValuation row, so Net Worth, reports, and everything else that already
|
||||
walks Asset rows needs zero changes to treat a crypto holding like any
|
||||
other manually tracked asset.
|
||||
"""Crypto price sync + position accounting: CoinGecko Demo API calls,
|
||||
written into the *existing* Asset/AssetValuation engine (see
|
||||
models/asset.py) rather than a parallel value-tracking system. A holding's
|
||||
value is an ordinary AssetValuation row, so Net Worth, reports, and
|
||||
everything else that already walks Asset rows needs zero changes to treat a
|
||||
crypto holding like any other manually tracked asset.
|
||||
|
||||
Quantity and average buy price are never stored — they're derived from the
|
||||
CryptoTransaction log every time (see _compute_position), the same
|
||||
"you record it, we derive it" shape as Goal/GoalContribution.
|
||||
|
||||
Refreshes are deliberately never automatic in the background — same
|
||||
reasoning as recurring transactions (see services/recurring_service.py):
|
||||
there's no scheduler/worker in this stack, and a rate-limited free API key
|
||||
makes "poll constantly" actively counterproductive anyway. Two triggers
|
||||
only: the "Refresh prices" button (force=True) and a lazy once-a-day check
|
||||
that piggybacks on GET /crypto/holdings (force=False) — see
|
||||
routes/crypto.py.
|
||||
makes "poll constantly" actively counterproductive anyway. Three triggers
|
||||
only: the "Refresh prices" button (force=True), a lazy once-a-day check
|
||||
that piggybacks on GET /crypto/holdings (force=False), and adding a brand
|
||||
new holding (a one-off seed fetch, justified by the explicit user action).
|
||||
Editing quantity via a buy/sell transaction never calls CoinGecko — it
|
||||
reuses the last cached price.
|
||||
"""
|
||||
from datetime import date as date_
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
@@ -28,9 +33,15 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.crypto import CryptoHolding, CryptoSyncState
|
||||
from app.models.enums import AssetClass, CapitalRole, RiskLevel
|
||||
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, CryptoSyncResult
|
||||
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
|
||||
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
|
||||
from app.schemas.crypto import (
|
||||
CryptoHoldingCreate,
|
||||
CryptoHoldingRead,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
CryptoTransactionCreate,
|
||||
)
|
||||
from app.services.settings_service import get_or_create_app_settings
|
||||
|
||||
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
|
||||
@@ -40,7 +51,7 @@ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
|
||||
# reason (see module docstring on why there's no background poller at all).
|
||||
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
|
||||
|
||||
_EAGER = (selectinload(CryptoHolding.asset).selectinload(Asset.valuations),)
|
||||
_EAGER = (selectinload(CryptoHolding.transactions),)
|
||||
|
||||
|
||||
def _require_api_key() -> str:
|
||||
@@ -56,27 +67,51 @@ def _require_api_key() -> str:
|
||||
return api_key
|
||||
|
||||
|
||||
async def _fetch_prices_batch(coingecko_ids: list[str], vs_currency: str) -> dict[str, Decimal]:
|
||||
"""One CoinGecko call for every tracked coin at once (up to 515 ids per
|
||||
call) — never one call per coin, that's what would actually burn
|
||||
through a free-tier rate limit."""
|
||||
class _MarketPoint(NamedTuple):
|
||||
price: Decimal
|
||||
change_1h: Decimal | None
|
||||
change_24h: Decimal | None
|
||||
change_7d: Decimal | None
|
||||
|
||||
|
||||
def _decimal_or_none(value: object) -> Decimal | None:
|
||||
return Decimal(str(value)) if value is not None else None
|
||||
|
||||
|
||||
async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict[str, _MarketPoint]:
|
||||
"""One CoinGecko call for every tracked coin at once (up to 250 ids per
|
||||
/coins/markets call) — never one call per coin, that's what would
|
||||
actually burn through a free-tier rate limit. Also returns 1h/24h/7d %
|
||||
change in the same call, so this fully replaces a separate
|
||||
/simple/price lookup."""
|
||||
if not coingecko_ids:
|
||||
return {}
|
||||
api_key = _require_api_key()
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{COINGECKO_BASE_URL}/simple/price",
|
||||
params={"ids": ",".join(coingecko_ids), "vs_currencies": vs_currency},
|
||||
f"{COINGECKO_BASE_URL}/coins/markets",
|
||||
params={
|
||||
"vs_currency": vs_currency,
|
||||
"ids": ",".join(coingecko_ids),
|
||||
"price_change_percentage": "1h,24h,7d",
|
||||
},
|
||||
headers={"x-cg-demo-api-key": api_key},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
prices: dict[str, Decimal] = {}
|
||||
for coin_id, values in data.items():
|
||||
price = values.get(vs_currency)
|
||||
if price is not None:
|
||||
prices[coin_id] = Decimal(str(price))
|
||||
return prices
|
||||
|
||||
points: dict[str, _MarketPoint] = {}
|
||||
for coin in data:
|
||||
price = _decimal_or_none(coin.get("current_price"))
|
||||
if price is None:
|
||||
continue
|
||||
points[coin["id"]] = _MarketPoint(
|
||||
price=price,
|
||||
change_1h=_decimal_or_none(coin.get("price_change_percentage_1h_in_currency")),
|
||||
change_24h=_decimal_or_none(coin.get("price_change_percentage_24h_in_currency")),
|
||||
change_7d=_decimal_or_none(coin.get("price_change_percentage_7d_in_currency")),
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
async def search_coins(query: str) -> list[CryptoSearchResult]:
|
||||
@@ -103,35 +138,67 @@ async def search_coins(query: str) -> list[CryptoSearchResult]:
|
||||
]
|
||||
|
||||
|
||||
def _compute_position(transactions: list[CryptoTransaction]) -> tuple[Decimal, Decimal | None]:
|
||||
"""Weighted-average-cost method, replayed over the transaction log in
|
||||
date order: a BUY blends into the running average cost; a SELL reduces
|
||||
quantity but leaves the average cost of what's still held unchanged —
|
||||
selling some coins doesn't retroactively change what you paid for the
|
||||
ones you kept. Returns (quantity, avg_buy_price); avg_buy_price is None
|
||||
once quantity hits zero (nothing left to have a cost basis)."""
|
||||
quantity = Decimal("0")
|
||||
avg_price: Decimal | None = None
|
||||
for tx in sorted(transactions, key=lambda t: (t.date, t.id)):
|
||||
if tx.type == CryptoTransactionType.BUY:
|
||||
existing_cost = (avg_price or Decimal("0")) * quantity
|
||||
quantity += tx.quantity
|
||||
avg_price = (existing_cost + tx.price_per_unit * tx.quantity) / quantity if quantity else None
|
||||
else:
|
||||
quantity -= tx.quantity
|
||||
if quantity <= 0:
|
||||
quantity = Decimal("0")
|
||||
avg_price = None
|
||||
return quantity, avg_price
|
||||
|
||||
|
||||
def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
|
||||
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
|
||||
value = latest.value if latest else None
|
||||
unit_price = (value / holding.quantity) if value is not None and holding.quantity else None
|
||||
quantity, avg_buy_price = _compute_position(holding.transactions)
|
||||
current_price = holding.last_price
|
||||
value = current_price * quantity if current_price is not None else None
|
||||
cost_basis = avg_buy_price * quantity if avg_buy_price is not None else None
|
||||
profit_loss = (value - cost_basis) if value is not None and cost_basis is not None else None
|
||||
profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None
|
||||
return CryptoHoldingRead(
|
||||
asset_id=holding.asset_id,
|
||||
coingecko_id=holding.coingecko_id,
|
||||
symbol=holding.symbol,
|
||||
name=holding.name,
|
||||
thumb_url=holding.thumb_url,
|
||||
quantity=holding.quantity,
|
||||
quantity=quantity,
|
||||
avg_buy_price=avg_buy_price,
|
||||
current_price=current_price,
|
||||
price_change_1h=holding.price_change_1h,
|
||||
price_change_24h=holding.price_change_24h,
|
||||
price_change_7d=holding.price_change_7d,
|
||||
value=value,
|
||||
unit_price=unit_price,
|
||||
as_of_date=latest.as_of_date.isoformat() if latest else None,
|
||||
cost_basis=cost_basis,
|
||||
profit_loss=profit_loss,
|
||||
profit_loss_percent=profit_loss_percent,
|
||||
)
|
||||
|
||||
|
||||
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
|
||||
# populate_existing=True: _upsert_valuation() below writes AssetValuation
|
||||
# rows via a raw Core statement, which the ORM's identity map doesn't
|
||||
# know happened. Without this, a session that already loaded a holding's
|
||||
# valuations once (list_holdings gets called both before and after a
|
||||
# refresh's writes) would keep serving the pre-refresh value here.
|
||||
result = await session.execute(
|
||||
select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name).execution_options(populate_existing=True)
|
||||
)
|
||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _get_holding_or_404(session: AsyncSession, asset_id: int) -> CryptoHolding:
|
||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
|
||||
holding = result.scalar_one_or_none()
|
||||
if holding is None:
|
||||
raise HTTPException(status_code=404, detail="Crypto holding not found")
|
||||
return holding
|
||||
|
||||
|
||||
async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
|
||||
state = await session.get(CryptoSyncState, 1)
|
||||
if state is None:
|
||||
@@ -145,7 +212,9 @@ async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
|
||||
async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal, as_of_date: date_) -> None:
|
||||
"""Same upsert-by-date pattern as routes/assets.py's POST
|
||||
/assets/{id}/valuations — re-syncing the same day updates that day's
|
||||
value instead of erroring."""
|
||||
value instead of erroring. Still needed even though quantity/price
|
||||
aren't stored on CryptoHolding: Net Worth's whole engine reads this
|
||||
table, not CryptoHolding, for a coin's value history."""
|
||||
upsert_stmt = (
|
||||
pg_insert(AssetValuation)
|
||||
.values(asset_id=asset_id, value=value, as_of_date=as_of_date)
|
||||
@@ -170,26 +239,34 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes
|
||||
if holdings:
|
||||
settings = await get_or_create_app_settings(session)
|
||||
try:
|
||||
prices = await _fetch_prices_batch([h.coingecko_id for h in holdings], settings.currency.lower())
|
||||
market_data = await _fetch_market_data([h.coingecko_id for h in holdings], settings.currency.lower())
|
||||
except httpx.HTTPError:
|
||||
# CoinGecko down/rate-limited — don't touch last_synced_at (the
|
||||
# next open of the tab retries) and don't touch any existing
|
||||
# AssetValuation. The tab still shows the last known values
|
||||
# instead of an empty/broken page over an external outage.
|
||||
# price/AssetValuation. The tab still shows the last known
|
||||
# values instead of an empty/broken page over an external outage.
|
||||
error_key = "unreachable"
|
||||
else:
|
||||
today = date_.today()
|
||||
for holding in holdings:
|
||||
price = prices.get(holding.coingecko_id)
|
||||
if price is None:
|
||||
continue # coin missing from the response — keep its last known value, don't zero it out
|
||||
await _upsert_valuation(session, holding.asset_id, holding.quantity * price, today)
|
||||
point = market_data.get(holding.coingecko_id)
|
||||
if point is None:
|
||||
continue # coin missing from the response — keep its last known values, don't zero them out
|
||||
holding.last_price = point.price
|
||||
holding.price_change_1h = point.change_1h
|
||||
holding.price_change_24h = point.change_24h
|
||||
holding.price_change_7d = point.change_7d
|
||||
quantity, _ = _compute_position(holding.transactions)
|
||||
if quantity > 0:
|
||||
await _upsert_valuation(session, holding.asset_id, quantity * point.price, today)
|
||||
|
||||
if error_key is None:
|
||||
state.last_synced_at = now
|
||||
await session.commit()
|
||||
|
||||
holdings = await list_holdings(session) # re-fetch so the response carries whatever was just written
|
||||
# holdings were mutated in place above (ordinary ORM attribute
|
||||
# assignment, not a raw Core write) — no need to re-query, they already
|
||||
# reflect what was just committed.
|
||||
return CryptoSyncResult(
|
||||
synced=error_key is None,
|
||||
last_synced_at=state.last_synced_at,
|
||||
@@ -220,18 +297,33 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
||||
symbol=payload.symbol.upper(),
|
||||
name=payload.name,
|
||||
thumb_url=payload.thumb_url,
|
||||
quantity=payload.quantity,
|
||||
)
|
||||
session.add(holding)
|
||||
|
||||
# Seed today's value immediately — a coin the user just added sitting at
|
||||
# 0 until tomorrow's auto-sync would be confusing, and this one call is
|
||||
# clearly justified by an explicit user action.
|
||||
holding.transactions.append(
|
||||
CryptoTransaction(
|
||||
type=CryptoTransactionType.BUY,
|
||||
quantity=payload.quantity,
|
||||
price_per_unit=payload.price_per_unit,
|
||||
date=payload.date,
|
||||
note=payload.note,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
# Seed today's live price immediately — a coin the user just added
|
||||
# sitting at "no price yet" until tomorrow's auto-sync would be
|
||||
# confusing, and this one call is clearly justified by an explicit
|
||||
# user action.
|
||||
try:
|
||||
prices = await _fetch_prices_batch([payload.coingecko_id], settings.currency.lower())
|
||||
price = prices.get(payload.coingecko_id)
|
||||
if price is not None:
|
||||
await _upsert_valuation(session, asset.id, payload.quantity * price, date_.today())
|
||||
market_data = await _fetch_market_data([payload.coingecko_id], settings.currency.lower())
|
||||
point = market_data.get(payload.coingecko_id)
|
||||
if point is not None:
|
||||
holding.last_price = point.price
|
||||
holding.price_change_1h = point.change_1h
|
||||
holding.price_change_24h = point.change_24h
|
||||
holding.price_change_7d = point.change_7d
|
||||
await _upsert_valuation(session, asset.id, payload.quantity * point.price, date_.today())
|
||||
# This counts as a real sync — bump the shared timestamp so the next
|
||||
# GET /crypto/holdings doesn't immediately re-fetch every holding
|
||||
# again a moment later (see refresh_prices' 24h window).
|
||||
@@ -241,35 +333,60 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
|
||||
pass # holding is still created — the next daily/manual sync will price it
|
||||
|
||||
await session.commit()
|
||||
|
||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset.id))
|
||||
return _to_read(result.scalar_one())
|
||||
return _to_read(holding)
|
||||
|
||||
|
||||
async def update_holding_quantity(session: AsyncSession, asset_id: int, quantity: Decimal) -> CryptoHoldingRead:
|
||||
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
|
||||
holding = result.scalar_one_or_none()
|
||||
if holding is None:
|
||||
raise HTTPException(status_code=404, detail="Crypto holding not found")
|
||||
async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead:
|
||||
"""A buy or sell against an existing holding — never calls CoinGecko
|
||||
(see module docstring): value is recomputed from the last cached
|
||||
price, same principle as the old quantity-only edit it replaces."""
|
||||
holding = await _get_holding_or_404(session, asset_id)
|
||||
|
||||
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
|
||||
if latest is not None and holding.quantity:
|
||||
# Recompute from the last known unit price — editing quantity must
|
||||
# NOT call CoinGecko (see module docstring: only the button, adding
|
||||
# a coin, and the once-a-day check are allowed to).
|
||||
unit_price = latest.value / holding.quantity
|
||||
await _upsert_valuation(session, asset_id, quantity * unit_price, date_.today())
|
||||
if payload.type == CryptoTransactionType.SELL:
|
||||
current_quantity, _ = _compute_position(holding.transactions)
|
||||
if payload.quantity > current_quantity:
|
||||
raise HTTPException(status_code=400, detail="Cannot sell more than you currently hold")
|
||||
|
||||
holding.quantity = quantity
|
||||
await session.commit()
|
||||
|
||||
# populate_existing=True — see list_holdings()'s comment: the AssetValuation
|
||||
# loaded above (before _upsert_valuation's raw Core write) is otherwise
|
||||
# served stale here.
|
||||
result = await session.execute(
|
||||
select(CryptoHolding)
|
||||
.options(*_EAGER)
|
||||
.where(CryptoHolding.asset_id == asset_id)
|
||||
.execution_options(populate_existing=True)
|
||||
holding.transactions.append(
|
||||
CryptoTransaction(
|
||||
type=payload.type,
|
||||
quantity=payload.quantity,
|
||||
price_per_unit=payload.price_per_unit,
|
||||
date=payload.date,
|
||||
note=payload.note,
|
||||
)
|
||||
)
|
||||
return _to_read(result.scalar_one())
|
||||
await session.flush()
|
||||
|
||||
quantity, _ = _compute_position(holding.transactions)
|
||||
if holding.last_price is not None:
|
||||
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
|
||||
|
||||
await session.commit()
|
||||
return _to_read(holding)
|
||||
|
||||
|
||||
async def list_transactions(session: AsyncSession, asset_id: int) -> list[CryptoTransaction]:
|
||||
await _get_holding_or_404(session, asset_id) # 404s if the holding itself doesn't exist
|
||||
result = await session.execute(
|
||||
select(CryptoTransaction)
|
||||
.where(CryptoTransaction.asset_id == asset_id)
|
||||
.order_by(CryptoTransaction.date.desc(), CryptoTransaction.id.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def delete_transaction(session: AsyncSession, transaction_id: int) -> None:
|
||||
transaction = await session.get(CryptoTransaction, transaction_id)
|
||||
if transaction is None:
|
||||
raise HTTPException(status_code=404, detail="Crypto transaction not found")
|
||||
asset_id = transaction.asset_id
|
||||
await session.delete(transaction)
|
||||
await session.flush()
|
||||
|
||||
holding = await _get_holding_or_404(session, asset_id)
|
||||
quantity, _ = _compute_position(holding.transactions)
|
||||
if holding.last_price is not None:
|
||||
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Crypto holdings: creation, quantity updates, and the lazy/forced price
|
||||
sync. CoinGecko itself is never called in tests —
|
||||
services/crypto_service.py's _fetch_prices_batch is monkeypatched with a
|
||||
canned price feed instead, same way any external dependency would be.
|
||||
"""Crypto holdings: buy/sell transaction log, weighted-average cost basis,
|
||||
and the lazy/forced price sync. CoinGecko itself is never called in tests —
|
||||
services/crypto_service.py's _fetch_market_data is monkeypatched with a
|
||||
canned market-data feed instead, same way any external dependency would be.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -12,35 +12,56 @@ from app.services import crypto_service
|
||||
from tests.helpers import money
|
||||
|
||||
|
||||
async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5") -> dict:
|
||||
resp = await client.post(
|
||||
"/crypto/holdings",
|
||||
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": quantity},
|
||||
def _point(price: str, change_1h: str | None = None, change_24h: str | None = None, change_7d: str | None = None):
|
||||
return crypto_service._MarketPoint(
|
||||
price=Decimal(price),
|
||||
change_1h=Decimal(change_1h) if change_1h else None,
|
||||
change_24h=Decimal(change_24h) if change_24h else None,
|
||||
change_7d=Decimal(change_7d) if change_7d else None,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _fake_fetch(prices: dict[str, Decimal]):
|
||||
def _fake_fetch(prices: dict[str, "crypto_service._MarketPoint"]):
|
||||
async def fetch(coingecko_ids, vs_currency):
|
||||
return {cid: prices[cid] for cid in coingecko_ids if cid in prices}
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
async def test_create_holding_seeds_todays_value_immediately(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5", price_per_unit: str = "40000") -> dict:
|
||||
resp = await client.post(
|
||||
"/crypto/holdings",
|
||||
json={
|
||||
"coingecko_id": "bitcoin",
|
||||
"symbol": "btc",
|
||||
"name": "Bitcoin",
|
||||
"quantity": quantity,
|
||||
"price_per_unit": price_per_unit,
|
||||
"date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
holding = await _add_bitcoin(client, "0.5")
|
||||
|
||||
async def test_create_holding_seeds_todays_value_and_position_immediately(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
|
||||
holding = await _add_bitcoin(client, "0.5", "40000")
|
||||
|
||||
assert holding["symbol"] == "BTC" # normalized to uppercase
|
||||
assert money(holding["quantity"]) == money("0.5")
|
||||
assert money(holding["avg_buy_price"]) == money("40000")
|
||||
assert money(holding["current_price"]) == money("50000")
|
||||
assert money(holding["value"]) == money("25000") # 0.5 * 50000
|
||||
assert money(holding["unit_price"]) == money("50000")
|
||||
assert money(holding["cost_basis"]) == money("20000") # 0.5 * 40000
|
||||
assert money(holding["profit_loss"]) == money("5000")
|
||||
assert round(holding["profit_loss_percent"], 2) == 25.0
|
||||
|
||||
|
||||
async def test_holding_shows_up_in_net_worth_breakdown(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
await _add_bitcoin(client, "1")
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
await _add_bitcoin(client, "1", "40000")
|
||||
|
||||
resp = await client.get("/net-worth/summary")
|
||||
|
||||
@@ -48,23 +69,96 @@ async def test_holding_shows_up_in_net_worth_breakdown(client: AsyncClient, monk
|
||||
assert money(breakdown["crypto"]["amount"]) == money("50000")
|
||||
|
||||
|
||||
async def test_update_quantity_recomputes_value_without_calling_coingecko(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
holding = await _add_bitcoin(client, "0.5")
|
||||
async def test_second_buy_blends_into_weighted_average_cost(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000") # cost basis: 40000
|
||||
|
||||
async def fail_if_called(coingecko_ids, vs_currency):
|
||||
raise AssertionError("updating quantity must not call CoinGecko")
|
||||
raise AssertionError("adding a transaction must not call CoinGecko")
|
||||
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", fail_if_called)
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", fail_if_called)
|
||||
|
||||
resp = await client.patch(f"/crypto/holdings/{holding['asset_id']}", json={"quantity": "2"})
|
||||
resp = await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "buy", "quantity": "1", "price_per_unit": "60000", "date": "2026-02-01"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert money(body["quantity"]) == money("2")
|
||||
# (1*40000 + 1*60000) / 2 = 50000
|
||||
assert money(body["avg_buy_price"]) == money("50000")
|
||||
assert money(body["value"]) == money("100000") # 2 * 50000 (last known price, unchanged)
|
||||
|
||||
|
||||
async def test_sell_reduces_quantity_but_not_the_average_cost_of_what_remains(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "2", "40000") # avg cost 40000
|
||||
|
||||
resp = await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "sell", "quantity": "1", "price_per_unit": "55000", "date": "2026-02-01"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert money(body["quantity"]) == money("1")
|
||||
assert money(body["avg_buy_price"]) == money("40000") # unchanged by the sell
|
||||
assert money(body["value"]) == money("50000") # 1 * 50000
|
||||
|
||||
|
||||
async def test_selling_more_than_held_is_rejected(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000")
|
||||
|
||||
resp = await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "sell", "quantity": "2", "price_per_unit": "55000", "date": "2026-02-01"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_deleting_a_transaction_recomputes_the_position(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000")
|
||||
|
||||
second = await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "buy", "quantity": "1", "price_per_unit": "60000", "date": "2026-02-01"},
|
||||
)
|
||||
tx_id = next(
|
||||
t["id"]
|
||||
for t in (await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")).json()
|
||||
if t["price_per_unit"] == "60000.000000000000000000"
|
||||
)
|
||||
assert second.json()["quantity"] == "2.000000000000000000"
|
||||
|
||||
resp = await client.delete(f"/crypto/transactions/{tx_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
refreshed = (await client.get("/crypto/holdings")).json()["holdings"][0]
|
||||
assert money(refreshed["quantity"]) == money("1")
|
||||
assert money(refreshed["avg_buy_price"]) == money("40000")
|
||||
|
||||
|
||||
async def test_transaction_history_lists_newest_first(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000", )
|
||||
await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "buy", "quantity": "1", "price_per_unit": "60000", "date": "2026-02-01"},
|
||||
)
|
||||
|
||||
resp = await client.get(f"/crypto/holdings/{holding['asset_id']}/transactions")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert money(resp.json()["value"]) == money("100000") # 2 * 50000 (last known price)
|
||||
dates = [t["date"] for t in resp.json()]
|
||||
assert dates == ["2026-02-01", "2026-01-01"]
|
||||
|
||||
|
||||
async def test_deleting_the_asset_removes_the_crypto_holding_too(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client)
|
||||
|
||||
resp = await client.delete(f"/assets/{holding['asset_id']}")
|
||||
@@ -75,7 +169,7 @@ async def test_deleting_the_asset_removes_the_crypto_holding_too(client: AsyncCl
|
||||
|
||||
|
||||
async def test_get_holdings_does_not_resync_within_24_hours(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
await _add_bitcoin(client)
|
||||
|
||||
calls: list[list[str]] = []
|
||||
@@ -84,7 +178,7 @@ async def test_get_holdings_does_not_resync_within_24_hours(client: AsyncClient,
|
||||
calls.append(coingecko_ids)
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", counting_fetch)
|
||||
|
||||
resp = await client.get("/crypto/holdings")
|
||||
|
||||
@@ -92,33 +186,37 @@ async def test_get_holdings_does_not_resync_within_24_hours(client: AsyncClient,
|
||||
assert calls == [] # creation's own sync already satisfied the 24h window
|
||||
|
||||
|
||||
async def test_manual_refresh_button_bypasses_the_24h_window(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
await _add_bitcoin(client)
|
||||
async def test_manual_refresh_button_bypasses_the_24h_window_and_updates_changes(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
await _add_bitcoin(client, "0.5", "40000")
|
||||
|
||||
calls: list[list[str]] = []
|
||||
|
||||
async def counting_fetch(coingecko_ids, vs_currency):
|
||||
calls.append(coingecko_ids)
|
||||
return {"bitcoin": Decimal("60000")}
|
||||
return {"bitcoin": _point("60000", change_1h="0.5", change_24h="2.1", change_7d="-3.4")}
|
||||
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", counting_fetch)
|
||||
|
||||
resp = await client.post("/crypto/refresh")
|
||||
|
||||
assert resp.json()["synced"] is True
|
||||
assert calls == [["bitcoin"]]
|
||||
assert money(resp.json()["holdings"][0]["value"]) == money("30000") # 0.5 * 60000
|
||||
holding = resp.json()["holdings"][0]
|
||||
assert money(holding["value"]) == money("30000") # 0.5 * 60000
|
||||
assert money(holding["price_change_1h"]) == money("0.5")
|
||||
assert money(holding["price_change_24h"]) == money("2.1")
|
||||
assert money(holding["price_change_7d"]) == money("-3.4")
|
||||
|
||||
|
||||
async def test_coingecko_outage_keeps_last_known_value_instead_of_failing(client: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
await _add_bitcoin(client)
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
await _add_bitcoin(client, "0.5", "40000")
|
||||
|
||||
async def broken_fetch(coingecko_ids, vs_currency):
|
||||
raise httpx.ConnectError("simulated outage")
|
||||
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", broken_fetch)
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", broken_fetch)
|
||||
|
||||
resp = await client.post("/crypto/refresh")
|
||||
|
||||
@@ -134,36 +232,42 @@ async def test_create_rejects_when_no_api_key_configured(client: AsyncClient, mo
|
||||
|
||||
resp = await client.post(
|
||||
"/crypto/holdings",
|
||||
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": "0.5"},
|
||||
json={
|
||||
"coingecko_id": "bitcoin",
|
||||
"symbol": "btc",
|
||||
"name": "Bitcoin",
|
||||
"quantity": "0.5",
|
||||
"price_per_unit": "40000",
|
||||
"date": "2026-01-01",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_backup_roundtrip_preserves_the_holding_not_just_the_asset(client: AsyncClient, monkeypatch):
|
||||
async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: AsyncClient, monkeypatch):
|
||||
"""A crypto holding is an Asset underneath, so it would still show up
|
||||
after a restore even without this — but as an inert manual asset,
|
||||
having silently lost which coin it was and how much was held. Backup
|
||||
must carry the CryptoHolding row too, not just Asset/AssetValuation."""
|
||||
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
|
||||
holding = await _add_bitcoin(client, "0.5")
|
||||
having silently lost which coin it was and its whole buy/sell history
|
||||
(so quantity and avg buy price couldn't be recomputed). Backup must
|
||||
carry both CryptoHolding and CryptoTransaction rows."""
|
||||
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
|
||||
holding = await _add_bitcoin(client, "1", "40000")
|
||||
await client.post(
|
||||
f"/crypto/holdings/{holding['asset_id']}/transactions",
|
||||
json={"type": "buy", "quantity": "1", "price_per_unit": "60000", "date": "2026-02-01"},
|
||||
)
|
||||
|
||||
payload = (await client.get("/backup/export")).json()
|
||||
assert len(payload["crypto_holdings"]) == 1
|
||||
backed_up = payload["crypto_holdings"][0]
|
||||
assert backed_up["asset_id"] == holding["asset_id"]
|
||||
assert backed_up["coingecko_id"] == "bitcoin"
|
||||
assert backed_up["symbol"] == "BTC"
|
||||
assert backed_up["name"] == "Bitcoin"
|
||||
assert backed_up["thumb_url"] is None
|
||||
assert money(backed_up["quantity"]) == money("0.5")
|
||||
assert len(payload["crypto_transactions"]) == 2
|
||||
|
||||
import_resp = await client.post("/backup/import", json=payload)
|
||||
assert import_resp.status_code == 200, import_resp.text
|
||||
|
||||
resp = await client.get("/crypto/holdings")
|
||||
restored = resp.json()["holdings"]
|
||||
assert len(restored) == 1
|
||||
assert restored[0]["coingecko_id"] == "bitcoin"
|
||||
assert restored[0]["asset_id"] == holding["asset_id"]
|
||||
assert money(restored[0]["value"]) == money("25000") # AssetValuation history survives too
|
||||
restored = (await client.get("/crypto/holdings")).json()["holdings"][0]
|
||||
assert restored["coingecko_id"] == "bitcoin"
|
||||
assert restored["asset_id"] == holding["asset_id"]
|
||||
assert money(restored["quantity"]) == money("2")
|
||||
assert money(restored["avg_buy_price"]) == money("50000")
|
||||
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { api } from "@/api/client";
|
||||
import type { CryptoHolding, CryptoHoldingInput, CryptoSearchResult, CryptoSyncResult } from "@/types";
|
||||
import type {
|
||||
CryptoHolding,
|
||||
CryptoHoldingCreateInput,
|
||||
CryptoSearchResult,
|
||||
CryptoSyncResult,
|
||||
CryptoTransaction,
|
||||
CryptoTransactionInput,
|
||||
} from "@/types";
|
||||
|
||||
// Also triggers the lazy once-a-day auto-refresh server-side — see
|
||||
// services/crypto_service.py.
|
||||
@@ -11,16 +18,26 @@ export function refreshCryptoPrices() {
|
||||
return api.post<CryptoSyncResult>("/crypto/refresh", {});
|
||||
}
|
||||
|
||||
export function createCryptoHolding(input: CryptoHoldingInput) {
|
||||
export function createCryptoHolding(input: CryptoHoldingCreateInput) {
|
||||
return api.post<CryptoHolding>("/crypto/holdings", input);
|
||||
}
|
||||
|
||||
export function updateCryptoHoldingQuantity(assetId: number, quantity: string) {
|
||||
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, { quantity });
|
||||
// Buy more of, or sell some of, a coin already being tracked.
|
||||
export function addCryptoTransaction(assetId: number, input: CryptoTransactionInput) {
|
||||
return api.post<CryptoHolding>(`/crypto/holdings/${assetId}/transactions`, input);
|
||||
}
|
||||
|
||||
export function fetchCryptoTransactions(assetId: number) {
|
||||
return api.get<CryptoTransaction[]>(`/crypto/holdings/${assetId}/transactions`);
|
||||
}
|
||||
|
||||
export function deleteCryptoTransaction(transactionId: number) {
|
||||
return api.delete<void>(`/crypto/transactions/${transactionId}`);
|
||||
}
|
||||
|
||||
// Reuses the existing asset-delete endpoint — deleting the Asset cascades
|
||||
// to the linked crypto_holdings row server-side, no dedicated endpoint.
|
||||
// to the linked crypto_holdings row (and its whole transaction log)
|
||||
// server-side, no dedicated endpoint.
|
||||
export function deleteCryptoHolding(assetId: number) {
|
||||
return api.delete<void>(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [pricePerUnit, setPricePerUnit] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,6 +36,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
setResults([]);
|
||||
setSelected(null);
|
||||
setQuantity("");
|
||||
setPricePerUnit("");
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setSearchError(null);
|
||||
setSaveError(null);
|
||||
}, [open]);
|
||||
@@ -74,6 +78,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
name: selected.name,
|
||||
thumb_url: selected.thumb_url,
|
||||
quantity,
|
||||
price_per_unit: pricePerUnit,
|
||||
date,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -139,17 +145,42 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||
<Input
|
||||
id="crypto-quantity"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
autoFocus
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="crypto-price">{t("crypto.form.pricePerUnitLabel")}</Label>
|
||||
<Input
|
||||
id="crypto-price"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
value={pricePerUnit}
|
||||
onChange={(event) => setPricePerUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||
<Label htmlFor="crypto-date">{t("crypto.form.dateLabel")}</Label>
|
||||
<Input
|
||||
id="crypto-quantity"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
id="crypto-date"
|
||||
type="date"
|
||||
required
|
||||
autoFocus
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
value={date}
|
||||
onChange={(event) => setDate(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useUpdateCryptoHoldingQuantity } from "@/hooks/useCrypto";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
|
||||
interface CryptoEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
holding: CryptoHolding | null;
|
||||
}
|
||||
|
||||
/** Quantity only — editing never calls CoinGecko (see
|
||||
* services/crypto_service.py's update_holding_quantity): the value is
|
||||
* recomputed from the last known price instead. */
|
||||
export function CryptoEditModal({ open, onClose, holding }: CryptoEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateQuantity = useUpdateCryptoHoldingQuantity();
|
||||
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !holding) return;
|
||||
setQuantity(holding.quantity);
|
||||
setError(null);
|
||||
}, [open, holding]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!holding) return;
|
||||
setError(null);
|
||||
try {
|
||||
await updateQuantity.mutateAsync({ assetId: holding.asset_id, quantity });
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("crypto.form.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!holding) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={t("crypto.form.editTitle", { name: holding.name })}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="crypto-edit-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||
<Input
|
||||
id="crypto-edit-quantity"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
autoFocus
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateQuantity.isPending}>
|
||||
{updateQuantity.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Bitcoin, Pencil, Trash2 } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
|
||||
interface CryptoHoldingsListProps {
|
||||
items: CryptoHolding[];
|
||||
onEdit: (holding: CryptoHolding) => void;
|
||||
onDelete: (holding: CryptoHolding) => void;
|
||||
}
|
||||
|
||||
export function CryptoHoldingsList({ items, onEdit, onDelete }: CryptoHoldingsListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gridline">
|
||||
{items.map((holding) => (
|
||||
<li key={holding.asset_id} className="flex items-center gap-3 py-3">
|
||||
{holding.thumb_url ? (
|
||||
<img src={holding.thumb_url} alt="" className="h-9 w-9 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-surface-2">
|
||||
<Bitcoin size={16} className="text-text-secondary" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
|
||||
<span className="block truncate text-xs text-text-muted">
|
||||
{Number(holding.quantity)} {holding.symbol}
|
||||
{holding.unit_price !== null && ` · ${formatCurrency(holding.unit_price)}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-sm font-medium tabular-nums text-text-primary">
|
||||
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => onEdit(holding)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(holding)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
151
frontend/src/components/crypto/CryptoHoldingsTable.tsx
Normal file
151
frontend/src/components/crypto/CryptoHoldingsTable.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Bitcoin, History, Plus, Trash2 } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
|
||||
interface CryptoHoldingsTableProps {
|
||||
items: CryptoHolding[];
|
||||
onTrade: (holding: CryptoHolding) => void;
|
||||
onViewHistory: (holding: CryptoHolding) => void;
|
||||
onDelete: (holding: CryptoHolding) => void;
|
||||
}
|
||||
|
||||
function PercentCell({ value }: { value: string | null }) {
|
||||
if (value === null) return <span className="text-text-muted">—</span>;
|
||||
const num = Number(value);
|
||||
const color = num > 0 ? "var(--success)" : num < 0 ? "var(--danger)" : "var(--text-muted)";
|
||||
return (
|
||||
<span className="tabular-nums" style={{ color }}>
|
||||
{num > 0 ? "+" : ""}
|
||||
{num.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-text-muted">
|
||||
<th className="py-2 pr-3 font-medium">{t("crypto.table.name")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.price")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change1h")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change24h")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change7d")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.holdings")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.profitLoss")}</th>
|
||||
<th className="py-2 pl-3 text-right font-medium">{t("crypto.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gridline">
|
||||
{items.map((holding) => {
|
||||
const profitColor =
|
||||
holding.profit_loss === null
|
||||
? undefined
|
||||
: Number(holding.profit_loss) > 0
|
||||
? "var(--success)"
|
||||
: Number(holding.profit_loss) < 0
|
||||
? "var(--danger)"
|
||||
: "var(--text-muted)";
|
||||
const sign = holding.profit_loss !== null && Number(holding.profit_loss) > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<tr key={holding.asset_id}>
|
||||
<td className="py-3 pr-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{holding.thumb_url ? (
|
||||
<img src={holding.thumb_url} alt="" className="h-7 w-7 shrink-0 rounded-full" />
|
||||
) : (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-surface-2">
|
||||
<Bitcoin size={14} className="text-text-secondary" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
|
||||
<span className="block text-xs text-text-muted">{holding.symbol}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
|
||||
{holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")}
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right">
|
||||
<PercentCell value={holding.price_change_1h} />
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right">
|
||||
<PercentCell value={holding.price_change_24h} />
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right">
|
||||
<PercentCell value={holding.price_change_7d} />
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right">
|
||||
<span className="block tabular-nums text-text-primary">
|
||||
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
|
||||
</span>
|
||||
<span className="block text-xs tabular-nums text-text-muted">
|
||||
{Number(holding.quantity)} {holding.symbol}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
|
||||
{holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-3 text-right">
|
||||
{holding.profit_loss !== null && holding.profit_loss_percent !== null ? (
|
||||
<>
|
||||
<span className="block tabular-nums" style={{ color: profitColor }}>
|
||||
{sign}
|
||||
{formatCurrency(holding.profit_loss)}
|
||||
</span>
|
||||
<span className="block text-xs tabular-nums" style={{ color: profitColor }}>
|
||||
{sign}
|
||||
{holding.profit_loss_percent.toFixed(2)}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pl-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("crypto.form.tradeButton")}
|
||||
onClick={() => onTrade(holding)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("crypto.history.viewButton")}
|
||||
onClick={() => onViewHistory(holding)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||
>
|
||||
<History size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => onDelete(holding)}
|
||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
|
||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding } from "@/types";
|
||||
|
||||
interface CryptoTransactionHistoryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
holding: CryptoHolding | null;
|
||||
}
|
||||
|
||||
export function CryptoTransactionHistoryModal({ open, onClose, holding }: CryptoTransactionHistoryModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
|
||||
const deleteTransaction = useDeleteCryptoTransaction();
|
||||
|
||||
if (!holding) return null;
|
||||
|
||||
function handleDelete(transactionId: number) {
|
||||
if (window.confirm(t("crypto.history.confirmDelete"))) {
|
||||
deleteTransaction.mutate(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={t("crypto.history.title", { name: holding.name })}>
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
||||
) : !transactions || transactions.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-text-muted">{t("crypto.history.empty")}</p>
|
||||
) : (
|
||||
<ul className="max-h-96 divide-y divide-gridline overflow-y-auto">
|
||||
{transactions.map((tx) => (
|
||||
<li key={tx.id} className="flex items-center gap-3 py-2.5">
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-xs font-medium"
|
||||
style={{
|
||||
color: tx.type === "buy" ? "var(--success)" : "var(--danger)",
|
||||
backgroundColor: tx.type === "buy" ? "color-mix(in srgb, var(--success) 12%, transparent)" : "color-mix(in srgb, var(--danger) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
{tx.type === "buy" ? t("crypto.form.buy") : t("crypto.form.sell")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-text-primary">
|
||||
{Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)}
|
||||
</span>
|
||||
<span className="block text-xs text-text-muted">
|
||||
{formatTransactionDate(tx.date, true)}
|
||||
{tx.note && ` · ${tx.note}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
className="shrink-0 rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
138
frontend/src/components/crypto/CryptoTransactionModal.tsx
Normal file
138
frontend/src/components/crypto/CryptoTransactionModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Input, Label } from "@/components/ui/Input";
|
||||
import { useAddCryptoTransaction } from "@/hooks/useCrypto";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CryptoHolding, CryptoTransactionType } from "@/types";
|
||||
|
||||
interface CryptoTransactionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
holding: CryptoHolding | null;
|
||||
}
|
||||
|
||||
/** Buy more of, or sell some of, a coin already being tracked — the "+"
|
||||
* action on the holdings table. Never calls CoinGecko (see
|
||||
* services/crypto_service.py's add_transaction): value is recomputed from
|
||||
* the last cached price. */
|
||||
export function CryptoTransactionModal({ open, onClose, holding }: CryptoTransactionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const addTransaction = useAddCryptoTransaction();
|
||||
|
||||
const [type, setType] = useState<CryptoTransactionType>("buy");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [pricePerUnit, setPricePerUnit] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setType("buy");
|
||||
setQuantity("");
|
||||
setPricePerUnit("");
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setNote("");
|
||||
setError(null);
|
||||
}, [open, holding]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!holding) return;
|
||||
setError(null);
|
||||
try {
|
||||
await addTransaction.mutateAsync({
|
||||
assetId: holding.asset_id,
|
||||
input: { type, quantity, price_per_unit: pricePerUnit, date, note: note || null },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.status === 400 ? t("crypto.form.errorSellTooMuch") : t("crypto.form.saveError")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!holding) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} title={t("crypto.form.tradeTitle", { name: holding.name })}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("buy")}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
type === "buy" ? "border-success bg-success/10 text-success" : "border-border text-text-secondary"
|
||||
)}
|
||||
>
|
||||
{t("crypto.form.buy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType("sell")}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
|
||||
type === "sell" ? "border-danger bg-danger/10 text-danger" : "border-border text-text-secondary"
|
||||
)}
|
||||
>
|
||||
{t("crypto.form.sell")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="tx-quantity">{t("crypto.form.quantityLabel")}</Label>
|
||||
<Input
|
||||
id="tx-quantity"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
autoFocus
|
||||
value={quantity}
|
||||
onChange={(event) => setQuantity(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tx-price">{t("crypto.form.pricePerUnitLabel")}</Label>
|
||||
<Input
|
||||
id="tx-price"
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
required
|
||||
value={pricePerUnit}
|
||||
onChange={(event) => setPricePerUnit(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tx-date">{t("crypto.form.dateLabel")}</Label>
|
||||
<Input id="tx-date" type="date" required value={date} onChange={(event) => setDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tx-note">{t("crypto.form.noteLabel")}</Label>
|
||||
<Input id="tx-note" value={note} onChange={(event) => setNote(event.target.value)} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={addTransaction.isPending}>
|
||||
{addTransaction.isPending ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
addCryptoTransaction,
|
||||
createCryptoHolding,
|
||||
deleteCryptoHolding,
|
||||
deleteCryptoTransaction,
|
||||
fetchCryptoHoldings,
|
||||
fetchCryptoTransactions,
|
||||
refreshCryptoPrices,
|
||||
searchCryptoCoins,
|
||||
updateCryptoHoldingQuantity,
|
||||
} from "@/api/crypto";
|
||||
import type { CryptoHoldingInput } from "@/types";
|
||||
import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types";
|
||||
|
||||
function useInvalidateCrypto() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
|
||||
// A crypto holding is a normal Asset under the hood — Net Worth's own
|
||||
// numbers change the moment one is added/edited/repriced/removed.
|
||||
// numbers change the moment one is added/traded/repriced/removed.
|
||||
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
|
||||
};
|
||||
}
|
||||
@@ -23,6 +25,14 @@ export function useCryptoHoldings() {
|
||||
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
|
||||
}
|
||||
|
||||
export function useCryptoTransactions(assetId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["crypto-transactions", assetId],
|
||||
queryFn: () => fetchCryptoTransactions(assetId!),
|
||||
enabled: assetId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshCryptoPrices() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
|
||||
@@ -31,16 +41,24 @@ export function useRefreshCryptoPrices() {
|
||||
export function useCreateCryptoHolding() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: (input: CryptoHoldingInput) => createCryptoHolding(input),
|
||||
mutationFn: (input: CryptoHoldingCreateInput) => createCryptoHolding(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCryptoHoldingQuantity() {
|
||||
export function useAddCryptoTransaction() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: ({ assetId, quantity }: { assetId: number; quantity: string }) =>
|
||||
updateCryptoHoldingQuantity(assetId, quantity),
|
||||
mutationFn: ({ assetId, input }: { assetId: number; input: CryptoTransactionInput }) =>
|
||||
addCryptoTransaction(assetId, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCryptoTransaction() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: (transactionId: number) => deleteCryptoTransaction(transactionId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -52,8 +70,3 @@ export function useDeleteCryptoHolding() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// Not a useQuery — this drives a search-as-you-type picker inside the "add
|
||||
// holding" modal, called imperatively (debounced) rather than kept in sync
|
||||
// with a query key.
|
||||
export { searchCryptoCoins };
|
||||
|
||||
@@ -123,9 +123,29 @@ const ru = {
|
||||
"crypto.search.empty": "Ничего не найдено.",
|
||||
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
|
||||
"crypto.form.quantityLabel": "Количество",
|
||||
"crypto.form.pricePerUnitLabel": "Цена за штуку",
|
||||
"crypto.form.dateLabel": "Дата",
|
||||
"crypto.form.noteLabel": "Заметка (необязательно)",
|
||||
"crypto.form.addTitle": "Добавить {{name}}",
|
||||
"crypto.form.editTitle": "Изменить количество: {{name}}",
|
||||
"crypto.form.tradeTitle": "Сделка: {{name}}",
|
||||
"crypto.form.tradeButton": "Купить / продать",
|
||||
"crypto.form.buy": "Покупка",
|
||||
"crypto.form.sell": "Продажа",
|
||||
"crypto.form.errorSellTooMuch": "Нельзя продать больше, чем у вас есть.",
|
||||
"crypto.form.saveError": "Не удалось сохранить. Проверьте данные и попробуйте снова.",
|
||||
"crypto.table.name": "Название",
|
||||
"crypto.table.price": "Цена",
|
||||
"crypto.table.change1h": "1ч%",
|
||||
"crypto.table.change24h": "24ч%",
|
||||
"crypto.table.change7d": "7д%",
|
||||
"crypto.table.holdings": "Держите",
|
||||
"crypto.table.avgBuyPrice": "Средняя цена покупки",
|
||||
"crypto.table.profitLoss": "Прибыль/убыток",
|
||||
"crypto.table.actions": "Действия",
|
||||
"crypto.history.title": "История сделок: {{name}}",
|
||||
"crypto.history.empty": "Пока нет ни одной сделки.",
|
||||
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
|
||||
"crypto.history.viewButton": "Смотреть историю",
|
||||
|
||||
"roi.calculator.title": "Калькулятор ROI",
|
||||
"roi.calculator.investmentLabel": "Сумма инвестиции",
|
||||
@@ -551,9 +571,29 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"crypto.search.empty": "Nothing found.",
|
||||
"crypto.search.noApiKey": "CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env (free key at coingecko.com/en/api/pricing).",
|
||||
"crypto.form.quantityLabel": "Quantity",
|
||||
"crypto.form.pricePerUnitLabel": "Price per unit",
|
||||
"crypto.form.dateLabel": "Date",
|
||||
"crypto.form.noteLabel": "Note (optional)",
|
||||
"crypto.form.addTitle": "Add {{name}}",
|
||||
"crypto.form.editTitle": "Edit quantity: {{name}}",
|
||||
"crypto.form.tradeTitle": "Trade: {{name}}",
|
||||
"crypto.form.tradeButton": "Buy / sell",
|
||||
"crypto.form.buy": "Buy",
|
||||
"crypto.form.sell": "Sell",
|
||||
"crypto.form.errorSellTooMuch": "You can't sell more than you currently hold.",
|
||||
"crypto.form.saveError": "Failed to save. Check the data and try again.",
|
||||
"crypto.table.name": "Name",
|
||||
"crypto.table.price": "Price",
|
||||
"crypto.table.change1h": "1h%",
|
||||
"crypto.table.change24h": "24h%",
|
||||
"crypto.table.change7d": "7d%",
|
||||
"crypto.table.holdings": "Holdings",
|
||||
"crypto.table.avgBuyPrice": "Avg. Buy Price",
|
||||
"crypto.table.profitLoss": "Profit/Loss",
|
||||
"crypto.table.actions": "Actions",
|
||||
"crypto.history.title": "Trade history: {{name}}",
|
||||
"crypto.history.empty": "No trades yet.",
|
||||
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
|
||||
"crypto.history.viewButton": "View history",
|
||||
|
||||
"roi.calculator.title": "ROI Calculator",
|
||||
"roi.calculator.investmentLabel": "Investment amount",
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Plus, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
||||
import { CryptoEditModal } from "@/components/crypto/CryptoEditModal";
|
||||
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList";
|
||||
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
||||
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
||||
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
||||
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
|
||||
import { formatCurrency, getIntlLocale } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
@@ -21,7 +22,8 @@ export function CryptoPage() {
|
||||
const deleteHolding = useDeleteCryptoHolding();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editingHolding, setEditingHolding] = useState<CryptoHolding | null>(null);
|
||||
const [tradingHolding, setTradingHolding] = useState<CryptoHolding | null>(null);
|
||||
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||
|
||||
const holdings = data?.holdings ?? [];
|
||||
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
||||
@@ -59,20 +61,32 @@ export function CryptoPage() {
|
||||
<p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{data?.last_synced_at ? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) }) : t("crypto.neverSynced")}
|
||||
{data?.last_synced_at
|
||||
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
|
||||
: t("crypto.neverSynced")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
|
||||
|
||||
<CryptoHoldingsList items={holdings} onEdit={setEditingHolding} onDelete={handleDelete} />
|
||||
<CryptoHoldingsTable
|
||||
items={holdings}
|
||||
onTrade={setTradingHolding}
|
||||
onViewHistory={setHistoryHolding}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} />
|
||||
<CryptoEditModal open={editingHolding !== null} onClose={() => setEditingHolding(null)} holding={editingHolding} />
|
||||
<CryptoTransactionModal open={tradingHolding !== null} onClose={() => setTradingHolding(null)} holding={tradingHolding} />
|
||||
<CryptoTransactionHistoryModal
|
||||
open={historyHolding !== null}
|
||||
onClose={() => setHistoryHolding(null)}
|
||||
holding={historyHolding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,26 +374,59 @@ export interface FinancialAlert {
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
export type CryptoTransactionType = "buy" | "sell";
|
||||
|
||||
export interface CryptoHolding {
|
||||
asset_id: number;
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
thumb_url: string | null;
|
||||
// Both derived from the buy/sell log (see CryptoTransaction) — never
|
||||
// edited directly.
|
||||
quantity: string;
|
||||
// null means "added but never priced yet" (CoinGecko was unreachable at
|
||||
// creation time) — distinct from a real zero value.
|
||||
avg_buy_price: string | null;
|
||||
// Cached from the last successful CoinGecko sync — null means "added but
|
||||
// never priced yet" (CoinGecko was unreachable right at creation),
|
||||
// distinct from a real zero.
|
||||
current_price: string | null;
|
||||
price_change_1h: string | null;
|
||||
price_change_24h: string | null;
|
||||
price_change_7d: string | null;
|
||||
value: string | null;
|
||||
unit_price: string | null;
|
||||
as_of_date: string | null;
|
||||
cost_basis: string | null;
|
||||
profit_loss: string | null;
|
||||
profit_loss_percent: number | null;
|
||||
}
|
||||
|
||||
export interface CryptoHoldingInput {
|
||||
export interface CryptoHoldingCreateInput {
|
||||
coingecko_id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
thumb_url?: string | null;
|
||||
// A holding always starts with its first buy.
|
||||
quantity: string;
|
||||
price_per_unit: string;
|
||||
date: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface CryptoTransaction {
|
||||
id: number;
|
||||
asset_id: number;
|
||||
type: CryptoTransactionType;
|
||||
quantity: string;
|
||||
price_per_unit: string;
|
||||
date: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface CryptoTransactionInput {
|
||||
type: CryptoTransactionType;
|
||||
quantity: string;
|
||||
price_per_unit: string;
|
||||
date: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface CryptoSyncResult {
|
||||
|
||||
Reference in New Issue
Block a user