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,365 @@
|
||||
"""MOEX ISS as the second payout feed: bondization and the dividend register.
|
||||
|
||||
Every request goes through respx — the ISS is never actually called.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.config import Settings
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
EventKind,
|
||||
Instrument,
|
||||
)
|
||||
from fintracker.sources.moex.client import AmortisationRow, CouponRow, MoexClient
|
||||
from fintracker.sources.moex.payouts import (
|
||||
MoexDividendRow,
|
||||
MoexPayoutsSource,
|
||||
coupon_payout,
|
||||
dividend_payout,
|
||||
fetch_dividends,
|
||||
nominal_schedule,
|
||||
)
|
||||
|
||||
ISS = "https://iss.moex.com/iss"
|
||||
D = Decimal
|
||||
TODAY = today_local()
|
||||
PAST = TODAY - timedelta(days=30)
|
||||
FUTURE = TODAY + timedelta(days=30)
|
||||
|
||||
|
||||
def block(name: str, columns: list[str], data: list[list]) -> dict:
|
||||
return {name: {"columns": columns, "data": data}}
|
||||
|
||||
|
||||
def bondization(coupons: list[list], amortisations: list[list] | None = None) -> dict:
|
||||
return {
|
||||
"coupons": {
|
||||
"columns": ["coupondate", "value", "valueprc", "faceunit"],
|
||||
"data": coupons,
|
||||
},
|
||||
"amortizations": {
|
||||
"columns": ["amortdate", "value", "facevalue", "faceunit"],
|
||||
"data": amortisations or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- mapping -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_coupon_row_maps_to_a_moex_sourced_corporate_action():
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=FUTURE, value=D("34.90"), value_pct=D("7.0"), currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.kind is CorporateActionKind.coupon
|
||||
assert payout.source == "moex"
|
||||
assert payout.source_id == f"cpn:{FUTURE.isoformat()}"
|
||||
assert payout.pay_date == FUTURE
|
||||
assert payout.amount_per_unit == D("34.90")
|
||||
assert payout.currency == "RUB"
|
||||
assert payout.status is CorporateActionStatus.announced
|
||||
|
||||
|
||||
def test_a_coupon_already_paid_is_stored_as_paid():
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=PAST, value=D("34.90"), value_pct=None, currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
assert payout is not None
|
||||
assert payout.status is CorporateActionStatus.paid
|
||||
|
||||
|
||||
def test_a_floating_coupon_without_a_rate_keeps_its_date_and_loses_its_amount():
|
||||
"""The date is published long before the rate is fixed, and the calendar needs it."""
|
||||
payout = coupon_payout(
|
||||
CouponRow(coupon_date=FUTURE, value=None, value_pct=None, currency="RUB"),
|
||||
instrument_id=9,
|
||||
today=TODAY,
|
||||
)
|
||||
assert payout is not None
|
||||
assert payout.amount_per_unit is None
|
||||
|
||||
|
||||
def test_a_dividend_register_row_states_the_record_date_and_nothing_else():
|
||||
payout = dividend_payout(
|
||||
MoexDividendRow(
|
||||
secid="SBER", registry_close_date=date(2026, 7, 10), value=D("52"), currency="RUB"
|
||||
),
|
||||
instrument_id=7,
|
||||
today=TODAY,
|
||||
)
|
||||
|
||||
assert payout is not None
|
||||
assert payout.record_date == date(2026, 7, 10)
|
||||
assert payout.pay_date is None
|
||||
assert payout.source_id == "div:2026-07-10"
|
||||
assert payout.amount_per_unit == D("52")
|
||||
|
||||
|
||||
def test_amortisations_run_the_nominal_down_to_zero():
|
||||
warnings: list[str] = []
|
||||
points = nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2028, 5, 5), value=D(500), face_value=D(500), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
|
||||
),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
assert [(p.effective_date, p.nominal) for p in points] == [
|
||||
(date(2026, 5, 5), D(750)),
|
||||
(date(2027, 5, 5), D(500)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
assert all(p.source == "moex" for p in points)
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_a_plan_that_does_not_add_up_to_the_stated_nominal_is_reported():
|
||||
warnings: list[str] = []
|
||||
nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D(250), face_value=D(1000), currency="SUR"
|
||||
),
|
||||
AmortisationRow(
|
||||
amort_date=date(2027, 5, 5), value=D(250), face_value=D(750), currency="SUR"
|
||||
),
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=warnings,
|
||||
secid="RU000A",
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert "1000" in warnings[0]
|
||||
|
||||
|
||||
def test_nominal_points_are_decimal_never_float():
|
||||
points = nominal_schedule(
|
||||
[
|
||||
AmortisationRow(
|
||||
amort_date=date(2026, 5, 5), value=D("1000"), face_value=None, currency=None
|
||||
)
|
||||
],
|
||||
instrument_id=9,
|
||||
currency="RUB",
|
||||
warnings=[],
|
||||
)
|
||||
assert all(isinstance(p.nominal, Decimal) and not isinstance(p.nominal, float) for p in points)
|
||||
|
||||
|
||||
# --- client --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_bondization_gives_the_whole_coupon_schedule_not_just_the_near_ones():
|
||||
"""This is why bonds are read from MOEX: the plan runs to maturity, in one request."""
|
||||
schedule = [
|
||||
[(TODAY + timedelta(days=30 * n)).isoformat(), 34.9, 7.0, "SUR"] for n in range(1, 25)
|
||||
]
|
||||
respx.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
|
||||
return_value=httpx.Response(200, json=bondization(schedule))
|
||||
)
|
||||
|
||||
async with MoexClient() as moex:
|
||||
coupons, amortisations = await moex.bondization("RU000A")
|
||||
|
||||
within_a_year = [
|
||||
c for c in coupons if c.coupon_date and c.coupon_date <= TODAY + timedelta(days=365)
|
||||
]
|
||||
assert len(within_a_year) >= 12
|
||||
assert all(c.value == D("34.9") for c in within_a_year)
|
||||
assert amortisations == []
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_the_dividend_extract_is_read_by_column_name():
|
||||
respx.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=block(
|
||||
"dividends",
|
||||
["secid", "isin", "registryclosedate", "value", "currencyid"],
|
||||
[["SBER", "RU0009029540", "2026-07-10", 34.84, "SUR"]],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(trust_env=False) as http:
|
||||
rows = await fetch_dividends(http, "SBER")
|
||||
|
||||
assert rows == [
|
||||
MoexDividendRow(
|
||||
secid="SBER",
|
||||
registry_close_date=date(2026, 7, 10),
|
||||
value=D("34.84"),
|
||||
currency="RUB", # ISS says SUR
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# --- sync ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def seed(asset_class: AssetClass, ticker: str) -> int:
|
||||
account_id = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
source_id=f"tinv-{ticker}",
|
||||
)
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = Instrument(
|
||||
asset_class=asset_class,
|
||||
ticker=ticker,
|
||||
board="TQBR",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
)
|
||||
session.add(instrument)
|
||||
await session.commit()
|
||||
await session.refresh(instrument)
|
||||
instrument_id = instrument.id
|
||||
await make_event(
|
||||
PAST, account_id=account_id, kind=EventKind.buy, instrument_id=instrument_id, quantity=10
|
||||
)
|
||||
return instrument_id
|
||||
|
||||
|
||||
async def rows_of(model, **where):
|
||||
async with get_sessionmaker()() as session:
|
||||
stmt = select(model)
|
||||
for column, value in where.items():
|
||||
stmt = stmt.where(getattr(model, column) == value)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
def mock_iss(mock_http) -> None:
|
||||
mock_http.get(url__startswith=f"{ISS}/securities/RU000A/bondization").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=bondization(
|
||||
[
|
||||
["2027-05-05", 34.9, 7.0, "SUR"],
|
||||
["2028-05-05", 34.9, 7.0, "SUR"],
|
||||
],
|
||||
[
|
||||
["2027-05-05", 250, 1000, "SUR"],
|
||||
["2028-05-05", 750, 750, "SUR"],
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
mock_http.get(url__startswith=f"{ISS}/securities/SBER/dividends").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=block(
|
||||
"dividends",
|
||||
["secid", "registryclosedate", "value", "currencyid"],
|
||||
[["SBER", "2026-07-10", 34.84, "SUR"]],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_a_bond_run_writes_coupons_and_the_nominal_schedule(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
|
||||
assert {(a.kind, a.source, a.source_id) for a in actions} == {
|
||||
(CorporateActionKind.coupon, "moex", "cpn:2027-05-05"),
|
||||
(CorporateActionKind.coupon, "moex", "cpn:2028-05-05"),
|
||||
}
|
||||
schedule = sorted(
|
||||
await rows_of(BondNominalSchedule, instrument_id=instrument_id),
|
||||
key=lambda p: p.effective_date,
|
||||
)
|
||||
assert [(p.effective_date, p.nominal) for p in schedule] == [
|
||||
(date(2027, 5, 5), D(750)),
|
||||
(date(2028, 5, 5), D(0)),
|
||||
]
|
||||
|
||||
|
||||
async def test_a_share_run_writes_the_register_dividend(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.share, "SBER")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
actions = await rows_of(CorporateAction, instrument_id=instrument_id)
|
||||
assert [(a.kind, a.source, a.amount_per_unit) for a in actions] == [
|
||||
(CorporateActionKind.dividend, "moex", D("34.84"))
|
||||
]
|
||||
|
||||
|
||||
async def test_a_second_run_stores_no_duplicates(app, mock_http, run_sync):
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
assert len(await rows_of(CorporateAction, instrument_id=instrument_id)) == 2
|
||||
assert len(await rows_of(BondNominalSchedule, instrument_id=instrument_id)) == 2
|
||||
|
||||
|
||||
async def test_moex_wins_the_nominal_row_a_weaker_source_already_wrote(app, mock_http, run_sync):
|
||||
"""`bond_nominal_schedule` is keyed without `source`, so precedence is decided on write."""
|
||||
instrument_id = await seed(AssetClass.bond, "RU000A")
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=date(2027, 5, 5),
|
||||
nominal=D(800),
|
||||
currency="RUB",
|
||||
source="tinvest",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
mock_iss(mock_http)
|
||||
|
||||
await run_sync(MoexPayoutsSource(), settings=Settings())
|
||||
|
||||
schedule = await rows_of(BondNominalSchedule, instrument_id=instrument_id)
|
||||
row = next(p for p in schedule if p.effective_date == date(2027, 5, 5))
|
||||
assert (row.nominal, row.source) == (D(750), "moex")
|
||||
Reference in New Issue
Block a user