feat: add a Crypto tab with live CoinGecko prices synced into Net Worth

Track individual coins (identity + quantity) in a dedicated tab, backed by
CoinGecko's Demo API (AURUM_COINGECKO_API_KEY). A holding is just another
Asset under the hood (asset_class=crypto) with its value written as a
regular AssetValuation — the existing Net Worth aggregation needs zero
changes to pick it up.

Prices never refresh in the background (no scheduler in this stack, same
reasoning as recurring transactions): a "Refresh prices" button forces a
sync, and GET /crypto/holdings lazily re-syncs at most once every 24h.
CoinGecko outages leave existing values untouched (error_key: "unreachable")
instead of failing the request. Backup/restore now carries CryptoHolding
rows too, so a restore doesn't silently strip a holding's coin identity.

Backend: 75/75 tests passing (9 new), including a real identity-map
staleness bug the tests caught in the raw-Core-upsert + ORM re-read pattern,
fixed via populate_existing=True.

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 03:58:04 +03:00
parent f74305f0be
commit 7e4508d1de
27 changed files with 1327 additions and 0 deletions

View File

@@ -2,6 +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.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
@@ -15,6 +16,8 @@ __all__ = [
"AssetValuation",
"Budget",
"Category",
"CryptoHolding",
"CryptoSyncState",
"Goal",
"GoalContribution",
"RecurringTransaction",

View File

@@ -0,0 +1,58 @@
"""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.
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.asset import Asset
from app.models.mixins import TimestampMixin
class CryptoHolding(Base, TimestampMixin):
"""1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as
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."""
__tablename__ = "crypto_holdings"
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
# which collides across unrelated coins (CoinGecko lists dozens of
# differently-named coins that all use e.g. "SOL"-like tickers).
coingecko_id: Mapped[str] = mapped_column(String(100), nullable=False)
# Denormalized from CoinGecko at add-time so the holdings list can
# render a ticker/name/logo without an extra lookup per row.
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
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)
asset: Mapped["Asset"] = relationship()
class CryptoSyncState(Base):
"""Singleton row (id=1, same get-or-create pattern as AppSettings) — the
single global timestamp of the last successful CoinGecko price refresh.
One timestamp for every holding, not one per holding: a refresh always
re-prices every tracked coin in a single batched request (see
services/crypto_service.py), so there's only ever one "last synced"
moment to track."""
__tablename__ = "crypto_sync_state"
id: Mapped[int] = mapped_column(primary_key=True)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)