/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, и
равенство выбрасывало бы такие события из ОБЕИХ половин фильтра.
176 lines
6.4 KiB
Python
176 lines
6.4 KiB
Python
"""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"}
|