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

@@ -14,6 +14,8 @@ from app.models import ( # noqa: F401 — registers metadata
AssetValuation,
Budget,
Category,
CryptoHolding,
CryptoSyncState,
Goal,
GoalContribution,
RecurringTransaction,

View File

@@ -0,0 +1,45 @@
"""add crypto holdings and sync state
Revision ID: 3e3503b61b20
Revises: f6a4d8b2e017
Create Date: 2026-08-30 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3e3503b61b20'
down_revision: Union[str, None] = 'f6a4d8b2e017'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crypto_holdings',
sa.Column('asset_id', sa.Integer(), nullable=False),
sa.Column('coingecko_id', sa.String(length=100), nullable=False),
sa.Column('symbol', sa.String(length=20), nullable=False),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('thumb_url', sa.String(length=500), nullable=True),
sa.Column('quantity', sa.Numeric(precision=38, scale=18), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('asset_id'),
)
op.create_table(
'crypto_sync_state',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
)
def downgrade() -> None:
op.drop_table('crypto_sync_state')
op.drop_table('crypto_holdings')

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoHoldingUpdate, CryptoSearchResult, CryptoSyncResult
from app.services.crypto_service import create_holding, refresh_prices, search_coins, update_holding_quantity
router = APIRouter(prefix="/crypto", tags=["crypto"])
@router.get("/holdings", response_model=CryptoSyncResult)
async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
"""Opening the Crypto tab lands here — this is also where the lazy
once-a-day auto-refresh happens (see services/crypto_service.py):
prices only actually get re-fetched from CoinGecko if 24h have passed
since the last sync, otherwise this just reads the current cache."""
return await refresh_prices(session, force=False)
@router.post("/refresh", response_model=CryptoSyncResult)
async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
"""The "Refresh prices" button — always hits CoinGecko regardless of
the once-a-day window."""
return await refresh_prices(session, force=True)
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
async def create_holding_route(
payload: CryptoHoldingCreate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead:
return await create_holding(session, payload)
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead)
async def update_holding_route(
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead:
return await update_holding_quantity(session, asset_id, payload.quantity)
@router.get("/search", response_model=list[CryptoSearchResult])
async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]:
return await search_coins(q)

View File

@@ -25,6 +25,12 @@ class Settings(BaseSettings):
# Comma-separated list of origins allowed to call the API (frontend dev server, etc.)
cors_origins: str = "*"
# CoinGecko Demo API key (free, no card required — https://www.coingecko.com/en/api/pricing)
# for services/crypto_service.py's price lookups. Empty by default; the
# Crypto tab's endpoints 400 with a clear message until this is set,
# rather than silently hitting CoinGecko's much stingier keyless tier.
coingecko_api_key: str = ""
@property
def database_url(self) -> str:
return (

View File

@@ -11,6 +11,7 @@ from app.api.routes import (
budgets,
cash_flow,
categories,
crypto,
dashboard,
goals,
insights,
@@ -63,6 +64,7 @@ app.include_router(goals.router, prefix="/api")
app.include_router(recurring.router, prefix="/api")
app.include_router(cash_flow.router, prefix="/api")
app.include_router(tags.router, prefix="/api")
app.include_router(crypto.router, prefix="/api")
@app.get("/api/health")

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)

View File

@@ -95,6 +95,19 @@ class AssetValuationBackup(BaseModel):
as_of_date: date_
class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
# asset_id doubles as this row's own primary key (see
# models/crypto.py's CryptoHolding) — there's no separate `id`.
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
quantity: Decimal
class BudgetBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -173,6 +186,9 @@ class BackupPayload(BaseModel):
transactions: list[TransactionBackup]
assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before crypto holdings existed still
# imports cleanly under the same format version.
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
# Defaulted so a backup exported before budgets existed still imports
# cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list)

View File

@@ -0,0 +1,60 @@
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class CryptoHoldingCreate(BaseModel):
coingecko_id: str = Field(min_length=1, max_length=100)
symbol: str = Field(min_length=1, max_length=20)
name: str = Field(min_length=1, max_length=150)
thumb_url: str | None = Field(default=None, max_length=500)
# 18 decimal places covers wei-level token amounts; still gt=0 since a
# zero/negative holding isn't a holding.
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingUpdate(BaseModel):
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
quantity: Decimal
# Both derived from the linked Asset's latest AssetValuation — null
# value/unit_price means "added but never priced yet" (CoinGecko was
# unreachable at creation time), not zero.
value: Decimal | None
unit_price: Decimal | None
as_of_date: str | None
class CryptoSyncResult(BaseModel):
# False when the lazy daily check ran but the 24h window hadn't
# elapsed yet (nothing called CoinGecko this time, error_key is None),
# OR when a refresh was attempted but CoinGecko couldn't be reached
# (error_key is set) — check error_key to tell the two apart. Either
# way `holdings` still carries the best data on hand, never blocked by
# an external outage.
#
# A key, not a message string: the frontend translates it (RU/EN), same
# key+params convention as FinancialAlert/AdviceItem — a raw English
# sentence here would silently break the app's bilingual UI.
synced: bool
last_synced_at: datetime | None
error_key: Literal["unreachable"] | None = None
holdings: list[CryptoHoldingRead]
class CryptoSearchResult(BaseModel):
coingecko_id: str
symbol: str
name: str
thumb_url: str | None

View File

@@ -21,6 +21,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.crypto import CryptoHolding
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
@@ -34,6 +35,7 @@ from app.schemas.backup import (
BackupPayload,
BudgetBackup,
CategoryBackup,
CryptoHoldingBackup,
GoalBackup,
GoalContributionBackup,
RecurringTransactionBackup,
@@ -51,6 +53,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transactions = (await session.execute(select(Transaction).options(selectinload(Transaction.tags)))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
budgets = (await session.execute(select(Budget))).scalars().all()
goals = (await session.execute(select(Goal))).scalars().all()
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
@@ -72,6 +75,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
],
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
budgets=[BudgetBackup.model_validate(row) for row in budgets],
goals=[GoalBackup.model_validate(row) for row in goals],
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
@@ -107,6 +111,10 @@ def _validate_references(payload: BackupPayload) -> None:
if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
for h in payload.crypto_holdings:
if h.asset_id not in asset_ids:
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
for b in payload.budgets:
if b.category_id not in category_ids:
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
@@ -154,6 +162,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
try:
# Children before parents.
await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoHolding))
await session.execute(delete(Budget))
await session.execute(delete(GoalContribution))
await session.execute(delete(Goal))
@@ -192,6 +201,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
session.add_all(transactions_by_id.values())
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(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)

View File

@@ -0,0 +1,275 @@
"""Crypto price sync: CoinGecko Demo API calls, written into the *existing*
Asset/AssetValuation engine (see models/asset.py) rather than a parallel
value-tracking system. A CryptoHolding only ever records identity (which
coin) and quantity (how much) — the actual value is an ordinary
AssetValuation row, so Net Worth, reports, and everything else that already
walks Asset rows needs zero changes to treat a crypto holding like any
other manually tracked asset.
Refreshes are deliberately never automatic in the background — same
reasoning as recurring transactions (see services/recurring_service.py):
there's no scheduler/worker in this stack, and a rate-limited free API key
makes "poll constantly" actively counterproductive anyway. Two triggers
only: the "Refresh prices" button (force=True) and a lazy once-a-day check
that piggybacks on GET /crypto/holdings (force=False) — see
routes/crypto.py.
"""
from datetime import date as date_
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Literal
import httpx
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.config import get_settings
from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState
from app.models.enums import AssetClass, CapitalRole, RiskLevel
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, CryptoSyncResult
from app.services.settings_service import get_or_create_app_settings
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# CoinGecko's own Demo-tier data only refreshes every 60s server-side
# (see docs.coingecko.com/reference/simple-price) — polling more often than
# once a day here buys nothing and only spends rate-limit budget for no
# reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
_EAGER = (selectinload(CryptoHolding.asset).selectinload(Asset.valuations),)
def _require_api_key() -> str:
api_key = get_settings().coingecko_api_key
if not api_key:
raise HTTPException(
status_code=400,
detail=(
"CoinGecko API key not configured — set AURUM_COINGECKO_API_KEY in .env "
"(free Demo key, no card required: coingecko.com/en/api/pricing)."
),
)
return api_key
async def _fetch_prices_batch(coingecko_ids: list[str], vs_currency: str) -> dict[str, Decimal]:
"""One CoinGecko call for every tracked coin at once (up to 515 ids per
call) — never one call per coin, that's what would actually burn
through a free-tier rate limit."""
if not coingecko_ids:
return {}
api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{COINGECKO_BASE_URL}/simple/price",
params={"ids": ",".join(coingecko_ids), "vs_currencies": vs_currency},
headers={"x-cg-demo-api-key": api_key},
)
response.raise_for_status()
data = response.json()
prices: dict[str, Decimal] = {}
for coin_id, values in data.items():
price = values.get(vs_currency)
if price is not None:
prices[coin_id] = Decimal(str(price))
return prices
async def search_coins(query: str) -> list[CryptoSearchResult]:
"""Backs the "add a coin" picker — the whole reason a user can find the
right coingecko_id without knowing it by heart, same as typing a name
into CoinMarketCap's own search box."""
api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{COINGECKO_BASE_URL}/search",
params={"query": query},
headers={"x-cg-demo-api-key": api_key},
)
response.raise_for_status()
data = response.json()
return [
CryptoSearchResult(
coingecko_id=coin["id"],
symbol=str(coin["symbol"]).upper(),
name=coin["name"],
thumb_url=coin.get("thumb"),
)
for coin in data.get("coins", [])[:20]
]
def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
value = latest.value if latest else None
unit_price = (value / holding.quantity) if value is not None and holding.quantity else None
return CryptoHoldingRead(
asset_id=holding.asset_id,
coingecko_id=holding.coingecko_id,
symbol=holding.symbol,
name=holding.name,
thumb_url=holding.thumb_url,
quantity=holding.quantity,
value=value,
unit_price=unit_price,
as_of_date=latest.as_of_date.isoformat() if latest else None,
)
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
# populate_existing=True: _upsert_valuation() below writes AssetValuation
# rows via a raw Core statement, which the ORM's identity map doesn't
# know happened. Without this, a session that already loaded a holding's
# valuations once (list_holdings gets called both before and after a
# refresh's writes) would keep serving the pre-refresh value here.
result = await session.execute(
select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name).execution_options(populate_existing=True)
)
return list(result.scalars().all())
async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
state = await session.get(CryptoSyncState, 1)
if state is None:
state = CryptoSyncState(id=1, last_synced_at=None)
session.add(state)
await session.commit()
await session.refresh(state)
return state
async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal, as_of_date: date_) -> None:
"""Same upsert-by-date pattern as routes/assets.py's POST
/assets/{id}/valuations — re-syncing the same day updates that day's
value instead of erroring."""
upsert_stmt = (
pg_insert(AssetValuation)
.values(asset_id=asset_id, value=value, as_of_date=as_of_date)
.on_conflict_do_update(
index_elements=[AssetValuation.asset_id, AssetValuation.as_of_date],
set_={"value": value},
)
)
await session.execute(upsert_stmt)
async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult:
state = await get_or_create_sync_state(session)
now = datetime.now(timezone.utc)
if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL:
holdings = await list_holdings(session)
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
holdings = await list_holdings(session)
error_key: Literal["unreachable"] | None = None
if holdings:
settings = await get_or_create_app_settings(session)
try:
prices = await _fetch_prices_batch([h.coingecko_id for h in holdings], settings.currency.lower())
except httpx.HTTPError:
# CoinGecko down/rate-limited — don't touch last_synced_at (the
# next open of the tab retries) and don't touch any existing
# AssetValuation. The tab still shows the last known values
# instead of an empty/broken page over an external outage.
error_key = "unreachable"
else:
today = date_.today()
for holding in holdings:
price = prices.get(holding.coingecko_id)
if price is None:
continue # coin missing from the response — keep its last known value, don't zero it out
await _upsert_valuation(session, holding.asset_id, holding.quantity * price, today)
if error_key is None:
state.last_synced_at = now
await session.commit()
holdings = await list_holdings(session) # re-fetch so the response carries whatever was just written
return CryptoSyncResult(
synced=error_key is None,
last_synced_at=state.last_synced_at,
error_key=error_key,
holdings=[_to_read(h) for h in holdings],
)
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
settings = await get_or_create_app_settings(session)
asset = Asset(
name=payload.name,
asset_class=AssetClass.CRYPTO,
currency=settings.currency,
capital_role=CapitalRole.NEUTRAL,
# Defaults to HIGH, not the Asset model's own MEDIUM default — matches
# this app's own risk-level copy, which names crypto as the textbook
# HIGH example (see lib/i18n.ts's netWorth.riskLevelFormHint.high).
risk_level=RiskLevel.HIGH,
)
session.add(asset)
await session.flush()
holding = CryptoHolding(
asset_id=asset.id,
coingecko_id=payload.coingecko_id,
symbol=payload.symbol.upper(),
name=payload.name,
thumb_url=payload.thumb_url,
quantity=payload.quantity,
)
session.add(holding)
# Seed today's value immediately — a coin the user just added sitting at
# 0 until tomorrow's auto-sync would be confusing, and this one call is
# clearly justified by an explicit user action.
try:
prices = await _fetch_prices_batch([payload.coingecko_id], settings.currency.lower())
price = prices.get(payload.coingecko_id)
if price is not None:
await _upsert_valuation(session, asset.id, payload.quantity * price, date_.today())
# This counts as a real sync — bump the shared timestamp so the next
# GET /crypto/holdings doesn't immediately re-fetch every holding
# again a moment later (see refresh_prices' 24h window).
state = await get_or_create_sync_state(session)
state.last_synced_at = datetime.now(timezone.utc)
except httpx.HTTPError:
pass # holding is still created — the next daily/manual sync will price it
await session.commit()
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset.id))
return _to_read(result.scalar_one())
async def update_holding_quantity(session: AsyncSession, asset_id: int, quantity: Decimal) -> CryptoHoldingRead:
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
holding = result.scalar_one_or_none()
if holding is None:
raise HTTPException(status_code=404, detail="Crypto holding not found")
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
if latest is not None and holding.quantity:
# Recompute from the last known unit price — editing quantity must
# NOT call CoinGecko (see module docstring: only the button, adding
# a coin, and the once-a-day check are allowed to).
unit_price = latest.value / holding.quantity
await _upsert_valuation(session, asset_id, quantity * unit_price, date_.today())
holding.quantity = quantity
await session.commit()
# populate_existing=True — see list_holdings()'s comment: the AssetValuation
# loaded above (before _upsert_valuation's raw Core write) is otherwise
# served stale here.
result = await session.execute(
select(CryptoHolding)
.options(*_EAGER)
.where(CryptoHolding.asset_id == asset_id)
.execution_options(populate_existing=True)
)
return _to_read(result.scalar_one())

View File

@@ -6,3 +6,4 @@ alembic==1.14.0
pydantic==2.10.4
pydantic-settings==2.7.0
python-dotenv==1.2.2
httpx==0.28.1

View File

@@ -0,0 +1,169 @@
"""Crypto holdings: creation, quantity updates, and the lazy/forced price
sync. CoinGecko itself is never called in tests —
services/crypto_service.py's _fetch_prices_batch is monkeypatched with a
canned price feed instead, same way any external dependency would be.
"""
from decimal import Decimal
import httpx
from httpx import AsyncClient
from app.services import crypto_service
from tests.helpers import money
async def _add_bitcoin(client: AsyncClient, quantity: str = "0.5") -> dict:
resp = await client.post(
"/crypto/holdings",
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": quantity},
)
assert resp.status_code == 201
return resp.json()
def _fake_fetch(prices: dict[str, Decimal]):
async def fetch(coingecko_ids, vs_currency):
return {cid: prices[cid] for cid in coingecko_ids if cid in prices}
return fetch
async def test_create_holding_seeds_todays_value_immediately(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
assert holding["symbol"] == "BTC" # normalized to uppercase
assert money(holding["value"]) == money("25000") # 0.5 * 50000
assert money(holding["unit_price"]) == money("50000")
async def test_holding_shows_up_in_net_worth_breakdown(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client, "1")
resp = await client.get("/net-worth/summary")
breakdown = {item["key"]: item for item in resp.json()["breakdown"]}
assert money(breakdown["crypto"]["amount"]) == money("50000")
async def test_update_quantity_recomputes_value_without_calling_coingecko(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
async def fail_if_called(coingecko_ids, vs_currency):
raise AssertionError("updating quantity must not call CoinGecko")
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", fail_if_called)
resp = await client.patch(f"/crypto/holdings/{holding['asset_id']}", json={"quantity": "2"})
assert resp.status_code == 200
assert money(resp.json()["value"]) == money("100000") # 2 * 50000 (last known price)
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")}))
holding = await _add_bitcoin(client)
resp = await client.delete(f"/assets/{holding['asset_id']}")
assert resp.status_code == 204
resp = await client.get("/crypto/holdings")
assert resp.json()["holdings"] == []
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")}))
await _add_bitcoin(client)
calls: list[list[str]] = []
async def counting_fetch(coingecko_ids, vs_currency):
calls.append(coingecko_ids)
return {}
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
resp = await client.get("/crypto/holdings")
assert resp.json()["synced"] is False
assert calls == [] # creation's own sync already satisfied the 24h window
async def test_manual_refresh_button_bypasses_the_24h_window(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client)
calls: list[list[str]] = []
async def counting_fetch(coingecko_ids, vs_currency):
calls.append(coingecko_ids)
return {"bitcoin": Decimal("60000")}
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", counting_fetch)
resp = await client.post("/crypto/refresh")
assert resp.json()["synced"] is True
assert calls == [["bitcoin"]]
assert money(resp.json()["holdings"][0]["value"]) == money("30000") # 0.5 * 60000
async def test_coingecko_outage_keeps_last_known_value_instead_of_failing(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
await _add_bitcoin(client)
async def broken_fetch(coingecko_ids, vs_currency):
raise httpx.ConnectError("simulated outage")
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", broken_fetch)
resp = await client.post("/crypto/refresh")
assert resp.status_code == 200
body = resp.json()
assert body["synced"] is False
assert body["error_key"] == "unreachable"
assert money(body["holdings"][0]["value"]) == money("25000") # unchanged from creation
async def test_create_rejects_when_no_api_key_configured(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service.get_settings(), "coingecko_api_key", "")
resp = await client.post(
"/crypto/holdings",
json={"coingecko_id": "bitcoin", "symbol": "btc", "name": "Bitcoin", "quantity": "0.5"},
)
assert resp.status_code == 400
async def test_backup_roundtrip_preserves_the_holding_not_just_the_asset(client: AsyncClient, monkeypatch):
"""A crypto holding is an Asset underneath, so it would still show up
after a restore even without this — but as an inert manual asset,
having silently lost which coin it was and how much was held. Backup
must carry the CryptoHolding row too, not just Asset/AssetValuation."""
monkeypatch.setattr(crypto_service, "_fetch_prices_batch", _fake_fetch({"bitcoin": Decimal("50000")}))
holding = await _add_bitcoin(client, "0.5")
payload = (await client.get("/backup/export")).json()
assert len(payload["crypto_holdings"]) == 1
backed_up = payload["crypto_holdings"][0]
assert backed_up["asset_id"] == holding["asset_id"]
assert backed_up["coingecko_id"] == "bitcoin"
assert backed_up["symbol"] == "BTC"
assert backed_up["name"] == "Bitcoin"
assert backed_up["thumb_url"] is None
assert money(backed_up["quantity"]) == money("0.5")
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
resp = await client.get("/crypto/holdings")
restored = resp.json()["holdings"]
assert len(restored) == 1
assert restored[0]["coingecko_id"] == "bitcoin"
assert restored[0]["asset_id"] == holding["asset_id"]
assert money(restored[0]["value"]) == money("25000") # AssetValuation history survives too