"""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"}