From e8cd536584aff8908144848cd090aa5388d81fa8 Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 04:25:03 +0300 Subject: [PATCH] feat: full CoinMarketCap-style crypto portfolio (buy/sell log, 1h/24h/7d%, P&L) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY --- DOCS.md | 67 ++-- README.md | 2 +- backend/alembic/env.py | 1 + .../3e3503b61b20_add_crypto_holdings.py | 29 +- backend/app/api/routes/crypto.py | 39 ++- backend/app/models/__init__.py | 3 +- backend/app/models/crypto.py | 61 +++- backend/app/models/enums.py | 5 + backend/app/schemas/backup.py | 21 +- backend/app/schemas/crypto.py | 61 +++- backend/app/services/backup_service.py | 13 +- backend/app/services/crypto_service.py | 289 ++++++++++++------ backend/tests/test_crypto.py | 214 +++++++++---- frontend/src/api/crypto.ts | 27 +- .../src/components/crypto/CryptoAddModal.tsx | 47 ++- .../src/components/crypto/CryptoEditModal.tsx | 75 ----- .../components/crypto/CryptoHoldingsList.tsx | 64 ---- .../components/crypto/CryptoHoldingsTable.tsx | 151 +++++++++ .../crypto/CryptoTransactionHistoryModal.tsx | 69 +++++ .../crypto/CryptoTransactionModal.tsx | 138 +++++++++ frontend/src/hooks/useCrypto.ts | 39 ++- frontend/src/lib/i18n.ts | 44 ++- frontend/src/pages/CryptoPage.tsx | 26 +- frontend/src/types/index.ts | 43 ++- 24 files changed, 1154 insertions(+), 374 deletions(-) delete mode 100644 frontend/src/components/crypto/CryptoEditModal.tsx delete mode 100644 frontend/src/components/crypto/CryptoHoldingsList.tsx create mode 100644 frontend/src/components/crypto/CryptoHoldingsTable.tsx create mode 100644 frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx create mode 100644 frontend/src/components/crypto/CryptoTransactionModal.tsx diff --git a/DOCS.md b/DOCS.md index f8e6a09..76e4299 100644 --- a/DOCS.md +++ b/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 diff --git a/README.md b/README.md index 2bd05c7..6913b49 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 443b8ac..9df911f 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -16,6 +16,7 @@ from app.models import ( # noqa: F401 — registers metadata Category, CryptoHolding, CryptoSyncState, + CryptoTransaction, Goal, GoalContribution, RecurringTransaction, diff --git a/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py b/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py index db117de..9bcf6cc 100644 --- a/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py +++ b/backend/alembic/versions/3e3503b61b20_add_crypto_holdings.py @@ -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') diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py index 62280fc..256d423 100644 --- a/backend/app/api/routes/crypto.py +++ b/backend/app/api/routes/crypto.py @@ -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]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8b36c38..272f48e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/crypto.py b/backend/app/models/crypto.py index 02ec315..a779da8 100644 --- a/backend/app/models/crypto.py +++ b/backend/app/models/crypto.py @@ -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): diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 64cb975..772c49e 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -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 diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index 9f07f14..c96143d 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -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) diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index a42643f..c3fa8bc 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -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): diff --git a/backend/app/services/backup_service.py b/backend/app/services/backup_service.py index 86cbdc8..089f656 100644 --- a/backend/app/services/backup_service.py +++ b/backend/app/services/backup_service.py @@ -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) diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index bea9596..ce0da97 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -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() diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index 51e43ba..8980869 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -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) diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index de1f91c..03738c9 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -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("/crypto/refresh", {}); } -export function createCryptoHolding(input: CryptoHoldingInput) { +export function createCryptoHolding(input: CryptoHoldingCreateInput) { return api.post("/crypto/holdings", input); } -export function updateCryptoHoldingQuantity(assetId: number, quantity: string) { - return api.patch(`/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(`/crypto/holdings/${assetId}/transactions`, input); +} + +export function fetchCryptoTransactions(assetId: number) { + return api.get(`/crypto/holdings/${assetId}/transactions`); +} + +export function deleteCryptoTransaction(transactionId: number) { + return api.delete(`/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(`/assets/${assetId}`); } diff --git a/frontend/src/components/crypto/CryptoAddModal.tsx b/frontend/src/components/crypto/CryptoAddModal.tsx index ebec449..cae75a7 100644 --- a/frontend/src/components/crypto/CryptoAddModal.tsx +++ b/frontend/src/components/crypto/CryptoAddModal.tsx @@ -26,6 +26,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) { const [searchError, setSearchError] = useState(null); const [selected, setSelected] = useState(null); const [quantity, setQuantity] = useState(""); + const [pricePerUnit, setPricePerUnit] = useState(""); + const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); const [saveError, setSaveError] = useState(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) { +
+
+ + setQuantity(event.target.value)} + /> +
+
+ + setPricePerUnit(event.target.value)} + /> +
+
+
- + setQuantity(event.target.value)} + value={date} + onChange={(event) => setDate(event.target.value)} />
diff --git a/frontend/src/components/crypto/CryptoEditModal.tsx b/frontend/src/components/crypto/CryptoEditModal.tsx deleted file mode 100644 index d008af0..0000000 --- a/frontend/src/components/crypto/CryptoEditModal.tsx +++ /dev/null @@ -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(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 ( - -
-
- - setQuantity(event.target.value)} - /> -
- - {error &&

{error}

} - -
- - -
-
-
- ); -} diff --git a/frontend/src/components/crypto/CryptoHoldingsList.tsx b/frontend/src/components/crypto/CryptoHoldingsList.tsx deleted file mode 100644 index 380a732..0000000 --- a/frontend/src/components/crypto/CryptoHoldingsList.tsx +++ /dev/null @@ -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

{t("crypto.empty")}

; - } - - return ( -
    - {items.map((holding) => ( -
  • - {holding.thumb_url ? ( - - ) : ( - - - - )} - - {holding.name} - - {Number(holding.quantity)} {holding.symbol} - {holding.unit_price !== null && ` · ${formatCurrency(holding.unit_price)}`} - - - - - {holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")} - - - - - - -
  • - ))} -
- ); -} diff --git a/frontend/src/components/crypto/CryptoHoldingsTable.tsx b/frontend/src/components/crypto/CryptoHoldingsTable.tsx new file mode 100644 index 0000000..c17d869 --- /dev/null +++ b/frontend/src/components/crypto/CryptoHoldingsTable.tsx @@ -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 —; + const num = Number(value); + const color = num > 0 ? "var(--success)" : num < 0 ? "var(--danger)" : "var(--text-muted)"; + return ( + + {num > 0 ? "+" : ""} + {num.toFixed(2)}% + + ); +} + +export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) { + const { t } = useTranslation(); + + if (items.length === 0) { + return

{t("crypto.empty")}

; + } + + return ( +
+ + + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + ); + })} + +
{t("crypto.table.name")}{t("crypto.table.price")}{t("crypto.table.change1h")}{t("crypto.table.change24h")}{t("crypto.table.change7d")}{t("crypto.table.holdings")}{t("crypto.table.avgBuyPrice")}{t("crypto.table.profitLoss")}{t("crypto.table.actions")}
+
+ {holding.thumb_url ? ( + + ) : ( + + + + )} + + {holding.name} + {holding.symbol} + +
+
+ {holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")} + + + + + + + + + {holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")} + + + {Number(holding.quantity)} {holding.symbol} + + + {holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"} + + {holding.profit_loss !== null && holding.profit_loss_percent !== null ? ( + <> + + {sign} + {formatCurrency(holding.profit_loss)} + + + {sign} + {holding.profit_loss_percent.toFixed(2)}% + + + ) : ( + — + )} + +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx new file mode 100644 index 0000000..40c8e97 --- /dev/null +++ b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx @@ -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 ( + + {isLoading ? ( +

{t("common.loading")}

+ ) : !transactions || transactions.length === 0 ? ( +

{t("crypto.history.empty")}

+ ) : ( +
    + {transactions.map((tx) => ( +
  • + + {tx.type === "buy" ? t("crypto.form.buy") : t("crypto.form.sell")} + + + + {Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)} + + + {formatTransactionDate(tx.date, true)} + {tx.note && ` · ${tx.note}`} + + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/crypto/CryptoTransactionModal.tsx b/frontend/src/components/crypto/CryptoTransactionModal.tsx new file mode 100644 index 0000000..cb73200 --- /dev/null +++ b/frontend/src/components/crypto/CryptoTransactionModal.tsx @@ -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("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(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 ( + +
+
+ + +
+ +
+
+ + setQuantity(event.target.value)} + /> +
+
+ + setPricePerUnit(event.target.value)} + /> +
+
+ +
+ + setDate(event.target.value)} /> +
+ +
+ + setNote(event.target.value)} /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index 2fe1988..736b053 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -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 }; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 2ba6dc8..0c65126 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { "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", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index f96ce7c..565b406 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -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(null); + const [tradingHolding, setTradingHolding] = useState(null); + const [historyHolding, setHistoryHolding] = useState(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() {

{formatCurrency(totalValue)}

- {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")}

{data?.error_key &&

{t(`crypto.syncError.${data.error_key}`)}

} - + )} setAddOpen(false)} /> - setEditingHolding(null)} holding={editingHolding} /> + setTradingHolding(null)} holding={tradingHolding} /> + setHistoryHolding(null)} + holding={historyHolding} + /> ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e314ad0..5a6096e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -374,26 +374,59 @@ export interface FinancialAlert { params: Record; } +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 {