From cd125e9e45a4db970ccba6c9d9902740c14f8ceb Mon Sep 17 00:00:00 2001
From: ZProger
Date: Fri, 4 Sep 2026 02:18:31 +0300
Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=20?=
=?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D1=8B=20=D0=BE=D1=82=D0=BA=D1=80?=
=?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B7=D0=B0=20=D0=B2=D1=81?=
=?UTF-8?q?=D1=91=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F,=20=D0=BB=D1=83=D1=87?=
=?UTF-8?q?=D1=88=D0=B8=D0=B9/=D1=85=D1=83=D0=B4=D1=88=D0=B8=D0=B9=20?=
=?UTF-8?q?=D1=80=D0=BE=D1=81=D1=82=20=E2=80=94=20=D0=BF=D0=BE=20=D0=B2?=
=?UTF-8?q?=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=D1=83=20=D0=BF?=
=?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
График во вкладке «Криптовалюта» теперь по умолчанию показывает всю
историю, а не 30 дней — короткое окно у портфеля, растущего годами,
часто попадало на локальную просадку и выглядело демотивирующе.
Плитки «Лучший рост» и «Худшее падение» раньше всегда считались за
24 часа. Теперь они следуют за диапазоном, выбранным у графика
(7д/30д/90д/всё время): для 7д/30д используются закэшированные данные
CoinGecko, для «всё время» — метрика за 1 год (дальше бесплатный
тариф CoinGecko историю не отдаёт), а для 90д — отдельный запрос по
каждой монете, который выполняется только когда пользователь реально
выбрал этот период.
---
.../c4e8f61a9d23_add_crypto_30d_1y_change.py | 28 +++++
backend/app/api/routes/crypto.py | 12 ++
backend/app/core/config.py | 2 +-
backend/app/models/crypto.py | 7 ++
backend/app/schemas/backup.py | 4 +
backend/app/schemas/crypto.py | 24 ++++
backend/app/services/crypto_service.py | 83 ++++++++++++-
backend/tests/test_crypto.py | 102 +++++++++++++++
frontend/package.json | 2 +-
frontend/src/api/crypto.ts | 6 +
.../src/components/crypto/CryptoStatsRow.tsx | 116 ++++++++++++++----
frontend/src/hooks/useCrypto.ts | 12 ++
frontend/src/lib/i18n.ts | 4 +-
frontend/src/pages/CryptoPage.tsx | 19 ++-
frontend/src/types/index.ts | 16 +++
15 files changed, 404 insertions(+), 33 deletions(-)
create mode 100644 backend/alembic/versions/c4e8f61a9d23_add_crypto_30d_1y_change.py
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)}
/>
-
+
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 51ec83b..7334717 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -453,6 +453,10 @@ export interface CryptoHolding {
price_change_1h: string | null;
price_change_24h: string | null;
price_change_7d: string | null;
+ price_change_30d: string | null;
+ // Stands in for "all time" on the Best/Worst Performer stat — CoinGecko's
+ // free tier caps historical lookback at 365 days regardless.
+ price_change_1y: string | null;
value: string | null;
cost_basis: string | null;
profit_loss: string | null;
@@ -507,6 +511,18 @@ export interface CryptoSearchResult {
thumb_url: string | null;
}
+// Real 90-day % price change per coin, fetched on demand only while the
+// 90d range is selected — see services/crypto_service.py's
+// get_90d_performance for why this can't ride along with the regular sync.
+export interface CryptoPerformancePoint {
+ asset_id: number;
+ price_change_percent: number | null;
+}
+
+export interface CryptoPerformanceResponse {
+ items: CryptoPerformancePoint[];
+}
+
// 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.