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

View File

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

View File

@@ -1,17 +1,24 @@
"""Crypto holdings: a thin layer on top of the existing Asset/AssetValuation
engine (see models/asset.py) rather than a parallel system. A CryptoHolding
only records identity (which coin) and quantity (how much) — the actual
value lives as ordinary AssetValuation rows, kept current by
services/crypto_service.py, so Net Worth requires zero changes to treat a
crypto holding like any other manually tracked asset.
engine (see models/asset.py) rather than a parallel system. Net worth's
number for a holding still lives as ordinary AssetValuation rows, kept
current by services/crypto_service.py — Net Worth requires zero changes to
treat a crypto holding like any other manually tracked asset.
Quantity and average buy price are NOT stored — they're computed from the
CryptoTransaction log (same "you record it, we derive it" shape as
Goal/GoalContribution), because a sell doesn't just subtract quantity, it
also has to leave the average cost basis of what's still held unchanged
(see services/crypto_service.py's _compute_position).
"""
from datetime import date as date_
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.asset import Asset
from app.models.enums import CryptoTransactionType
from app.models.mixins import TimestampMixin
@@ -20,7 +27,8 @@ class CryptoHolding(Base, TimestampMixin):
this table's own primary key rather than a separate id+unique
constraint, so the relationship can never drift into a 1:many by
accident. Deleting the Asset (the existing DELETE /assets/{id}) cascades
here too — there's no separate delete endpoint for a holding."""
here too, and cascades again to every CryptoTransaction — there's no
separate "delete the whole holding" endpoint."""
__tablename__ = "crypto_holdings"
@@ -36,12 +44,43 @@ class CryptoHolding(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(150), nullable=False)
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Bitcoin has 8 decimal places; several tokens go to 18 (wei-level
# amounts). Deliberately NOT Numeric(14, 2) like every money field in
# this app — this is a coin count, not a currency amount.
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
# Cached from the last successful sync (services/crypto_service.py) —
# in the app's display currency (see AppSettings.currency). Null until
# the first sync completes.
last_price: Mapped[Numeric | None] = mapped_column(Numeric(38, 18), nullable=True)
price_change_1h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
price_change_24h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
asset: Mapped["Asset"] = relationship()
transactions: Mapped[list["CryptoTransaction"]] = relationship(
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
)
class CryptoTransaction(Base, TimestampMixin):
"""One buy or sell against a holding. Quantity held and average buy
price are both derived by replaying this log in date order — see
services/crypto_service.py's _compute_position for why a sell can't
just be "negative quantity" the way it can for a Goal contribution: it
must leave the remaining coins' average cost basis alone."""
__tablename__ = "crypto_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("crypto_holdings.asset_id", ondelete="CASCADE"), nullable=False)
type: Mapped[CryptoTransactionType] = mapped_column(
Enum(CryptoTransactionType, name="crypto_transaction_type", native_enum=False, length=10), nullable=False
)
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
# Price paid (buy) or received (sell) per unit, in the app's display
# currency at the time of the transaction — not re-derived later, since
# what you actually paid doesn't change with today's market price.
price_per_unit: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
date: Mapped[date_] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
holding: Mapped["CryptoHolding"] = relationship(back_populates="transactions")
class CryptoSyncState(Base):

View File

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