Второй источник выплат: 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).
286 lines
9.5 KiB
Python
286 lines
9.5 KiB
Python
"""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)
|