"""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)