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