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:
@@ -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
|
||||
Reference in New Issue
Block a user