mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
58
backend/app/models/crypto.py
Normal file
58
backend/app/models/crypto.py
Normal 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)
|
||||
Reference in New Issue
Block a user