mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
fix: сортировать активы в «Криптовалюте» по вложенной сумме
Монеты в таблице активов шли в алфавитном порядке (сортировка по имени в SQL-запросе) — с импортированными портфелями это выглядело хаотично. Теперь список отсортирован по cost basis (сколько денег реально вложено в монету) от большего к меньшему; полностью проданная позиция уходит в конец списка, а не в начало.
This commit is contained in:
@@ -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.3"
|
||||
APP_VERSION = "1.1.4"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
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)
|
||||
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,
|
||||
last_synced_at=state.last_synced_at,
|
||||
error_key=error_key,
|
||||
holdings=[_to_read(h) for h in visible],
|
||||
holdings=_sort_by_invested([_to_read(h) for h in visible]),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
|
||||
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):
|
||||
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user