fix: сортировать активы в «Криптовалюте» по вложенной сумме

Монеты в таблице активов шли в алфавитном порядке (сортировка по
имени в SQL-запросе) — с импортированными портфелями это выглядело
хаотично. Теперь список отсортирован по cost basis (сколько денег
реально вложено в монету) от большего к меньшему; полностью
проданная позиция уходит в конец списка, а не в начало.
This commit is contained in:
ZProger
2026-09-04 02:26:37 +03:00
parent cd125e9e45
commit bd97e316cc
4 changed files with 69 additions and 4 deletions

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.3" APP_VERSION = "1.1.4"
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -231,6 +231,15 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
) )
def _sort_by_invested(reads: list[CryptoHoldingRead]) -> list[CryptoHoldingRead]:
"""Biggest cost basis (money actually put in, not current market value)
first, so the Holdings table reads as "what am I most committed to"
rather than an arbitrary/alphabetical order. A fully-exited position
(cost_basis None — everything's been sold) sorts last, not first: -1 can
never collide with a real cost basis, which is never negative."""
return sorted(reads, key=lambda r: r.cost_basis if r.cost_basis is not None else Decimal("-1"), reverse=True)
def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead: def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead:
return CryptoPortfolioRead.model_validate(portfolio) return CryptoPortfolioRead.model_validate(portfolio)
@@ -351,7 +360,9 @@ async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: in
if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL: if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL:
holdings = await list_holdings(session, portfolio_id) holdings = await list_holdings(session, portfolio_id)
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings]) return CryptoSyncResult(
synced=False, last_synced_at=state.last_synced_at, holdings=_sort_by_invested([_to_read(h) for h in holdings])
)
holdings = await list_holdings(session) holdings = await list_holdings(session)
error_key: Literal["unreachable"] | None = None error_key: Literal["unreachable"] | None = None
@@ -393,7 +404,7 @@ async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: in
synced=error_key is None, synced=error_key is None,
last_synced_at=state.last_synced_at, last_synced_at=state.last_synced_at,
error_key=error_key, error_key=error_key,
holdings=[_to_read(h) for h in visible], holdings=_sort_by_invested([_to_read(h) for h in visible]),
) )

View File

@@ -509,6 +509,60 @@ async def test_90d_performance_filters_by_portfolio(client: AsyncClient, monkeyp
assert [item["asset_id"] for item in items] == [holding_b["asset_id"]] assert [item["asset_id"] for item in items] == [holding_b["asset_id"]]
async def test_holdings_are_sorted_by_invested_amount_descending(client: AsyncClient, monkeypatch):
monkeypatch.setattr(
crypto_service,
"_fetch_market_data",
_fake_fetch({"bitcoin": _point("50000"), "ethereum": _point("2000"), "litecoin": _point("60")}),
)
async def add(coingecko_id, symbol, quantity, price_per_unit):
resp = await client.post(
"/crypto/holdings",
json={
"coingecko_id": coingecko_id,
"symbol": symbol,
"name": symbol,
"quantity": quantity,
"price_per_unit": price_per_unit,
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
# Cost basis: LTC 100, BTC 5000, ETH 1000 — expect BTC, ETH, LTC.
await add("litecoin", "ltc", "2", "50")
await add("bitcoin", "btc", "0.1", "50000")
await add("ethereum", "eth", "1", "1000")
resp = await client.get("/crypto/holdings")
symbols = [h["symbol"] for h in resp.json()["holdings"]]
assert symbols == ["BTC", "ETH", "LTC"]
async def test_fully_sold_holding_sorts_last(client: AsyncClient, monkeypatch):
monkeypatch.setattr(
crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000"), "litecoin": _point("60")})
)
sold = await _add_bitcoin(client, "1", "40000")
resp = await client.post(
f"/crypto/holdings/{sold['asset_id']}/transactions",
json={"type": "sell", "quantity": "1", "price_per_unit": "45000", "date": "2026-02-01"},
)
assert resp.status_code == 201, resp.text
resp = await client.post(
"/crypto/holdings",
json={"coingecko_id": "litecoin", "symbol": "ltc", "name": "Litecoin", "quantity": "1", "price_per_unit": "50", "date": "2026-01-01"},
)
assert resp.status_code == 201, resp.text
resp = await client.get("/crypto/holdings")
symbols = [h["symbol"] for h in resp.json()["holdings"]]
assert symbols == ["LTC", "BTC"] # BTC has no cost basis left (fully sold)
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.3", "version": "1.1.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",