mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user