From a3df465a76b6959af40531cecf3a1153fa9c407c Mon Sep 17 00:00:00 2001 From: Neko65536 Date: Sat, 26 Sep 2026 16:31:53 +0800 Subject: [PATCH 1/6] Pin evidence-bound data kit revision --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5cea937..f5d3a64 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@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@a459b328f79fbc55663d4f0c041d3805ccde21ce", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index caff9d3..3174b72 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@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@a459b328f79fbc55663d4f0c041d3805ccde21ce # via quant-execution (pyproject.toml) referencing==0.37.0 # via From b29dcc3cab26e9b4cc11f0021d5d1b5d43fb900e Mon Sep 17 00:00:00 2001 From: Neko65536 Date: Sat, 26 Sep 2026 17:04:19 +0800 Subject: [PATCH 2/6] Pin final historical master revision --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5d3a64..a0ad895 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@a459b328f79fbc55663d4f0c041d3805ccde21ce", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@db5e37d2aae43dc896a5d4b8ed43fad7840d770f", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 3174b72..9564f63 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@a459b328f79fbc55663d4f0c041d3805ccde21ce +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@db5e37d2aae43dc896a5d4b8ed43fad7840d770f # via quant-execution (pyproject.toml) referencing==0.37.0 # via From 99ab9b1445d72164fa4e8c6d1ebde859b80dba1f Mon Sep 17 00:00:00 2001 From: Neko65536 Date: Sat, 26 Sep 2026 17:07:41 +0800 Subject: [PATCH 3/6] Pin formatted final data kit revision --- pyproject.toml | 2 +- requirements.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0ad895..1708756 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@db5e37d2aae43dc896a5d4b8ed43fad7840d770f", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@b5621379e1a15562371be31c03f354a6acf512e4", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 9564f63..d9295dc 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@db5e37d2aae43dc896a5d4b8ed43fad7840d770f +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@b5621379e1a15562371be31c03f354a6acf512e4 # via quant-execution (pyproject.toml) referencing==0.37.0 # via From 0088bc6e1f3dce8a63fccc80740493205297d317 Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:47:01 +0800 Subject: [PATCH 4/6] Add settled USD research accounts and exact terminal equity settlement --- docs/us-cash.md | 11 ++ pyproject.toml | 4 +- requirements.lock | 33 +++-- src/quant_execution/ledger.py | 64 ++++++++- src/quant_execution/rules.py | 31 +++++ src/quant_execution/us_cash.py | 231 +++++++++++++++++++++++++++++++++ tests/test_us_cash.py | 118 +++++++++++++++++ 7 files changed, 477 insertions(+), 15 deletions(-) create mode 100644 docs/us-cash.md create mode 100644 src/quant_execution/us_cash.py create mode 100644 tests/test_us_cash.py diff --git a/docs/us-cash.md b/docs/us-cash.md new file mode 100644 index 0000000..be70436 --- /dev/null +++ b/docs/us-cash.md @@ -0,0 +1,11 @@ +# US cash research account + +`quant_execution.us_cash.USCashAccount` uses `ExactAccountLedger` for fills, fees, corporate actions and valuation. Cash is booked on trade date; `buying_power` separately subtracts unsettled sale proceeds. This is a conservative cash account using settled funds, not a margin/PDT or live brokerage model. + +Standard equity settlement uses T+2 before 2024-05-28 and T+1 from that date. Same-day resale is allowed for holdings bought using settled funds. Account units permit six decimal places for normalized-price and fractional-share research; no broker fillability claim is implied. + +Explicit `us_equity`/`us_etf` product types select `USCashEquityRule`. Legacy A-share product types preserve their prior rules. Costs are supplied assumptions, not a fixed regulatory fee schedule. + +`terminal_cash` corporate events retire all shares and remove book cost, posting a final known cash amount and realized P&L. They require a zero share ratio and nonnegative cash in the settlement currency. A zero recovery must be explicit upstream evidence, never a substitute for missing prices. + +Fractional equity sells now quantize posted cash and removed book cost before computing P&L. This corrects a one-unit rounding imbalance caused by independently quantizing all three amounts. diff --git a/pyproject.toml b/pyproject.toml index 5cea937..3953647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,13 @@ requires-python = ">=3.10" dependencies = [ "pyarrow>=14.0", "jsonschema>=4.20", - "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f4a3fe532b59ed4e5112ad9d188245c180773855", ] [project.optional-dependencies] +us-research = ["exchange-calendars>=4.5,<5"] dev = [ + "exchange-calendars>=4.5,<5", "exceptiongroup>=1", "pytest>=7.4", "pytest-cov>=5.0", diff --git a/requirements.lock b/requirements.lock index caff9d3..ef9bd50 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,8 +1,8 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# python -m piptools compile --extra dev --build-deps-for editable --allow-unsafe --strip-extras --resolver backtracking --index-url https://pypi.org/simple --constraint requirements-constraints.txt --output-file requirements.lock pyproject.toml +# pip-compile --allow-unsafe --build-deps-for=editable --extra=dev --index-url=https://pypi.org/simple --no-index --output-file=requirements.lock --strip-extras pyproject.toml # attrs==26.1.0 # via @@ -15,9 +15,9 @@ coverage==7.16.0 duckdb==1.5.5 # via quant-data-kit exceptiongroup==1.3.1 - # via - # pytest - # quant-execution (pyproject.toml) + # via quant-execution (pyproject.toml) +exchange-calendars==4.13.2 + # via quant-execution (pyproject.toml) iniconfig==2.3.0 # via pytest jsonschema==4.26.0 @@ -28,8 +28,11 @@ jsonschema-rs==0.52.1 # via quant-data-kit jsonschema-specifications==2025.9.1 # via jsonschema +korean-lunar-calendar==0.4.0 + # via exchange-calendars numpy==2.2.6 # via + # exchange-calendars # pandas # quant-data-kit orjson==3.12.0 @@ -37,7 +40,9 @@ orjson==3.12.0 packaging==26.3 # via pytest pandas==2.3.3 - # via quant-data-kit + # via + # exchange-calendars + # quant-data-kit pluggy==1.6.0 # via # pytest @@ -48,6 +53,8 @@ pyarrow==25.0.1 # quant-execution (pyproject.toml) pygments==2.21.0 # via pytest +pyluach==2.3.0 + # via exchange-calendars pytest==9.1.1 # via # pytest-cov @@ -60,7 +67,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@104f1ef8a3b1278c0ea5420fadaa9d1d863ce726 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@f4a3fe532b59ed4e5112ad9d188245c180773855 # via quant-execution (pyproject.toml) referencing==0.37.0 # via @@ -68,7 +75,6 @@ referencing==0.37.0 # jsonschema-specifications rpds-py==0.30.0 # via - # -c requirements-constraints.txt # jsonschema # referencing ruff==0.16.5 @@ -76,17 +82,18 @@ ruff==0.16.5 six==1.17.0 # via python-dateutil tomli==2.4.1 - # via - # coverage - # pytest - # quant-execution (pyproject.toml) + # via quant-execution (pyproject.toml) +toolz==1.1.0 + # via exchange-calendars typing-extensions==4.16.0 # via # exceptiongroup # quant-data-kit # referencing tzdata==2026.3 - # via pandas + # via + # exchange-calendars + # pandas websockets==15.0.1 # via quant-data-kit diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 3f2a7a7..34f9f11 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -1273,6 +1273,10 @@ def _prepare_lot_update( return lots, prior_close, today_close def _apply_split_state(self, event: CorporateActionEvent) -> None: + if event.action_type == "terminal_cash": + self._position_lots[event.instrument_id] = [] + self._marks.pop(event.instrument_id, None) + return ratio = decimal(event.ratio) self._position_lots[event.instrument_id] = [ (day, quantity * ratio) @@ -1312,6 +1316,19 @@ def _apply_dividend_state(self, event: CorporateActionEvent) -> None: def _validate_corporate_action(self, event: CorporateActionEvent) -> None: spec = self._spec(event.instrument_id) + if event.action_type == "terminal_cash": + if ( + spec.asset_class not in {AssetClass.EQUITY, AssetClass.ETF} + or event.ratio is None + or event.ratio.units != 0 + or event.cash_amount is None + or event.cash_amount.units < 0 + or event.currency != spec.settlement_currency + ): + raise ValidationError( + "terminal_cash requires a zero share ratio and nonnegative settlement cash" + ) + return 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") @@ -1474,7 +1491,11 @@ def _fill_transaction(self, fill_event: Fill) -> LedgerTransaction: ] ) else: - cost_removed = average * quantity * multiplier + # Quantize cash and removed book cost first. Independently rounding + # their difference can leave a one-unit imbalance for fractional + # equity fills. P&L is the exact residual of the posted amounts. + notional = decimal(fixed(notional, self.money_scale)) + cost_removed = decimal(fixed(average * quantity * multiplier, self.money_scale)) postings.extend( [ self._posting("assets:cash", spec.settlement_currency, notional), @@ -1606,6 +1627,47 @@ def _settlement_transaction(self, event: Settlement) -> LedgerTransaction: def _corporate_action_transaction(self, event: CorporateActionEvent) -> LedgerTransaction: spec = self._spec(event.instrument_id) quantity = self._positions.get(event.instrument_id, Decimal(0)) + if event.action_type == "terminal_cash": + cash = decimal(fixed(quantity * decimal(event.cash_amount), self.money_scale)) + cost = self._position_cost(event.instrument_id, derivative=False) + postings = ( + self._posting("assets:cash", spec.settlement_currency, cash), + self._posting( + "assets:position_cost", + spec.settlement_currency, + -cost, + instrument_id=event.instrument_id, + ), + self._posting( + "income:realized_pnl", + spec.settlement_currency, + cost - cash, + instrument_id=event.instrument_id, + ), + self._posting( + "assets:position", + spec.settlement_currency, + Decimal(0), + instrument_id=event.instrument_id, + quantity_delta=-quantity, + quantity_scale=spec.quantity_step.scale, + ), + self._posting( + "memo:position_counter", + spec.settlement_currency, + Decimal(0), + instrument_id=event.instrument_id, + quantity_delta=quantity, + quantity_scale=spec.quantity_step.scale, + ), + ) + return self._make_transaction( + event_type=LedgerEventType.CORPORATE_ACTION, + reference_id=event.event_id, + idempotency_key=f"corporate_action:{event.event_id}", + event_time=event.available_at, + postings=postings, + ) postings: list[Posting] = [] if event.cash_amount is not None: currency = str(event.currency) diff --git a/src/quant_execution/rules.py b/src/quant_execution/rules.py index 4772836..1be13be 100644 --- a/src/quant_execution/rules.py +++ b/src/quant_execution/rules.py @@ -168,6 +168,35 @@ def fee_rate( return _metadata_decimal(spec, key, default="0") +class USCashEquityRule(_AssetRule): + """Explicit US cash equity rule; settlement is separate from resale permission.""" + + code = "US_CASH_EQUITY" + + def check(self, intent, snapshot, state, spec, ledger): + from quant_execution.us_cash import settled_cash + + base = super().check(intent, snapshot, state, spec, ledger) + if not base.accepted: + return base + if spec.settlement_currency != "USD": + return RiskDecision(False, "US_CURRENCY", "US cash research requires USD") + quantity = decimal(intent.quantity) + if intent.side is Side.SELL and quantity > _value( + snapshot.positions.get(spec.instrument_id) + ): + return RiskDecision(False, "US_NO_SHORT", "cash account cannot sell short") + if intent.side is Side.BUY: + cost = quantity * _intent_price(intent, state) * decimal(spec.contract_multiplier) + cost *= Decimal(1) + _metadata_decimal(spec, "commission_rate") + if cost > settled_cash(ledger, state.event.available_at): + return RiskDecision(False, "US_UNSETTLED_CASH", "insufficient settled USD") + return _ACCEPTED_DECISION + + def fee_rate(self, fill, order, state, spec, ledger): + return _metadata_decimal(spec, "commission_rate") + + class AShareRule(_AssetRule): code = "A_SHARE" @@ -956,6 +985,8 @@ def _reservation_requirement( def _rule(spec: InstrumentSpec) -> _AssetRule: product = spec.product_type.lower() if spec.asset_class in {AssetClass.EQUITY, AssetClass.ETF}: + if product in {"us_equity", "us_etf"}: + return USCashEquityRule() return AShareRule() if spec.asset_class is AssetClass.FUTURE: return FuturesRule() diff --git a/src/quant_execution/us_cash.py b/src/quant_execution/us_cash.py new file mode 100644 index 0000000..c90015b --- /dev/null +++ b/src/quant_execution/us_cash.py @@ -0,0 +1,231 @@ +"""USD cash research account backed by the shared exact double-entry ledger. + +Trade-date cash and settled buying power are distinct. Sale proceeds remain +unavailable until standard settlement. Purchases use settled funds only; thus +same-day resale does not inherit the domestic T+1 holding restriction. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import ROUND_DOWN, Decimal + +from quant_data_kit import ( + AssetClass, + CorporateActionEvent, + FixedPoint, + InstrumentSpec, + MarkPriceEvent, +) +from quant_data_kit.us_research.calendar import settlement_session +from quant_data_kit.us_research.prices import utc + +from .contracts import Fee, Fill, LedgerEventType, Side +from .ledger import ExactAccountLedger + + +def money(value) -> Decimal: + result = Decimal(str(value)) + if not result.is_finite(): + raise ValueError("finite decimal required") + return result + + +def fp(value, scale: int = 8) -> FixedPoint: + return FixedPoint.from_decimal(money(value).quantize(Decimal(1).scaleb(-scale)), scale) + + +def settled_cash(ledger: ExactAccountLedger, at: datetime) -> Decimal: + day = str(utc(at).tz_convert("America/New_York").date()) + pending = Decimal(0) + for transaction in ledger.transactions: + if transaction.event_type is not LedgerEventType.FILL: + continue + trade_day = str(utc(transaction.event_time).tz_convert("America/New_York").date()) + if settlement_session(trade_day) > day: + pending += sum( + ( + money(p.amount.to_decimal()) + for p in transaction.postings + if p.ledger_account == "assets:cash" + and p.currency == "USD" + and p.amount.units > 0 + ), + Decimal(0), + ) + return ledger.cash_balance("USD") - pending + + +def instrument( + instrument_id: str, symbol: str, at: datetime, *, etf: bool = False +) -> InstrumentSpec: + return InstrumentSpec( + instrument_id=instrument_id, + asset_class=AssetClass.ETF if etf else AssetClass.EQUITY, + product_type="us_etf" if etf else "us_equity", + venue="US-CONSOLIDATED", + native_symbol=symbol, + settlement_currency="USD", + quote_currency="USD", + price_tick=fp("0.00000001"), + quantity_step=fp("0.000001", 6), + contract_multiplier=fp(1, 0), + calendar_id="XNYS", + effective_from=at, + available_at=at, + metadata={"market": "US", "commission_rate": "0"}, + ) + + +class USCashAccount: + """Long-only, fractional research units, settled-cash funded, no broker API.""" + + def __init__( + self, + instruments: dict[str, InstrumentSpec], + initial_cash, + opened_at: datetime, + *, + commission_bps=1, + slippage_bps=2, + ): + self.commission = money(commission_bps) / 10000 + self.slippage = money(slippage_bps) / 10000 + if not 0 <= self.commission < 1 or not 0 <= self.slippage < 1 or money(initial_cash) <= 0: + raise ValueError("invalid cash or costs") + if any( + spec.settlement_currency != "USD" or spec.product_type not in {"us_equity", "us_etf"} + for spec in instruments.values() + ): + raise ValueError("explicit USD US equity instruments required") + self.ledger = ExactAccountLedger( + account_id="us-research", + base_currency="USD", + instruments=instruments, + initial_cash={"USD": fp(initial_cash)}, + opened_at=opened_at, + ) + self.fills: list[dict] = [] + self._trades: dict[str, tuple] = {} + + def quantity(self, instrument_id: str) -> Decimal: + value = self.ledger.snapshot().positions.get(instrument_id) + return Decimal(0) if value is None else money(value.to_decimal()) + + def buying_power(self, at) -> Decimal: + return max(Decimal(0), settled_cash(self.ledger, utc(at).to_pydatetime())) + + @staticmethod + def _fields(event_id, instrument_id, at): + stamp = utc(at) + day = stamp.tz_convert("America/New_York").date() + return { + "event_id": event_id, + "instrument_id": instrument_id, + "event_time": stamp.to_pydatetime(), + "received_at": stamp.to_pydatetime(), + "available_at": stamp.to_pydatetime(), + "source": "us-research-model", + "trading_day": day, + "session_id": f"XNYS:{day}", + "sequence": 0, + } + + def mark(self, instrument_id, price, at, event_id): + self.ledger.mark( + MarkPriceEvent(**self._fields(event_id, instrument_id, at), price=fp(price)) + ) + + def action( + self, instrument_id, at, event_id, *, ratio=None, cash=None, payment=False, ex_date=None + ): + fields = self._fields(event_id, instrument_id, at) + effective = fields["trading_day"] if ex_date is None else ex_date + self.ledger.apply( + CorporateActionEvent( + **fields, + action_type=("cash_dividend_payment" if payment else "cash_dividend_entitlement") + if cash is not None + else "split", + effective_date=effective, + ratio=fp(ratio, 6) if ratio is not None else None, + cash_amount=fp(cash) if cash is not None else None, + currency="USD" if cash is not None else None, + ) + ) + + def trade(self, instrument_id, quantity, reference_price, at, trade_id) -> dict: + quantity, price = money(quantity), money(reference_price) + stamp = utc(at).to_pydatetime() + fingerprint = (instrument_id, quantity, price, stamp) + if trade_id in self._trades: + if self._trades[trade_id] != fingerprint: + raise ValueError("trade id reused with changed content") + return next(row for row in self.fills if row["trade_id"] == trade_id) + if quantity == 0 or price <= 0: + raise ValueError("nonzero quantity and positive price required") + side = Side.BUY if quantity > 0 else Side.SELL + absolute = abs(quantity).quantize(Decimal("0.000001"), rounding=ROUND_DOWN) + if absolute == 0 or absolute != abs(quantity): + raise ValueError("quantity exceeds supported 6-decimal precision") + execution_price = fp(price * (1 + self.slippage if side is Side.BUY else 1 - self.slippage)) + notional = money(execution_price.to_decimal()) * absolute + fee = fp(notional * self.commission) + if side is Side.SELL and absolute > self.quantity(instrument_id): + raise ValueError("cash account cannot sell short") + if side is Side.BUY and notional + money(fee.to_decimal()) > self.buying_power(stamp): + raise ValueError("insufficient settled USD buying power") + fill = Fill( + fill_id=trade_id, + order_id=trade_id, + account_id="us-research", + strategy_id="daily", + instrument_id=instrument_id, + side=side, + quantity=fp(absolute, 6), + price=execution_price, + event_time=stamp, + ) + # Both events are prevalidated; rollback keeps the pair atomic on any ledger rejection. + state = self.ledger.capture_state() + try: + self.ledger.apply(fill) + self.ledger.apply( + Fee( + fee_id=f"{trade_id}:fee", + fill_id=trade_id, + account_id="us-research", + amount=fee, + currency="USD", + event_time=stamp, + fee_type="commission", + ) + ) + except Exception: + self.ledger.restore_state(state) + raise + record = { + "trade_id": trade_id, + "instrument_id": instrument_id, + "at": stamp.isoformat(), + "side": side.value, + "quantity": str(absolute), + "price": str(execution_price.to_decimal()), + "fee": str(fee.to_decimal()), + "reference_price": str(price), + } + self._trades[trade_id] = fingerprint + self.fills.append(record) + return record + + def validate_balance(self): + for transaction in self.ledger.transactions: + totals: dict[str, Decimal] = {} + for posting in transaction.postings: + totals[posting.currency] = totals.get(posting.currency, Decimal(0)) + money( + posting.amount.to_decimal() + ) + if any(value != 0 for value in totals.values()): + raise AssertionError( + f"unbalanced QExec transaction {transaction.transaction_id}: {totals}; {transaction.postings}" + ) diff --git a/tests/test_us_cash.py b/tests/test_us_cash.py new file mode 100644 index 0000000..ecf2bb9 --- /dev/null +++ b/tests/test_us_cash.py @@ -0,0 +1,118 @@ +from datetime import date +from decimal import Decimal + +import pandas as pd +import pytest +from quant_data_kit import CorporateActionEvent + +from quant_execution.rules import RuleBookRiskGate, USCashEquityRule +from quant_execution.us_cash import USCashAccount, fp, instrument, settled_cash + + +def account(at="2024-05-24T14:00:00Z", cash=1000, **kwargs): + stamp = pd.Timestamp(at).to_pydatetime() + spec = instrument("US:A", "AAPL", stamp) + return USCashAccount({"US:A": spec}, cash, stamp, **kwargs) + + +def test_same_day_resale_allowed_but_proceeds_not_reusable(): + a = account(commission_bps=0, slippage_bps=0) + at = "2024-05-24T14:00:00Z" + a.mark("US:A", 100, at, "m") + a.trade("US:A", 10, 100, at, "b") + a.trade("US:A", -10, 100, at, "s") + assert a.quantity("US:A") == 0 + assert a.buying_power(at) == 0 + with pytest.raises(ValueError, match="settled"): + a.trade("US:A", 1, 100, at, "blocked") + assert settled_cash(a.ledger, pd.Timestamp("2024-05-28T14:00Z")) == 0 + assert settled_cash(a.ledger, pd.Timestamp("2024-05-29T14:00Z")) == 1000 + a.validate_balance() + + +def test_post_transition_t1_settlement_and_us_rule_dispatch(): + a = account(at="2024-05-28T14:00Z", commission_bps=0, slippage_bps=0) + at = "2024-05-28T14:00Z" + a.mark("US:A", 100, at, "m") + a.trade("US:A", 10, 100, at, "b") + a.trade("US:A", -10, 100, at, "s") + assert a.buying_power("2024-05-29T14:00Z") == 1000 + assert isinstance(RuleBookRiskGate._rule(a.ledger.instruments["US:A"]), USCashEquityRule) + + +def test_dividend_entitlement_survives_sale_and_is_not_early_cash(): + a = account(commission_bps=0, slippage_bps=0) + at = "2024-05-24T14:00Z" + a.mark("US:A", 100, at, "m") + a.trade("US:A", 10, 100, at, "b") + a.action("US:A", "2024-05-28T13:30Z", "ex", cash=1) + assert a.ledger.dividend_receivable_balance("USD") == 10 + assert a.buying_power("2024-05-28T13:30Z") == 0 + a.trade("US:A", -10, 100, "2024-05-28T13:30Z", "s") + a.action("US:A", "2024-05-30T13:30Z", "pay", cash=1, payment=True, ex_date=date(2024, 5, 28)) + assert a.ledger.dividend_receivable_balance("USD") == 0 + assert a.ledger.cash_balance("USD") == 1010 + a.validate_balance() + + +def test_split_preserves_nav_and_fractional_sell_balances(): + a = account(cash=100000, commission_bps=0, slippage_bps=0) + at = "2024-05-24T14:00Z" + a.mark("US:A", "102.73921149", at, "m") + a.trade("US:A", "30.123456", "102.73921149", at, "b") + before = a.ledger.snapshot().nav + a.action("US:A", "2024-05-28T13:30Z", "split", ratio=2) + a.mark("US:A", "51.36960574", "2024-05-28T13:30Z", "m2") + assert a.quantity("US:A") == Decimal("60.246912") + assert abs(a.ledger.snapshot().nav.to_decimal() - before.to_decimal()) < Decimal("0.000001") + a.trade("US:A", "-1.389008", "102.07636123", "2024-05-28T13:30Z", "s") + a.validate_balance() + + +def test_terminal_zero_has_explicit_evidence_and_clears_position(): + a = account(commission_bps=0, slippage_bps=0) + at = "2024-05-24T14:00Z" + a.mark("US:A", 100, at, "m") + a.trade("US:A", 5, 100, at, "b") + a.ledger.apply( + CorporateActionEvent( + **a._fields("delist", "US:A", "2024-05-28T13:30Z"), + action_type="terminal_cash", + effective_date=date(2024, 5, 28), + ratio=fp(0, 6), + cash_amount=fp(0), + currency="USD", + ) + ) + assert a.quantity("US:A") == 0 + assert a.ledger.snapshot().nav.to_decimal() == 500 + a.validate_balance() + + +def test_idempotency_costs_shorting_and_invalid_quantities(): + a = account(commission_bps=10, slippage_bps=20) + at = "2024-05-24T14:00Z" + a.mark("US:A", 100, at, "m") + fill = a.trade("US:A", 1, 100, at, "b") + assert fill["price"] == "100.20000000" + assert fill["fee"] == "0.10020000" + count = len(a.ledger.transactions) + assert a.trade("US:A", 1, 100, at, "b") == fill + assert len(a.ledger.transactions) == count + with pytest.raises(ValueError, match="reused"): + a.trade("US:A", 2, 100, at, "b") + for quantity in (0, -2, "0.0000001"): + with pytest.raises(ValueError): + a.trade("US:A", quantity, 100, at, str(quantity)) + a.validate_balance() + + +def test_fractional_sales_round_postings_before_pnl(): + a = account(cash=100000, commission_bps=0, slippage_bps=0) + at = "2024-05-24T14:00Z" + a.mark("US:A", 100, at, "m") + for number in range(10): + a.trade("US:A", "13.123457", str(100 + number / 7), at, f"buy{number}") + for number in range(30): + a.trade("US:A", "-1.389008", str(102 + number / 13), at, f"sell{number}") + a.validate_balance() From 0818220165c76a1a6da181325838a27f93e51b6c Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:00:31 +0800 Subject: [PATCH 5/6] Cover US risk decisions and pin validated settlement calendar --- README.md | 4 +-- pyproject.toml | 2 +- requirements.lock | 5 ++-- tests/test_us_cash.py | 69 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 578f011..d8a90fc 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ editable-build requirements for Python3.10-3.12. Every registry package is fixed version. The `dev` extra names Python3.10's conditional compatibility dependencies explicitly so a lock compiled on Python3.12 remains complete for the whole matrix. The internal package is also fixed to the reviewed research-data revision in this development branch: -`quant-data-kit@8fed47b8f62694c36830dec270cfa21759133f2f`, from +`quant-data-kit@6c40fedfd01fb6cff795c3faeacb04ff7f094c83`, from `https://github.com/PureSaber/quant-data-kit.git`. The project declaration and lock -use the same immutable source as the daily A-share research stack, so a clean +use the same immutable source as the US research stack in this branch, so a clean resolver does not combine incompatible direct URLs. Existing release tags remain unchanged. Regenerate the lock only after reviewing dependency changes in `pyproject.toml`: diff --git a/pyproject.toml b/pyproject.toml index 3953647..95cc25e 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@f4a3fe532b59ed4e5112ad9d188245c180773855", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@6c40fedfd01fb6cff795c3faeacb04ff7f094c83", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index ef9bd50..908f84e 100644 --- a/requirements.lock +++ b/requirements.lock @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # -# pip-compile --allow-unsafe --build-deps-for=editable --extra=dev --index-url=https://pypi.org/simple --no-index --output-file=requirements.lock --strip-extras pyproject.toml +# pip-compile --allow-unsafe --build-deps-for=editable --constraint=requirements-constraints.txt --extra=dev --index-url=https://pypi.org/simple --no-emit-index-url --no-index --output-file=requirements.lock --strip-extras pyproject.toml # attrs==26.1.0 # via @@ -67,7 +67,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@f4a3fe532b59ed4e5112ad9d188245c180773855 +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@6c40fedfd01fb6cff795c3faeacb04ff7f094c83 # via quant-execution (pyproject.toml) referencing==0.37.0 # via @@ -75,6 +75,7 @@ referencing==0.37.0 # jsonschema-specifications rpds-py==0.30.0 # via + # -c requirements-constraints.txt # jsonschema # referencing ruff==0.16.5 diff --git a/tests/test_us_cash.py b/tests/test_us_cash.py index ecf2bb9..ca1b191 100644 --- a/tests/test_us_cash.py +++ b/tests/test_us_cash.py @@ -1,11 +1,14 @@ +from dataclasses import replace from datetime import date from decimal import Decimal import pandas as pd import pytest -from quant_data_kit import CorporateActionEvent +from quant_data_kit import CorporateActionEvent, MarkPriceEvent +from quant_data_kit.exceptions import ValidationError -from quant_execution.rules import RuleBookRiskGate, USCashEquityRule +from quant_execution.contracts import OrderIntent, OrderType, Side, TimeInForce +from quant_execution.rules import MarketState, RuleBookRiskGate, USCashEquityRule from quant_execution.us_cash import USCashAccount, fp, instrument, settled_cash @@ -116,3 +119,65 @@ def test_fractional_sales_round_postings_before_pnl(): for number in range(30): a.trade("US:A", "-1.389008", str(102 + number / 13), at, f"sell{number}") a.validate_balance() + + +def test_us_risk_gate_checks_currency_settlement_shorting_and_fee(): + a = account(commission_bps=0, slippage_bps=0) + at = pd.Timestamp("2024-05-24T14:00Z").to_pydatetime() + spec = a.ledger.instruments["US:A"] + event = MarkPriceEvent(**a._fields("risk-mark", "US:A", at), price=fp(100)) + state = MarketState(event=event, reference_price=fp(100), status="open") + intent = OrderIntent( + idempotency_key="risk", + account_id="us-research", + strategy_id="test", + instrument_id="US:A", + side=Side.BUY, + quantity=fp(1, 6), + order_type=OrderType.MARKET, + time_in_force=TimeInForce.DAY, + created_at=at, + ) + rule = USCashEquityRule() + snapshot = a.ledger.snapshot() + assert rule.check(intent, snapshot, state, spec, a.ledger).accepted + assert not rule.check( + intent, snapshot, replace(state, reference_price=None), spec, a.ledger + ).accepted + assert ( + rule.check(intent, snapshot, state, replace(spec, settlement_currency="CNY"), a.ledger).code + == "US_CURRENCY" + ) + sell = replace(intent, side=Side.SELL) + assert rule.check(sell, snapshot, state, spec, a.ledger).code == "US_NO_SHORT" + a.mark("US:A", 100, at, "m") + a.trade("US:A", 10, 100, at, "b") + assert rule.check(sell, a.ledger.snapshot(), state, spec, a.ledger).accepted + a.trade("US:A", -10, 100, at, "s") + assert ( + rule.check(intent, a.ledger.snapshot(), state, spec, a.ledger).code == "US_UNSETTLED_CASH" + ) + assert rule.fee_rate( + None, None, state, replace(spec, metadata={"commission_rate": "0.001"}), a.ledger + ) == Decimal("0.001") + + +@pytest.mark.parametrize( + "ratio,cash,currency", + [(1, 0, "USD"), (None, 0, "USD"), (0, None, "USD"), (0, -1, "USD"), (0, 0, "CNY")], +) +def test_terminal_action_rejects_incomplete_or_invalid_evidence(ratio, cash, currency): + a = account() + before = a.ledger.snapshot() + with pytest.raises(ValidationError): + a.ledger.apply( + CorporateActionEvent( + **a._fields("invalid-terminal", "US:A", "2024-05-28T13:30Z"), + action_type="terminal_cash", + effective_date=date(2024, 5, 28), + ratio=None if ratio is None else fp(ratio, 6), + cash_amount=None if cash is None else fp(cash), + currency=currency, + ) + ) + assert a.ledger.snapshot() == before From 1bf6a627abf5a9a0d2cd2c0b4d3ad5740d464b2a Mon Sep 17 00:00:00 2001 From: PureSaber <88314620+PureSaber@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:11:25 +0800 Subject: [PATCH 6/6] feat: exact corporate actions and fail-closed status evidence --- docs/FINANCIAL_FOUNDATIONS.md | 11 + pyproject.toml | 2 +- requirements.lock | 2 +- src/quant_execution/corporate_actions.py | 253 +++++++++++++++++++++++ src/quant_execution/ledger.py | 6 + src/quant_execution/rules.py | 17 +- tests/test_financial_actions.py | 171 +++++++++++++++ tests/test_rules.py | 25 +++ 8 files changed, 481 insertions(+), 6 deletions(-) create mode 100644 docs/FINANCIAL_FOUNDATIONS.md create mode 100644 src/quant_execution/corporate_actions.py create mode 100644 tests/test_financial_actions.py diff --git a/docs/FINANCIAL_FOUNDATIONS.md b/docs/FINANCIAL_FOUNDATIONS.md new file mode 100644 index 0000000..26f5980 --- /dev/null +++ b/docs/FINANCIAL_FOUNDATIONS.md @@ -0,0 +1,11 @@ +# 03 / 04 公司行动与未知状态执行 + +`ledger.apply_corporate_action(ActionTerms(...), at=...)` 接受 QDK financial.actions 契约,复用现有精确双式账本,不制造成交。支持拆股、分红权益/支付、换股、分拆、供股权分派/显式行权、碎股现金和终止现金。source/evidence_id、原价口径、目标估值及成本分配必须明确。失败恢复全部账本状态;同 ID 同条款幂等,不同条款拒绝。 + +分红权益先产生应收,支付才入现金。碎股现金需要 retired_quantity;供股需要 election_quantity,不能默认全额认购;新认购股的 lot 日期为行权日。复杂转换只支持同币种、单位乘数、非做空现金证券,不含自动 FX/税费/税务成本规则。目标必须先注册;不支持 streaming artifact sink 的原子行动批次。复杂事件必须与交易和估值按时间顺序回放。 + +底层账本现金不是市场交收模型:美股/港股适配在行权前另外检查已交收可用现金。其他调用者也必须提供市场层约束。分拆/合并目标价格来自显式输入,不是自动找价。 + +`resolve_a_share_replay_status` 保留缺失 flag 为 unknown。RuleBookRiskGate 对 unknown/no_restriction 返回 MARKET_STATUS_UNKNOWN。InstrumentSpec.metadata["requires_status_evidence"]="true" 启用严格模式:只有行情、没有状态事件时不默认开市;状态不能沿用到下一 trading_day。旧数据未启用该开关保留兼容行为;严格研究必须启用或每日显式投递状态。 + +测试:tests/test_financial_actions.py(财富/成本守恒、幂等、现金、失败回滚),tests/test_rules.py(状态缺失与跨日过期)。不涉及真实券商订单。 diff --git a/pyproject.toml b/pyproject.toml index d4652de..ec0be39 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@7a8813b3d1e52f476f8fe51f05ea714be41a52bb", + "quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@271a65ee383158b3dd7570e19a3f2b4123f09f79", ] [project.optional-dependencies] diff --git a/requirements.lock b/requirements.lock index 48b64b7..5798488 100644 --- a/requirements.lock +++ b/requirements.lock @@ -67,7 +67,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@7a8813b3d1e52f476f8fe51f05ea714be41a52bb +quant-data-kit @ git+https://github.com/PureSaber/quant-data-kit.git@271a65ee383158b3dd7570e19a3f2b4123f09f79 # via quant-execution (pyproject.toml) referencing==0.37.0 # via diff --git a/src/quant_execution/corporate_actions.py b/src/quant_execution/corporate_actions.py new file mode 100644 index 0000000..4c0a7ac --- /dev/null +++ b/src/quant_execution/corporate_actions.py @@ -0,0 +1,253 @@ +"""Atomic, exact corporate-action application to the existing ledger. + +No synthetic fills, no automatic rights election, and no inferred FX or cost +allocation. Complex conversions currently require same-currency cash equities. +Fractional quantities must be represented by the declared instrument step; +cash-in-lieu is a separate evidenced event, never a guessed rounding price. +""" + +from decimal import Decimal + +from quant_data_kit import CorporateActionEvent +from quant_data_kit.exceptions import ValidationError +from quant_data_kit.financial.actions import ActionTerms +from quant_data_kit.financial.common import number, utc + +from ._fixed import decimal, fixed +from .contracts import LedgerEventType, LedgerTransaction + + +def apply_action(ledger, action: ActionTerms, *, at): + ledger._require_mutable() + stamp = utc(at).to_pydatetime() + if stamp < utc(action.effective_at) or stamp < utc(action.available_at): + raise ValidationError("corporate action is not effective and known yet") + fingerprint = action.fingerprint() + key = f"financial-action:{action.event_id}" + if key in ledger._event_fingerprints: + if ledger._event_fingerprints[key] != fingerprint: + raise ValidationError("corporate action ID reused with changed terms") + return ledger.snapshot() + if stamp < ledger.snapshot().event_time: + raise ValidationError("corporate action cannot reverse account time") + if ledger._artifact_sink is not None: + raise ValidationError("complex action batch requires nonstreaming atomic replay") + spec = ledger._spec(action.instrument_id) + if ledger._is_derivative(spec) or spec.settlement_currency != action.currency: + raise ValidationError("action requires matching cash-asset currency") + state = ledger.capture_state() + try: + if action.kind == "cash_in_lieu": + _cash_in_lieu(ledger, action, stamp) + elif action.kind in {"split", "dividend_entitlement", "dividend_payment", "terminal_cash"}: + kind = { + "dividend_entitlement": "cash_dividend_entitlement", + "dividend_payment": "cash_dividend_payment", + }.get(action.kind, action.kind) + cash = ( + None + if action.kind == "split" + else fixed(number(action.cash_per_unit), ledger.money_scale) + ) + ratio = fixed(number(action.ratio), 12) if action.kind == "split" else None + if action.kind == "terminal_cash": + ratio = fixed(Decimal(0), 0) + ledger.apply( + CorporateActionEvent( + event_id=key, + instrument_id=action.instrument_id, + action_type=kind, + event_time=stamp, + received_at=stamp, + available_at=stamp, + source=action.source + "#" + action.evidence_id, + trading_day=stamp.date(), + session_id="financial-actions", + sequence=0, + effective_date=( + utc(action.entitlement_date + "T00:00:00Z").date() + if action.entitlement_date + else stamp.date() + ), + ratio=ratio, + cash_amount=cash, + currency=action.currency if cash is not None else None, + ) + ) + else: + _convert(ledger, action, stamp) + ledger._event_fingerprints[key] = fingerprint + return ledger.snapshot(stamp) + except Exception: + ledger.restore_state(state) + raise + + +def _cash_in_lieu(ledger, action, stamp): + spec = ledger._spec(action.instrument_id) + old = ledger._positions.get(action.instrument_id, Decimal(0)) + retired = number(action.retired_quantity) + if retired > old or retired % decimal(spec.quantity_step): + raise ValidationError("cash-in-lieu quantity exceeds holding or violates declared step") + cost = decimal( + fixed( + ledger._position_cost(action.instrument_id, derivative=False) * retired / old, + ledger.money_scale, + ) + ) + cash = decimal(fixed(retired * number(action.cash_per_unit), ledger.money_scale)) + currency = action.currency + postings = [ + ledger._posting("assets:cash", currency, cash), + ledger._posting( + "assets:position_cost", currency, -cost, instrument_id=action.instrument_id + ), + ledger._posting( + "income:realized_pnl", currency, cost - cash, instrument_id=action.instrument_id + ), + ] + for account, delta in (("assets:position", -retired), ("memo:position_counter", retired)): + postings.append( + ledger._posting( + account, + currency, + Decimal(0), + instrument_id=action.instrument_id, + quantity_delta=delta, + quantity_scale=spec.quantity_step.scale, + ) + ) + tx = ledger._make_transaction( + event_type=LedgerEventType.CORPORATE_ACTION, + reference_id=action.event_id, + idempotency_key=f"financial-action:{action.event_id}", + event_time=stamp, + postings=tuple(postings), + ) + LedgerTransaction.__post_init__(tx) + ledger._post(tx) + remaining, lots = retired, [] + for acquired, quantity in ledger._position_lots.get(action.instrument_id, []): + take = min(quantity, remaining) + remaining -= take + if quantity > take: + lots.append((acquired, quantity - take)) + if remaining: + raise ValidationError("cash-in-lieu quantity is not backed by holding lots") + ledger._position_lots[action.instrument_id] = lots + if retired == old: + ledger._marks.pop(action.instrument_id, None) + ledger._event_time = stamp + + +def _convert(ledger, action, stamp): + parent, target = ledger._spec(action.instrument_id), ledger._spec(action.target_id) + if ( + ledger._is_derivative(target) + or target.settlement_currency != action.currency + or decimal(parent.contract_multiplier) != 1 + or decimal(target.contract_multiplier) != 1 + ): + raise ValidationError("conversion requires same-currency unit-multiplier cash assets") + old = ledger._positions.get(action.instrument_id, Decimal(0)) + if old < 0: + raise ValidationError("short corporate-action conversion is not supported") + exercise = action.kind == "rights_exercise" + distribution = action.kind in {"spin_off", "rights_distribution"} + eligible = number(action.election_quantity) if exercise else old + if eligible > old: + raise ValidationError("election exceeds held rights") + added = eligible * number(action.ratio) + removed = Decimal(0) if distribution else eligible + if added % decimal(target.quantity_step) or removed % decimal(parent.quantity_step): + raise ValidationError( + "fractional entitlement requires explicit step or cash-in-lieu evidence" + ) + cost = ledger._position_cost(action.instrument_id, derivative=False) + moved = ( + cost + * (eligible / old if old else Decimal(0)) + * (Decimal(1) if exercise else number(action.cost_fraction)) + ) + moved = decimal(fixed(moved, ledger.money_scale)) + removed_cost = ( + moved + if distribution + else decimal(fixed(cost * (eligible / old if old else Decimal(0)), ledger.money_scale)) + ) + cash = eligible * number(action.cash_per_unit) * (-1 if exercise else 1) + cash = decimal(fixed(cash, ledger.money_scale)) + if ledger.cash_balance(action.currency) + cash < 0: + raise ValidationError("insufficient cash for explicitly elected rights subscription") + target_cost = moved - cash if exercise else moved + postings = [ + ledger._posting("assets:cash", action.currency, cash), + ledger._posting( + "assets:position_cost", + action.currency, + -removed_cost, + instrument_id=action.instrument_id, + ), + ledger._posting( + "assets:position_cost", action.currency, target_cost, instrument_id=action.target_id + ), + ledger._posting( + "income:realized_pnl", + action.currency, + removed_cost - target_cost - cash, + instrument_id=action.instrument_id, + ), + ] + for instrument, delta, step in ( + (action.instrument_id, -removed, parent.quantity_step), + (action.target_id, added, target.quantity_step), + ): + for account, sign in (("assets:position", 1), ("memo:position_counter", -1)): + postings.append( + ledger._posting( + account, + action.currency, + Decimal(0), + instrument_id=instrument, + quantity_delta=delta * sign, + quantity_scale=step.scale, + ) + ) + tx = ledger._make_transaction( + event_type=LedgerEventType.CORPORATE_ACTION, + reference_id=action.event_id, + idempotency_key=f"financial-action:{action.event_id}", + event_time=stamp, + postings=tuple(postings), + ) + # _make_transaction is a trusted fast path; independently verify exact balance here. + LedgerTransaction.__post_init__(tx) + if any( + p.amount.units or (p.quantity_delta is not None and p.quantity_delta.units) + for p in postings + ): + ledger._post(tx) + if removed: + residual, consumed = [], [] + remaining = removed + for acquired, quantity in ledger._position_lots.get(action.instrument_id, []): + taken = min(remaining, quantity) + remaining -= taken + if taken: + consumed.append( + (stamp.date() if exercise else acquired, taken * number(action.ratio)) + ) + if quantity > taken: + residual.append((acquired, quantity - taken)) + if remaining: + raise ValidationError("position lots do not cover conversion") + ledger._position_lots[action.instrument_id] = residual + else: + consumed = [(stamp.date(), added)] if added else [] + ledger._position_lots.setdefault(action.target_id, []).extend(consumed) + ledger._marks[action.target_id] = (number(action.target_mark), stamp, action.event_id) + if distribution: + ledger._marks[action.instrument_id] = (number(action.parent_mark), stamp, action.event_id) + elif not ledger._positions.get(action.instrument_id): + ledger._marks.pop(action.instrument_id, None) + ledger._event_time = stamp diff --git a/src/quant_execution/ledger.py b/src/quant_execution/ledger.py index 34f9f11..47bcc38 100644 --- a/src/quant_execution/ledger.py +++ b/src/quant_execution/ledger.py @@ -802,6 +802,12 @@ def liquidation_required(self, event_time: datetime | None = None) -> bool: rounded_maintenance = fixed(maintenance_margin, self.money_scale) return rounded_maintenance.units > 0 and rounded_nav.units <= rounded_maintenance.units + def apply_corporate_action(self, action, *, at): + """Apply evidenced cross-market terms atomically without synthetic trades.""" + from quant_execution.corporate_actions import apply_action + + return apply_action(self, action, at=at) + def apply(self, event: LedgerEvent, *, create_snapshot: bool = True) -> AccountSnapshot | None: trading_day = event.event_time.date() if isinstance(event, Fill) else None return self._apply( diff --git a/src/quant_execution/rules.py b/src/quant_execution/rules.py index ec964b1..7e4c75c 100644 --- a/src/quant_execution/rules.py +++ b/src/quant_execution/rules.py @@ -79,9 +79,9 @@ def resolve_a_share_replay_status( *, listed: bool, delisted: bool, - tradable: bool, - limit_up: bool, - limit_down: bool, + tradable: bool | None, + limit_up: bool | None, + limit_down: bool | None, ) -> str: """Map complete point-in-time A-share flags to one QExec market status. @@ -96,8 +96,10 @@ def resolve_a_share_replay_status( "limit_up": limit_up, "limit_down": limit_down, } - if any(type(value) is not bool for value in flags.values()): + if any(value is not None and type(value) is not bool for value in flags.values()): raise ValidationError("A-share replay status flags must be booleans") + if any(value is None for value in flags.values()): + return "unknown" if delisted and listed: raise ValidationError("a delisted instrument cannot remain listed") if not listed and (tradable or limit_up or limit_down): @@ -418,7 +420,12 @@ def restore_state(self, state: dict[str, object]) -> None: def observe(self, event: MarketEvent) -> None: prior = self._states.get(event.instrument_id) + spec = self.instruments.get(event.instrument_id) + strict = spec is not None and spec.metadata.get("requires_status_evidence") == "true" + same_day = prior is not None and prior.event.trading_day == event.trading_day status = prior.status if prior is not None else "open" + if strict and not same_day: + status = "unknown" reference = prior.reference_price if prior is not None else None if isinstance(event, StatusEvent): status = event.status.lower() @@ -538,6 +545,8 @@ def _check( spec.effective_to is not None and as_of >= spec.effective_to ): return RiskDecision(False, "INSTRUMENT_INACTIVE", "instrument lifecycle is inactive") + if state.status in {"unknown", "no_restriction"}: + return RiskDecision(False, "MARKET_STATUS_UNKNOWN", state.status) if state.status in {"halted", "suspended", "closed"}: return RiskDecision(False, "MARKET_NOT_TRADABLE", state.status) if state.status == "limit_up" and order_intent.side is Side.BUY: diff --git a/tests/test_financial_actions.py b/tests/test_financial_actions.py new file mode 100644 index 0000000..c6db7ac --- /dev/null +++ b/tests/test_financial_actions.py @@ -0,0 +1,171 @@ +from dataclasses import replace +from datetime import timedelta +from decimal import Decimal + +import pytest +from conftest import T0, fp, spec +from quant_data_kit import AssetClass +from quant_data_kit.exceptions import ValidationError +from quant_data_kit.financial.actions import ActionTerms + +from quant_execution.ledger import ExactAccountLedger +from quant_execution.rules import resolve_a_share_replay_status + + +def account(cash="1000"): + instruments = { + name: spec( + name, + asset_class=AssetClass.EQUITY, + product_type="a_share", + settlement_currency="CNY", + quantity_step="0.01", + ) + for name in ("A", "B", "RIGHT") + } + ledger = ExactAccountLedger( + account_id="test", + base_currency="CNY", + instruments=instruments, + initial_cash={"CNY": fp(cash)}, + opened_at=T0, + ) + ledger.book_opening_position( + instrument_id="A", quantity=fp(100), average_cost=fp(10), acquired_on=T0.date() + ) + return ledger + + +def terms(kind, **kw): + return ActionTerms( + **{ + "event_id": kind, + "instrument_id": "A", + "kind": kind, + "effective_at": (T0 + timedelta(days=1)).isoformat(), + "available_at": T0.isoformat(), + "currency": "CNY", + "source": "synthetic", + "evidence_id": "fixture", + **kw, + } + ) + + +def test_split_and_dividend_payment_have_distinct_cash_and_total_nav(): + ledger = account() + before = ledger.snapshot().nav.to_decimal() + split = terms("split", ratio="2") + after = ledger.apply_corporate_action(split, at=split.effective_at) + assert after.positions["A"].to_decimal() == 200 and after.nav.to_decimal() == before + ex = terms("dividend_entitlement", cash_per_unit="1", entitlement_date="2026-01-03") + ledger.apply_corporate_action(ex, at=ex.effective_at) + assert ledger.cash_balance("CNY") == 1000 + assert ledger.dividend_receivable_balance("CNY") == 200 + pay = terms( + "dividend_payment", + cash_per_unit="1", + entitlement_date="2026-01-03", + effective_at=(T0 + timedelta(days=4)).isoformat(), + ) + ledger.apply_corporate_action(pay, at=pay.effective_at) + assert ledger.cash_balance("CNY") == 1200 and ledger.dividend_receivable_balance("CNY") == 0 + before_repeat = ledger.snapshot() + assert ledger.apply_corporate_action(pay, at=pay.effective_at) == before_repeat + + +@pytest.mark.parametrize( + "kind,ratio,allocation,parent_mark,target_mark,parent_quantity", + [ + ("merger", ".5", "1", None, "20", 0), + ("spin_off", ".5", ".2", "8", "4", 100), + ], +) +def test_conversion_conserves_wealth_cost_and_idempotency( + kind, ratio, allocation, parent_mark, target_mark, parent_quantity +): + ledger = account() + before = ledger.snapshot().nav.to_decimal() + event = terms( + kind, + ratio=ratio, + cost_fraction=allocation, + target_id="B", + target_mark=target_mark, + parent_mark=parent_mark, + ) + result = ledger.apply_corporate_action(event, at=event.effective_at) + assert result.nav.to_decimal() == before + assert ledger._positions.get("A", 0) == parent_quantity + assert result.positions["B"].to_decimal() == 50 + assert not ledger._fills # No invented historical trades. + assert sum(ledger._position_cost(x, derivative=False) for x in ("A", "B")) == 1000 + assert ledger.apply_corporate_action(event, at=event.effective_at) == result + with pytest.raises(ValidationError, match="reused"): + ledger.apply_corporate_action(replace(event, ratio=".6"), at=event.effective_at) + + +def test_rights_distribution_election_cash_and_atomic_failure(): + ledger = account(cash="50") + issue = terms( + "rights_distribution", + target_id="RIGHT", + ratio=".2", + cost_fraction="0", + parent_mark="9.8", + target_mark="1", + ) + ledger.apply_corporate_action(issue, at=issue.effective_at) + assert ledger._positions["RIGHT"] == 20 + exercise = terms( + "rights_exercise", + instrument_id="RIGHT", + target_id="A", + ratio="1", + election_quantity="20", + cash_per_unit="5", + target_mark="9.8", + ) + before = ledger.capture_state() + with pytest.raises(ValidationError, match="insufficient cash"): + ledger.apply_corporate_action(exercise, at=exercise.effective_at) + assert ledger.capture_state() == before + exercise = replace(exercise, election_quantity="10") + ledger.apply_corporate_action(exercise, at=exercise.effective_at) + assert ledger._positions["RIGHT"] == 10 and ledger._positions["A"] == 110 + assert ledger.cash_balance("CNY") == 0 + + +def test_future_action_and_fractional_steps_do_not_change_account(): + ledger = account() + event = terms("merger", target_id="B", ratio=".00001", cost_fraction="1", target_mark="10") + before = ledger.capture_state() + with pytest.raises(ValidationError): + ledger.apply_corporate_action(event, at=T0) + with pytest.raises(ValidationError, match="fractional"): + ledger.apply_corporate_action(event, at=event.effective_at) + assert ledger.capture_state() == before + + +def test_missing_status_preserved_and_price_limit_sides(): + flags = { + "listed": True, + "delisted": False, + "tradable": None, + "limit_up": False, + "limit_down": False, + } + assert resolve_a_share_replay_status(**flags) == "unknown" + assert ( + resolve_a_share_replay_status(**{**flags, "tradable": True, "limit_up": True}) == "limit_up" + ) + + +def test_cash_in_lieu_retires_shares_not_a_fake_dividend(): + ledger = account() + before = ledger.snapshot().nav.to_decimal() + event = terms("cash_in_lieu", retired_quantity=".25", cash_per_unit="10") + ledger.apply_corporate_action(event, at=event.effective_at) + assert ledger._positions["A"] == Decimal("99.75") + assert ledger.cash_balance("CNY") == Decimal("1002.5") + assert ledger.snapshot().nav.to_decimal() == before diff --git a/tests/test_rules.py b/tests/test_rules.py index 472cc76..ec7c37e 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from datetime import date, timedelta from decimal import Decimal @@ -27,6 +28,30 @@ PERP = "crypto:test:BTCUSDT-PERP" +def test_evidenced_status_expires_each_trading_day_and_missing_is_blocked(): + instruments = specs() + instruments[STOCK] = replace( + instruments[STOCK], + metadata={**instruments[STOCK].metadata, "requires_status_evidence": "true"}, + ) + ledger = ExactAccountLedger( + account_id="account", + base_currency="CNY", + instruments=instruments, + initial_cash={"CNY": fp("100000")}, + opened_at=T0, + ) + gate = RuleBookRiskGate(instruments=instruments, ledger=ledger) + gate.observe(state_event(STOCK, "10")) + order = intent(STOCK, Side.BUY, "100", key="strict") + assert gate.check(order, ledger.snapshot()).code == "MARKET_STATUS_UNKNOWN" + gate.observe(StatusEvent(**event_fields("known", STOCK), status="open")) + gate.observe(state_event(STOCK, "10")) + assert gate.check(order, ledger.snapshot()).accepted + gate.observe(state_event(STOCK, "10", trading_day=date(2026, 1, 5))) + assert gate.check(order, ledger.snapshot()).code == "MARKET_STATUS_UNKNOWN" + + def specs(): return { STOCK: spec(