feat(api): аналитика инвестиций, события и карточка инструмента

/analytics/{scopes,summary,value-series,holdings,returns,allocation}, /events с
фильтрами и /instruments/{id}. На запрос ничего не считается — это чтение metric_*,
благодаря чему экраны читаются быстро и показывают одно и то же число.

scope (all | account:<id> | portfolio:<id>) резолвится через ту же функцию, что его
построила, valuation.account_scopes: scope, для которого метрик нет, отдаёт 404, а не
пустой график, который читался бы как пустой портфель.

asset_class уходит наружу строкой, а не енумом. Одно из его значений — index, а
Dart-енум не может назвать член index: он конфликтует с Enum.index, и сгенерированный
клиент перестаёт компилироваться. flutter analyze это пропускает, flutter test ловит.

Фильтр /events?external_flow=false сравнивает meta через is_not_distinct_from, а не
через равенство: у события без meta сравнение даёт NULL, NOT NULL это тоже NULL, и
равенство выбрасывало бы такие события из ОБЕИХ половин фильтра.
This commit is contained in:
Dmitry
2026-09-18 14:20:50 +03:00
parent b58ffb3aac
commit 3727419506
8 changed files with 2964 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
"""The investment analytics endpoints over a small real portfolio."""
from datetime import timedelta
import pytest
from httpx import AsyncClient
from factories import (
make_account,
make_event,
make_instrument,
make_price,
refresh,
)
from fintracker.analytics import today_local
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
"""One broker account: a priced share, an unpriced bond and some leftover cash."""
t = today_local()
bought = t - timedelta(days=40)
account = await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
gazp = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
silent = await make_instrument(
ticker="SIBN6P4", name="Газпром Нефть", asset_class=AssetClass.bond, board="SPBRUBND"
)
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="20000")
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=gazp,
quantity="100",
price="100",
amount="-10000",
)
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=silent,
quantity="5",
price="1000",
amount="-5000",
)
await make_event(
t - timedelta(days=10),
account_id=account,
kind=EventKind.dividend,
instrument_id=gazp,
amount="700",
)
d = bought
while d <= t:
await make_price(d, instrument_id=gazp, close="110")
d += timedelta(days=1)
await refresh()
return {"account": account, "gazp": gazp, "silent": silent}
async def test_scopes_list_all_and_each_account(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/scopes", headers=auth_headers)
assert r.status_code == 200, r.text
scopes = {s["scope"]: s for s in r.json()}
assert scopes["all"]["name"] == "Все счета"
assert scopes[f"account:{portfolio['account']}"]["name"] == "Брокерский"
async def test_summary_reports_totals_and_names_what_is_missing(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/summary", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert body["market_value_rub"] == "11000.0000000000" # the bond has no price at all
assert body["cash_rub"] == "5700.0000000000"
assert body["invested_net_rub"] == "20000.0000000000"
assert body["pnl_total_rub"] is None # incomplete, so not reported as a number
assert body["income_rub"] == "700.0000000000"
assert body["holding_count"] == 2
assert body["unpriced_count"] == 1
assert [p["period"] for p in body["returns"]][:1] == ["1m"]
async def test_holdings_put_the_unpriced_position_last_with_nulls(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/holdings", headers=auth_headers)
assert r.status_code == 200, r.text
rows = r.json()
assert [row["ticker"] for row in rows] == ["GAZP", "SIBN6P4"]
assert rows[0]["value_rub"] == "11000.0000000000"
assert rows[0]["unrealized_pnl_rub"] == "1000.0000000000"
assert rows[1]["price_status"] == "missing"
assert rows[1]["value_rub"] is None
assert rows[1]["weight"] is None
async def test_allocation_covers_the_same_total_in_every_dimension(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/allocation", headers=auth_headers)
assert r.status_code == 200, r.text
per_dimension: dict[str, list[dict]] = {}
for row in r.json():
per_dimension.setdefault(row["dimension"], []).append(row)
totals = {
dimension: sum(float(row["value_rub"]) for row in rows)
for dimension, rows in per_dimension.items()
}
assert set(totals) == {"asset_class", "sector", "country", "currency"}
assert len(set(totals.values())) == 1 # 11000 of shares + 5700 of cash, four ways
by_bucket = {row["bucket"]: row for row in per_dimension["asset_class"]}
assert by_bucket["share"]["holding_count"] == 1
assert by_bucket["cash"]["holding_count"] == 0
async def test_value_series_defaults_to_the_last_year(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/value-series", headers=auth_headers)
assert r.status_code == 200, r.text
rows = r.json()
assert rows[-1]["d"] == today_local().isoformat()
assert rows[-1]["missing_price_count"] == 1
async def test_an_unknown_scope_is_a_problem_not_an_empty_chart(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/analytics/summary?scope=account:999", headers=auth_headers)
assert r.status_code == 404
assert r.headers["content-type"].startswith("application/problem+json")
async def test_the_instrument_card_carries_lots_events_and_prices(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get(f"/api/v1/instruments/{portfolio['gazp']}", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert body["instrument"]["ticker"] == "GAZP"
assert body["holding"]["qty"] == "100.0000000000"
assert len(body["lots"]) == 1
assert {e["kind"] for e in body["events"]} == {"buy", "dividend"}
assert body["prices"][0]["close"] == "110.0000000000"
async def test_events_can_be_filtered_down_to_the_external_flows(
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
):
r = await client.get("/api/v1/events?external_flow=true", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert body["total"] == 1
assert body["items"][0]["kind"] == "deposit"
assert body["items"][0]["external_flow"] is True
r = await client.get("/api/v1/events?external_flow=false", headers=auth_headers)
kinds = {item["kind"] for item in r.json()["items"]}
assert kinds == {"buy", "dividend"}