diff --git a/backend/alembic/versions/c4e8f61a9d23_add_crypto_30d_1y_change.py b/backend/alembic/versions/c4e8f61a9d23_add_crypto_30d_1y_change.py new file mode 100644 index 0000000..b4c1157 --- /dev/null +++ b/backend/alembic/versions/c4e8f61a9d23_add_crypto_30d_1y_change.py @@ -0,0 +1,28 @@ +"""add crypto price_change_30d/1y + +Revision ID: c4e8f61a9d23 +Revises: b7d4e9f2a610 +Create Date: 2026-09-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c4e8f61a9d23' +down_revision: Union[str, None] = 'b7d4e9f2a610' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('crypto_holdings', sa.Column('price_change_30d', sa.Numeric(precision=10, scale=4), nullable=True)) + op.add_column('crypto_holdings', sa.Column('price_change_1y', sa.Numeric(precision=10, scale=4), nullable=True)) + + +def downgrade() -> None: + op.drop_column('crypto_holdings', 'price_change_1y') + op.drop_column('crypto_holdings', 'price_change_30d') diff --git a/backend/app/api/routes/crypto.py b/backend/app/api/routes/crypto.py index f92c19e..b44ddbb 100644 --- a/backend/app/api/routes/crypto.py +++ b/backend/app/api/routes/crypto.py @@ -6,6 +6,7 @@ from app.schemas.crypto import ( CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoPerformanceResponse, CryptoPortfolioCreate, CryptoPortfolioRead, CryptoPortfolioUpdate, @@ -22,6 +23,7 @@ from app.services.crypto_service import ( create_portfolio, delete_portfolio, delete_transaction, + get_90d_performance, get_crypto_history, list_portfolios, list_transactions, @@ -130,3 +132,13 @@ async def read_crypto_history( session: AsyncSession = Depends(get_session), ) -> CryptoHistoryResponse: return await get_crypto_history(session, range, portfolio_id) + + +@router.get("/performance/90d", response_model=CryptoPerformanceResponse) +async def read_90d_performance( + portfolio_id: int | None = None, session: AsyncSession = Depends(get_session) +) -> CryptoPerformanceResponse: + """Backs the Best/Worst Performer stat only while the 90d range is + selected — see get_90d_performance's docstring for why this is a + separate on-demand call instead of a cached field like 7d/30d/1y.""" + return await get_90d_performance(session, portfolio_id) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0031624..1fc57c2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict # title, /api/health (which the frontend reads to show it in Settings), and # embedded in exported backups so an old file can be told apart from a # current one. -APP_VERSION = "1.1.2" +APP_VERSION = "1.1.3" class Settings(BaseSettings): diff --git a/backend/app/models/crypto.py b/backend/app/models/crypto.py index 4973359..0cdf010 100644 --- a/backend/app/models/crypto.py +++ b/backend/app/models/crypto.py @@ -83,6 +83,13 @@ class CryptoHolding(Base, TimestampMixin): price_change_1h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) price_change_24h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) + # 30d and 1y ride along on the same batched CoinGecko call as the three + # above — no extra API cost. 1y stands in for "all time" on the Crypto + # tab's Best/Worst Performer stat (see routes/crypto.py): CoinGecko's + # free tier caps historical lookback at 365 days regardless, so 1y is + # already the most "all time" this API key can ever give us. + price_change_30d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) + price_change_1y: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) asset: Mapped["Asset"] = relationship() portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings") diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py index ffca788..e5958bb 100644 --- a/backend/app/schemas/backup.py +++ b/backend/app/schemas/backup.py @@ -136,6 +136,10 @@ class CryptoHoldingBackup(BaseModel): price_change_1h: Decimal | None = None price_change_24h: Decimal | None = None price_change_7d: Decimal | None = None + # Defaulted so a backup exported before 30d/1y existed still imports + # cleanly under the same format version. + price_change_30d: Decimal | None = None + price_change_1y: Decimal | None = None class CryptoTransactionBackup(BaseModel): diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index b9cd019..edbcc90 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -93,6 +93,11 @@ class CryptoHoldingRead(BaseModel): price_change_1h: Decimal | None price_change_24h: Decimal | None price_change_7d: Decimal | None + price_change_30d: Decimal | None + # Stands in for "all time" on the Best/Worst Performer stat — CoinGecko's + # free tier caps historical lookback at 365 days regardless (see + # services/crypto_service.py's module docstring). + price_change_1y: Decimal | None # All derived from the above: value = current_price * quantity, # cost_basis = avg_buy_price * quantity, profit_loss = value - cost_basis. @@ -102,6 +107,25 @@ class CryptoHoldingRead(BaseModel): profit_loss_percent: float | None +class CryptoPerformancePoint(BaseModel): + asset_id: int + # None when CoinGecko's 90-day chart couldn't be fetched for this coin + # (rate-limited, unreachable, or the coin simply lacks 90 days of + # history) — the item is still listed so the frontend can render "—" + # for that one coin instead of the whole tile disappearing. + price_change_percent: float | None + + +class CryptoPerformanceResponse(BaseModel): + """Real 90-day % price change per currently-held coin, for the Crypto + tab's Best/Worst Performer stat when the 90d range is selected — the one + window CoinGecko's batched /coins/markets call can't give us (see + services/crypto_service.py's get_90d_performance), so this is fetched + on demand, once, only when a viewer actually picks 90d.""" + + items: list[CryptoPerformancePoint] + + 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), diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index 5be1f44..c2c0168 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -19,6 +19,7 @@ new holding (a one-off seed fetch, justified by the explicit user action). Editing quantity via a buy/sell transaction never calls CoinGecko — it reuses the last cached price. """ +import asyncio from datetime import date as date_ from datetime import datetime, timedelta, timezone from decimal import Decimal @@ -41,6 +42,8 @@ from app.schemas.crypto import ( CryptoHistoryResponse, CryptoHoldingCreate, CryptoHoldingRead, + CryptoPerformancePoint, + CryptoPerformanceResponse, CryptoPortfolioCreate, CryptoPortfolioRead, CryptoPortfolioUpdate, @@ -100,6 +103,12 @@ class _MarketPoint(NamedTuple): change_1h: Decimal | None change_24h: Decimal | None change_7d: Decimal | None + # Defaulted so existing call sites/tests built before these two existed + # don't have to name them. 1y stands in for "all time" on the Best/Worst + # Performer stat (see get_90d_performance's docstring on why 90d alone + # needs a different, on-demand code path). + change_30d: Decimal | None = None + change_1y: Decimal | None = None def _decimal_or_none(value: object) -> Decimal | None: @@ -109,9 +118,12 @@ def _decimal_or_none(value: object) -> Decimal | None: async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict[str, _MarketPoint]: """One CoinGecko call for every tracked coin at once (up to 250 ids per /coins/markets call) — never one call per coin, that's what would - actually burn through a free-tier rate limit. Also returns 1h/24h/7d % - change in the same call, so this fully replaces a separate - /simple/price lookup.""" + actually burn through a free-tier rate limit. Also returns 1h/24h/7d/30d/1y + % change in the same call, so this fully replaces a separate + /simple/price lookup. (90d isn't offered here at all — CoinGecko's own + supported windows skip straight from 30d to 200d; see + get_90d_performance for how the Crypto tab's 90d Best/Worst Performer + view is computed instead.)""" if not coingecko_ids: return {} api_key = _require_api_key() @@ -121,7 +133,7 @@ async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict params={ "vs_currency": vs_currency, "ids": ",".join(coingecko_ids), - "price_change_percentage": "1h,24h,7d", + "price_change_percentage": "1h,24h,7d,30d,1y", }, headers={"x-cg-demo-api-key": api_key}, ) @@ -138,6 +150,8 @@ async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict change_1h=_decimal_or_none(coin.get("price_change_percentage_1h_in_currency")), change_24h=_decimal_or_none(coin.get("price_change_percentage_24h_in_currency")), change_7d=_decimal_or_none(coin.get("price_change_percentage_7d_in_currency")), + change_30d=_decimal_or_none(coin.get("price_change_percentage_30d_in_currency")), + change_1y=_decimal_or_none(coin.get("price_change_percentage_1y_in_currency")), ) return points @@ -208,6 +222,8 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead: price_change_1h=holding.price_change_1h, price_change_24h=holding.price_change_24h, price_change_7d=holding.price_change_7d, + price_change_30d=holding.price_change_30d, + price_change_1y=holding.price_change_1y, value=value, cost_basis=cost_basis, profit_loss=profit_loss, @@ -359,6 +375,8 @@ async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: in holding.price_change_1h = point.change_1h holding.price_change_24h = point.change_24h holding.price_change_7d = point.change_7d + holding.price_change_30d = point.change_30d + holding.price_change_1y = point.change_1y quantity, _ = _compute_position(holding.transactions) if quantity > 0: await _upsert_valuation(session, holding.asset_id, quantity * point.price, today) @@ -436,6 +454,8 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> holding.price_change_1h = point.change_1h holding.price_change_24h = point.change_24h holding.price_change_7d = point.change_7d + holding.price_change_30d = point.change_30d + holding.price_change_1y = point.change_1y await _upsert_valuation(session, asset.id, payload.quantity * point.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 @@ -610,3 +630,58 @@ async def get_crypto_history( change_percent=change_percent, series=points, ) + + +async def _fetch_90d_change(client: httpx.AsyncClient, api_key: str, coingecko_id: str, vs_currency: str) -> float | None: + """A coin's own real price 90 days ago vs now, straight from CoinGecko's + market_chart series — the closest and last point in the returned series, + respectively. Any failure (rate limit, outage, a coin CoinGecko has less + than 90 days of data for) returns None rather than raising: one coin's + hiccup shouldn't blank out every other tile (same "best data on hand" + principle as refresh_prices' own CoinGecko-outage handling).""" + try: + response = await client.get( + f"{COINGECKO_BASE_URL}/coins/{coingecko_id}/market_chart", + params={"vs_currency": vs_currency, "days": 90}, + headers={"x-cg-demo-api-key": api_key}, + ) + response.raise_for_status() + prices = response.json().get("prices", []) + except httpx.HTTPError: + return None + if len(prices) < 2: + return None + first_price, last_price = prices[0][1], prices[-1][1] + if not first_price: + return None + return (last_price - first_price) / first_price * 100 + + +async def get_90d_performance(session: AsyncSession, portfolio_id: int | None) -> CryptoPerformanceResponse: + """Real 90-day % price change per currently-held coin, for the Crypto + tab's Best/Worst Performer stat when the 90d range is picked. Unlike + 1h/24h/7d/30d/1y (all one field on the same batched /coins/markets sync + call, see _fetch_market_data), CoinGecko has no 90-day window on that + endpoint at all — the only way to get it is one market_chart call per + coin, so this is deliberately never part of the regular sync and only + ever runs on an explicit "look at 90d" user action, same justification + as the seed fetch on adding a new holding. Bounded concurrency (5 at + once) keeps a big portfolio from firing dozens of requests in the same + instant.""" + holdings = await list_holdings(session, portfolio_id) + held = [h for h in holdings if _compute_position(h.transactions)[0] > 0] + if not held: + return CryptoPerformanceResponse(items=[]) + + settings = await get_or_create_app_settings(session) + api_key = _require_api_key() + semaphore = asyncio.Semaphore(5) + + async def fetch_one(client: httpx.AsyncClient, holding: CryptoHolding) -> CryptoPerformancePoint: + async with semaphore: + change = await _fetch_90d_change(client, api_key, holding.coingecko_id, settings.currency.lower()) + return CryptoPerformancePoint(asset_id=holding.asset_id, price_change_percent=change) + + async with httpx.AsyncClient(timeout=10.0) as client: + items = await asyncio.gather(*(fetch_one(client, h) for h in held)) + return CryptoPerformanceResponse(items=list(items)) diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index fc5f43d..78c200d 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -407,6 +407,108 @@ async def test_update_transaction_rejects_editing_a_sell_to_exceed_holdings(clie assert resp.status_code == 400 +async def test_refresh_updates_30d_and_1y_change(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + await _add_bitcoin(client, "0.5", "40000") + + async def counting_fetch(coingecko_ids, vs_currency): + return { + "bitcoin": crypto_service._MarketPoint( + price=Decimal("60000"), + change_1h=None, + change_24h=None, + change_7d=None, + change_30d=Decimal("15.5"), + change_1y=Decimal("120.25"), + ) + } + + monkeypatch.setattr(crypto_service, "_fetch_market_data", counting_fetch) + + resp = await client.post("/crypto/refresh") + + holding = resp.json()["holdings"][0] + assert money(holding["price_change_30d"]) == money("15.5") + assert money(holding["price_change_1y"]) == money("120.25") + + +async def test_90d_performance_reflects_a_real_market_chart_fetch(client: AsyncClient, monkeypatch): + """The 90d Best/Worst Performer window can't ride along with the regular + sync (CoinGecko's /coins/markets has no 90d bucket) — it's a separate + on-demand fetch, monkeypatched here at the same seam _fetch_market_data + is patched at elsewhere in this file.""" + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + holding = await _add_bitcoin(client) + + async def fake_90d_change(client_, api_key, coingecko_id, vs_currency): + return 12.5 if coingecko_id == "bitcoin" else None + + monkeypatch.setattr(crypto_service, "_fetch_90d_change", fake_90d_change) + + resp = await client.get("/crypto/performance/90d") + + assert resp.status_code == 200 + assert resp.json()["items"] == [{"asset_id": holding["asset_id"], "price_change_percent": 12.5}] + + +async def test_90d_performance_skips_fully_sold_holdings(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + holding = await _add_bitcoin(client, "1", "40000") + resp = await client.post( + f"/crypto/holdings/{holding['asset_id']}/transactions", + json={"type": "sell", "quantity": "1", "price_per_unit": "45000", "date": "2026-02-01"}, + ) + assert resp.status_code == 201, resp.text + + calls: list[str] = [] + + async def fake_90d_change(client_, api_key, coingecko_id, vs_currency): + calls.append(coingecko_id) + return 5.0 + + monkeypatch.setattr(crypto_service, "_fetch_90d_change", fake_90d_change) + + resp = await client.get("/crypto/performance/90d") + + assert resp.json()["items"] == [] + assert calls == [] # a coin with zero quantity has nothing to fetch a price for + + +async def test_90d_performance_filters_by_portfolio(client: AsyncClient, monkeypatch): + monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")})) + portfolio_a = (await client.post("/crypto/portfolios", json={"name": "A"})).json() + portfolio_b = (await client.post("/crypto/portfolios", json={"name": "B"})).json() + + async def add(portfolio_id): + resp = await client.post( + "/crypto/holdings", + json={ + "portfolio_id": portfolio_id, + "coingecko_id": "bitcoin", + "symbol": "btc", + "name": "Bitcoin", + "quantity": "1", + "price_per_unit": "40000", + "date": "2026-01-01", + }, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + await add(portfolio_a["id"]) + holding_b = await add(portfolio_b["id"]) + + async def fake_90d_change(client_, api_key, coingecko_id, vs_currency): + return 3.0 + + monkeypatch.setattr(crypto_service, "_fetch_90d_change", fake_90d_change) + + resp = await client.get(f"/crypto/performance/90d?portfolio_id={portfolio_b['id']}") + + items = resp.json()["items"] + assert [item["asset_id"] for item in items] == [holding_b["asset_id"]] + + async def test_update_transaction_404_for_unknown_id(client: AsyncClient): resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"}) diff --git a/frontend/package.json b/frontend/package.json index cbd808e..fb67bd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "1.1.2", + "version": "1.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index a93a145..faa9e9b 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -3,6 +3,7 @@ import type { CryptoHistoryResponse, CryptoHolding, CryptoHoldingCreateInput, + CryptoPerformanceResponse, CryptoPortfolio, CryptoPortfolioInput, CryptoRange, @@ -78,3 +79,8 @@ export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | nu const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : ""; return api.get(`/crypto/history?range=${range}${query}`); } + +export function fetchCrypto90dPerformance(portfolioId?: number | null) { + const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : ""; + return api.get(`/crypto/performance/90d${query}`); +} diff --git a/frontend/src/components/crypto/CryptoStatsRow.tsx b/frontend/src/components/crypto/CryptoStatsRow.tsx index 258ce9c..45ae69d 100644 --- a/frontend/src/components/crypto/CryptoStatsRow.tsx +++ b/frontend/src/components/crypto/CryptoStatsRow.tsx @@ -2,12 +2,21 @@ import type { ReactNode } from "react"; import { Card, CardContent } from "@/components/ui/Card"; import { formatCurrency, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; -import type { CryptoHolding } from "@/types"; +import type { CryptoHolding, CryptoRange } from "@/types"; interface CryptoStatsRowProps { holdings: CryptoHolding[]; isLoading: boolean; hidden: boolean; + // Which window Best/Worst Performer measures price move over — kept in + // sync with the History chart's own range selector one level up, so + // switching one switches both (see CryptoPage). + range: CryptoRange; + // Real per-coin 90d % change, fetched on demand only while range==="90d" + // (see services/crypto_service.py's get_90d_performance) — undefined + // while that fetch is still in flight. + performance90d?: Map; + isPerformance90dLoading: boolean; } interface Totals { @@ -15,28 +24,49 @@ interface Totals { totalCostBasis: number; totalProfitLoss: number; totalProfitLossPercent: number | null; - best: CryptoHolding | null; - worst: CryptoHolding | null; + best: { holding: CryptoHolding; percent: number } | null; + worst: { holding: CryptoHolding; percent: number } | null; } -function computeTotals(holdings: CryptoHolding[]): Totals { +// Best/Worst Performer compare coins against *each other* by market price +// move over the selected window, not against what the user personally paid +// — a coin bought yesterday at the top can still be "best performer" here +// if it's simply up the most of anything held today, independent of entry +// price/timing. +function changePercentFor( + holding: CryptoHolding, + range: CryptoRange, + performance90d: Map | undefined +): number | null { + switch (range) { + case "7d": + return holding.price_change_7d !== null ? Number(holding.price_change_7d) : null; + case "30d": + return holding.price_change_30d !== null ? Number(holding.price_change_30d) : null; + case "90d": + return performance90d?.get(holding.asset_id) ?? null; + case "all": + return holding.price_change_1y !== null ? Number(holding.price_change_1y) : null; + } +} + +function computeTotals( + holdings: CryptoHolding[], + range: CryptoRange, + performance90d: Map | undefined +): Totals { let totalValue = 0; let totalCostBasis = 0; - let best: CryptoHolding | null = null; - let worst: CryptoHolding | null = null; + let best: Totals["best"] = null; + let worst: Totals["worst"] = null; for (const holding of holdings) { if (holding.value !== null) totalValue += Number(holding.value); if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis); - // Best/Worst Performer compare coins against *each other* by market - // price move (24h % change), not against what the user personally paid - // — a coin someone bought yesterday at the top can still be "best - // performer" here if it's simply up the most of anything they hold - // today, independent of their own entry price/timing. - if (holding.price_change_24h === null) continue; - const change = Number(holding.price_change_24h); - if (best === null || change > Number(best.price_change_24h)) best = holding; - if (worst === null || change < Number(worst.price_change_24h)) worst = holding; + const percent = changePercentFor(holding, range, performance90d); + if (percent === null) continue; + if (best === null || percent > best.percent) best = { holding, percent }; + if (worst === null || percent < worst.percent) worst = { holding, percent }; } const totalProfitLoss = totalValue - totalCostBasis; @@ -56,16 +86,37 @@ function StatTile({ label, children }: { label: string; children: ReactNode }) { ); } -function PerformerTile({ label, holding }: { label: string; holding: CryptoHolding | null }) { +// Same labels CryptoRangeSelector shows on its own pills — "all" is the +// only one actually translated there too. +const RANGE_LABELS: Record, string> = { "7d": "7D", "30d": "30D", "90d": "90D" }; + +function PerformerTile({ + label, + entry, + rangeLabel, + isLoading, +}: { + label: string; + entry: Totals["best"]; + rangeLabel: string; + isLoading: boolean; +}) { const { t } = useTranslation(); - if (holding === null || holding.price_change_24h === null) { + if (isLoading) { + return ( + +

{t("common.loading")}

+
+ ); + } + if (entry === null) { return (

—

); } - const percent = Number(holding.price_change_24h); + const { holding, percent } = entry; const color = percent >= 0 ? "var(--success)" : "var(--danger)"; return ( @@ -81,12 +132,19 @@ function PerformerTile({ label, holding }: { label: string; holding: CryptoHoldi {percent >= 0 ? "+" : ""} {percent.toFixed(2)}%

-

{t("crypto.stats.change24h")}

+

{t("crypto.stats.changeLabel", { range: rangeLabel })}

); } -export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowProps) { +export function CryptoStatsRow({ + holdings, + isLoading, + hidden, + range, + performance90d, + isPerformance90dLoading, +}: CryptoStatsRowProps) { const { t } = useTranslation(); if (isLoading) { @@ -94,8 +152,10 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr } if (holdings.length === 0) return null; - const totals = computeTotals(holdings); + const totals = computeTotals(holdings, range, performance90d); const profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)"; + const rangeLabel = range === "all" ? t("common.allShort") : RANGE_LABELS[range]; + const performerLoading = range === "90d" && isPerformance90dLoading; return (
@@ -118,8 +178,18 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr

{t("crypto.stats.costBasisHint")}

- - + +
); } diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index e2b1fe3..087dc89 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -6,6 +6,7 @@ import { deleteCryptoHolding, deleteCryptoPortfolio, deleteCryptoTransaction, + fetchCrypto90dPerformance, fetchCryptoHistory, fetchCryptoHoldings, fetchCryptoPortfolios, @@ -74,6 +75,17 @@ export function useCryptoHistory(range: CryptoRange, portfolioId?: number | null }); } +// Only fires while the 90d range is actually selected — a real CoinGecko +// call per held coin (see get_90d_performance's docstring), not something +// to run on every Crypto tab visit the way 7d/30d/1y already do for free. +export function useCrypto90dPerformance(range: CryptoRange, portfolioId?: number | null) { + return useQuery({ + queryKey: ["crypto-performance-90d", portfolioId ?? null], + queryFn: () => fetchCrypto90dPerformance(portfolioId), + enabled: range === "90d", + }); +} + export function useCryptoTransactions(assetId: number | null) { return useQuery({ queryKey: ["crypto-transactions", assetId], diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index c84336e..ab5bb98 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -161,7 +161,7 @@ const ru = { "crypto.stats.costBasisHint": "сумма покупок по факту", "crypto.stats.bestPerformer": "Лучший рост", "crypto.stats.worstPerformer": "Худшее падение", - "crypto.stats.change24h": "изменение цены за 24ч", + "crypto.stats.changeLabel": "изменение цены за {{range}}", "crypto.portfolio.all": "Все портфели", "crypto.portfolio.addLabel": "Добавить портфель", "crypto.portfolio.archiveLabel": "Архивировать портфель", @@ -655,7 +655,7 @@ const en: Record = { "crypto.stats.costBasisHint": "actual amount spent buying", "crypto.stats.bestPerformer": "Best Performer", "crypto.stats.worstPerformer": "Worst Performer", - "crypto.stats.change24h": "24h price change", + "crypto.stats.changeLabel": "price change over {{range}}", "crypto.portfolio.all": "All portfolios", "crypto.portfolio.addLabel": "Add portfolio", "crypto.portfolio.archiveLabel": "Archive portfolio", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index 4d613ec..75cbc9e 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -11,6 +11,7 @@ import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow"; import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal"; import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal"; import { + useCrypto90dPerformance, useCryptoHistory, useCryptoHoldings, useCryptoPortfolios, @@ -27,7 +28,9 @@ function formatSyncedAt(iso: string): string { export function CryptoPage() { const { t } = useTranslation(); - const [range, setRange] = useState("30d"); + // "all" by default — a 30-day window makes a portfolio that's actually + // grown steadily for years look demotivating whenever it's mid-dip. + const [range, setRange] = useState("all"); // Single "hide balance" switch for the whole tab — owned here so every // money-displaying section (chart, stats, allocation, table, trade // history) masks together instead of the toggle only affecting whichever @@ -38,6 +41,7 @@ export function CryptoPage() { const { data: portfolios } = useCryptoPortfolios(); const { data, isLoading } = useCryptoHoldings(portfolioFilter); const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter); + const { data: performance90d, isLoading: isPerformance90dLoading } = useCrypto90dPerformance(range, portfolioFilter); const refresh = useRefreshCryptoPrices(); const deleteHolding = useDeleteCryptoHolding(); @@ -55,6 +59,10 @@ export function CryptoPage() { const holdings = data?.holdings ?? []; const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); const portfoliosById = useMemo(() => new Map((portfolios ?? []).map((p) => [p.id, p])), [portfolios]); + const performance90dByAsset = useMemo( + () => new Map((performance90d?.items ?? []).map((item) => [item.asset_id, item.price_change_percent])), + [performance90d] + ); function openAddPortfolioModal() { setEditingPortfolio(null); @@ -110,7 +118,14 @@ export function CryptoPage() { onToggleHidden={() => setHidden((prev) => !prev)} /> -