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 1/2] 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 2/2] 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