feat: crypto portfolio analytics (history chart, allocation, P&L, cost basis)

Adds the analytics half of the CoinMarketCap reference: a value-over-time
chart (GET /crypto/history, forward-filled from AssetValuation like Net
Worth's own series but scoped to crypto assets), an allocation breakdown by
coin, and stat tiles for all-time profit, total invested (cost basis), and
best/worst performer.

Deliberately no "24h" range — history is only as dense as the sync cadence
(button or once/day), so a 24h chart would be one or two points, not a
smooth intraday line. Cost basis / all-time profit / best-worst performer
aren't separate endpoints: they're trivial to derive client-side from
fields already in GET /crypto/holdings, so no redundant backend logic.

Allocation card follows the dataviz skill's categorical rule: top 7 coins
by value get a fixed-order color from the app's already-validated 8-slot
ramp, an 8th+ folds into "Other" rather than generating a new hue.

82/82 backend tests passing (3 new for /crypto/history). Verified live
against the real CoinGecko API — and caught a real holding the user had
already added directly through the UI between messages, which the smoke
test correctly left untouched while cleaning up only its own test data.

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:38:51 +03:00
parent e8cd536584
commit 4ea83a9cda
14 changed files with 593 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.crypto import (
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoSearchResult,
@@ -11,9 +12,11 @@ from app.schemas.crypto import (
CryptoTransactionRead,
)
from app.services.crypto_service import (
CRYPTO_RANGE_DAYS,
add_transaction,
create_holding,
delete_transaction,
get_crypto_history,
list_transactions,
refresh_prices,
search_coins,
@@ -68,3 +71,15 @@ async def delete_transaction_route(transaction_id: int, session: AsyncSession =
@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)
_HISTORY_RANGES = sorted(set(CRYPTO_RANGE_DAYS) | {"all"})
_HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
@router.get("/history", response_model=CryptoHistoryResponse)
async def read_crypto_history(
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
session: AsyncSession = Depends(get_session),
) -> CryptoHistoryResponse:
return await get_crypto_history(session, range)

View File

@@ -93,3 +93,21 @@ class CryptoSearchResult(BaseModel):
symbol: str
name: str
thumb_url: str | None
class CryptoHistoryPoint(BaseModel):
date: date_
value: Decimal
class CryptoHistoryResponse(BaseModel):
"""Total crypto holdings value over time — same "cumulative point
events, forward-filled" shape as NetWorthSummary's own series, but
scoped to crypto-class assets only (see services/crypto_service.py's
get_crypto_history). Powers the History chart on the Crypto tab."""
range: str
current: Decimal
change_amount: Decimal
change_percent: float | None
series: list[CryptoHistoryPoint]

View File

@@ -22,6 +22,7 @@ reuses the last cached price.
from datetime import date as date_
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from itertools import groupby
from typing import Literal, NamedTuple
import httpx
@@ -36,6 +37,8 @@ from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import (
CryptoHistoryPoint,
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoSearchResult,
@@ -51,6 +54,12 @@ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
# Deliberately no "24h" option, unlike Net Worth's own RANGE_DAYS — our price
# history is only ever as dense as our sync cadence (at most a few points a
# day, see AUTO_REFRESH_INTERVAL above), so a 24h chart would show one or
# two points, not the smooth intraday line a denser data source could draw.
CRYPTO_RANGE_DAYS: dict[str, int] = {"7d": 7, "30d": 30, "90d": 90}
_EAGER = (selectinload(CryptoHolding.transactions),)
@@ -390,3 +399,63 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
await session.commit()
async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse:
"""Total crypto holdings value over time, for the History chart on the
Crypto tab. Same "cumulative point events, forward-filled" approach as
net_worth_service.py's own asset series — duplicated here rather than
imported, so this can't destabilize the already-tested Net Worth engine,
and scoped to crypto-class assets only (a deleted holding's
AssetValuation rows are gone via cascade, so its history naturally
drops out here too, same as it already does for Net Worth)."""
today = date_.today()
crypto_asset_ids = set(
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all()
)
if not crypto_asset_ids:
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
valuations = (
await session.execute(
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value)
.where(AssetValuation.asset_id.in_(crypto_asset_ids))
.order_by(AssetValuation.as_of_date)
)
).all()
if not valuations:
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])
current_by_asset: dict[int, Decimal] = {}
events: list[tuple[date_, Decimal]] = []
for day, group in groupby(valuations, key=lambda row: row[1]):
for asset_id, _, value in group:
current_by_asset[asset_id] = value
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
start = today - timedelta(days=CRYPTO_RANGE_DAYS[range_key] - 1) if range_key in CRYPTO_RANGE_DAYS else events[0][0]
points: list[CryptoHistoryPoint] = []
idx, n = 0, len(events)
running = Decimal("0")
day = start
while day <= today:
while idx < n and events[idx][0] <= day:
running = events[idx][1]
idx += 1
points.append(CryptoHistoryPoint(date=day, value=running))
day += timedelta(days=1)
current = points[-1].value if points else Decimal("0")
start_value = points[0].value if points else Decimal("0")
change_amount = current - start_value
change_percent = float(change_amount / start_value * 100) if start_value else None
return CryptoHistoryResponse(
range=range_key,
current=current,
change_amount=change_amount,
change_percent=change_percent,
series=points,
)

View File

@@ -271,3 +271,32 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As
assert money(restored["quantity"]) == money("2")
assert money(restored["avg_buy_price"]) == money("50000")
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
await _add_bitcoin(client, "1", "40000")
resp = await client.get("/crypto/history?range=30d")
assert resp.status_code == 200
body = resp.json()
assert money(body["current"]) == money("50000")
assert len(body["series"]) == 30
assert money(body["series"][-1]["value"]) == money("50000")
assert money(body["series"][0]["value"]) == money("0") # nothing existed 30 days ago
async def test_history_is_empty_series_with_no_holdings(client: AsyncClient):
resp = await client.get("/crypto/history?range=7d")
assert resp.status_code == 200
body = resp.json()
assert body["series"] == []
assert money(body["current"]) == money("0")
async def test_history_rejects_unknown_range(client: AsyncClient):
resp = await client.get("/crypto/history?range=24h")
assert resp.status_code == 422