From a45f884779f2cd145fecc39edbdb5e8eb9628e89 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Thu, 15 Oct 2026 18:45:18 +0800 Subject: [PATCH 1/2] Handle evidenced share credits and legal odd-lot exits in native ledger --- .github/workflows/ci.yml | 3 +- docs/share-entitlements.md | 22 +++++++ pyproject.toml | 2 +- requirements.lock | 2 +- src/quant_execution/artifacts.py | 6 ++ src/quant_execution/ledger.py | 45 ++++++++++++-- src/quant_execution/rules.py | 29 +++++++-- tests/test_rules.py | 94 +++++++++++++++++++++++++++++ tests/test_share_entitlements.py | 100 +++++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 docs/share-entitlements.md create mode 100644 tests/test_share_entitlements.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47a9807..c540b58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,11 @@ permissions: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest, windows-latest] python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/docs/share-entitlements.md b/docs/share-entitlements.md new file mode 100644 index 0000000..48a0843 --- /dev/null +++ b/docs/share-entitlements.md @@ -0,0 +1,22 @@ +# Share credits and odd-lot exits + +The native ledger accepts the optional QDataKit `ShareEntitlement` on a corporate +action. The resolver uses exact integer credited quantity or an explicitly named +sensitivity rule. Unresolved rights fail before any mutation. Dividends use +record-date eligible quantity. Reference price is independent of share quantity. +Credited lots remain unavailable before their declared sellable date; ordinary +same-day purchases retain T+1. FixedPoint cash journals balance exactly. + +The original event identity makes retries idempotent. Different content for an +applied identity is rejected. Capture/restore preserves the ledger's applied +events and lots; revisions require restoration before the event and a separately +identified replay. This does not rewrite the original historical account. + +A-share buy requests require board lots. Sell requests may additionally dispose +of the entire odd remainder of the actual holding, subject to reservations and +T+1. After a legal order partially fills, its accepted remainder may continue; +a newly submitted buy request is validated again and cannot claim this exemption. + +Restore the pre-upgrade software lock and immutable account checkpoint to roll +back. Keep all new event and ledger evidence separately. Old execution readers +cannot consume the new entitlement extension. diff --git a/pyproject.toml b/pyproject.toml index 57ffcce..36576f9 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@ad3440700b226497c40093197f1df4321b49d1e8", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@b6edcf755c681e0e73ad02a99334b2714af9b2f7", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index f82e281..8e5cdac 100644 --- a/requirements.lock +++ b/requirements.lock @@ -69,7 +69,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@ad3440700b226497c40093197f1df4321b49d1e8 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@b6edcf755c681e0e73ad02a99334b2714af9b2f7 # via quant-execution (pyproject.toml) referencing==0.37.0 # via diff --git a/src/quant_execution/artifacts.py b/src/quant_execution/artifacts.py index 689697d..a4d35c1 100644 --- a/src/quant_execution/artifacts.py +++ b/src/quant_execution/artifacts.py @@ -30,6 +30,7 @@ ) from quant_data_kit.exceptions import ValidationError from quant_data_kit.financial import DividendLifecycle, PitFxRate +from quant_data_kit.share_entitlements import ShareEntitlement from quant_execution._json import fixed_token, parse_utc_timestamp, string_token, utc_token from quant_execution.contracts import ( @@ -946,6 +947,11 @@ def _ledger_event_from_business_fact(value: object): else None ), currency=payload["currency"], + share_entitlement=( + ShareEntitlement.from_payload(payload["share_entitlement"]) + if payload.get("share_entitlement") is not None + else None + ), ) raise ValidationError("unsupported ledger event business fact") diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 0c4559c..99bca20 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -6,7 +6,7 @@ import json from collections.abc import Mapping from copy import deepcopy -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from decimal import ROUND_HALF_EVEN, Decimal from functools import lru_cache from types import MappingProxyType @@ -1220,7 +1220,21 @@ def _apply( ) elif isinstance(event, CorporateActionEvent): self._apply_dividend_state(event) - if event.ratio is not None: + if event.share_entitlement is not None: + terms = event.share_entitlement + # The lot becomes sellable on the declared date. An ordinary + # buy still has today's lot date and remains T+1 restricted. + self._position_lots[event.instrument_id] = [ + *self._position_lots.get(event.instrument_id, ()), + (terms.sellable_date - timedelta(days=1), terms.resolved_quantity()), + ] + if terms.reference_price is not None: + self._marks[event.instrument_id] = ( + decimal(terms.reference_price), + event.available_at, + event.event_id, + ) + elif event.ratio is not None: self._apply_split_state(event) if not trusted_unique: self._event_fingerprints[reference_id] = ( @@ -1635,7 +1649,7 @@ def acquired_today(self, instrument_id: str, trading_day: object) -> Decimal: ( abs(quantity) for day, quantity in self._position_lots.get(instrument_id, ()) - if day == trading_day + if day >= trading_day ), Decimal(0), ) @@ -1725,6 +1739,14 @@ def _apply_dividend_state(self, event: CorporateActionEvent) -> None: def _validate_corporate_action(self, event: CorporateActionEvent) -> None: spec = self._spec(event.instrument_id) + if event.share_entitlement is not None: + terms = event.share_entitlement + if terms.account_id != self.account_id: + raise ValidationError("share entitlement belongs to a different account") + if spec.asset_class not in {AssetClass.EQUITY, AssetClass.ETF}: + raise ValidationError("share entitlement requires a cash equity instrument") + if terms.resolved_quantity() % decimal(spec.quantity_step): + raise ValidationError("credited entitlement violates the instrument quantity step") if event.action_type == "terminal_cash": if ( spec.asset_class @@ -2174,8 +2196,15 @@ def _corporate_action_transaction(self, event: CorporateActionEvent) -> LedgerTr ] ) else: + entitled_quantity = ( + decimal(event.share_entitlement.eligible_quantity) + if event.share_entitlement is not None + else quantity + ) cash_delta = ( - quantity * decimal(event.cash_amount) * decimal(spec.contract_multiplier) + entitled_quantity + * decimal(event.cash_amount) + * decimal(spec.contract_multiplier) ) asset_account = ( "assets:dividend_receivable" @@ -2202,8 +2231,12 @@ def _corporate_action_transaction(self, event: CorporateActionEvent) -> LedgerTr ), ] ) - if event.ratio is not None: - quantity_delta = quantity * (decimal(event.ratio) - Decimal(1)) + if event.ratio is not None or event.share_entitlement is not None: + quantity_delta = ( + event.share_entitlement.resolved_quantity() + if event.share_entitlement is not None + else quantity * (decimal(event.ratio) - Decimal(1)) + ) postings.extend( [ self._posting( diff --git a/src/quant_execution/rules.py b/src/quant_execution/rules.py index 76f506a..4b58ec3 100644 --- a/src/quant_execution/rules.py +++ b/src/quant_execution/rules.py @@ -209,15 +209,23 @@ def check( state: MarketState, spec: InstrumentSpec, ledger: ExactAccountLedger, + *, + accepted_partial_order: bool = False, ) -> RiskDecision: base = super().check(intent, snapshot, state, spec, ledger) if not base.accepted: return base lot_size = _metadata_decimal(spec, "lot_size", required=True) - if decimal(intent.quantity) % lot_size: - return RiskDecision(False, "A_SHARE_LOT", "quantity is not an A-share board lot") quantity = decimal(intent.quantity) position = _value(snapshot.positions.get(intent.instrument_id)) + if ( + not accepted_partial_order + and quantity % lot_size + and not (intent.side is Side.SELL and quantity % lot_size == position % lot_size) + ): + return RiskDecision( + False, "A_SHARE_LOT", "buy requires board lots; sell must clear the odd remainder" + ) if intent.side is Side.SELL: acquired = ledger.acquired_today(intent.instrument_id, state.event.trading_day) if quantity > position - acquired: @@ -491,7 +499,12 @@ def check_open_order( created_at=event_time, ) ) - return self._check(remaining_intent, account_snapshot, as_of=event_time) + return self._check( + remaining_intent, + account_snapshot, + as_of=event_time, + accepted_partial_order=order.filled_quantity.units > 0, + ) def check_open_order_current( self, @@ -518,6 +531,7 @@ def check_open_order_current( remaining_intent, self._current_view(event_time, order.intent.instrument_id), as_of=event_time, + accepted_partial_order=order.filled_quantity.units > 0, ) def _check( @@ -526,6 +540,7 @@ def _check( account_snapshot: AccountSnapshot | _RiskAccountView, *, as_of: datetime, + accepted_partial_order: bool = False, prepared_reservation: list[ tuple[tuple[str, Decimal] | None, Decimal, tuple[str, Decimal] | None] ] @@ -560,9 +575,13 @@ def _check( if value is not None and not aligned(value, spec.price_tick): return RiskDecision(False, "PRICE_TICK", f"{field_name} violates price tick") try: - decision = self._rule_for(spec).check( - order_intent, account_snapshot, state, spec, self.ledger + rule = self._rule_for(spec) + extra = ( + {"accepted_partial_order": accepted_partial_order} + if isinstance(rule, AShareRule) + else {} ) + decision = rule.check(order_intent, account_snapshot, state, spec, self.ledger, **extra) if not decision.accepted: return decision reservation = self._reservation_requirement(order_intent, state, spec) diff --git a/tests/test_rules.py b/tests/test_rules.py index ec7c37e..dcdc68f 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -12,7 +12,9 @@ from quant_execution import ( Fill, LiquidityRole, + Order, OrderIntent, + OrderStatus, OrderType, Side, TimeInForce, @@ -214,6 +216,98 @@ def test_a_share_lot_t1_limit_status_and_stamp_duty() -> None: ) +def test_a_share_odd_remainder_sale_preserves_t1_and_reservations(): + registry = specs() + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments=registry, + initial_cash={"CNY": fp("100000")}, + ) + gate = RuleBookRiskGate(instruments=registry, ledger=ledger) + market = state_event(STOCK, "10") + gate.observe(market) + ledger.observe_market(market) + # Partial fills or a distribution can leave an integer odd balance. + fill = Fill( + fill_id="odd-fill", + order_id="old", + account_id="account", + strategy_id="strategy", + instrument_id=STOCK, + side=Side.BUY, + quantity=fp("255"), + price=fp("10"), + event_time=T0, + liquidity_role=LiquidityRole.TAKER, + ) + ledger.apply_with_trading_day(fill, trading_day=market.trading_day) + odd = intent(STOCK, Side.SELL, "55", key="odd-sell") + assert gate.check(odd, ledger.snapshot()).code == "A_SHARE_T_PLUS_ONE" + gate.observe(state_event(STOCK, "10", trading_day=date(2026, 1, 3))) + for quantity in ("55", "155", "255", "100", "200"): + assert gate.check( + intent(STOCK, Side.SELL, quantity, key="s" + quantity), ledger.snapshot() + ).accepted + for quantity in ("5", "50", "150"): + assert ( + gate.check( + intent(STOCK, Side.SELL, quantity, key="bad" + quantity), ledger.snapshot() + ).code + == "A_SHARE_LOT" + ) + assert ( + gate.check(intent(STOCK, Side.SELL, "355", key="oversell"), ledger.snapshot()).code + == "A_SHARE_T_PLUS_ONE" + ) + # A partially filled original sell remains valid. A retry uses the entire + # new remainder, not the original odd quantity after it has been consumed. + sold = replace( + fill, + fill_id="sell-part", + order_id="sell", + side=Side.SELL, + quantity=fp("125"), + event_time=T0 + timedelta(days=1), + ) + ledger.apply_with_trading_day(sold, trading_day=date(2026, 1, 3)) + assert gate.check(intent(STOCK, Side.SELL, "130", key="remaining"), ledger.snapshot()).accepted + assert gate.check(odd, ledger.snapshot()).code == "A_SHARE_LOT" + assert ( + gate.check(intent(STOCK, Side.BUY, "30", key="bad-buy"), ledger.snapshot()).code + == "A_SHARE_LOT" + ) + + +def test_live_partial_order_keeps_its_original_lot_admission_but_new_retry_does_not(): + registry = specs() + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments=registry, + initial_cash={"CNY": fp("100000")}, + ) + gate = RuleBookRiskGate(instruments=registry, ledger=ledger) + gate.observe(state_event(STOCK, "10")) + original = intent(STOCK, Side.BUY, "100", key="accepted-board-lot") + assert gate.check(original, ledger.snapshot()).accepted + order = Order( + order_id="partial", + intent=original, + status=OrderStatus.PARTIALLY_FILLED, + filled_quantity=fp("30", 0), + version=2, + ) + assert gate.check_open_order(order, ledger.snapshot(), event_time=T0).accepted + assert gate.check_open_order_current(order, event_time=T0).accepted + assert ( + gate.check( + replace(original, idempotency_key="new-retry", quantity=fp("70", 0)), ledger.snapshot() + ).code + == "A_SHARE_LOT" + ) + + def test_a_share_replay_status_is_closed_and_lifecycle_aware() -> None: assert ( resolve_a_share_replay_status( diff --git a/tests/test_share_entitlements.py b/tests/test_share_entitlements.py new file mode 100644 index 0000000..d3b4ece --- /dev/null +++ b/tests/test_share_entitlements.py @@ -0,0 +1,100 @@ +from dataclasses import replace +from datetime import timedelta +from decimal import Decimal + +import pytest +from conftest import T0, event_fields, fp +from quant_data_kit import CorporateActionEvent +from quant_data_kit.exceptions import ValidationError +from quant_data_kit.share_entitlements import EntitlementUnresolved, ShareEntitlement +from test_ledger import STOCK, fill, stock_spec + +from quant_execution import Side +from quant_execution.ledger import ExactAccountLedger + + +def setup(): + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments={STOCK: stock_spec()}, + initial_cash={"CNY": fp("10000")}, + ) + ledger.apply(fill("buy", STOCK, Side.BUY, "100", "10", seconds=0)) + terms = ShareEntitlement( + entitlement_id="distribution", + account_id="account", + record_date=T0.date(), + eligible_quantity=fp("100"), + ordinary_additional_ratio=fp("0.400595", 6), + known_at=T0, + credit_at=T0 + timedelta(days=1), + sellable_date=(T0 + timedelta(days=3)).date(), + evidence_source="test:official-announcement", + evidence_sha256="a" * 64, + fractional_rule="largest_fraction", + ) + event = CorporateActionEvent( + **event_fields("distribution:account", STOCK, seconds=86400), + action_type="share_credit", + effective_date=T0.date(), + share_entitlement=terms, + ) + return ledger, event + + +def test_unresolved_cannot_change_cash_or_holdings_then_confirmed_credit_is_idempotent(): + ledger, event = setup() + before = ledger.capture_state() + with pytest.raises(EntitlementUnresolved) as error: + ledger.apply(event) + assert error.value.diagnostics["publishable"] is False + assert ledger.capture_state() == before + terms = replace( + event.share_entitlement, evidence_status="confirmed", confirmed_quantity=fp("40") + ) + confirmed = replace(event, share_entitlement=terms) + result = ledger.apply(confirmed) + assert result.positions[STOCK].to_decimal() == 140 + assert result.cash_balances["CNY"].to_decimal() == Decimal(9000) + assert ledger.acquired_today(STOCK, (T0 + timedelta(days=1)).date()) == 40 + assert ledger.acquired_today(STOCK, (T0 + timedelta(days=2)).date()) == 40 + assert ledger.acquired_today(STOCK, (T0 + timedelta(days=3)).date()) == 0 + assert ledger.apply(confirmed) == result + with pytest.raises(ValidationError): + ledger.apply( + replace(confirmed, share_entitlement=replace(terms, confirmed_quantity=fp("41"))) + ) + assert ledger.snapshot() == result + # Restoring the captured native state removes every effect; a deterministic + # replay can apply a separately evidenced revision without a second ledger. + ledger.restore_state(before) + assert ledger.apply(confirmed) == result + for transaction in ledger.transactions: + totals = {} + for posting in transaction.postings: + totals[posting.currency] = ( + totals.get(posting.currency, Decimal(0)) + posting.amount.to_decimal() + ) + assert all(value == 0 for value in totals.values()) + + +@pytest.mark.parametrize("status, quantity", [("sensitivity_floor", 40), ("sensitivity_ceil", 41)]) +def test_separately_identified_sensitivity_quantities(status, quantity): + ledger, event = setup() + branch = replace( + event, share_entitlement=replace(event.share_entitlement, evidence_status=status) + ) + assert ledger.apply(branch).positions[STOCK].to_decimal() == 100 + quantity + + +def test_wrong_account_and_future_evidence_are_rejected(): + _, event = setup() + with pytest.raises(ValidationError, match="event ID"): + replace(event, share_entitlement=replace(event.share_entitlement, account_id="different")) + with pytest.raises(ValidationError, match="not available"): + replace(event, available_at=T0, received_at=T0, event_time=T0) + with pytest.raises(ValidationError, match="allocation rule"): + replace( + event.share_entitlement, fractional_rule="unknown", evidence_status="sensitivity_floor" + ) From f31022edf5272b4cda6e7c0ff4d0eefa671a68fa Mon Sep 17 00:00:00 2001 From: PureSaber Date: Thu, 15 Oct 2026 18:58:08 +0800 Subject: [PATCH 2/2] Pin final share-entitlement protocol revision --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 36576f9..583641a 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@b6edcf755c681e0e73ad02a99334b2714af9b2f7", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 8e5cdac..024c7d7 100644 --- a/requirements.lock +++ b/requirements.lock @@ -69,7 +69,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@b6edcf755c681e0e73ad02a99334b2714af9b2f7 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6 # via quant-execution (pyproject.toml) referencing==0.37.0 # via