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
+477
View File
@@ -0,0 +1,477 @@
"""Rebalancing: the pure planning rules, then the refresh step end to end."""
from datetime import timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import FINDINGS, today_local
from fintracker.analytics.rebalance import (
Position,
Target,
build_plan,
compute_rebalance,
rebuild_rebalance,
)
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AllocationDimension,
AssetClass,
EventKind,
Instrument,
MetricAllocation,
MetricRebalance,
Portfolio,
PortfolioAccount,
PortfolioTarget,
)
D = Decimal
DIM = AllocationDimension
def position(
instrument_id: int = 1,
*,
ticker: str = "SBER",
lot: int = 10,
qty: str = "100",
unit: str = "275",
) -> Position:
return Position(
instrument_id=instrument_id,
ticker=ticker,
name=ticker,
lot=lot,
qty=D(qty),
unit_value_rub=D(unit),
price=D(unit),
price_currency="RUB",
)
def plan(
*,
bucket_values: dict[str, Decimal],
positions: dict[str, list[Position]],
targets: dict[str, Target],
cash: str = "1000000",
):
total = sum((v for v in bucket_values.values() if v > 0), start=D(0))
return build_plan(
portfolio_id=1,
dimension=DIM.asset_class,
as_of=today_local(),
total_value_rub=total,
bucket_values=bucket_values,
positions=positions,
targets=targets,
cash_available_rub=D(cash),
)
def bucket(result, name: str):
return next(b for b in result.buckets if b.bucket == name)
# --------------------------------------------------------------------------- lots and cash
def test_a_buy_is_whole_lots_even_when_the_money_would_stretch_further():
# 100 lots' worth of money, a lot of 10 at 275 => 2750 a lot
result = plan(
bucket_values={"share": D("27500"), "cash": D("22500")},
positions={"share": [position(qty="100", unit="275", lot=10)]},
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
cash="22500",
)
trade = bucket(result, "share").trades[0]
assert trade.action == "buy"
# 0.8 * 50000 - 27500 = 12500 -> 45.45 units -> 4 lots = 40, never 45
assert trade.qty == D(40)
assert trade.qty % trade.lot == 0
assert trade.amount_rub == D(40) * D("275")
def test_a_buy_is_cut_to_the_cash_on_hand_and_says_so():
result = plan(
bucket_values={"share": D("27500"), "cash": D("22500")},
positions={"share": [position(qty="100", unit="275", lot=10)]},
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
cash="6000",
)
trade = bucket(result, "share").trades[0]
# 6000 buys two lots (5500), not the 4 the target asks for
assert trade.qty == D(20)
assert trade.blocked_by_cash is True
assert trade.amount_rub <= D("6000")
def test_no_cash_at_all_still_reports_the_blocked_buy_rather_than_hiding_it():
result = plan(
bucket_values={"share": D("27500"), "cash": D("22500")},
positions={"share": [position(qty="100", unit="275", lot=10)]},
targets={"share": Target(D("0.8")), "cash": Target(D("0.2"))},
cash="0",
)
trade = bucket(result, "share").trades[0]
assert trade.qty == D(0)
assert trade.blocked_by_cash is True
def test_cash_is_spent_once_across_buckets():
result = plan(
bucket_values={"share": D("1000"), "bond": D("1000"), "cash": D("8000")},
positions={
"share": [position(1, ticker="SBER", qty="10", unit="100", lot=1)],
"bond": [position(2, ticker="OFZ", qty="10", unit="100", lot=1)],
},
targets={"share": Target(D("0.45")), "bond": Target(D("0.45")), "cash": Target(D("0.1"))},
cash="1000",
)
spent = sum(t.amount_rub for b in result.buckets for t in b.trades if t.action == "buy")
assert spent <= D("1000")
# --------------------------------------------------------------------------- the band
def test_a_drift_inside_the_band_proposes_nothing():
result = plan(
bucket_values={"share": D("6200"), "bond": D("3800")},
positions={"share": [position(qty="62", unit="100", lot=1)]},
targets={"share": Target(D("0.6"), D("0.05")), "bond": Target(D("0.4"), D("0.05"))},
)
share = bucket(result, "share")
assert share.drift == D("0.02")
assert share.within_band is True
assert share.trades == []
assert share.delta_value_rub == D(0)
def test_the_same_drift_outside_the_band_proposes_a_trade():
result = plan(
bucket_values={"share": D("6200"), "bond": D("3800")},
positions={
"share": [position(qty="62", unit="100", lot=1)],
"bond": [position(2, ticker="OFZ", qty="38", unit="100", lot=1)],
},
targets={"share": Target(D("0.6"), D("0.01")), "bond": Target(D("0.4"), D("0.01"))},
)
share = bucket(result, "share")
assert share.within_band is False
assert share.trades[0].action == "sell"
assert share.trades[0].qty == D(2)
# --------------------------------------------------------------------------- sells
def test_a_sell_never_exceeds_the_position_and_never_goes_short():
# the bucket must shrink by more than it holds: the target moved to zero
result = plan(
bucket_values={"share": D("1000"), "bond": D("9000")},
positions={"share": [position(qty="10", unit="100", lot=1)]},
targets={"share": Target(D("0")), "bond": Target(D("1"))},
)
trade = bucket(result, "share").trades[0]
assert trade.action == "sell"
assert trade.qty == D(10)
assert trade.qty <= D(10)
def test_a_sell_is_capped_to_whole_lots_of_what_is_held():
# 25 units of a 10-lot paper: at most two lots can be sold
result = plan(
bucket_values={"share": D("2500"), "bond": D("7500")},
positions={"share": [position(qty="25", unit="100", lot=10)]},
targets={"share": Target(D("0")), "bond": Target(D("1"))},
)
trade = bucket(result, "share").trades[0]
assert trade.qty == D(20)
def test_a_bucket_is_trimmed_proportionally_not_from_one_paper():
result = plan(
bucket_values={"share": D("10000"), "bond": D("0")},
positions={
"share": [
position(1, ticker="BIG", qty="75", unit="100", lot=1),
position(2, ticker="SMALL", qty="25", unit="100", lot=1),
]
},
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
)
by_ticker = {t.ticker: t.qty for t in bucket(result, "share").trades}
# 5000 to raise, split 75/25 by value: 37 and 12 units (floored to whole lots)
assert by_ticker == {"BIG": D(37), "SMALL": D(12)}
def test_a_bucket_with_nothing_priced_in_it_warns_instead_of_inventing_a_trade():
result = plan(
bucket_values={"share": D("10000"), "bond": D("0")},
positions={},
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
)
assert bucket(result, "share").trades == []
assert any("share" in w for w in result.warnings)
def test_the_cash_bucket_needs_no_trades_and_produces_no_warning():
result = plan(
bucket_values={"share": D("5000"), "cash": D("5000")},
positions={"share": [position(qty="50", unit="100", lot=1)]},
targets={"share": Target(D("0.9")), "cash": Target(D("0.1"))},
cash="5000",
)
assert bucket(result, "cash").trades == []
assert not any("cash" in w for w in result.warnings)
def test_a_bucket_without_a_target_is_reported_but_never_traded():
result = plan(
bucket_values={"share": D("5000"), "etf": D("5000")},
positions={"etf": [position(2, ticker="TMOS", qty="50", unit="100", lot=1)]},
targets={"share": Target(D("1"))},
)
etf = bucket(result, "etf")
assert etf.target_weight is None
assert etf.drift is None
assert etf.trades == []
def test_every_number_in_the_plan_is_a_decimal():
result = plan(
bucket_values={"share": D("6200"), "bond": D("3800")},
positions={"share": [position(qty="62", unit="100", lot=1)]},
targets={"share": Target(D("0.5")), "bond": Target(D("0.5"))},
)
for b in result.buckets:
for value in (b.current_value_rub, b.current_weight, b.delta_value_rub):
assert isinstance(value, Decimal)
for t in b.trades:
for value in (t.qty, t.price, t.amount_rub):
assert isinstance(value, Decimal)
# --------------------------------------------------------------------------- database
async def _portfolio_with(*, unpriced: bool) -> dict[str, int]:
"""A broker account in a portfolio: 500 SBER (lot 10), 20 OFZ, the rest 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",
)
ids = {"account": account, "portfolio": portfolio_id, "sber": sber, "ofz": ofz}
if unpriced:
silent = await make_instrument(
ticker="SIBN6P4", name="Без цены", asset_class=AssetClass.share, board="SPBRUBND"
)
await make_event(
bought,
account_id=account,
kind=EventKind.buy,
instrument_id=silent,
quantity="5",
price="1000",
amount="-5000",
)
ids["silent"] = silent
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 ids
async def _set_targets(portfolio_id: int, rows: list[tuple[str, str, str]]) -> None:
async with get_sessionmaker()() as session:
for bucket_name, weight, band in rows:
session.add(
PortfolioTarget(
portfolio_id=portfolio_id,
dimension=DIM.asset_class,
bucket=bucket_name,
target_weight=D(weight),
band=D(band),
)
)
await session.commit()
@pytest.fixture
async def portfolio(app) -> dict[str, int]:
ids = await _portfolio_with(unpriced=False)
await _set_targets(
ids["portfolio"],
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
)
return ids
async def test_the_step_fills_the_target_columns_of_metric_allocation(portfolio):
async with get_sessionmaker()() as session:
await rebuild_rebalance(session)
await session.commit()
rows = (
(
await session.execute(
select(MetricAllocation).where(
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
MetricAllocation.dimension == DIM.asset_class,
)
)
)
.scalars()
.all()
)
by_bucket = {r.bucket: r for r in rows}
assert by_bucket["share"].target_weight == D("0.6")
assert by_bucket["share"].weight == D("0.5")
assert by_bucket["share"].drift == D("-0.1")
assert by_bucket["cash"].target_weight == D("0.2")
assert by_bucket["cash"].drift == D("0.1")
async def test_metric_rebalance_agrees_with_metric_allocation(portfolio):
async with get_sessionmaker()() as session:
await rebuild_rebalance(session)
await session.commit()
allocation = {
r.bucket: r
for r in (
(
await session.execute(
select(MetricAllocation).where(
MetricAllocation.scope == f"portfolio:{portfolio['portfolio']}",
MetricAllocation.dimension == DIM.asset_class,
)
)
)
.scalars()
.all()
)
}
summaries = {
r.bucket: r
for r in (
(
await session.execute(
select(MetricRebalance).where(MetricRebalance.instrument_id.is_(None))
)
)
.scalars()
.all()
)
}
trades = (
(
await session.execute(
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
)
)
.scalars()
.all()
)
for name, row in summaries.items():
assert row.current_weight == allocation[name].weight
assert row.target_weight == allocation[name].target_weight
assert row.current_value_rub == allocation[name].value_rub
# 0.6 of 100 000 is 60 000 against 50 000 held: 100 more shares at 100, lot 10
buy = next(t for t in trades if t.instrument_id == portfolio["sber"])
assert buy.suggested_qty == D(100)
assert buy.suggested_qty is not None
assert buy.lot is not None
assert buy.suggested_qty % buy.lot == 0
assert buy.blocked_by_cash is False
# the bond bucket sits exactly on its target and proposes nothing
assert summaries["bond"].within_band is True
assert not [t for t in trades if t.instrument_id == portfolio["ofz"]]
async def test_an_instrument_without_a_price_is_left_out_but_reported(app):
ids = await _portfolio_with(unpriced=True)
await _set_targets(
ids["portfolio"],
[("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")],
)
FINDINGS.reset()
async with get_sessionmaker()() as session:
await rebuild_rebalance(session)
await session.commit()
trades = (
(
await session.execute(
select(MetricRebalance).where(MetricRebalance.instrument_id.is_not(None))
)
)
.scalars()
.all()
)
assert ids["silent"] not in {t.instrument_id for t in trades}
assert any(
f.check_name == "rebalance_incomplete" and "SIBN6P4" in f.detail for f in FINDINGS.items
)
async def test_the_what_if_cash_overrides_the_real_balance(portfolio):
async with get_sessionmaker()() as session:
real = await compute_rebalance(session, portfolio["portfolio"], DIM.asset_class)
poor = await compute_rebalance(
session, portfolio["portfolio"], DIM.asset_class, cash_available_rub=D("500")
)
assert real.cash_available_rub == D("30000")
rich_trade = next(t for b in real.buckets for t in b.trades)
poor_trade = next(t for b in poor.buckets for t in b.trades)
assert poor_trade.qty < rich_trade.qty
assert poor_trade.blocked_by_cash is True