feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4

Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+285
View File
@@ -0,0 +1,285 @@
"""Target weights and rebalancing over HTTP (docs/ai/phase4-contract.md §2).
The router is mounted here rather than taken from `create_app`: wiring it into
`api/app.py` belongs to the phase-4 integration, and these tests should not wait on it.
"""
from collections.abc import AsyncIterator
from datetime import timedelta
from decimal import Decimal
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
EventKind,
Instrument,
Portfolio,
PortfolioAccount,
)
D = Decimal
PREFIX = "/api/v1"
@pytest.fixture
async def client(app) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import rebalance
app.include_router(rebalance.router, prefix=PREFIX)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
"""500 SBER (lot 10) at 100, 20 ОФЗ at 1000, 30 000 ₽ left in 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",
)
sber = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
ofz = await make_instrument(ticker="OFZ", name="ОФЗ", asset_class=AssetClass.bond)
async with get_sessionmaker()() as session:
instrument = await session.get(Instrument, sber)
assert instrument is not None
instrument.lot = 10
portfolio = Portfolio(name="Основной")
session.add(portfolio)
await session.flush()
session.add(PortfolioAccount(portfolio_id=portfolio.id, account_id=account))
portfolio_id = portfolio.id
await session.commit()
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="100000")
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=sber,
quantity="500",
price="100",
amount="-50000",
)
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=ofz,
quantity="20",
price="1000",
amount="-20000",
)
d = bought
while d <= t:
await make_price(d, instrument_id=sber, close="100")
await make_price(d, instrument_id=ofz, close="1000")
d += timedelta(days=1)
await refresh()
return {"portfolio": portfolio_id, "sber": sber, "ofz": ofz}
def targets(*rows: tuple[str, str, str]) -> dict:
return {
"dimension": "asset_class",
"targets": [{"bucket": b, "target_weight": w, "band": band} for b, w, band in rows],
}
async def put(client, auth_headers, portfolio_id: int, body: dict):
return await client.put(
f"{PREFIX}/portfolios/{portfolio_id}/targets", json=body, headers=auth_headers
)
# --------------------------------------------------------------------------- targets
async def test_targets_round_trip_and_report_their_sum(client, auth_headers, portfolio):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["weights_sum"]) == 1
assert [t["bucket"] for t in body["targets"]] == ["bond", "cash", "share"]
assert Decimal(body["targets"][0]["target_weight"]) == Decimal("0.2")
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets", headers=auth_headers
)
assert r.status_code == 200
assert r.json() == body
async def test_weights_that_do_not_add_up_are_refused_with_the_actual_sum(
client, auth_headers, portfolio
):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.3", "0.01")),
)
assert r.status_code == 422
body = r.json()
assert "0.9" in body["detail"]
assert Decimal(body["weights_sum"]) == Decimal("0.9")
async def test_a_set_is_replaced_whole_not_merged(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.7", "0.01"), ("cash", "0.3", "0.01")),
)
assert r.status_code == 200
assert [t["bucket"] for t in r.json()["targets"]] == ["cash", "share"]
async def test_a_duplicated_bucket_is_refused(client, auth_headers, portfolio):
r = await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.5", "0.01"), ("share", "0.5", "0.01")),
)
assert r.status_code == 422
assert "share" in r.json()["detail"]
async def test_an_unknown_dimension_is_refused(client, auth_headers, portfolio):
body = targets(("share", "1", "0.01"))
body["dimension"] = "mood"
r = await put(client, auth_headers, portfolio["portfolio"], body)
assert r.status_code == 422
async def test_an_unknown_portfolio_is_a_404(client, auth_headers, portfolio):
r = await client.get(f"{PREFIX}/portfolios/999/targets", headers=auth_headers)
assert r.status_code == 404
async def test_targets_need_a_token(client, portfolio):
r = await client.get(f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets")
assert r.status_code == 401
# --------------------------------------------------------------------------- suggestions
async def test_the_suggestion_respects_the_lot_and_the_cash(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["total_value_rub"]) == 100000
assert Decimal(body["cash_available_rub"]) == 30000
share = next(b for b in body["buckets"] if b["bucket"] == "share")
assert Decimal(share["current_weight"]) == Decimal("0.5")
assert Decimal(share["target_weight"]) == Decimal("0.6")
assert Decimal(share["drift"]) == Decimal("-0.1")
assert share["within_band"] is False
trade = share["trades"][0]
assert trade["action"] == "buy"
assert trade["lot"] == 10
assert Decimal(trade["suggested_qty"]) % 10 == 0
assert Decimal(trade["suggested_qty"]) == 100
assert trade["blocked_by_cash"] is False
bond = next(b for b in body["buckets"] if b["bucket"] == "bond")
assert bond["within_band"] is True
assert bond["trades"] == []
async def test_the_what_if_cash_blocks_the_buy(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance",
params={"cash_available": "2500"},
headers=auth_headers,
)
assert r.status_code == 200
trade = next(t for b in r.json()["buckets"] for t in b["trades"] if b["bucket"] == "share")
assert Decimal(trade["suggested_qty"]) == 20
assert trade["blocked_by_cash"] is True
async def test_a_wide_band_silences_every_suggestion(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.5"), ("bond", "0.2", "0.5"), ("cash", "0.2", "0.5")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
body = r.json()
assert all(b["within_band"] for b in body["buckets"] if b["target_weight"] is not None)
assert all(t["suggested_qty"] is None for b in body["buckets"] for t in b["trades"])
async def test_without_targets_there_is_nothing_to_rebalance(client, auth_headers, portfolio):
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
assert r.status_code == 200
assert all(b["target_weight"] is None for b in r.json()["buckets"])
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
await put(
client,
auth_headers,
portfolio["portfolio"],
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
)
r = await client.get(
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
)
body = r.json()
for key in ("total_value_rub", "cash_available_rub"):
assert isinstance(body[key], str)
for b in body["buckets"]:
for key in ("current_value_rub", "current_weight", "delta_value_rub"):
assert isinstance(b[key], str)
for t in b["trades"]:
for key in ("suggested_qty", "price", "amount_rub"):
assert t[key] is None or isinstance(t[key], str)