fix: график крипты открывать за всё время, лучший/худший рост — по выбранному периоду

График во вкладке «Криптовалюта» теперь по умолчанию показывает всю
историю, а не 30 дней — короткое окно у портфеля, растущего годами,
часто попадало на локальную просадку и выглядело демотивирующе.

Плитки «Лучший рост» и «Худшее падение» раньше всегда считались за
24 часа. Теперь они следуют за диапазоном, выбранным у графика
(7д/30д/90д/всё время): для 7д/30д используются закэшированные данные
CoinGecko, для «всё время» — метрика за 1 год (дальше бесплатный
тариф CoinGecko историю не отдаёт), а для 90д — отдельный запрос по
каждой монете, который выполняется только когда пользователь реально
выбрал этот период.
This commit is contained in:
ZProger
2026-09-04 02:18:31 +03:00
parent aefd6cc035
commit cd125e9e45
15 changed files with 404 additions and 33 deletions

View File

@@ -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')

View File

@@ -6,6 +6,7 @@ from app.schemas.crypto import (
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoPerformanceResponse,
CryptoPortfolioCreate, CryptoPortfolioCreate,
CryptoPortfolioRead, CryptoPortfolioRead,
CryptoPortfolioUpdate, CryptoPortfolioUpdate,
@@ -22,6 +23,7 @@ from app.services.crypto_service import (
create_portfolio, create_portfolio,
delete_portfolio, delete_portfolio,
delete_transaction, delete_transaction,
get_90d_performance,
get_crypto_history, get_crypto_history,
list_portfolios, list_portfolios,
list_transactions, list_transactions,
@@ -130,3 +132,13 @@ async def read_crypto_history(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> CryptoHistoryResponse: ) -> CryptoHistoryResponse:
return await get_crypto_history(session, range, portfolio_id) 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)

View File

@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# title, /api/health (which the frontend reads to show it in Settings), and # 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 # embedded in exported backups so an old file can be told apart from a
# current one. # current one.
APP_VERSION = "1.1.2" APP_VERSION = "1.1.3"
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -83,6 +83,13 @@ class CryptoHolding(Base, TimestampMixin):
price_change_1h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True) 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_24h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
price_change_7d: 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() asset: Mapped["Asset"] = relationship()
portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings") portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings")

View File

@@ -136,6 +136,10 @@ class CryptoHoldingBackup(BaseModel):
price_change_1h: Decimal | None = None price_change_1h: Decimal | None = None
price_change_24h: Decimal | None = None price_change_24h: Decimal | None = None
price_change_7d: 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): class CryptoTransactionBackup(BaseModel):

View File

@@ -93,6 +93,11 @@ class CryptoHoldingRead(BaseModel):
price_change_1h: Decimal | None price_change_1h: Decimal | None
price_change_24h: Decimal | None price_change_24h: Decimal | None
price_change_7d: 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, # All derived from the above: value = current_price * quantity,
# cost_basis = avg_buy_price * quantity, profit_loss = value - cost_basis. # cost_basis = avg_buy_price * quantity, profit_loss = value - cost_basis.
@@ -102,6 +107,25 @@ class CryptoHoldingRead(BaseModel):
profit_loss_percent: float | None 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): class CryptoSyncResult(BaseModel):
# False when the lazy daily check ran but the 24h window hadn't # False when the lazy daily check ran but the 24h window hadn't
# elapsed yet (nothing called CoinGecko this time, error_key is None), # elapsed yet (nothing called CoinGecko this time, error_key is None),

View File

@@ -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 Editing quantity via a buy/sell transaction never calls CoinGecko — it
reuses the last cached price. reuses the last cached price.
""" """
import asyncio
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
@@ -41,6 +42,8 @@ from app.schemas.crypto import (
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHoldingCreate, CryptoHoldingCreate,
CryptoHoldingRead, CryptoHoldingRead,
CryptoPerformancePoint,
CryptoPerformanceResponse,
CryptoPortfolioCreate, CryptoPortfolioCreate,
CryptoPortfolioRead, CryptoPortfolioRead,
CryptoPortfolioUpdate, CryptoPortfolioUpdate,
@@ -100,6 +103,12 @@ class _MarketPoint(NamedTuple):
change_1h: Decimal | None change_1h: Decimal | None
change_24h: Decimal | None change_24h: Decimal | None
change_7d: 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: 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]: 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 """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 /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 % 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 % change in the same call, so this fully replaces a separate
/simple/price lookup.""" /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: if not coingecko_ids:
return {} return {}
api_key = _require_api_key() api_key = _require_api_key()
@@ -121,7 +133,7 @@ async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict
params={ params={
"vs_currency": vs_currency, "vs_currency": vs_currency,
"ids": ",".join(coingecko_ids), "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}, 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_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_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_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 return points
@@ -208,6 +222,8 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
price_change_1h=holding.price_change_1h, price_change_1h=holding.price_change_1h,
price_change_24h=holding.price_change_24h, price_change_24h=holding.price_change_24h,
price_change_7d=holding.price_change_7d, price_change_7d=holding.price_change_7d,
price_change_30d=holding.price_change_30d,
price_change_1y=holding.price_change_1y,
value=value, value=value,
cost_basis=cost_basis, cost_basis=cost_basis,
profit_loss=profit_loss, 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_1h = point.change_1h
holding.price_change_24h = point.change_24h holding.price_change_24h = point.change_24h
holding.price_change_7d = point.change_7d 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) quantity, _ = _compute_position(holding.transactions)
if quantity > 0: if quantity > 0:
await _upsert_valuation(session, holding.asset_id, quantity * point.price, today) 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_1h = point.change_1h
holding.price_change_24h = point.change_24h holding.price_change_24h = point.change_24h
holding.price_change_7d = point.change_7d 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()) 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 # This counts as a real sync — bump the shared timestamp so the next
# GET /crypto/holdings doesn't immediately re-fetch every holding # GET /crypto/holdings doesn't immediately re-fetch every holding
@@ -610,3 +630,58 @@ async def get_crypto_history(
change_percent=change_percent, change_percent=change_percent,
series=points, 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))

View File

@@ -407,6 +407,108 @@ async def test_update_transaction_rejects_editing_a_sell_to_exceed_holdings(clie
assert resp.status_code == 400 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): async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"}) resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})

View File

@@ -1,7 +1,7 @@
{ {
"name": "aurum-frontend", "name": "aurum-frontend",
"private": true, "private": true,
"version": "1.1.2", "version": "1.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -3,6 +3,7 @@ import type {
CryptoHistoryResponse, CryptoHistoryResponse,
CryptoHolding, CryptoHolding,
CryptoHoldingCreateInput, CryptoHoldingCreateInput,
CryptoPerformanceResponse,
CryptoPortfolio, CryptoPortfolio,
CryptoPortfolioInput, CryptoPortfolioInput,
CryptoRange, CryptoRange,
@@ -78,3 +79,8 @@ export function fetchCryptoHistory(range: CryptoRange, portfolioId?: number | nu
const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : ""; const query = portfolioId != null ? `&portfolio_id=${portfolioId}` : "";
return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`); return api.get<CryptoHistoryResponse>(`/crypto/history?range=${range}${query}`);
} }
export function fetchCrypto90dPerformance(portfolioId?: number | null) {
const query = portfolioId != null ? `?portfolio_id=${portfolioId}` : "";
return api.get<CryptoPerformanceResponse>(`/crypto/performance/90d${query}`);
}

View File

@@ -2,12 +2,21 @@ import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/Card"; import { Card, CardContent } from "@/components/ui/Card";
import { formatCurrency, maskAmount } from "@/lib/format"; import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types"; import type { CryptoHolding, CryptoRange } from "@/types";
interface CryptoStatsRowProps { interface CryptoStatsRowProps {
holdings: CryptoHolding[]; holdings: CryptoHolding[];
isLoading: boolean; isLoading: boolean;
hidden: 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<number, number | null>;
isPerformance90dLoading: boolean;
} }
interface Totals { interface Totals {
@@ -15,28 +24,49 @@ interface Totals {
totalCostBasis: number; totalCostBasis: number;
totalProfitLoss: number; totalProfitLoss: number;
totalProfitLossPercent: number | null; totalProfitLossPercent: number | null;
best: CryptoHolding | null; best: { holding: CryptoHolding; percent: number } | null;
worst: CryptoHolding | 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<number, number | null> | 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<number, number | null> | undefined
): Totals {
let totalValue = 0; let totalValue = 0;
let totalCostBasis = 0; let totalCostBasis = 0;
let best: CryptoHolding | null = null; let best: Totals["best"] = null;
let worst: CryptoHolding | null = null; let worst: Totals["worst"] = null;
for (const holding of holdings) { for (const holding of holdings) {
if (holding.value !== null) totalValue += Number(holding.value); if (holding.value !== null) totalValue += Number(holding.value);
if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis); if (holding.cost_basis !== null) totalCostBasis += Number(holding.cost_basis);
// Best/Worst Performer compare coins against *each other* by market const percent = changePercentFor(holding, range, performance90d);
// price move (24h % change), not against what the user personally paid if (percent === null) continue;
// — a coin someone bought yesterday at the top can still be "best if (best === null || percent > best.percent) best = { holding, percent };
// performer" here if it's simply up the most of anything they hold if (worst === null || percent < worst.percent) worst = { holding, percent };
// 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 totalProfitLoss = totalValue - totalCostBasis; 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<Exclude<CryptoRange, "all">, 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(); const { t } = useTranslation();
if (holding === null || holding.price_change_24h === null) { if (isLoading) {
return (
<StatTile label={label}>
<p className="text-lg font-semibold text-text-muted">{t("common.loading")}</p>
</StatTile>
);
}
if (entry === null) {
return ( return (
<StatTile label={label}> <StatTile label={label}>
<p className="text-lg font-semibold text-text-muted">—</p> <p className="text-lg font-semibold text-text-muted">—</p>
</StatTile> </StatTile>
); );
} }
const percent = Number(holding.price_change_24h); const { holding, percent } = entry;
const color = percent >= 0 ? "var(--success)" : "var(--danger)"; const color = percent >= 0 ? "var(--success)" : "var(--danger)";
return ( return (
<StatTile label={label}> <StatTile label={label}>
@@ -81,12 +132,19 @@ function PerformerTile({ label, holding }: { label: string; holding: CryptoHoldi
{percent >= 0 ? "+" : ""} {percent >= 0 ? "+" : ""}
{percent.toFixed(2)}% {percent.toFixed(2)}%
</p> </p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.change24h")}</p> <p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.changeLabel", { range: rangeLabel })}</p>
</StatTile> </StatTile>
); );
} }
export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowProps) { export function CryptoStatsRow({
holdings,
isLoading,
hidden,
range,
performance90d,
isPerformance90dLoading,
}: CryptoStatsRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (isLoading) { if (isLoading) {
@@ -94,8 +152,10 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr
} }
if (holdings.length === 0) return null; 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 profitColor = totals.totalProfitLoss >= 0 ? "var(--success)" : "var(--danger)";
const rangeLabel = range === "all" ? t("common.allShort") : RANGE_LABELS[range];
const performerLoading = range === "90d" && isPerformance90dLoading;
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -118,8 +178,18 @@ export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowPr
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.costBasisHint")}</p> <p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.costBasisHint")}</p>
</StatTile> </StatTile>
<PerformerTile label={t("crypto.stats.bestPerformer")} holding={totals.best} /> <PerformerTile
<PerformerTile label={t("crypto.stats.worstPerformer")} holding={totals.worst} /> label={t("crypto.stats.bestPerformer")}
entry={totals.best}
rangeLabel={rangeLabel}
isLoading={performerLoading}
/>
<PerformerTile
label={t("crypto.stats.worstPerformer")}
entry={totals.worst}
rangeLabel={rangeLabel}
isLoading={performerLoading}
/>
</div> </div>
); );
} }

View File

@@ -6,6 +6,7 @@ import {
deleteCryptoHolding, deleteCryptoHolding,
deleteCryptoPortfolio, deleteCryptoPortfolio,
deleteCryptoTransaction, deleteCryptoTransaction,
fetchCrypto90dPerformance,
fetchCryptoHistory, fetchCryptoHistory,
fetchCryptoHoldings, fetchCryptoHoldings,
fetchCryptoPortfolios, 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) { export function useCryptoTransactions(assetId: number | null) {
return useQuery({ return useQuery({
queryKey: ["crypto-transactions", assetId], queryKey: ["crypto-transactions", assetId],

View File

@@ -161,7 +161,7 @@ const ru = {
"crypto.stats.costBasisHint": "сумма покупок по факту", "crypto.stats.costBasisHint": "сумма покупок по факту",
"crypto.stats.bestPerformer": "Лучший рост", "crypto.stats.bestPerformer": "Лучший рост",
"crypto.stats.worstPerformer": "Худшее падение", "crypto.stats.worstPerformer": "Худшее падение",
"crypto.stats.change24h": "изменение цены за 24ч", "crypto.stats.changeLabel": "изменение цены за {{range}}",
"crypto.portfolio.all": "Все портфели", "crypto.portfolio.all": "Все портфели",
"crypto.portfolio.addLabel": "Добавить портфель", "crypto.portfolio.addLabel": "Добавить портфель",
"crypto.portfolio.archiveLabel": "Архивировать портфель", "crypto.portfolio.archiveLabel": "Архивировать портфель",
@@ -655,7 +655,7 @@ const en: Record<keyof typeof ru, string> = {
"crypto.stats.costBasisHint": "actual amount spent buying", "crypto.stats.costBasisHint": "actual amount spent buying",
"crypto.stats.bestPerformer": "Best Performer", "crypto.stats.bestPerformer": "Best Performer",
"crypto.stats.worstPerformer": "Worst 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.all": "All portfolios",
"crypto.portfolio.addLabel": "Add portfolio", "crypto.portfolio.addLabel": "Add portfolio",
"crypto.portfolio.archiveLabel": "Archive portfolio", "crypto.portfolio.archiveLabel": "Archive portfolio",

View File

@@ -11,6 +11,7 @@ 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 { import {
useCrypto90dPerformance,
useCryptoHistory, useCryptoHistory,
useCryptoHoldings, useCryptoHoldings,
useCryptoPortfolios, useCryptoPortfolios,
@@ -27,7 +28,9 @@ function formatSyncedAt(iso: string): string {
export function CryptoPage() { export function CryptoPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [range, setRange] = useState<CryptoRange>("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<CryptoRange>("all");
// Single "hide balance" switch for the whole tab — owned here so every // Single "hide balance" switch for the whole tab — owned here so every
// money-displaying section (chart, stats, allocation, table, trade // money-displaying section (chart, stats, allocation, table, trade
// history) masks together instead of the toggle only affecting whichever // history) masks together instead of the toggle only affecting whichever
@@ -38,6 +41,7 @@ export function CryptoPage() {
const { data: portfolios } = useCryptoPortfolios(); const { data: portfolios } = useCryptoPortfolios();
const { data, isLoading } = useCryptoHoldings(portfolioFilter); const { data, isLoading } = useCryptoHoldings(portfolioFilter);
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter); const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range, portfolioFilter);
const { data: performance90d, isLoading: isPerformance90dLoading } = useCrypto90dPerformance(range, portfolioFilter);
const refresh = useRefreshCryptoPrices(); const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding(); const deleteHolding = useDeleteCryptoHolding();
@@ -55,6 +59,10 @@ export function CryptoPage() {
const holdings = data?.holdings ?? []; const holdings = data?.holdings ?? [];
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0); 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 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() { function openAddPortfolioModal() {
setEditingPortfolio(null); setEditingPortfolio(null);
@@ -110,7 +118,14 @@ export function CryptoPage() {
onToggleHidden={() => setHidden((prev) => !prev)} onToggleHidden={() => setHidden((prev) => !prev)}
/> />
<CryptoStatsRow holdings={holdings} isLoading={isLoading} hidden={hidden} /> <CryptoStatsRow
holdings={holdings}
isLoading={isLoading}
hidden={hidden}
range={range}
performance90d={performance90dByAsset}
isPerformance90dLoading={isPerformance90dLoading}
/>
<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">

View File

@@ -453,6 +453,10 @@ export interface CryptoHolding {
price_change_1h: string | null; price_change_1h: string | null;
price_change_24h: string | null; price_change_24h: string | null;
price_change_7d: 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; value: string | null;
cost_basis: string | null; cost_basis: string | null;
profit_loss: string | null; profit_loss: string | null;
@@ -507,6 +511,18 @@ export interface CryptoSearchResult {
thumb_url: string | null; 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 // 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 // services/crypto_service.py's AUTO_REFRESH_INTERVAL), so a 24h chart would
// be one or two points, not a smooth intraday line. // be one or two points, not a smooth intraday line.