Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions docs/share-entitlements.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6",
]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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@a84c7306a2f3ad6d2f2c90901d6525d091ab49d6
# via quant-execution (pyproject.toml)
referencing==0.37.0
# via
Expand Down
6 changes: 6 additions & 0 deletions src/quant_execution/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")

Expand Down
45 changes: 39 additions & 6 deletions src/quant_execution/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] = (
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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(
Expand Down
29 changes: 24 additions & 5 deletions src/quant_execution/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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]
]
Expand Down Expand Up @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
from quant_execution import (
Fill,
LiquidityRole,
Order,
OrderIntent,
OrderStatus,
OrderType,
Side,
TimeInForce,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading