From bd97e316ccbe24137b0fe0df49ec419eaa9fcc68 Mon Sep 17 00:00:00 2001 From: ZProger Date: Fri, 4 Sep 2026 02:26:37 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=20=D0=B2=20=C2=AB=D0=9A=D1=80=D0=B8=D0=BF=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D1=8E=D1=82=D0=B5=C2=BB=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=83=D0=BC=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Монеты в таблице активов шли в алфавитном порядке (сортировка по имени в SQL-запросе) — с импортированными портфелями это выглядело хаотично. Теперь список отсортирован по cost basis (сколько денег реально вложено в монету) от большего к меньшему; полностью проданная позиция уходит в конец списка, а не в начало. --- backend/app/core/config.py | 2 +- backend/app/services/crypto_service.py | 15 ++++++- backend/tests/test_crypto.py | 54 ++++++++++++++++++++++++++ frontend/package.json | 2 +- 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1fc57c2..fb8f56e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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): diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index c2c0168..ff5fbf0 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -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]), ) diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py index 78c200d..3a45de3 100644 --- a/backend/tests/test_crypto.py +++ b/backend/tests/test_crypto.py @@ -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"}) diff --git a/frontend/package.json b/frontend/package.json index fb67bd2..161e9bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "1.1.3", + "version": "1.1.4", "type": "module", "scripts": { "dev": "vite",