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,
|
||||
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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user