From 3d975916763e77f214fc9ac61472d5664a54e3d7 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:39:37 +0800 Subject: [PATCH 1/6] Support status replay and dividend receivables --- README.md | 11 ++++ src/quant_execution/__init__.py | 2 + src/quant_execution/ledger.py | 104 ++++++++++++++++++++++++++++---- src/quant_execution/rules.py | 42 +++++++++++++ tests/test_ledger.py | 61 +++++++++++++++++++ tests/test_rules.py | 52 ++++++++++++++++ 6 files changed, 260 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9eef660..c4b7e9b 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,17 @@ Risk policies are read-only plugins: built-in asset/cash/position/margin checks first, policies cannot transmit orders, and missing mark/FX context or policy errors produce explicit rejection codes instead of bypassing risk. +`resolve_a_share_replay_status` converts a complete point-in-time listing/tradability/price-limit +snapshot into the existing `open`, `suspended`, `limit_up`, `limit_down`, or `closed` QExec status. +It rejects contradictory or missing-typed flags and deliberately has no universe-membership input: +leaving an index or strategy universe is not evidence of delisting or a disposal transaction. + +Deferred cash dividends use two explicit ledger events. `cash_dividend_entitlement` recognizes an +`assets:dividend_receivable` on the ex-date so NAV includes the evidenced entitlement without +making it spendable cash. `cash_dividend_payment` moves that exact receivable into cash on the true +payment date. The payment remains valid after the position is sold because entitlement is fixed on +the ex-date; missing payment dates must be rejected by the upstream corporate-action bridge. + ## Deterministic replay `DeterministicRunEngine.replay` sorts by availability/event time and stable stream diff --git a/src/quant_execution/__init__.py b/src/quant_execution/__init__.py index 6748527..a91deab 100644 --- a/src/quant_execution/__init__.py +++ b/src/quant_execution/__init__.py @@ -54,6 +54,7 @@ LinearPerpetualRule, MarketState, RuleBookRiskGate, + resolve_a_share_replay_status, ) from quant_execution.schemas import ( ACCOUNT_SNAPSHOT_SCHEMA_ID, @@ -143,6 +144,7 @@ "get_json_schema", "load_stored_artifacts", "remaining_quantity", + "resolve_a_share_replay_status", "transition_order", "validate_arrow_table", "validate_json_record", diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 55fb639..75e5a5d 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -504,6 +504,42 @@ def convert_to_base( def cash_balance(self, currency: str) -> Decimal: return self._accounts.get(("assets:cash", currency, None), Decimal(0)) + def dividend_receivable_balance( + self, + currency: str, + *, + instrument_id: str | None = None, + ) -> Decimal: + """Return declared cash dividends that have not reached their payment date.""" + + currency = _currency(currency) + return sum( + ( + amount + for (account, entry_currency, receivable_key), amount in self._accounts.items() + if account == "assets:dividend_receivable" + and entry_currency == currency + and ( + instrument_id is None + or ( + receivable_key is not None + and receivable_key.startswith(f"{instrument_id}@") + ) + ) + ), + Decimal(0), + ) + + def _dividend_receivable_value(self, event_time: datetime) -> Decimal: + return sum( + ( + self._to_base(amount, currency, event_time) + for (account, currency, _), amount in self._accounts.items() + if account == "assets:dividend_receivable" + ), + Decimal(0), + ) + @property def has_open_derivative_position(self) -> bool: return any( @@ -525,6 +561,7 @@ def risk_balances( (self._to_base(amount, currency, event_time) for currency, amount in cash.items()), Decimal(0), ) + nav += self._dividend_receivable_value(event_time) initial_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) @@ -733,6 +770,7 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: ), Decimal(0), ) + nav += self._dividend_receivable_value(at) maintenance_margin = Decimal(0) for instrument_id, quantity in self._positions.items(): spec = self._spec(instrument_id) @@ -1086,6 +1124,7 @@ def snapshot(self, event_time: datetime | None = None) -> AccountSnapshot: nav = sum( (self._to_base(value, currency, at) for currency, value in cash.items()), Decimal(0) ) + nav += self._dividend_receivable_value(at) initial_margin = Decimal(0) maintenance_margin = Decimal(0) for instrument_id, quantity in sorted(self._positions.items()): @@ -1142,6 +1181,7 @@ def assert_nav_residual(self, snapshot: AccountSnapshot) -> None: at = snapshot.event_time for currency, balance in snapshot.cash_balances.items(): expected += self._to_base(decimal(balance), currency, at) + expected += self._dividend_receivable_value(at) for instrument_id, quantity_fp in snapshot.positions.items(): spec = self._spec(instrument_id) quantity = decimal(quantity_fp) @@ -1224,6 +1264,11 @@ def _apply_split_state(self, event: CorporateActionEvent) -> None: def _validate_corporate_action(self, event: CorporateActionEvent) -> None: spec = self._spec(event.instrument_id) + if event.action_type in {"cash_dividend_entitlement", "cash_dividend_payment"}: + if event.cash_amount is None or event.currency is None: + raise ValidationError(f"{event.action_type} requires cash_amount and currency") + if event.action_type == "cash_dividend_payment" and event.ratio is not None: + raise ValidationError("cash dividend payment cannot carry a share ratio") if event.ratio is None: return ratio = decimal(event.ratio) @@ -1492,18 +1537,53 @@ def _corporate_action_transaction(self, event: CorporateActionEvent) -> LedgerTr quantity = self._positions.get(event.instrument_id, Decimal(0)) postings: list[Posting] = [] if event.cash_amount is not None: - cash_delta = quantity * decimal(event.cash_amount) * decimal(spec.contract_multiplier) - postings.extend( - [ - self._posting("assets:cash", str(event.currency), cash_delta), - self._posting( - "income:corporate_action", - str(event.currency), - -cash_delta, - instrument_id=event.instrument_id, - ), - ] - ) + currency = str(event.currency) + receivable_key = f"{event.instrument_id}@{event.effective_date.isoformat()}" + if event.action_type == "cash_dividend_payment": + cash_delta = self._accounts.get( + ("assets:dividend_receivable", currency, receivable_key), + Decimal(0), + ) + postings.extend( + [ + self._posting("assets:cash", currency, cash_delta), + self._posting( + "assets:dividend_receivable", + currency, + -cash_delta, + instrument_id=receivable_key, + ), + ] + ) + else: + cash_delta = ( + quantity * decimal(event.cash_amount) * decimal(spec.contract_multiplier) + ) + asset_account = ( + "assets:dividend_receivable" + if event.action_type == "cash_dividend_entitlement" + else "assets:cash" + ) + postings.extend( + [ + self._posting( + asset_account, + currency, + cash_delta, + instrument_id=( + receivable_key + if event.action_type == "cash_dividend_entitlement" + else None + ), + ), + self._posting( + "income:corporate_action", + currency, + -cash_delta, + instrument_id=event.instrument_id, + ), + ] + ) if event.ratio is not None: quantity_delta = quantity * (decimal(event.ratio) - Decimal(1)) postings.extend( diff --git a/src/quant_execution/rules.py b/src/quant_execution/rules.py index ea51316..4772836 100644 --- a/src/quant_execution/rules.py +++ b/src/quant_execution/rules.py @@ -75,6 +75,48 @@ class MarketState: status: str +def resolve_a_share_replay_status( + *, + listed: bool, + delisted: bool, + tradable: bool, + limit_up: bool, + limit_down: bool, +) -> str: + """Map complete point-in-time A-share flags to one QExec market status. + + Listing lifecycle and index/universe membership are intentionally separate. + Callers must never turn a universe exit into ``delisted=True``. + """ + + flags = { + "listed": listed, + "delisted": delisted, + "tradable": tradable, + "limit_up": limit_up, + "limit_down": limit_down, + } + if any(type(value) is not bool for value in flags.values()): + raise ValidationError("A-share replay status flags must be booleans") + if delisted and listed: + raise ValidationError("a delisted instrument cannot remain listed") + if not listed and (tradable or limit_up or limit_down): + raise ValidationError("an unlisted instrument cannot be tradable or price-limited") + if not tradable and (limit_up or limit_down): + raise ValidationError("a non-tradable instrument cannot be at a tradable price limit") + if limit_up and limit_down: + raise ValidationError("an instrument cannot be both limit-up and limit-down") + if not listed: + return "closed" + if not tradable: + return "suspended" + if limit_up: + return "limit_up" + if limit_down: + return "limit_down" + return "open" + + @dataclass(frozen=True, slots=True) class _RiskAccountView: account_id: str diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 7ba4a74..a47cd5c 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -206,6 +206,67 @@ def test_corporate_action_multi_currency_fx_and_night_trading_day() -> None: assert snapshot.nav.to_decimal() == Decimal(1514) +def test_cash_dividend_receivable_preserves_entitlement_until_payment() -> None: + trading_day = date(2026, 1, 3) + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments={STOCK: stock_spec()}, + initial_cash={"CNY": fp("2000")}, + ) + ledger.mark(mark(STOCK, "10", 1)) + ledger.apply_with_trading_day( + fill("buy-entitled", STOCK, Side.BUY, "100", "10", seconds=1), + trading_day=trading_day, + ) + ledger.mark(mark(STOCK, "9.85", 2)) + entitlement = CorporateActionEvent( + **event_fields("dividend:entitlement", STOCK, seconds=3, trading_day=trading_day), + action_type="cash_dividend_entitlement", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + ledger.apply(entitlement) + entitled = ledger.snapshot() + assert ledger.cash_balance("CNY") == Decimal(1000) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == Decimal(15) + assert entitled.nav.to_decimal() == Decimal(2000) + assert ledger.portfolio_risk_snapshot( + entitlement.event_time + ).cash_value.to_decimal() == Decimal(1000) + + ledger.apply_with_trading_day( + fill("sell-after-record", STOCK, Side.SELL, "100", "9.85", seconds=4), + trading_day=trading_day + timedelta(days=1), + ) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == Decimal(15) + payment = CorporateActionEvent( + **event_fields( + "dividend:payment", + STOCK, + seconds=5, + trading_day=trading_day + timedelta(days=5), + ), + action_type="cash_dividend_payment", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + ledger.apply(payment) + paid = ledger.snapshot() + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == 0 + assert paid.cash_balances["CNY"].to_decimal() == Decimal(2000) + assert paid.nav.to_decimal() == Decimal(2000) + assert ledger.portfolio_risk_snapshot(payment.event_time).cash_value.to_decimal() == Decimal( + 2000 + ) + assert all( + sum(posting.amount.units for posting in transaction.postings) == 0 + for transaction in ledger.transactions + ) + + def test_missing_fx_is_fail_closed() -> None: ledger = ExactAccountLedger( account_id="account", diff --git a/tests/test_rules.py b/tests/test_rules.py index 0dfe967..472cc76 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -3,8 +3,10 @@ from datetime import date, timedelta from decimal import Decimal +import pytest from conftest import T0, event_fields, fp, spec from quant_data_kit import AssetClass, MarginMode, MarkPriceEvent, StatusEvent, TradeEvent +from quant_data_kit.exceptions import ValidationError from quant_execution import ( Fill, @@ -13,6 +15,7 @@ OrderType, Side, TimeInForce, + resolve_a_share_replay_status, ) from quant_execution.broker import DeterministicBroker from quant_execution.ledger import ExactAccountLedger @@ -186,6 +189,55 @@ def test_a_share_lot_t1_limit_status_and_stamp_duty() -> None: ) +def test_a_share_replay_status_is_closed_and_lifecycle_aware() -> None: + assert ( + resolve_a_share_replay_status( + listed=True, + delisted=False, + tradable=True, + limit_up=False, + limit_down=False, + ) + == "open" + ) + assert ( + resolve_a_share_replay_status( + listed=True, + delisted=False, + tradable=False, + limit_up=False, + limit_down=False, + ) + == "suspended" + ) + assert ( + resolve_a_share_replay_status( + listed=False, + delisted=True, + tradable=False, + limit_up=False, + limit_down=False, + ) + == "closed" + ) + with pytest.raises(ValidationError, match="delisted"): + resolve_a_share_replay_status( + listed=True, + delisted=True, + tradable=False, + limit_up=False, + limit_down=False, + ) + with pytest.raises(ValidationError, match="both"): + resolve_a_share_replay_status( + listed=True, + delisted=False, + tradable=True, + limit_up=True, + limit_down=True, + ) + + def test_futures_margin_reduce_only_night_day_and_close_today_fee() -> None: registry = specs() ledger = ExactAccountLedger( From 7f44d5a77ebbc6eb5e3714d4c28c109ec2ca0011 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:52:23 +0800 Subject: [PATCH 2/6] Pin ETF research dataset dependency --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5471985..23a6f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-python = ">=3.10" dependencies = [ "pyarrow>=14.0", "jsonschema>=4.20", - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@fbbad913c9e592fdcbf8b473c4230e2da8594cef", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@4248bfdf4dcf525962e3c19f5f2c5b5fa5222534", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 40a6ad3..2c0c119 100644 --- a/requirements.lock +++ b/requirements.lock @@ -60,7 +60,7 @@ pytz==2026.3.post1 # via pandas pyyaml==6.0.3 # via quant-data-kit -quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@fbbad913c9e592fdcbf8b473c4230e2da8594cef +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@4248bfdf4dcf525962e3c19f5f2c5b5fa5222534 # via quant-execution (pyproject.toml) referencing==0.37.0 # via From e0813f65d4d9d4396de58dc50e2e5149b16f117c Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:07:22 +0800 Subject: [PATCH 3/6] Validate deferred dividend payments --- README.md | 5 ++- src/quant_execution/ledger.py | 79 ++++++++++++++++++++++++++++++++--- tests/test_ledger.py | 51 ++++++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c4b7e9b..578f011 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,10 @@ Deferred cash dividends use two explicit ledger events. `cash_dividend_entitleme `assets:dividend_receivable` on the ex-date so NAV includes the evidenced entitlement without making it spendable cash. `cash_dividend_payment` moves that exact receivable into cash on the true payment date. The payment remains valid after the position is sold because entitlement is fixed on -the ex-date; missing payment dates must be rejected by the upstream corporate-action bridge. +the ex-date. The ledger rejects a payment without the matching entitlement or with a different +cash-per-share amount or receivable total, while a registered zero-holding entitlement remains a +valid zero-effect lifecycle event. Missing payment dates must be rejected by the upstream +corporate-action bridge. ## Deterministic replay diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 75e5a5d..87683aa 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -144,6 +144,7 @@ def reset(self, *, opened_at: datetime | None = None) -> None: self._fill_trading_days: dict[str, date] = {} self._position_lots: dict[str, list[tuple[date, Decimal]]] = {} self._fill_close_allocations: dict[str, tuple[Decimal, Decimal]] = {} + self._dividend_entitlements: dict[tuple[str, str, date], tuple[Decimal, Decimal]] = {} self._posting_cache: dict[ tuple[str, str, Decimal, str | None, Decimal | None, int], Posting ] = {} @@ -331,6 +332,7 @@ def capture_state(self) -> dict[str, object]: "fill_trading_days": self._fill_trading_days, "position_lots": self._position_lots, "fill_close_allocations": self._fill_close_allocations, + "dividend_entitlements": self._dividend_entitlements, "posting_cache": self._posting_cache, "fx": self._fx, "fx_history": self._fx_history, @@ -360,6 +362,7 @@ def _restore_captured_state(self, state: dict[str, object]) -> None: self._fill_trading_days = restored["fill_trading_days"] self._position_lots = restored["position_lots"] self._fill_close_allocations = restored["fill_close_allocations"] + self._dividend_entitlements = restored["dividend_entitlements"] self._posting_cache = restored["posting_cache"] self._fx = restored["fx"] self._fx_history = restored["fx_history"] @@ -877,8 +880,10 @@ def _apply( event.event_time, event.settlement_id, ) - elif isinstance(event, CorporateActionEvent) and event.ratio is not None: - self._apply_split_state(event) + elif isinstance(event, CorporateActionEvent): + self._apply_dividend_state(event) + if event.ratio is not None: + self._apply_split_state(event) if not trusted_unique: self._event_fingerprints[reference_id] = ( self._event_fingerprint(event) if self._artifact_sink is not None else event @@ -916,6 +921,12 @@ def _capture_apply_undo( and posting.quantity_delta is not None } instrument_id = getattr(event, "instrument_id", None) + entitlement_key = ( + self._dividend_entitlement_key(event) + if isinstance(event, CorporateActionEvent) + and event.action_type in {"cash_dividend_entitlement", "cash_dividend_payment"} + else None + ) return { "transaction_count": len(self._transactions), "transaction_key": transaction.idempotency_key in self._transaction_keys, @@ -945,6 +956,12 @@ def _capture_apply_undo( "mark": ( self._marks.get(instrument_id, _MISSING) if instrument_id is not None else _MISSING ), + "dividend_entitlement_key": entitlement_key, + "dividend_entitlement": ( + self._dividend_entitlements.get(entitlement_key, _MISSING) + if entitlement_key is not None + else _MISSING + ), "event_time": self._event_time, } @@ -986,6 +1003,13 @@ def _rollback_apply( undo["position_lots"], ) self._restore_value(self._marks, instrument_id, undo["mark"]) + entitlement_key = undo["dividend_entitlement_key"] + if entitlement_key is not None: + self._restore_value( + self._dividend_entitlements, + entitlement_key, + undo["dividend_entitlement"], + ) self._event_time = undo["event_time"] @staticmethod @@ -1262,13 +1286,58 @@ def _apply_split_state(self, event: CorporateActionEvent) -> None: event.event_id, ) + @staticmethod + def _dividend_entitlement_key(event: CorporateActionEvent) -> tuple[str, str, date]: + assert event.currency is not None + return (event.instrument_id, str(event.currency), event.effective_date) + + @staticmethod + def _dividend_receivable_instrument(key: tuple[str, str, date]) -> str: + return f"{key[0]}@{key[2].isoformat()}" + + def _apply_dividend_state(self, event: CorporateActionEvent) -> None: + if event.action_type not in {"cash_dividend_entitlement", "cash_dividend_payment"}: + return + key = self._dividend_entitlement_key(event) + if event.action_type == "cash_dividend_payment": + del self._dividend_entitlements[key] + return + spec = self._spec(event.instrument_id) + quantity = self._positions.get(event.instrument_id, Decimal(0)) + assert event.cash_amount is not None + total = quantity * decimal(event.cash_amount) * decimal(spec.contract_multiplier) + self._dividend_entitlements[key] = (decimal(event.cash_amount), total) + def _validate_corporate_action(self, event: CorporateActionEvent) -> None: spec = self._spec(event.instrument_id) if event.action_type in {"cash_dividend_entitlement", "cash_dividend_payment"}: if event.cash_amount is None or event.currency is None: raise ValidationError(f"{event.action_type} requires cash_amount and currency") + if decimal(event.cash_amount) < 0: + raise ValidationError("cash dividend amount must be non-negative") + key = self._dividend_entitlement_key(event) + declaration = self._dividend_entitlements.get(key) + if event.action_type == "cash_dividend_entitlement" and declaration is not None: + raise ValidationError("cash dividend entitlement is already registered") if event.action_type == "cash_dividend_payment" and event.ratio is not None: raise ValidationError("cash dividend payment cannot carry a share ratio") + if event.action_type == "cash_dividend_payment": + if declaration is None: + raise ValidationError("cash dividend payment has no registered entitlement") + declared_amount, declared_total = declaration + if decimal(event.cash_amount) != declared_amount: + raise ValidationError( + "cash dividend payment amount does not match the registered entitlement" + ) + receivable_key = self._dividend_receivable_instrument(key) + ledger_total = self._accounts.get( + ("assets:dividend_receivable", str(event.currency), receivable_key), + Decimal(0), + ) + if ledger_total != declared_total: + raise ValidationError( + "cash dividend receivable does not match the registered entitlement" + ) if event.ratio is None: return ratio = decimal(event.ratio) @@ -1540,10 +1609,8 @@ def _corporate_action_transaction(self, event: CorporateActionEvent) -> LedgerTr currency = str(event.currency) receivable_key = f"{event.instrument_id}@{event.effective_date.isoformat()}" if event.action_type == "cash_dividend_payment": - cash_delta = self._accounts.get( - ("assets:dividend_receivable", currency, receivable_key), - Decimal(0), - ) + entitlement_key = self._dividend_entitlement_key(event) + cash_delta = self._dividend_entitlements[entitlement_key][1] postings.extend( [ self._posting("assets:cash", currency, cash_delta), diff --git a/tests/test_ledger.py b/tests/test_ledger.py index a47cd5c..3629f42 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -241,6 +241,21 @@ def test_cash_dividend_receivable_preserves_entitlement_until_payment() -> None: trading_day=trading_day + timedelta(days=1), ) assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == Decimal(15) + mismatched_payment = CorporateActionEvent( + **event_fields( + "dividend:mismatched-payment", + STOCK, + seconds=5, + trading_day=trading_day + timedelta(days=5), + ), + action_type="cash_dividend_payment", + effective_date=trading_day, + cash_amount=fp("99"), + currency="CNY", + ) + with pytest.raises(ValidationError, match="does not match the registered entitlement"): + ledger.apply(mismatched_payment) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == Decimal(15) payment = CorporateActionEvent( **event_fields( "dividend:payment", @@ -267,6 +282,42 @@ def test_cash_dividend_receivable_preserves_entitlement_until_payment() -> None: ) +def test_cash_dividend_payment_requires_entitlement_but_allows_zero_holding() -> None: + trading_day = date(2026, 1, 3) + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments={STOCK: stock_spec()}, + initial_cash={"CNY": fp("2000")}, + ) + payment = CorporateActionEvent( + **event_fields( + "dividend:isolated-payment", + STOCK, + seconds=1, + trading_day=trading_day + timedelta(days=5), + ), + action_type="cash_dividend_payment", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + with pytest.raises(ValidationError, match="has no registered entitlement"): + ledger.apply(payment) + + entitlement = CorporateActionEvent( + **event_fields("dividend:zero-entitlement", STOCK, seconds=1, trading_day=trading_day), + action_type="cash_dividend_entitlement", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + ledger.apply(entitlement) + ledger.apply(payment) + assert ledger.cash_balance("CNY") == Decimal(2000) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == 0 + + def test_missing_fx_is_fail_closed() -> None: ledger = ExactAccountLedger( account_id="account", From b913436c819e9d4c8ab029d5449cc3dd2a1cb5a3 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:14:16 +0800 Subject: [PATCH 4/6] Preserve pre-split dividend entitlement total --- src/quant_execution/ledger.py | 8 ++++--- tests/test_ledger.py | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 87683aa..3f2a7a7 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -1302,10 +1302,12 @@ def _apply_dividend_state(self, event: CorporateActionEvent) -> None: if event.action_type == "cash_dividend_payment": del self._dividend_entitlements[key] return - spec = self._spec(event.instrument_id) - quantity = self._positions.get(event.instrument_id, Decimal(0)) assert event.cash_amount is not None - total = quantity * decimal(event.cash_amount) * decimal(spec.contract_multiplier) + receivable_key = self._dividend_receivable_instrument(key) + total = self._accounts.get( + ("assets:dividend_receivable", str(event.currency), receivable_key), + Decimal(0), + ) self._dividend_entitlements[key] = (decimal(event.cash_amount), total) def _validate_corporate_action(self, event: CorporateActionEvent) -> None: diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 3629f42..28adf6a 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -318,6 +318,47 @@ def test_cash_dividend_payment_requires_entitlement_but_allows_zero_holding() -> assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == 0 +def test_dividend_entitlement_uses_pre_split_quantity_for_later_payment() -> None: + trading_day = date(2026, 1, 3) + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments={STOCK: stock_spec()}, + initial_cash={"CNY": fp("2000")}, + ) + ledger.mark(mark(STOCK, "10", 1)) + ledger.apply_with_trading_day( + fill("buy-before-split", STOCK, Side.BUY, "100", "10", seconds=1), + trading_day=trading_day, + ) + entitlement = CorporateActionEvent( + **event_fields("dividend:split-entitlement", STOCK, seconds=2, trading_day=trading_day), + action_type="cash_dividend_entitlement", + effective_date=trading_day, + ratio=fp("2"), + cash_amount=fp("0.15"), + currency="CNY", + ) + ledger.apply(entitlement) + assert ledger.snapshot().positions[STOCK].to_decimal() == Decimal(200) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == Decimal(15) + payment = CorporateActionEvent( + **event_fields( + "dividend:split-payment", + STOCK, + seconds=3, + trading_day=trading_day + timedelta(days=5), + ), + action_type="cash_dividend_payment", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + ledger.apply(payment) + assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == 0 + assert ledger.cash_balance("CNY") == Decimal(1015) + + def test_missing_fx_is_fail_closed() -> None: ledger = ExactAccountLedger( account_id="account", From db69483ed4e122a316b0bfb4afcdb99afb1770c4 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:14:57 +0800 Subject: [PATCH 5/6] Pin finalized data-kit dependency --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23a6f0e..5cea937 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-python = ">=3.10" dependencies = [ "pyarrow>=14.0", "jsonschema>=4.20", - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@4248bfdf4dcf525962e3c19f5f2c5b5fa5222534", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 2c0c119..caff9d3 100644 --- a/requirements.lock +++ b/requirements.lock @@ -60,7 +60,7 @@ pytz==2026.3.post1 # via pandas pyyaml==6.0.3 # via quant-data-kit -quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@4248bfdf4dcf525962e3c19f5f2c5b5fa5222534 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726 # via quant-execution (pyproject.toml) referencing==0.37.0 # via From dc03e0693031169a80e0eefd06fff424d0a1bc25 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:20:34 +0800 Subject: [PATCH 6/6] Cover duplicate dividend entitlement rejection --- tests/test_ledger.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index 28adf6a..f8a8158 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -313,6 +313,15 @@ def test_cash_dividend_payment_requires_entitlement_but_allows_zero_holding() -> currency="CNY", ) ledger.apply(entitlement) + duplicate_entitlement = CorporateActionEvent( + **event_fields("dividend:duplicate-entitlement", STOCK, seconds=1, trading_day=trading_day), + action_type="cash_dividend_entitlement", + effective_date=trading_day, + cash_amount=fp("0.15"), + currency="CNY", + ) + with pytest.raises(ValidationError, match="entitlement is already registered"): + ledger.apply(duplicate_entitlement) ledger.apply(payment) assert ledger.cash_balance("CNY") == Decimal(2000) assert ledger.dividend_receivable_balance("CNY", instrument_id=STOCK) == 0