GET /analytics/cashflow-broker читает metric_cash_flow_broker. POST
/instruments/{id}/prices апсертит price_manual по (instrument_id, d);
pricing/prices.py подмешивает его в ту же серию, что price_daily, с тем же
протягиванием и порогом устаревания.
238 lines
8.7 KiB
Python
238 lines
8.7 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_manual_price_fills_the_gap_and_shows_up_as_manual(
|
|
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
|
|
):
|
|
silent = portfolio["silent"]
|
|
d = today_local().isoformat()
|
|
|
|
r = await client.post(
|
|
f"/api/v1/instruments/{silent}/prices",
|
|
headers=auth_headers,
|
|
json={"d": d, "price": "1050", "currency": "RUB", "note": "ручная оценка"},
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
assert r.json()["price"] == "1050.0000000000"
|
|
|
|
r = await client.get(f"/api/v1/instruments/{silent}", headers=auth_headers)
|
|
prices = r.json()["prices"]
|
|
assert prices[-1] == {
|
|
"d": d,
|
|
"close": "1050.0000000000",
|
|
"currency": "RUB",
|
|
"accrued_interest": None,
|
|
"source": "manual",
|
|
}
|
|
|
|
# a second call for the same day replaces it rather than adding a row
|
|
r = await client.post(
|
|
f"/api/v1/instruments/{silent}/prices",
|
|
headers=auth_headers,
|
|
json={"d": d, "price": "1100", "currency": "RUB"},
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
r = await client.get(f"/api/v1/instruments/{silent}", headers=auth_headers)
|
|
manual_prices = [p for p in r.json()["prices"] if p["source"] == "manual"]
|
|
assert len(manual_prices) == 1
|
|
assert manual_prices[0]["close"] == "1100.0000000000"
|
|
|
|
|
|
async def test_manual_price_on_an_unknown_instrument_is_a_problem(
|
|
client: AsyncClient, auth_headers: dict[str, str]
|
|
):
|
|
r = await client.post(
|
|
"/api/v1/instruments/999999/prices",
|
|
headers=auth_headers,
|
|
json={"d": today_local().isoformat(), "price": "1", "currency": "RUB"},
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_cashflow_broker_reports_the_months_money_moved(
|
|
client: AsyncClient, auth_headers: dict[str, str], portfolio: dict[str, int]
|
|
):
|
|
r = await client.get("/api/v1/analytics/cashflow-broker", headers=auth_headers)
|
|
assert r.status_code == 200, r.text
|
|
rows = r.json()
|
|
assert rows # the fixture's 20000 ₽ deposit lands in exactly one month
|
|
total_deposits = sum(float(row["deposits_rub"]) for row in rows)
|
|
total_withdrawals = sum(float(row["withdrawals_rub"]) for row in rows)
|
|
assert total_deposits == 20000.0
|
|
assert total_withdrawals == 0.0
|
|
assert all(row["net_rub"] == row["deposits_rub"] for row in rows)
|
|
|
|
|
|
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"}
|