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

23
DOCS.md
View File

@@ -536,6 +536,7 @@ of the whole request failing.
| `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. | | `GET` | `/crypto/holdings/{asset_id}/transactions` | Full buy/sell history for one holding, newest first. |
| `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. | | `DELETE` | `/crypto/transactions/{transaction_id}` | Remove one transaction; quantity/avg buy price/value are recomputed from what's left. |
| `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). | | `GET` | `/crypto/search` | `?q=` — search CoinGecko for a coin to add (name/ticker, returns its `coingecko_id` + logo). |
| `GET` | `/crypto/history` | `?range=7d\|30d\|90d\|all` (default `30d`) — total crypto holdings value over time, for the portfolio chart. No `24h` — resolution is only as dense as the sync cadence above. |
**Create body** (`POST /crypto/holdings`): **Create body** (`POST /crypto/holdings`):
@@ -600,6 +601,28 @@ was down right when it was added) — distinct from a real `0`. `avg_buy_price`/
zero (nothing left to have a cost basis). `error_key` is `"unreachable"` when a sync attempt couldn't zero (nothing left to have a cost basis). `error_key` is `"unreachable"` when a sync attempt couldn't
reach CoinGecko (existing values are kept as-is), or `null` otherwise. reach CoinGecko (existing values are kept as-is), or `null` otherwise.
**`GET /crypto/history` response:**
```json
{
"range": "30d",
"current": "9727.68",
"change_amount": "1240.16",
"change_percent": 14.6,
"series": [
{ "date": "2026-08-01", "value": "8487.52" },
{ "date": "2026-08-02", "value": "8487.52" }
]
}
```
One point per calendar day, forward-filled from whatever `AssetValuation` snapshots actually exist
(same technique as `/net-worth/summary`'s own `series`, just scoped to crypto-class assets) — a day
with no sync simply repeats the last known total rather than leaving a gap. Total invested (cost
basis), all-time profit/loss, and best/worst performer aren't separate endpoints — they're trivial to
derive client-side by summing/comparing the `value`/`cost_basis`/`profit_loss_percent` fields already
in every `GET /crypto/holdings` response.
## Dashboard, Cash Flow & Reports ## Dashboard, Cash Flow & Reports
Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session from app.api.deps import get_session
from app.schemas.crypto import ( from app.schemas.crypto import (
CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoSearchResult, CryptoSearchResult,
@@ -11,9 +12,11 @@ from app.schemas.crypto import (
CryptoTransactionRead, CryptoTransactionRead,
) )
from app.services.crypto_service import ( from app.services.crypto_service import (
CRYPTO_RANGE_DAYS,
add_transaction, add_transaction,
create_holding, create_holding,
delete_transaction, delete_transaction,
get_crypto_history,
list_transactions, list_transactions,
refresh_prices, refresh_prices,
search_coins, search_coins,
@@ -68,3 +71,15 @@ async def delete_transaction_route(transaction_id: int, session: AsyncSession =
@router.get("/search", response_model=list[CryptoSearchResult]) @router.get("/search", response_model=list[CryptoSearchResult])
async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]: async def search_coins_route(q: str = Query(min_length=1, max_length=100)) -> list[CryptoSearchResult]:
return await search_coins(q) 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 symbol: str
name: str name: str
thumb_url: str | None 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 date as date_
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from itertools import groupby
from typing import Literal, NamedTuple from typing import Literal, NamedTuple
import httpx import httpx
@@ -36,6 +37,8 @@ from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import ( from app.schemas.crypto import (
CryptoHistoryPoint,
CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoSearchResult, 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). # reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24) 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),) _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 _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
await session.commit() 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["quantity"]) == money("2")
assert money(restored["avg_buy_price"]) == money("50000") assert money(restored["avg_buy_price"]) == money("50000")
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 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

View File

@@ -1,7 +1,9 @@
import { api } from "@/api/client"; import { api } from "@/api/client";
import type { import type {
CryptoHistoryResponse,
CryptoHolding, CryptoHolding,
CryptoHoldingCreateInput, CryptoHoldingCreateInput,
CryptoRange,
CryptoSearchResult, CryptoSearchResult,
CryptoSyncResult, CryptoSyncResult,
CryptoTransaction, CryptoTransaction,
@@ -45,3 +47,7 @@ export function deleteCryptoHolding(assetId: number) {
export function searchCryptoCoins(query: string) { export function searchCryptoCoins(query: string) {
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`); return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
} }
export function fetchCryptoHistory(range: CryptoRange) {
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}`);
}

View File

@@ -0,0 +1,119 @@
import { Bitcoin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoAllocationCardProps {
holdings: CryptoHolding[];
isLoading: boolean;
}
// Fixed categorical order, same 8-slot ramp the rest of the app already
// validated (see net_worth_service.py's _CLASS_META comment) — a coin never
// gets a freshly generated hue. Only the first 7 get their own color; an 8th+
// coin folds into "Other" (--series-other) rather than cycling back through
// the ramp, per the dataviz skill's categorical-color rule.
const SERIES_COLORS = [
"var(--series-1)",
"var(--series-2)",
"var(--series-3)",
"var(--series-4)",
"var(--series-5)",
"var(--series-6)",
"var(--series-7)",
];
const OTHER_COLOR = "var(--series-other)";
const MAX_SLICES = SERIES_COLORS.length;
interface Slice {
key: string;
name: string;
symbol: string | null;
thumbUrl: string | null;
amount: number;
color: string;
}
function buildSlices(holdings: CryptoHolding[]): Slice[] {
const priced = holdings
.filter((h) => h.value !== null && Number(h.value) > 0)
.map((h) => ({ key: String(h.asset_id), name: h.name, symbol: h.symbol, thumbUrl: h.thumb_url, amount: Number(h.value) }))
.sort((a, b) => b.amount - a.amount);
const top = priced.slice(0, MAX_SLICES).map((item, index) => ({ ...item, color: SERIES_COLORS[index] }));
const rest = priced.slice(MAX_SLICES);
if (rest.length === 0) return top;
const otherTotal = rest.reduce((sum, item) => sum + item.amount, 0);
return [...top, { key: "other", name: "Other", symbol: null, thumbUrl: null, amount: otherTotal, color: OTHER_COLOR }];
}
export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCardProps) {
const { t } = useTranslation();
const slices = buildSlices(holdings);
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
return (
<Card>
<CardHeader>
<CardTitle>{t("crypto.allocation.title")}</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : slices.length === 0 ? (
<p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>
) : (
<>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
{slices.map((slice) => (
<div
key={slice.key}
style={{ width: `${(slice.amount / total) * 100}%`, backgroundColor: slice.color }}
className="h-full first:rounded-l-full last:rounded-r-full"
/>
))}
</div>
<ul className="mt-4 divide-y divide-gridline border-t border-gridline">
{slices.map((slice) => {
const percent = (slice.amount / total) * 100;
return (
<li key={slice.key} className="flex items-center gap-3 py-2.5 first:pt-3">
{slice.thumbUrl ? (
<img src={slice.thumbUrl} alt="" className="h-7 w-7 shrink-0 rounded-full" />
) : (
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${slice.color}26` }}
>
<Bitcoin size={14} style={{ color: slice.color }} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
{slice.key === "other" ? t("crypto.allocation.other") : slice.name}
{slice.symbol && <span className="text-text-muted"> · {slice.symbol}</span>}
</span>
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<span className="block h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: slice.color }} />
</span>
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">{percent.toFixed(0)}%</span>
</span>
<span className="w-14 shrink-0 text-right text-xs text-text-muted tabular-nums sm:hidden">
{percent.toFixed(0)}%
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
{formatCurrency(slice.amount)}
</span>
</li>
);
})}
</ul>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,108 @@
import { useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHistoryResponse, CryptoRange } from "@/types";
interface CryptoHistoryChartProps {
history: CryptoHistoryResponse | undefined;
isLoading: boolean;
range: CryptoRange;
onRangeChange: (range: CryptoRange) => void;
}
function formatAxisDate(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(new Date(`${iso}T00:00:00`));
}
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) {
if (!active || !payload?.length) return null;
const point = payload[0].payload;
return (
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
<p className="text-text-muted">{formatAxisDate(point.date)}</p>
<p className="font-medium text-text-primary">{formatCurrency(point.value)}</p>
</div>
);
}
/** The Crypto tab's headline chart — same recipe as NetWorthChart, plus an
* eye toggle to mask the big number (screen-over-someone's-shoulder
* privacy, doesn't hide anything else on the page). */
export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }: CryptoHistoryChartProps) {
const { t } = useTranslation();
const [hidden, setHidden] = useState(false);
const isPositive = history ? Number(history.change_amount) >= 0 : true;
const trendColor = isPositive ? "var(--success)" : "var(--danger)";
const chartData = history?.series.map((point) => ({ date: point.date, value: Number(point.value) })) ?? [];
return (
<Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-start">
<div>
<CardTitle>{t("nav.crypto")}</CardTitle>
<p className="mt-1.5 flex items-center gap-2 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
{isLoading ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)}
<button
type="button"
aria-label={hidden ? t("crypto.chart.show") : t("crypto.chart.hide")}
onClick={() => setHidden((prev) => !prev)}
className="text-text-muted hover:text-text-primary"
>
{hidden ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</p>
{history && !hidden && (
<p className="mt-1 flex items-center gap-1 text-sm font-medium" style={{ color: trendColor }}>
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
{formatSignedCurrency(history.change_amount)}
{history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
<span className="font-normal text-text-muted">{t("netWorth.periodSuffix")}</span>
</p>
)}
</div>
<CryptoRangeSelector value={range} onChange={onRangeChange} />
</CardHeader>
<CardContent>
<div className="h-56 w-full sm:h-64">
{isLoading || chartData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-text-muted">
{isLoading ? t("common.loading") : t("netWorth.noChartData")}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
<defs>
<linearGradient id="cryptoHistoryFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={trendColor} stopOpacity={0.18} />
<stop offset="100%" stopColor={trendColor} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis hide domain={["auto", "auto"]} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
<Area
type="monotone"
dataKey="value"
stroke={trendColor}
strokeWidth={2}
fill="url(#cryptoHistoryFill)"
isAnimationActive={false}
activeDot={{ r: 4, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{chartData.length > 1 && (
<div className="mt-2 flex justify-between text-xs text-text-muted">
<span>{formatAxisDate(chartData[0].date)}</span>
<span>{formatAxisDate(chartData[chartData.length - 1].date)}</span>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,20 @@
import { PillSelector } from "@/components/layout/PillSelector";
import { useTranslation } from "@/lib/i18n";
import type { CryptoRange } from "@/types";
interface CryptoRangeSelectorProps {
value: CryptoRange;
onChange: (value: CryptoRange) => void;
}
export function CryptoRangeSelector({ value, onChange }: CryptoRangeSelectorProps) {
const { t } = useTranslation();
const ranges: Array<{ value: CryptoRange; label: string }> = [
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "90d", label: "90D" },
{ value: "all", label: t("common.allShort") },
];
return <PillSelector options={ranges} value={value} onChange={onChange} />;
}

View File

@@ -0,0 +1,117 @@
import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/Card";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoStatsRowProps {
holdings: CryptoHolding[];
isLoading: boolean;
}
interface Totals {
totalValue: number;
totalCostBasis: number;
totalProfitLoss: number;
totalProfitLossPercent: number | null;
best: CryptoHolding | null;
worst: CryptoHolding | null;
}
function computeTotals(holdings: CryptoHolding[]): Totals {
let totalValue = 0;
let totalCostBasis = 0;
let best: CryptoHolding | null = null;
let worst: CryptoHolding | null = null;
for (const holding of holdings) {
if (holding.value !== null) totalValue += Number(holding.value);
if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis);
if (holding.profit_loss_percent === null) continue;
if (best === null || holding.profit_loss_percent > best.profit_loss_percent!) best = holding;
if (worst === null || holding.profit_loss_percent < worst.profit_loss_percent!) worst = holding;
}
const totalProfitLoss = totalValue - totalCostBasis;
const totalProfitLossPercent = totalCostBasis > 0 ? (totalProfitLoss / totalCostBasis) * 100 : null;
return { totalValue, totalCostBasis, totalProfitLoss, totalProfitLossPercent, best, worst };
}
function StatTile({ label, children }: { label: string; children: ReactNode }) {
return (
<Card>
<CardContent className="p-3.5 sm:p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">{label}</p>
<div className="mt-1.5">{children}</div>
</CardContent>
</Card>
);
}
function PerformerTile({ label, holding }: { label: string; holding: CryptoHolding | null }) {
const { t } = useTranslation();
if (holding === null || holding.profit_loss_percent === null) {
return (
<StatTile label={label}>
<p className="text-lg font-semibold text-text-muted">—</p>
</StatTile>
);
}
const percent = holding.profit_loss_percent;
const color = percent >= 0 ? "var(--success)" : "var(--danger)";
return (
<StatTile label={label}>
<div className="flex items-center gap-2">
{holding.thumb_url ? (
<img src={holding.thumb_url} alt="" className="h-5 w-5 shrink-0 rounded-full" />
) : (
<span className="h-5 w-5 shrink-0 rounded-full bg-surface-2" />
)}
<p className="truncate text-lg font-semibold text-text-primary">{holding.symbol}</p>
</div>
<p className="mt-0.5 text-sm font-medium tabular-nums" style={{ color }}>
{percent >= 0 ? "+" : ""}
{percent.toFixed(2)}%
</p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.since", { name: holding.name })}</p>
</StatTile>
);
}
export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
const { t } = useTranslation();
if (isLoading) {
return <p className="py-4 text-center text-sm text-text-muted">{t("common.loading")}</p>;
}
if (holdings.length === 0) return null;
const totals = computeTotals(holdings);
const profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)";
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label={t("crypto.stats.allTimeProfit")}>
<p className="text-lg font-semibold tabular-nums" style={{ color: profitColor }}>
{totals.totalProfitLoss >= 0 ? "+" : ""}
{formatCurrency(totals.totalProfitLoss)}
</p>
{totals.totalProfitLossPercent !== null && (
<p className="mt-0.5 text-sm font-medium tabular-nums" style={{ color: profitColor }}>
{totals.totalProfitLossPercent >= 0 ? "+" : ""}
{totals.totalProfitLossPercent.toFixed(2)}%
</p>
)}
</StatTile>
<StatTile label={t("crypto.stats.costBasis")}>
<p className="text-lg font-semibold tabular-nums text-text-primary">{formatCurrency(totals.totalCostBasis)}</p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.costBasisHint")}</p>
</StatTile>
<PerformerTile label={t("crypto.stats.bestPerformer")} holding={totals.best} />
<PerformerTile label={t("crypto.stats.worstPerformer")} holding={totals.worst} />
</div>
);
}

View File

@@ -4,17 +4,19 @@ import {
createCryptoHolding, createCryptoHolding,
deleteCryptoHolding, deleteCryptoHolding,
deleteCryptoTransaction, deleteCryptoTransaction,
fetchCryptoHistory,
fetchCryptoHoldings, fetchCryptoHoldings,
fetchCryptoTransactions, fetchCryptoTransactions,
refreshCryptoPrices, refreshCryptoPrices,
} from "@/api/crypto"; } from "@/api/crypto";
import type { CryptoHoldingCreateInput, CryptoTransactionInput } from "@/types"; import type { CryptoHoldingCreateInput, CryptoRange, CryptoTransactionInput } from "@/types";
function useInvalidateCrypto() { function useInvalidateCrypto() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return () => { return () => {
queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] }); queryClient.invalidateQueries({ queryKey: ["crypto-holdings"] });
queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] }); queryClient.invalidateQueries({ queryKey: ["crypto-transactions"] });
queryClient.invalidateQueries({ queryKey: ["crypto-history"] });
// A crypto holding is a normal Asset under the hood — Net Worth's own // A crypto holding is a normal Asset under the hood — Net Worth's own
// numbers change the moment one is added/traded/repriced/removed. // numbers change the moment one is added/traded/repriced/removed.
queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] }); queryClient.invalidateQueries({ queryKey: ["net-worth-summary"] });
@@ -25,6 +27,10 @@ export function useCryptoHoldings() {
return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings }); return useQuery({ queryKey: ["crypto-holdings"], queryFn: fetchCryptoHoldings });
} }
export function useCryptoHistory(range: CryptoRange) {
return useQuery({ queryKey: ["crypto-history", range], queryFn: () => fetchCryptoHistory(range) });
}
export function useCryptoTransactions(assetId: number | null) { export function useCryptoTransactions(assetId: number | null) {
return useQuery({ return useQuery({
queryKey: ["crypto-transactions", assetId], queryKey: ["crypto-transactions", assetId],

View File

@@ -146,6 +146,17 @@ const ru = {
"crypto.history.empty": "Пока нет ни одной сделки.", "crypto.history.empty": "Пока нет ни одной сделки.",
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.", "crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
"crypto.history.viewButton": "Смотреть историю", "crypto.history.viewButton": "Смотреть историю",
"crypto.holdingsTitle": "Активы",
"crypto.chart.show": "Показать сумму",
"crypto.chart.hide": "Скрыть сумму",
"crypto.allocation.title": "Распределение по монетам",
"crypto.allocation.other": "Другое",
"crypto.stats.allTimeProfit": "Прибыль за всё время",
"crypto.stats.costBasis": "Вложено всего",
"crypto.stats.costBasisHint": "сумма покупок по факту",
"crypto.stats.bestPerformer": "Лучший рост",
"crypto.stats.worstPerformer": "Худшее падение",
"crypto.stats.since": "с момента покупки {{name}}",
"roi.calculator.title": "Калькулятор ROI", "roi.calculator.title": "Калькулятор ROI",
"roi.calculator.investmentLabel": "Сумма инвестиции", "roi.calculator.investmentLabel": "Сумма инвестиции",
@@ -594,6 +605,17 @@ const en: Record<keyof typeof ru, string> = {
"crypto.history.empty": "No trades yet.", "crypto.history.empty": "No trades yet.",
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.", "crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
"crypto.history.viewButton": "View history", "crypto.history.viewButton": "View history",
"crypto.holdingsTitle": "Holdings",
"crypto.chart.show": "Show amount",
"crypto.chart.hide": "Hide amount",
"crypto.allocation.title": "Allocation by coin",
"crypto.allocation.other": "Other",
"crypto.stats.allTimeProfit": "All-time profit",
"crypto.stats.costBasis": "Total invested",
"crypto.stats.costBasisHint": "actual amount spent buying",
"crypto.stats.bestPerformer": "Best Performer",
"crypto.stats.worstPerformer": "Worst Performer",
"crypto.stats.since": "since buying {{name}}",
"roi.calculator.title": "ROI Calculator", "roi.calculator.title": "ROI Calculator",
"roi.calculator.investmentLabel": "Investment amount", "roi.calculator.investmentLabel": "Investment amount",

View File

@@ -3,13 +3,16 @@ import { Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal"; import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
import { CryptoAllocationCard } from "@/components/crypto/CryptoAllocationCard";
import { CryptoHistoryChart } from "@/components/crypto/CryptoHistoryChart";
import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable"; import { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal"; import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
import { useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto"; import { useCryptoHistory, useCryptoHoldings, useDeleteCryptoHolding, useRefreshCryptoPrices } from "@/hooks/useCrypto";
import { formatCurrency, getIntlLocale } from "@/lib/format"; import { getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types"; import type { CryptoHolding, CryptoRange } from "@/types";
function formatSyncedAt(iso: string): string { function formatSyncedAt(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
@@ -17,7 +20,9 @@ function formatSyncedAt(iso: string): string {
export function CryptoPage() { export function CryptoPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [range, setRange] = useState<CryptoRange>("30d");
const { data, isLoading } = useCryptoHoldings(); const { data, isLoading } = useCryptoHoldings();
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range);
const refresh = useRefreshCryptoPrices(); const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding(); const deleteHolding = useDeleteCryptoHolding();
@@ -26,7 +31,6 @@ export function CryptoPage() {
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null); const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
const holdings = data?.holdings ?? []; const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
function handleDelete(holding: CryptoHolding) { function handleDelete(holding: CryptoHolding) {
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) { if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
@@ -36,9 +40,22 @@ export function CryptoPage() {
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<CryptoHistoryChart history={history} isLoading={isHistoryLoading} range={range} onRangeChange={setRange} />
<CryptoStatsRow holdings={holdings} isLoading={isLoading} />
<CryptoAllocationCard holdings={holdings} isLoading={isLoading} />
<Card> <Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<CardTitle>{t("nav.crypto")}</CardTitle> <div>
<CardTitle>{t("crypto.holdingsTitle")}</CardTitle>
<p className="mt-0.5 text-xs text-text-muted">
{data?.last_synced_at
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
: t("crypto.neverSynced")}
</p>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}> <Button variant="secondary" onClick={() => refresh.mutate()} disabled={refresh.isPending}>
<RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} /> <RefreshCw size={16} className={refresh.isPending ? "animate-spin" : undefined} />
@@ -55,20 +72,7 @@ export function CryptoPage() {
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p> <p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : ( ) : (
<> <>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2 border-b border-border pb-4">
<div>
<p className="text-xs uppercase tracking-wide text-text-muted">{t("crypto.totalValueLabel")}</p>
<p className="text-2xl font-semibold tabular-nums text-text-primary">{formatCurrency(totalValue)}</p>
</div>
<p className="text-xs text-text-muted">
{data?.last_synced_at
? t("crypto.lastSynced", { time: formatSyncedAt(data.last_synced_at) })
: t("crypto.neverSynced")}
</p>
</div>
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>} {data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
<CryptoHoldingsTable <CryptoHoldingsTable
items={holdings} items={holdings}
onTrade={setTradingHolding} onTrade={setTradingHolding}

View File

@@ -443,6 +443,24 @@ export interface CryptoSearchResult {
thumb_url: string | null; thumb_url: string | null;
} }
// No "24h" — the price history is only as dense as the sync cadence (see
// services/crypto_service.py's AUTO_REFRESH_INTERVAL), so a 24h chart would
// be one or two points, not a smooth intraday line.
export type CryptoRange = "7d" | "30d" | "90d" | "all";
export interface CryptoHistoryPoint {
date: string;
value: string;
}
export interface CryptoHistoryResponse {
range: CryptoRange;
current: string;
change_amount: string;
change_percent: number | null;
series: CryptoHistoryPoint[];
}
export interface AppSettings { export interface AppSettings {
currency: string; currency: string;
negative_cash_flow_threshold_months: number; negative_cash_flow_threshold_months: number;