Files
fin-tracker/backend/tests/pricing/test_payout_merge.py
T
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

205 lines
7.2 KiB
Python

"""Which feed wins when both describe the same payout, and what happens to the difference.
These run on plain `CorporateAction` objects with no session: the priority rule is a read
rule, and `analytics/income.py` has to be able to apply it to whatever it already loaded.
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from fintracker.analytics import FINDINGS
from fintracker.models.pricing import CorporateAction, CorporateActionKind, CorporateActionStatus
from fintracker.pricing.payouts import (
nominal_outranks,
resolve,
resolve_payouts,
weaker_or_equal_nominal_sources,
)
DIV = CorporateActionKind.dividend
CPN = CorporateActionKind.coupon
def action(
*,
kind=DIV,
source="tinvest",
amount: str | None = "10",
status=CorporateActionStatus.announced,
instrument_id=1,
record_date=None,
ex_date=None,
pay_date=None,
currency="RUB",
source_id=None,
) -> CorporateAction:
return CorporateAction(
instrument_id=instrument_id,
kind=kind,
status=status,
record_date=record_date,
ex_date=ex_date,
pay_date=pay_date,
amount_per_unit=None if amount is None else Decimal(amount),
currency=currency,
ratio=None,
source=source,
source_id=source_id or f"{source}-1",
)
def test_bond_coupon_is_read_from_moex():
"""bondization is the issuer's registered schedule; T-Invest answers for a window."""
moex = action(kind=CPN, source="moex", amount="34.90", pay_date=date(2026, 11, 5))
tinvest = action(kind=CPN, source="tinvest", amount="34.90", pay_date=date(2026, 11, 5))
resolved = resolve_payouts([tinvest, moex], report=False)
assert len(resolved) == 1
assert resolved[0].source == "moex"
def test_share_dividend_is_read_from_tinvest():
"""T-Invest states what will settle on the account; MOEX states the register."""
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
tinvest = action(
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
)
resolved = resolve_payouts([moex, tinvest], report=False)
assert len(resolved) == 1
assert resolved[0].source == "tinvest"
def test_feeds_merge_on_the_record_date_they_share():
"""MOEX states only the register date and T-Invest also states the payment date.
Keying the merge on the payment date alone would leave them in separate buckets and
count the dividend twice.
"""
moex = action(source="moex", amount="52", record_date=date(2026, 7, 10))
tinvest = action(
source="tinvest", amount="52", record_date=date(2026, 7, 10), pay_date=date(2026, 7, 24)
)
assert len(resolve_payouts([moex, tinvest], report=False)) == 1
def test_two_different_payouts_of_one_paper_stay_two():
interim = action(source="tinvest", amount="17", record_date=date(2026, 1, 12))
final = action(source="tinvest", amount="52", record_date=date(2026, 7, 10), source_id="t-2")
assert len(resolve_payouts([interim, final], report=False)) == 2
def test_a_mismatch_in_amount_produces_a_finding_and_keeps_the_winner():
"""The winner is still the winner; the gap goes to the quality report, not to /dev/null."""
FINDINGS.reset()
tinvest = action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10))
moex = action(source="moex", amount="52.00", record_date=date(2026, 7, 10))
resolved = resolve_payouts([moex, tinvest])
assert [a.source for a in resolved] == ["tinvest"]
assert resolved[0].amount_per_unit == Decimal("45.24")
finding = next(f for f in FINDINGS.items if f.check_name == "payout_amount_mismatch")
assert finding.severity == "warn"
assert finding.ref == {"instruments": [1]}
FINDINGS.reset()
def test_amounts_within_rounding_are_not_a_mismatch():
FINDINGS.reset()
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2026, 11, 5), kind=CPN)
moex = action(source="moex", amount="34.9000000001", pay_date=date(2026, 11, 5), kind=CPN)
resolve_payouts([tinvest, moex])
assert FINDINGS.items == []
def test_a_missing_amount_is_a_gap_not_a_disagreement():
"""A floating coupon whose rate is unfixed arrives dated and priceless — every refresh."""
FINDINGS.reset()
moex = action(source="moex", amount=None, pay_date=date(2027, 2, 5), kind=CPN)
tinvest = action(source="tinvest", amount="34.90", pay_date=date(2027, 2, 5), kind=CPN)
resolved = resolve_payouts([moex, tinvest])
assert resolved[0].source == "moex"
assert FINDINGS.items == []
def test_a_paid_row_outranks_an_announcement_from_the_stronger_feed():
"""Money that has moved beats a feed's announcement of the same payout, either way round."""
ledger = action(
kind=CPN,
source="tinvest",
amount="34.90",
status=CorporateActionStatus.paid,
pay_date=date(2026, 5, 5),
)
announced = action(
kind=CPN,
source="moex",
amount="34.90",
status=CorporateActionStatus.announced,
pay_date=date(2026, 5, 5),
)
resolved = resolve_payouts([announced, ledger], report=False)
assert [(a.source, a.status) for a in resolved] == [("tinvest", CorporateActionStatus.paid)]
def test_different_papers_never_merge():
a = action(source="moex", record_date=date(2026, 7, 10), instrument_id=1)
b = action(source="moex", record_date=date(2026, 7, 10), instrument_id=2)
assert len(resolve_payouts([a, b], report=False)) == 2
def test_an_undated_row_survives_on_its_own():
dated = action(source="tinvest", record_date=date(2026, 7, 10))
undated = action(source="tinvest", amount="9", source_id="t-2")
assert len(resolve_payouts([dated, undated], report=False)) == 2
def test_conflicts_are_reported_per_group_not_summed_into_one_line():
result = resolve(
[
action(source="tinvest", amount="45", record_date=date(2026, 7, 10)),
action(source="moex", amount="52", record_date=date(2026, 7, 10)),
action(source="tinvest", amount="10", record_date=date(2026, 1, 9), source_id="t-2"),
action(source="moex", amount="12", record_date=date(2026, 1, 9), source_id="m-2"),
]
)
assert len(result.payouts) == 2
assert len(result.conflicts) == 2
def test_nominal_schedule_precedence_is_a_write_rule():
"""`bond_nominal_schedule` is keyed without `source`, so the two feeds share one row."""
assert nominal_outranks("moex", "tinvest")
assert not nominal_outranks("tinvest", "moex")
assert nominal_outranks("moex", "moex") # a feed must be able to correct itself
assert set(weaker_or_equal_nominal_sources("moex")) == {"moex", "tinvest", "ledger"}
assert set(weaker_or_equal_nominal_sources("tinvest")) == {"tinvest", "ledger"}
def test_no_float_anywhere_in_a_resolution():
resolved = resolve_payouts(
[
action(source="tinvest", amount="45.24", record_date=date(2026, 7, 10)),
action(source="moex", amount="52.00", record_date=date(2026, 7, 10)),
],
report=False,
)
assert all(not isinstance(a.amount_per_unit, float) for a in resolved)
assert isinstance(resolved[0].amount_per_unit, Decimal)