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:
ZProger
2026-08-30 04:25:03 +03:00
parent 7e4508d1de
commit e8cd536584
24 changed files with 1154 additions and 374 deletions

67
DOCS.md
View File

@@ -505,14 +505,21 @@ personal vehicle)
## Crypto ## Crypto
Live-priced crypto holdings — a coin plus a quantity, kept current against Live-priced crypto holdings with a full buy/sell history — a CoinMarketCap-style portfolio view, kept
[CoinGecko](https://www.coingecko.com/en/api/pricing)'s Demo API. Under the hood each holding **is** an current against [CoinGecko](https://www.coingecko.com/en/api/pricing)'s Demo API. Under the hood each
Asset (`asset_class=crypto`, see [Assets & Net Worth](#assets--net-worth)) — deleting one is `DELETE holding **is** an Asset (`asset_class=crypto`, see [Assets & Net Worth](#assets--net-worth)) — deleting
/assets/{asset_id}`, not a separate endpoint, and it shows up in `/net-worth/summary` like any other one is `DELETE /assets/{asset_id}`, not a separate endpoint, and it shows up in `/net-worth/summary`
asset automatically. 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 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 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 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 | | 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/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. | | `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. |
| `PATCH` | `/crypto/holdings/{asset_id}` | Change `quantity`. Recomputes value from the last known price — does **not** call CoinGecko. | | `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). | | `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 ```json
{ {
@@ -536,13 +545,25 @@ of the whole request failing.
"symbol": "btc", "symbol": "btc",
"name": "Bitcoin", "name": "Bitcoin",
"thumb_url": "https://...", "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 `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 coins) — get it from `/crypto/search` rather than guessing. `quantity`/`price_per_unit` support up to
places (wei-level token amounts). 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:** **`GET /crypto/holdings` / `POST /crypto/refresh` response:**
@@ -559,17 +580,25 @@ places (wei-level token amounts).
"name": "Bitcoin", "name": "Bitcoin",
"thumb_url": "https://...", "thumb_url": "https://...",
"quantity": "0.05", "quantity": "0.05",
"value": "3000.00", "avg_buy_price": "55000.00",
"unit_price": "60000.00", "current_price": "61000.00",
"as_of_date": "2026-08-30" "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 `current_price`/`value` are `null` only for a holding whose very first price fetch failed (CoinGecko
down right when it was added) — distinct from a real `0`. `error_key` is `"unreachable"` when this was down right when it was added) — distinct from a real `0`. `avg_buy_price`/`cost_basis`/
sync attempt couldn't reach CoinGecko (existing values are kept as-is), or `null` otherwise. `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 ## Dashboard, Cash Flow & Reports

View File

@@ -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. 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 ### 🪙 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 ### 🎯 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. 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.

View File

@@ -16,6 +16,7 @@ from app.models import ( # noqa: F401 — registers metadata
Category, Category,
CryptoHolding, CryptoHolding,
CryptoSyncState, CryptoSyncState,
CryptoTransaction,
Goal, Goal,
GoalContribution, GoalContribution,
RecurringTransaction, RecurringTransaction,

View File

@@ -1,4 +1,4 @@
"""add crypto holdings and sync state """add crypto holdings, transactions, and sync state
Revision ID: 3e3503b61b20 Revision ID: 3e3503b61b20
Revises: f6a4d8b2e017 Revises: f6a4d8b2e017
@@ -26,12 +26,36 @@ def upgrade() -> None:
sa.Column('symbol', sa.String(length=20), nullable=False), sa.Column('symbol', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False), sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('thumb_url', sa.String(length=500), nullable=True), 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('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.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'), sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('asset_id'), 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( op.create_table(
'crypto_sync_state', 'crypto_sync_state',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
@@ -42,4 +66,5 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
op.drop_table('crypto_sync_state') op.drop_table('crypto_sync_state')
op.drop_table('crypto_transactions')
op.drop_table('crypto_holdings') op.drop_table('crypto_holdings')

View File

@@ -2,8 +2,22 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoHoldingUpdate, CryptoSearchResult, CryptoSyncResult from app.schemas.crypto import (
from app.services.crypto_service import create_holding, refresh_prices, search_coins, update_holding_quantity 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"]) router = APIRouter(prefix="/crypto", tags=["crypto"])
@@ -31,11 +45,24 @@ async def create_holding_route(
return await create_holding(session, payload) return await create_holding(session, payload)
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead) @router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201)
async def update_holding_route( async def add_transaction_route(
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session) asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead: ) -> 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]) @router.get("/search", response_model=list[CryptoSearchResult])

View File

@@ -2,7 +2,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category 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.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
@@ -18,6 +18,7 @@ __all__ = [
"Category", "Category",
"CryptoHolding", "CryptoHolding",
"CryptoSyncState", "CryptoSyncState",
"CryptoTransaction",
"Goal", "Goal",
"GoalContribution", "GoalContribution",
"RecurringTransaction", "RecurringTransaction",

View File

@@ -1,17 +1,24 @@
"""Crypto holdings: a thin layer on top of the existing Asset/AssetValuation """Crypto holdings: a thin layer on top of the existing Asset/AssetValuation
engine (see models/asset.py) rather than a parallel system. A CryptoHolding engine (see models/asset.py) rather than a parallel system. Net worth's
only records identity (which coin) and quantity (how much) — the actual number for a holding still lives as ordinary AssetValuation rows, kept
value lives as ordinary AssetValuation rows, kept current by current by services/crypto_service.py — Net Worth requires zero changes to
services/crypto_service.py, so Net Worth requires zero changes to treat a treat a crypto holding like any other manually tracked asset.
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 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base from app.db.base import Base
from app.models.asset import Asset from app.models.asset import Asset
from app.models.enums import CryptoTransactionType
from app.models.mixins import TimestampMixin 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 this table's own primary key rather than a separate id+unique
constraint, so the relationship can never drift into a 1:many by constraint, so the relationship can never drift into a 1:many by
accident. Deleting the Asset (the existing DELETE /assets/{id}) cascades 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" __tablename__ = "crypto_holdings"
@@ -36,12 +44,43 @@ class CryptoHolding(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(150), nullable=False) name: Mapped[str] = mapped_column(String(150), nullable=False)
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True) thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Bitcoin has 8 decimal places; several tokens go to 18 (wei-level # Cached from the last successful sync (services/crypto_service.py) —
# amounts). Deliberately NOT Numeric(14, 2) like every money field in # in the app's display currency (see AppSettings.currency). Null until
# this app — this is a coin count, not a currency amount. # the first sync completes.
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False) 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() 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): class CryptoSyncState(Base):

View File

@@ -51,6 +51,11 @@ class RecurringFrequency(str, enum.Enum):
YEARLY = "yearly" YEARLY = "yearly"
class CryptoTransactionType(str, enum.Enum):
BUY = "buy"
SELL = "sell"
class RiskLevel(str, enum.Enum): class RiskLevel(str, enum.Enum):
"""Risk of loss, not asset class — set by the user, not inferred: real """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 estate can be a paid-off primary home (LOW) or a leveraged rental

View File

@@ -10,6 +10,7 @@ from app.models.enums import (
AssetClass, AssetClass,
CapitalRole, CapitalRole,
CategoryKind, CategoryKind,
CryptoTransactionType,
RecurringFrequency, RecurringFrequency,
RiskLevel, RiskLevel,
TransactionType, TransactionType,
@@ -99,13 +100,30 @@ class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# asset_id doubles as this row's own primary key (see # 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 asset_id: int
coingecko_id: str coingecko_id: str
symbol: str symbol: str
name: str name: str
thumb_url: str | None 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 quantity: Decimal
price_per_unit: Decimal
date: date_
note: str | None
class BudgetBackup(BaseModel): class BudgetBackup(BaseModel):
@@ -189,6 +207,7 @@ class BackupPayload(BaseModel):
# Defaulted so a backup exported before crypto holdings existed still # Defaulted so a backup exported before crypto holdings existed still
# imports cleanly under the same format version. # imports cleanly under the same format version.
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list) 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 # Defaulted so a backup exported before budgets existed still imports
# cleanly under the same format version. # cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list) budgets: list[BudgetBackup] = Field(default_factory=list)

View File

@@ -1,39 +1,74 @@
from datetime import date as date_
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field 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): class CryptoHoldingCreate(BaseModel):
coingecko_id: str = Field(min_length=1, max_length=100) coingecko_id: str = Field(min_length=1, max_length=100)
symbol: str = Field(min_length=1, max_length=20) symbol: str = Field(min_length=1, max_length=20)
name: str = Field(min_length=1, max_length=150) name: str = Field(min_length=1, max_length=150)
thumb_url: str | None = Field(default=None, max_length=500) thumb_url: str | None = Field(default=None, max_length=500)
# 18 decimal places covers wei-level token amounts; still gt=0 since a # A holding always starts with a buy — there's nothing to "start
# zero/negative holding isn't a holding. # tracking" with a sell.
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingUpdate(BaseModel):
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18) 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): class CryptoHoldingRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
asset_id: int asset_id: int
coingecko_id: str coingecko_id: str
symbol: str symbol: str
name: str name: str
thumb_url: str | None 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 quantity: Decimal
# Both derived from the linked Asset's latest AssetValuation — null avg_buy_price: Decimal | None
# value/unit_price means "added but never priced yet" (CoinGecko was
# unreachable at creation time), not zero. # 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 value: Decimal | None
unit_price: Decimal | None cost_basis: Decimal | None
as_of_date: str | None profit_loss: Decimal | None
profit_loss_percent: float | None
class CryptoSyncResult(BaseModel): class CryptoSyncResult(BaseModel):

View File

@@ -21,7 +21,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget from app.models.budget import Budget
from app.models.category import Category 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.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings from app.models.settings import AppSettings
@@ -36,6 +36,7 @@ from app.schemas.backup import (
BudgetBackup, BudgetBackup,
CategoryBackup, CategoryBackup,
CryptoHoldingBackup, CryptoHoldingBackup,
CryptoTransactionBackup,
GoalBackup, GoalBackup,
GoalContributionBackup, GoalContributionBackup,
RecurringTransactionBackup, RecurringTransactionBackup,
@@ -54,6 +55,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
assets = (await session.execute(select(Asset))).scalars().all() assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all() valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).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() budgets = (await session.execute(select(Budget))).scalars().all()
goals = (await session.execute(select(Goal))).scalars().all() goals = (await session.execute(select(Goal))).scalars().all()
goal_contributions = (await session.execute(select(GoalContribution))).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], assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations], asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings], 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], budgets=[BudgetBackup.model_validate(row) for row in budgets],
goals=[GoalBackup.model_validate(row) for row in goals], goals=[GoalBackup.model_validate(row) for row in goals],
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions], 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: if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}") 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: for h in payload.crypto_holdings:
if h.asset_id not in asset_ids: if h.asset_id not in asset_ids:
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}") 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: for b in payload.budgets:
if b.category_id not in category_ids: if b.category_id not in category_ids:
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}") 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: try:
# Children before parents. # Children before parents.
await session.execute(delete(AssetValuation)) await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoTransaction))
await session.execute(delete(CryptoHolding)) await session.execute(delete(CryptoHolding))
await session.execute(delete(Budget)) await session.execute(delete(Budget))
await session.execute(delete(GoalContribution)) 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(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(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(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals) 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) 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, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions) await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations) 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, "budgets", payload.budgets)
await _reset_sequence(session, "goals", payload.goals) await _reset_sequence(session, "goals", payload.goals)
await _reset_sequence(session, "goal_contributions", payload.goal_contributions) await _reset_sequence(session, "goal_contributions", payload.goal_contributions)

View File

@@ -1,23 +1,28 @@
"""Crypto price sync: CoinGecko Demo API calls, written into the *existing* """Crypto price sync + position accounting: CoinGecko Demo API calls,
Asset/AssetValuation engine (see models/asset.py) rather than a parallel written into the *existing* Asset/AssetValuation engine (see
value-tracking system. A CryptoHolding only ever records identity (which models/asset.py) rather than a parallel value-tracking system. A holding's
coin) and quantity (how much) — the actual value is an ordinary value is an ordinary AssetValuation row, so Net Worth, reports, and
AssetValuation row, so Net Worth, reports, and everything else that already everything else that already walks Asset rows needs zero changes to treat a
walks Asset rows needs zero changes to treat a crypto holding like any crypto holding like any other manually tracked asset.
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 Refreshes are deliberately never automatic in the background — same
reasoning as recurring transactions (see services/recurring_service.py): reasoning as recurring transactions (see services/recurring_service.py):
there's no scheduler/worker in this stack, and a rate-limited free API key there's no scheduler/worker in this stack, and a rate-limited free API key
makes "poll constantly" actively counterproductive anyway. Two triggers makes "poll constantly" actively counterproductive anyway. Three triggers
only: the "Refresh prices" button (force=True) and a lazy once-a-day check only: the "Refresh prices" button (force=True), a lazy once-a-day check
that piggybacks on GET /crypto/holdings (force=False) — see that piggybacks on GET /crypto/holdings (force=False), and adding a brand
routes/crypto.py. 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 date as date_
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal, NamedTuple
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
@@ -28,9 +33,15 @@ from sqlalchemy.orm import selectinload
from app.core.config import get_settings from app.core.config import get_settings
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, RiskLevel from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, CryptoSyncResult from app.schemas.crypto import (
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
)
from app.services.settings_service import get_or_create_app_settings from app.services.settings_service import get_or_create_app_settings
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3" 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). # reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24) AUTO_REFRESH_INTERVAL = timedelta(hours=24)
_EAGER = (selectinload(CryptoHolding.asset).selectinload(Asset.valuations),) _EAGER = (selectinload(CryptoHolding.transactions),)
def _require_api_key() -> str: def _require_api_key() -> str:
@@ -56,27 +67,51 @@ def _require_api_key() -> str:
return api_key return api_key
async def _fetch_prices_batch(coingecko_ids: list[str], vs_currency: str) -> dict[str, Decimal]: class _MarketPoint(NamedTuple):
"""One CoinGecko call for every tracked coin at once (up to 515 ids per price: Decimal
call) — never one call per coin, that's what would actually burn change_1h: Decimal | None
through a free-tier rate limit.""" 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: if not coingecko_ids:
return {} return {}
api_key = _require_api_key() api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get( response = await client.get(
f"{COINGECKO_BASE_URL}/simple/price", f"{COINGECKO_BASE_URL}/coins/markets",
params={"ids": ",".join(coingecko_ids), "vs_currencies": vs_currency}, params={
"vs_currency": vs_currency,
"ids": ",".join(coingecko_ids),
"price_change_percentage": "1h,24h,7d",
},
headers={"x-cg-demo-api-key": api_key}, headers={"x-cg-demo-api-key": api_key},
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
prices: dict[str, Decimal] = {}
for coin_id, values in data.items(): points: dict[str, _MarketPoint] = {}
price = values.get(vs_currency) for coin in data:
if price is not None: price = _decimal_or_none(coin.get("current_price"))
prices[coin_id] = Decimal(str(price)) if price is None:
return prices 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]: 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: def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
latest = holding.asset.valuations[-1] if holding.asset.valuations else None quantity, avg_buy_price = _compute_position(holding.transactions)
value = latest.value if latest else None current_price = holding.last_price
unit_price = (value / holding.quantity) if value is not None and holding.quantity else None 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( return CryptoHoldingRead(
asset_id=holding.asset_id, asset_id=holding.asset_id,
coingecko_id=holding.coingecko_id, coingecko_id=holding.coingecko_id,
symbol=holding.symbol, symbol=holding.symbol,
name=holding.name, name=holding.name,
thumb_url=holding.thumb_url, 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, value=value,
unit_price=unit_price, cost_basis=cost_basis,
as_of_date=latest.as_of_date.isoformat() if latest else None, profit_loss=profit_loss,
profit_loss_percent=profit_loss_percent,
) )
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]: async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
# populate_existing=True: _upsert_valuation() below writes AssetValuation result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
# 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)
)
return list(result.scalars().all()) 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: async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
state = await session.get(CryptoSyncState, 1) state = await session.get(CryptoSyncState, 1)
if state is None: 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: 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 """Same upsert-by-date pattern as routes/assets.py's POST
/assets/{id}/valuations — re-syncing the same day updates that day's /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 = ( upsert_stmt = (
pg_insert(AssetValuation) pg_insert(AssetValuation)
.values(asset_id=asset_id, value=value, as_of_date=as_of_date) .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: if holdings:
settings = await get_or_create_app_settings(session) settings = await get_or_create_app_settings(session)
try: 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: except httpx.HTTPError:
# CoinGecko down/rate-limited — don't touch last_synced_at (the # CoinGecko down/rate-limited — don't touch last_synced_at (the
# next open of the tab retries) and don't touch any existing # next open of the tab retries) and don't touch any existing
# AssetValuation. The tab still shows the last known values # price/AssetValuation. The tab still shows the last known
# instead of an empty/broken page over an external outage. # values instead of an empty/broken page over an external outage.
error_key = "unreachable" error_key = "unreachable"
else: else:
today = date_.today() today = date_.today()
for holding in holdings: for holding in holdings:
price = prices.get(holding.coingecko_id) point = market_data.get(holding.coingecko_id)
if price is None: if point is None:
continue # coin missing from the response — keep its last known value, don't zero it out continue # coin missing from the response — keep its last known values, don't zero them out
await _upsert_valuation(session, holding.asset_id, holding.quantity * price, today) 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: if error_key is None:
state.last_synced_at = now state.last_synced_at = now
await session.commit() 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( return CryptoSyncResult(
synced=error_key is None, synced=error_key is None,
last_synced_at=state.last_synced_at, last_synced_at=state.last_synced_at,
@@ -220,18 +297,33 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
symbol=payload.symbol.upper(), symbol=payload.symbol.upper(),
name=payload.name, name=payload.name,
thumb_url=payload.thumb_url, thumb_url=payload.thumb_url,
quantity=payload.quantity,
) )
session.add(holding) session.add(holding)
# Seed today's value immediately — a coin the user just added sitting at holding.transactions.append(
# 0 until tomorrow's auto-sync would be confusing, and this one call is CryptoTransaction(
# clearly justified by an explicit user action. 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: try:
prices = await _fetch_prices_batch([payload.coingecko_id], settings.currency.lower()) market_data = await _fetch_market_data([payload.coingecko_id], settings.currency.lower())
price = prices.get(payload.coingecko_id) point = market_data.get(payload.coingecko_id)
if price is not None: if point is not None:
await _upsert_valuation(session, asset.id, payload.quantity * price, date_.today()) 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 # This counts as a real sync — bump the shared timestamp so the next
# GET /crypto/holdings doesn't immediately re-fetch every holding # GET /crypto/holdings doesn't immediately re-fetch every holding
# again a moment later (see refresh_prices' 24h window). # 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 pass # holding is still created — the next daily/manual sync will price it
await session.commit() await session.commit()
return _to_read(holding)
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset.id))
return _to_read(result.scalar_one())
async def update_holding_quantity(session: AsyncSession, asset_id: int, quantity: Decimal) -> CryptoHoldingRead: async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead:
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id)) """A buy or sell against an existing holding — never calls CoinGecko
holding = result.scalar_one_or_none() (see module docstring): value is recomputed from the last cached
if holding is None: price, same principle as the old quantity-only edit it replaces."""
raise HTTPException(status_code=404, detail="Crypto holding not found") holding = await _get_holding_or_404(session, asset_id)
latest = holding.asset.valuations[-1] if holding.asset.valuations else None if payload.type == CryptoTransactionType.SELL:
if latest is not None and holding.quantity: current_quantity, _ = _compute_position(holding.transactions)
# Recompute from the last known unit price — editing quantity must if payload.quantity > current_quantity:
# NOT call CoinGecko (see module docstring: only the button, adding raise HTTPException(status_code=400, detail="Cannot sell more than you currently hold")
# 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())
holding.quantity = quantity holding.transactions.append(
await session.commit() CryptoTransaction(
type=payload.type,
# populate_existing=True — see list_holdings()'s comment: the AssetValuation quantity=payload.quantity,
# loaded above (before _upsert_valuation's raw Core write) is otherwise price_per_unit=payload.price_per_unit,
# served stale here. date=payload.date,
result = await session.execute( note=payload.note,
select(CryptoHolding) )
.options(*_EAGER)
.where(CryptoHolding.asset_id == asset_id)
.execution_options(populate_existing=True)
) )
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()

View File

@@ -1,7 +1,7 @@
"""Crypto holdings: creation, quantity updates, and the lazy/forced price """Crypto holdings: buy/sell transaction log, weighted-average cost basis,
sync. CoinGecko itself is never called in tests — 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 services/crypto_service.py's _fetch_market_data is monkeypatched with a
canned price feed instead, same way any external dependency would be. canned market-data feed instead, same way any external dependency would be.
""" """
from decimal import Decimal from decimal import Decimal
@@ -12,35 +12,56 @@ from app.services import crypto_service
from tests.helpers import money from tests.helpers import money
async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5") -> dict: def _point(price: str, change_1h: str | None = None, change_24h: str | None = None, change_7d: str | None = None):
resp = await client.post( return crypto_service._MarketPoint(
"/crypto/holdings", price=Decimal(price),
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": quantity}, 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): async def fetch(coingecko_ids, vs_currency):
return {cid: prices[cid] for cid in coingecko_ids if cid in prices} return {cid: prices[cid] for cid in coingecko_ids if cid in prices}
return fetch return fetch
async def test_create_holding_seeds_todays_value_immediately(client: AsyncClient, monkeypatch): async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5", price_per_unit: str = "40000") -> dict:
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")})) 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 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["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): 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")})) monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
await _add_bitcoin(client, "1") await _add_bitcoin(client, "1", "40000")
resp = await client.get("/net-worth/summary") 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") assert money(breakdown["crypto"]["amount"]) == money("50000")
async def test_update_quantity_recomputes_value_without_calling_coingecko(client: AsyncClient, monkeypatch): async def test_second_buy_blends_into_weighted_average_cost(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, "0.5") holding = await _add_bitcoin(client, "1", "40000") # cost basis: 40000
async def fail_if_called(coingecko_ids, vs_currency): 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 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): 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) holding = await _add_bitcoin(client)
resp = await client.delete(f"/assets/{holding['asset_id']}") 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): 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) await _add_bitcoin(client)
calls: list[list[str]] = [] 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) calls.append(coingecko_ids)
return {} 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") 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 assert calls == [] # creation's own sync already satisfied the 24h window
async def test_manual_refresh_button_bypasses_the_24h_window(client: AsyncClient, monkeypatch): async def test_manual_refresh_button_bypasses_the_24h_window_and_updates_changes(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) await _add_bitcoin(client, "0.5", "40000")
calls: list[list[str]] = [] calls: list[list[str]] = []
async def counting_fetch(coingecko_ids, vs_currency): async def counting_fetch(coingecko_ids, vs_currency):
calls.append(coingecko_ids) 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") resp = await client.post("/crypto/refresh")
assert resp.json()["synced"] is True assert resp.json()["synced"] is True
assert calls == [["bitcoin"]] 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): 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")})) monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
await _add_bitcoin(client) await _add_bitcoin(client, "0.5", "40000")
async def broken_fetch(coingecko_ids, vs_currency): async def broken_fetch(coingecko_ids, vs_currency):
raise httpx.ConnectError("simulated outage") 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") 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( resp = await client.post(
"/crypto/holdings", "/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 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 """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, 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 having silently lost which coin it was and its whole buy/sell history
must carry the CryptoHolding row too, not just Asset/AssetValuation.""" (so quantity and avg buy price couldn't be recomputed). Backup must
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")})) carry both CryptoHolding and CryptoTransaction rows."""
holding = await _add_bitcoin(client, "0.5") 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() payload = (await client.get("/backup/export")).json()
assert len(payload["crypto_holdings"]) == 1 assert len(payload["crypto_holdings"]) == 1
backed_up = payload["crypto_holdings"][0] assert len(payload["crypto_transactions"]) == 2
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")
import_resp = await client.post("/backup/import", json=payload) import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text assert import_resp.status_code == 200, import_resp.text
resp = await client.get("/crypto/holdings") restored = (await client.get("/crypto/holdings")).json()["holdings"][0]
restored = resp.json()["holdings"] assert restored["coingecko_id"] == "bitcoin"
assert len(restored) == 1 assert restored["asset_id"] == holding["asset_id"]
assert restored[0]["coingecko_id"] == "bitcoin" assert money(restored["quantity"]) == money("2")
assert restored[0]["asset_id"] == holding["asset_id"] assert money(restored["avg_buy_price"]) == money("50000")
assert money(restored[0]["value"]) == money("25000") # AssetValuation history survives too assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)

View File

@@ -1,5 +1,12 @@
import { api } from "@/api/client"; 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 // Also triggers the lazy once-a-day auto-refresh server-side — see
// services/crypto_service.py. // services/crypto_service.py.
@@ -11,16 +18,26 @@ export function refreshCryptoPrices() {
return api.post<CryptoSyncResult>("/crypto/refresh", {}); return api.post<CryptoSyncResult>("/crypto/refresh", {});
} }
export function createCryptoHolding(input: CryptoHoldingInput) { export function createCryptoHolding(input: CryptoHoldingCreateInput) {
return api.post<CryptoHolding>("/crypto/holdings", input); return api.post<CryptoHolding>("/crypto/holdings", input);
} }
export function updateCryptoHoldingQuantity(assetId: number, quantity: string) { // Buy more of, or sell some of, a coin already being tracked.
return api.patch<CryptoHolding>(`/crypto/holdings/${assetId}`, { quantity }); 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 // 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) { export function deleteCryptoHolding(assetId: number) {
return api.delete<void>(`/assets/${assetId}`); return api.delete<void>(`/assets/${assetId}`);
} }

View File

@@ -26,6 +26,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
const [searchError, setSearchError] = useState<string | null>(null); const [searchError, setSearchError] = useState<string | null>(null);
const [selected, setSelected] = useState<CryptoSearchResult | null>(null); const [selected, setSelected] = useState<CryptoSearchResult | null>(null);
const [quantity, setQuantity] = useState(""); 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); const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -34,6 +36,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
setResults([]); setResults([]);
setSelected(null); setSelected(null);
setQuantity(""); setQuantity("");
setPricePerUnit("");
setDate(new Date().toISOString().slice(0, 10));
setSearchError(null); setSearchError(null);
setSaveError(null); setSaveError(null);
}, [open]); }, [open]);
@@ -74,6 +78,8 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
name: selected.name, name: selected.name,
thumb_url: selected.thumb_url, thumb_url: selected.thumb_url,
quantity, quantity,
price_per_unit: pricePerUnit,
date,
}); });
onClose(); onClose();
} catch { } catch {
@@ -139,17 +145,42 @@ export function CryptoAddModal({ open, onClose }: CryptoAddModalProps) {
</span> </span>
</div> </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> <div>
<Label htmlFor="crypto-quantity">{t("crypto.form.quantityLabel")}</Label> <Label htmlFor="crypto-date">{t("crypto.form.dateLabel")}</Label>
<Input <Input
id="crypto-quantity" id="crypto-date"
type="number" type="date"
step="any"
min="0"
required required
autoFocus value={date}
value={quantity} onChange={(event) => setDate(event.target.value)}
onChange={(event) => setQuantity(event.target.value)}
/> />
</div> </div>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -1,20 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
addCryptoTransaction,
createCryptoHolding, createCryptoHolding,
deleteCryptoHolding, deleteCryptoHolding,
deleteCryptoTransaction,
fetchCryptoHoldings, fetchCryptoHoldings,
fetchCryptoTransactions,
refreshCryptoPrices, refreshCryptoPrices,
searchCryptoCoins,
updateCryptoHoldingQuantity,
} from "@/api/crypto"; } from "@/api/crypto";
import type { CryptoHoldingInput } from "@/types"; import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types";
function useInvalidateCrypto() { function useInvalidateCrypto() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return () => { return () => {
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] }); queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
// A crypto holding is a normal Asset under the hood — Net Worth's own // 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"] }); queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
}; };
} }
@@ -23,6 +25,14 @@ export function useCryptoHoldings() {
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings }); 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() { export function useRefreshCryptoPrices() {
const invalidate = useInvalidateCrypto(); const invalidate = useInvalidateCrypto();
return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate }); return useMutation({ mutationFn: refreshCryptoPrices, onSuccess: invalidate });
@@ -31,16 +41,24 @@ export function useRefreshCryptoPrices() {
export function useCreateCryptoHolding() { export function useCreateCryptoHolding() {
const invalidate = useInvalidateCrypto(); const invalidate = useInvalidateCrypto();
return useMutation({ return useMutation({
mutationFn: (input: CryptoHoldingInput) => createCryptoHolding(input), mutationFn: (input: CryptoHoldingCreateInput) => createCryptoHolding(input),
onSuccess: invalidate, onSuccess: invalidate,
}); });
} }
export function useUpdateCryptoHoldingQuantity() { export function useAddCryptoTransaction() {
const invalidate = useInvalidateCrypto(); const invalidate = useInvalidateCrypto();
return useMutation({ return useMutation({
mutationFn: ({ assetId, quantity }: { assetId: number; quantity: string }) => mutationFn: ({ assetId, input }: { assetId: number; input: CryptoTransactionInput }) =>
updateCryptoHoldingQuantity(assetId, quantity), addCryptoTransaction(assetId, input),
onSuccess: invalidate,
});
}
export function useDeleteCryptoTransaction() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: (transactionId: number) => deleteCryptoTransaction(transactionId),
onSuccess: invalidate, onSuccess: invalidate,
}); });
} }
@@ -52,8 +70,3 @@ export function useDeleteCryptoHolding() {
onSuccess: invalidate, 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 };

View File

@@ -123,9 +123,29 @@ const ru = {
"crypto.search.empty": "Ничего не найдено.", "crypto.search.empty": "Ничего не найдено.",
"crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).", "crypto.search.noApiKey": "Не настроен ключ CoinGecko API — задайте AURUM_COINGECKO_API_KEY в .env (бесплатный ключ на coingecko.com/en/api/pricing).",
"crypto.form.quantityLabel": "Количество", "crypto.form.quantityLabel": "Количество",
"crypto.form.pricePerUnitLabel": "Цена за штуку",
"crypto.form.dateLabel": "Дата",
"crypto.form.noteLabel": "Заметка (необязательно)",
"crypto.form.addTitle": "Добавить {{name}}", "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.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.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции", "roi.calculator.investmentLabel": "Сумма инвестиции",
@@ -551,9 +571,29 @@ const en: Record<keyof typeof ru, string> = {
"crypto.search.empty": "Nothing found.", "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.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.quantityLabel": "Quantity",
"crypto.form.pricePerUnitLabel": "Price per unit",
"crypto.form.dateLabel": "Date",
"crypto.form.noteLabel": "Note (optional)",
"crypto.form.addTitle": "Add {{name}}", "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.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.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount", "roi.calculator.investmentLabel": "Investment amount",

View File

@@ -3,8 +3,9 @@ import { Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal"; import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
import { CryptoEditModal } from "@/components/crypto/CryptoEditModal"; import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
import { CryptoHoldingsList } from "@/components/crypto/CryptoHoldingsList"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
import { formatCurrency, getIntlLocale } from "@/lib/format"; import { formatCurrency, getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
@@ -21,7 +22,8 @@ export function CryptoPage() {
const deleteHolding = useDeleteCryptoHolding(); const deleteHolding = useDeleteCryptoHolding();
const [addOpen, setAddOpen] = useState(false); 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 holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); 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> <p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
</div> </div>
<p className="text-xs text-text-muted"> <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> </p>
</div> </div>
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>} {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> </CardContent>
</Card> </Card>
<CryptoAddModal open={addOpen} onClose={() => setAddOpen(false)} /> <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> </div>
); );
} }

View File

@@ -374,26 +374,59 @@ export interface FinancialAlert {
params: Record<string, number>; params: Record<string, number>;
} }
export type CryptoTransactionType = "buy" | "sell";
export interface CryptoHolding { export interface CryptoHolding {
asset_id: number; asset_id: number;
coingecko_id: string; coingecko_id: string;
symbol: string; symbol: string;
name: string; name: string;
thumb_url: string | null; thumb_url: string | null;
// Both derived from the buy/sell log (see CryptoTransaction) — never
// edited directly.
quantity: string; quantity: string;
// null means "added but never priced yet" (CoinGecko was unreachable at avg_buy_price: string | null;
// creation time) — distinct from a real zero value. // 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; value: string | null;
unit_price: string | null; cost_basis: string | null;
as_of_date: string | null; profit_loss: string | null;
profit_loss_percent: number | null;
} }
export interface CryptoHoldingInput { export interface CryptoHoldingCreateInput {
coingecko_id: string; coingecko_id: string;
symbol: string; symbol: string;
name: string; name: string;
thumb_url?: string | null; thumb_url?: string | null;
// A holding always starts with its first buy.
quantity: string; 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 { export interface CryptoSyncResult {