mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
fix: график крипты открывать за всё время, лучший/худший рост — по выбранному периоду
График во вкладке «Криптовалюта» теперь по умолчанию показывает всю историю, а не 30 дней — короткое окно у портфеля, растущего годами, часто попадало на локальную просадку и выглядело демотивирующе. Плитки «Лучший рост» и «Худшее падение» раньше всегда считались за 24 часа. Теперь они следуют за диапазоном, выбранным у графика (7д/30д/90д/всё время): для 7д/30д используются закэшированные данные CoinGecko, для «всё время» — метрика за 1 год (дальше бесплатный тариф CoinGecko историю не отдаёт), а для 90д — отдельный запрос по каждой монете, который выполняется только когда пользователь реально выбрал этот период.
This commit is contained in:
@@ -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')
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user