diff --git a/application/execution_service.py b/application/execution_service.py index 9874c6c..b0c9846 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -13,6 +13,7 @@ from typing import Any import pandas as pd +from application.paper_execution_admission import evaluate_ibkr_paper_execution_admission try: from quant_platform_kit.common.cash_sweep import should_sell_cash_sweep_to_fund_whole_share_buy except ImportError: # pragma: no cover - compatibility with older pinned shared wheels @@ -1416,6 +1417,9 @@ def execute_rebalance( execution_lock_dir=None, return_summary=False, cash_only_execution=True, + paper_execution_admission_enabled=False, + runtime_release_receipt=None, + expected_strategy_release=None, ): """Execute trades to reach target weights.""" del target_weights @@ -1496,6 +1500,7 @@ def record_quote_snapshot(symbol, snapshot) -> None: "snapshot_price_fallback_symbols": [], "snapshot_price_fallback_count": 0, "lock_path": None, + "paper_execution_admission": {}, } equity = float(account_values.get("equity", 0) or 0.0) cash_only_deleverage_mode = bool(signal_metadata.get("cash_only_deleverage_mode")) @@ -1726,6 +1731,26 @@ def append_small_account_allocation_drift_notes(): target_hash = _build_target_hash(target_weights) execution_summary["target_vs_current"] = _build_target_diff_rows(target_weights, current_mv, equity) + if paper_execution_admission_enabled: + paper_admission = evaluate_ibkr_paper_execution_admission( + signal_metadata=signal_metadata, + strategy_profile=strategy_profile, + account_scope=account_group, + positions=positions, + prices=prices, + target_market_values=target_mv, + option_order_intents=option_order_intents, + runtime_release_receipt=runtime_release_receipt, + expected_strategy_release=expected_strategy_release, + ) + execution_summary["paper_execution_admission"] = paper_admission + if not paper_admission["broker_write_allowed"]: + reason = "paper_execution_admission_blocked" + execution_summary["execution_status"] = "blocked" + execution_summary["no_op_reason"] = reason + execution_summary["skipped_reasons"].append(reason) + trade_logs.append(translator("failed", reason=reason)) + return _finalize_result(trade_logs, execution_summary, return_summary=return_summary) if equity > 0: current_safe_haven_mv = current_mv.get(safe_haven_symbol, 0.0) if safe_haven_symbol else 0.0 execution_summary["current_safe_haven_weight"] = float(current_safe_haven_mv / equity) diff --git a/application/paper_execution_admission.py b/application/paper_execution_admission.py new file mode 100644 index 0000000..87e8a98 --- /dev/null +++ b/application/paper_execution_admission.py @@ -0,0 +1,276 @@ +"""Fail-closed PAPER admission for IBKR's ordinary rebalance path. + +This adapter deliberately has no broker dependency. A strategy/control-plane +producer supplies an immutable QPK ``ExecutionCommand`` in signal metadata; +this module verifies its embedded deterministic-risk receipt and the current +runtime release before the normal rebalance service can submit any order. + +Exposure is classified from reconciled quantities and the quotes used by this +cycle. In particular, a buy/sell label is never treated as evidence that an +order increases or reduces risk. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from typing import Any + +from quant_platform_kit.common.execution_commands import ExecutionCommand +from quant_platform_kit.common.paper_execution_admission import ( + PAPER_RISK_ADMISSION_RECEIPT_INTENT_FIELD, + PaperRiskAdmissionReceipt, + evaluate_paper_execution_admission, +) +from quant_platform_kit.common.runtime_command_gate import ( + RuntimeCommandAction, + RuntimeCommandExposureEffect, + RuntimeCommandGateEnforcement, + RuntimeCommandGatePolicy, + evaluate_runtime_command_gate, +) + + +PAPER_EXECUTION_ADMISSION_SCHEMA_VERSION = "ibkr.paper_execution_admission.v1" +PAPER_EXECUTION_COMMAND_SIGNAL_FIELD = "paper_execution_command" +_PAPER_ADMISSION_GATE_POLICY = RuntimeCommandGatePolicy( + enforcement=RuntimeCommandGateEnforcement.ENFORCE, +) +_EPSILON = 0.01 + + +def resolve_paper_execution_admission_enabled( + *, + env_reader, + dry_run_only: bool, + execution_mode: object, +) -> bool: + """Resolve the opt-in flag and reject every non-PAPER configuration.""" + + raw_value = str(env_reader("IBKR_PAPER_EXECUTION_ADMISSION_ENABLED", "") or "").strip().lower() + enabled = raw_value in {"1", "true", "t", "yes", "y", "on"} + if not enabled: + return False + normalized_mode = str(execution_mode or "").strip().lower().replace("-", "_") + if dry_run_only or normalized_mode != "paper": + raise RuntimeError( + "IBKR_PAPER_EXECUTION_ADMISSION_ENABLED is only supported for ordinary execution_mode=paper" + ) + return True + + +def _append_finding(findings: list[str], finding: str) -> None: + if finding not in findings: + findings.append(finding) + + +def _finite_nonnegative(value: object) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(number) or number < 0.0: + return None + return number + + +def _effective_session(signal_metadata: Mapping[str, object]) -> str | None: + value = signal_metadata.get("effective_date") or signal_metadata.get("trade_date") + text = str(value or "").strip() + return text[:10] or None + + +def _load_command(signal_metadata: Mapping[str, object]) -> tuple[ExecutionCommand | None, list[str]]: + raw_command = signal_metadata.get(PAPER_EXECUTION_COMMAND_SIGNAL_FIELD) + if not isinstance(raw_command, Mapping): + return None, ["paper_risk_admission_receipt_missing"] + try: + return ExecutionCommand.from_dict(raw_command), [] + except (TypeError, ValueError): + return None, ["command_digest_mismatch"] + + +def _command_contract_findings( + command: ExecutionCommand | None, + *, + strategy_profile: object, + account_scope: object, + effective_session: str | None, +) -> list[str]: + if command is None: + return [] + findings: list[str] = [] + if command.platform != "ibkr": + _append_finding(findings, "durable_event_history_invalid") + if command.execution_mode != "paper": + _append_finding(findings, "paper_execution_mode_invalid") + if command.strategy_profile != str(strategy_profile or "").strip().lower(): + _append_finding(findings, "command_digest_mismatch") + if command.account_scope != str(account_scope or "").strip().lower(): + _append_finding(findings, "durable_event_history_invalid") + if not effective_session or command.effective_date != effective_session: + _append_finding(findings, "signal_timing_invalid") + return findings + + +def _exposure_facts( + *, + positions: Mapping[str, Mapping[str, object]], + prices: Mapping[str, object], + target_market_values: Mapping[str, object], +) -> tuple[tuple[dict[str, object], ...], tuple[str, ...]]: + """Classify target changes using position quantities and current quotes.""" + + normalized_positions = { + str(symbol).strip().upper(): details + for symbol, details in positions.items() + if str(symbol).strip() and isinstance(details, Mapping) + } + normalized_prices = { + str(symbol).strip().upper(): value + for symbol, value in prices.items() + if str(symbol).strip() + } + normalized_targets = { + str(symbol).strip().upper(): value + for symbol, value in target_market_values.items() + if str(symbol).strip() + } + facts: list[dict[str, object]] = [] + findings: list[str] = [] + symbols = sorted(set(normalized_positions) | set(normalized_targets)) + for symbol in symbols: + position = normalized_positions.get(symbol) or {} + quantity = _finite_nonnegative(position.get("quantity")) + target_value = _finite_nonnegative(normalized_targets.get(symbol, 0.0)) + price = _finite_nonnegative(normalized_prices.get(symbol)) + if quantity is None or target_value is None or price is None or price <= 0.0: + _append_finding(findings, "position_reconciliation_mismatch") + continue + current_value = quantity * price + before_exposure = abs(current_value) + after_exposure = abs(target_value) + exposure_delta = after_exposure - before_exposure + if exposure_delta > _EPSILON: + effect = RuntimeCommandExposureEffect.INCREASES + elif exposure_delta < -_EPSILON: + effect = RuntimeCommandExposureEffect.REDUCES + else: + effect = RuntimeCommandExposureEffect.NEUTRAL + facts.append( + { + "symbol": symbol, + "position_quantity": round(quantity, 8), + "quote_price": round(price, 8), + "current_market_value": round(current_value, 8), + "target_market_value": round(target_value, 8), + "exposure_effect": effect.value, + } + ) + return tuple(facts), tuple(findings) + + +def evaluate_ibkr_paper_execution_admission( + *, + signal_metadata: Mapping[str, object] | None, + strategy_profile: object, + account_scope: object, + positions: Mapping[str, Mapping[str, object]], + prices: Mapping[str, object], + target_market_values: Mapping[str, object], + option_order_intents: Sequence[Mapping[str, object]] = (), + runtime_release_receipt: Mapping[str, Any] | None, + expected_strategy_release: Any = None, +) -> dict[str, object]: + """Return durable audit evidence and block if a PAPER broker write is unsafe. + + The caller must invoke this after it has collected the current portfolio + and quotes, but before it invokes any submit adapter. + """ + + metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {} + command, findings = _load_command(metadata) + effective_session = _effective_session(metadata) + for finding in _command_contract_findings( + command, + strategy_profile=strategy_profile, + account_scope=account_scope, + effective_session=effective_session, + ): + _append_finding(findings, finding) + + paper_receipt: Mapping[str, object] | None = None + if command is not None: + raw_receipt = command.intent.get(PAPER_RISK_ADMISSION_RECEIPT_INTENT_FIELD) + if isinstance(raw_receipt, Mapping): + try: + paper_receipt = PaperRiskAdmissionReceipt.from_dict(raw_receipt).to_dict() + except (TypeError, ValueError): + # Invalid untrusted payloads must not be copied into reports. + paper_receipt = None + admission = evaluate_paper_execution_admission( + command=command, + expected_strategy_release=expected_strategy_release, + ) + for finding in admission.integrity_findings: + _append_finding(findings, finding) + else: + admission = None + + if option_order_intents: + # The ordinary equity target model has no reconciled option valuation + # contract yet. Treating a side label as exposure evidence would be + # unsafe, so PAPER admission closes the whole cycle instead. + _append_finding(findings, "durable_event_history_invalid") + + facts, fact_findings = _exposure_facts( + positions=positions, + prices=prices, + target_market_values=target_market_values, + ) + for finding in fact_findings: + _append_finding(findings, finding) + + effects = [RuntimeCommandExposureEffect(fact["exposure_effect"]) for fact in facts] + if not effects: + effects = [RuntimeCommandExposureEffect.NEUTRAL] + gate_receipts = [] + for effect in effects: + decision = evaluate_runtime_command_gate( + action=RuntimeCommandAction.SUBMIT, + exposure_effect=effect, + command=command, + as_of_session=effective_session, + runtime_release_receipt=runtime_release_receipt, + expected_strategy_release=expected_strategy_release, + integrity_findings=findings, + policy=_PAPER_ADMISSION_GATE_POLICY, + ) + gate_receipts.append(decision.to_receipt()) + + return { + "schema_version": PAPER_EXECUTION_ADMISSION_SCHEMA_VERSION, + "enabled": True, + "command_id": command.command_id if command is not None else None, + "decision_digest": command.decision_digest if command is not None else None, + "effective_session": effective_session, + "risk_admission_receipt": dict(paper_receipt or {}), + "risk_admission_receipt_sha256": ( + admission.receipt_sha256 if admission is not None else None + ), + "risk_disposition": admission.disposition.value if admission is not None else "halted", + "integrity_findings": list(dict.fromkeys(findings)), + "exposure_facts": list(facts), + "runtime_command_gate_receipts": gate_receipts, + "broker_write_allowed": bool(gate_receipts) and all( + bool(receipt["broker_write_allowed"]) for receipt in gate_receipts + ), + } + + +__all__ = [ + "PAPER_EXECUTION_ADMISSION_SCHEMA_VERSION", + "PAPER_EXECUTION_COMMAND_SIGNAL_FIELD", + "evaluate_ibkr_paper_execution_admission", + "resolve_paper_execution_admission_enabled", +] diff --git a/application/reconciliation_service.py b/application/reconciliation_service.py index 9b81b16..62998db 100644 --- a/application/reconciliation_service.py +++ b/application/reconciliation_service.py @@ -85,6 +85,7 @@ def build_reconciliation_record( "current_safe_haven_weight": execution_summary.get("current_safe_haven_weight"), "price_source_mode": execution_summary.get("price_source_mode"), "quote_snapshot": execution_summary.get("quote_snapshot") or {}, + "paper_execution_admission": execution_summary.get("paper_execution_admission") or {}, "snapshot_price_fallback_used": execution_summary.get("snapshot_price_fallback_used"), "snapshot_price_fallback_count": execution_summary.get("snapshot_price_fallback_count"), "snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols") or [], diff --git a/application/runtime_broker_adapters.py b/application/runtime_broker_adapters.py index 190fc7d..3bec388 100644 --- a/application/runtime_broker_adapters.py +++ b/application/runtime_broker_adapters.py @@ -61,6 +61,9 @@ class IBKRRuntimeBrokerAdapters: printer: Any = print refresh_host_fn: Any = None trading_permission_probe_fn: Any = None + paper_execution_admission_enabled: bool = False + runtime_release_receipt: Any = None + expected_strategy_release: Any = None def validate_configured_accounts(self, ib): if not self.account_ids: @@ -294,6 +297,9 @@ def execute_rebalance( sell_settle_delay_sec=self.sell_settle_delay_sec, return_summary=True, cash_only_execution=self.cash_only_execution, + paper_execution_admission_enabled=self.paper_execution_admission_enabled, + runtime_release_receipt=self.runtime_release_receipt, + expected_strategy_release=self.expected_strategy_release, ) def format_liquidation_orders(self, orders) -> str: @@ -390,6 +396,9 @@ def build_runtime_broker_adapters( printer=print, refresh_host_fn=None, trading_permission_probe_fn=None, + paper_execution_admission_enabled: bool = False, + runtime_release_receipt=None, + expected_strategy_release=None, ) -> IBKRRuntimeBrokerAdapters: return IBKRRuntimeBrokerAdapters( host_resolver=host_resolver, @@ -433,4 +442,7 @@ def build_runtime_broker_adapters( printer=printer, refresh_host_fn=refresh_host_fn, trading_permission_probe_fn=trading_permission_probe_fn, + paper_execution_admission_enabled=bool(paper_execution_admission_enabled), + runtime_release_receipt=runtime_release_receipt, + expected_strategy_release=expected_strategy_release, ) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index a8fb059..f77598e 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -7,6 +7,7 @@ from typing import Any, Mapping from application.runtime_dependencies import IBKRRebalanceConfig, IBKRRebalanceRuntime +from application.paper_execution_admission import resolve_paper_execution_admission_enabled from application.runtime_notification_adapters import build_runtime_notification_adapters from application.runtime_reporting_adapters import build_runtime_reporting_adapters from quant_platform_kit.common.execution_state import ( @@ -17,6 +18,7 @@ from quant_platform_kit.common.runtime_target import build_runtime_context_fields from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort from quant_platform_kit.common.runtime_target import RuntimeTarget +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt @dataclass(frozen=True) @@ -187,6 +189,19 @@ def build_rebalance_config(self, *, extra_notification_lines=(), cash_only_execu project_id=self.project_id, ), execution_state_account_scope=execution_state_account_scope, + paper_execution_admission_enabled=resolve_paper_execution_admission_enabled( + env_reader=self.env_reader, + dry_run_only=self.dry_run_only, + execution_mode=execution_mode, + ), + runtime_release_receipt=build_runtime_loaded_receipt( + strategy_release=( + self.runtime_target.strategy_release if self.runtime_target is not None else None + ), + ), + expected_strategy_release=( + self.runtime_target.strategy_release if self.runtime_target is not None else None + ), ) diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index f23cc57..22452e3 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -25,6 +25,9 @@ class IBKRRebalanceConfig: execution_dedup_enabled: bool = False execution_state_store: Any = None execution_state_account_scope: str = "" + paper_execution_admission_enabled: bool = False + runtime_release_receipt: Any = None + expected_strategy_release: Any = None @dataclass(frozen=True) diff --git a/docs/ibkr_paper_execution_admission.md b/docs/ibkr_paper_execution_admission.md new file mode 100644 index 0000000..cb11441 --- /dev/null +++ b/docs/ibkr_paper_execution_admission.md @@ -0,0 +1,26 @@ +# IBKR PAPER execution admission + +`IBKR_PAPER_EXECUTION_ADMISSION_ENABLED` is an opt-in guard for the ordinary +IBKR `execution_mode=paper` rebalance path. Its default is disabled. It does +not apply to `IBKR_PAPER_LIQUIDATE_ONLY`, dry-run previews, live execution, +deployment workflows, or schedulers. + +When explicitly enabled, the strategy/control-plane producer must place a QPK +`execution_command.v1` object at `signal_metadata.paper_execution_command`. +The command must be content-addressed, use `execution_mode=paper`, and contain: + +- the exact promoted `strategy_release` identity; +- an embedded `paper_risk_admission_receipt.v1`; and +- the same effective session, profile, and account scope as the paper cycle. + +Before any order adapter is invoked, the platform verifies that command and +receipt, self-attests the runtime release, and derives every exposure effect +from the current position quantities and quotes used by the cycle. Invalid or +missing evidence, an unmodelled option intent, or a `reducing_only` receipt +that would increase exposure blocks the whole cycle. The safe receipt, +reconciled exposure facts, and enforced runtime-gate receipts are persisted in +the normal reconciliation record under `paper_execution_admission`. + +Do not enable the flag until an upstream producer can supply this immutable +command contract. Enabling it for a non-PAPER or dry-run target fails at +startup rather than silently weakening the guard. diff --git a/main.py b/main.py index 7c45791..06f0220 100644 --- a/main.py +++ b/main.py @@ -49,6 +49,7 @@ publish_strategy_plugin_alerts as dispatch_strategy_plugin_alerts, ) from quant_platform_kit.common.runtime_assembly import build_runtime_assembly +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt from quant_platform_kit.common.runtime_reports import ( append_runtime_report_error, build_runtime_report_base, @@ -86,6 +87,7 @@ get_market_prices as application_get_market_prices, ) from application.paper_liquidation_service import execute_paper_liquidation +from application.paper_execution_admission import resolve_paper_execution_admission_enabled from runtime_logging import build_run_id, emit_runtime_log, extract_cloud_trace from runtime_config_support import ( EXECUTION_BACKEND_GATEWAY, @@ -571,6 +573,12 @@ def fetch_market_portfolio_snapshot(ib, **kwargs): def build_broker_adapters(*, dry_run_only_override: bool | None = None): effective_dry_run_only = RUNTIME_SETTINGS.dry_run_only if dry_run_only_override is None else bool(dry_run_only_override) + effective_execution_mode = "dry_run" if effective_dry_run_only else RUNTIME_SETTINGS.ib_gateway_mode + expected_strategy_release = ( + RUNTIME_SETTINGS.runtime_target.strategy_release + if RUNTIME_SETTINGS.runtime_target is not None + else None + ) return build_runtime_broker_adapters( host_resolver=get_ib_host, refresh_host_fn=refresh_ib_host, @@ -610,7 +618,16 @@ def build_broker_adapters(*, dry_run_only_override: bool | None = None): strategy_display_name=strategy_display_name, sleep_fn=time.sleep, market_currency=MARKET_CURRENCY, - execution_mode="dry_run" if effective_dry_run_only else RUNTIME_SETTINGS.ib_gateway_mode, + execution_mode=effective_execution_mode, + paper_execution_admission_enabled=resolve_paper_execution_admission_enabled( + env_reader=os.getenv, + dry_run_only=effective_dry_run_only, + execution_mode=effective_execution_mode, + ), + runtime_release_receipt=build_runtime_loaded_receipt( + strategy_release=expected_strategy_release, + ), + expected_strategy_release=expected_strategy_release, printer=print, ) diff --git a/pyproject.toml b/pyproject.toml index 1986e84..e463ff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@54d4ba901ae4e72e09c143c051747b900de55022", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9f7e7f8335e97f83f66677ad5e73254a1a421759", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@cec2a6a7aac02bd06ff9c83703b14c61166b245a", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@f07a1eeb46a82dd28cf2fa6f357e88866cfb3ff9", ] @@ -64,5 +64,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@54d4ba901ae4e72e09c143c051747b900de55022", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9f7e7f8335e97f83f66677ad5e73254a1a421759", ] diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 7ff5c16..ee5ca10 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -3,6 +3,7 @@ from application.execution_service import check_order_submitted, execute_rebalance, get_available_buying_power from notifications.telegram import build_translator from quant_platform_kit.common.models import OrderIntent +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt def _weight_allocation(targets, *, risk_symbols=(), income_symbols=(), safe_haven_symbols=()): @@ -187,6 +188,67 @@ def fake_fetch_quote_snapshots(_ib, symbols): assert any(log.startswith("buy VOO") for log in trade_logs) +def test_execute_rebalance_paper_admission_blocks_before_calling_the_broker(tmp_path): + class FakeIB: + def openTrades(self): + return [] + + def accountValues(self): + return [SimpleNamespace(tag="AvailableFunds", currency="USD", value="5000")] + + release = { + "release_id": "soxl-p2-v3.20260824", + "manifest_sha256": "a" * 64, + "strategy_revision": "soxl-p2-v3", + "config_sha256": "b" * 64, + "risk_policy_sha256": "c" * 64, + "evidence_sha256": "d" * 64, + "plugin_bundle_sha256": "e" * 64, + "effective_session": "2026-04-01", + } + submitted = [] + + trade_logs, summary = execute_rebalance( + FakeIB(), + {"VOO": 1.0}, + {}, + {"equity": 1000.0, "buying_power": 1000.0}, + fetch_quote_snapshots=lambda _ib, symbols: { + symbol: SimpleNamespace(last_price=100.0) for symbol in symbols + }, + submit_order_intent=lambda _ib, intent: submitted.append(intent), + order_intent_cls=OrderIntent, + translator=translate, + strategy_symbols=["VOO"], + strategy_profile="soxl_soxx_trend_income", + account_group="paper", + signal_metadata=_signal_metadata( + {"VOO": 1.0}, + risk_symbols=("VOO",), + trade_date="2026-04-01", + snapshot_as_of="2026-03-31", + effective_date="2026-04-01", + ), + dry_run_only=False, + execution_mode="paper", + cash_reserve_ratio=0.0, + rebalance_threshold_ratio=0.02, + limit_buy_premium=1.005, + sell_settle_delay_sec=0, + execution_lock_dir=tmp_path, + return_summary=True, + paper_execution_admission_enabled=True, + runtime_release_receipt=build_runtime_loaded_receipt(strategy_release=release), + expected_strategy_release=release, + ) + + assert submitted == [] + assert summary["execution_status"] == "blocked" + assert summary["no_op_reason"] == "paper_execution_admission_blocked" + assert summary["paper_execution_admission"]["broker_write_allowed"] is False + assert any("paper_execution_admission_blocked" in line for line in trade_logs) + + def test_execute_rebalance_blocks_when_all_order_submissions_are_rejected(tmp_path): class FakeIB: def openTrades(self): diff --git a/tests/test_paper_execution_admission.py b/tests/test_paper_execution_admission.py new file mode 100644 index 0000000..a53820a --- /dev/null +++ b/tests/test_paper_execution_admission.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import pytest + +from application.paper_execution_admission import ( + PAPER_EXECUTION_COMMAND_SIGNAL_FIELD, + evaluate_ibkr_paper_execution_admission, + resolve_paper_execution_admission_enabled, +) +from quant_platform_kit.common.execution_commands import ExecutionCommand +from quant_platform_kit.common.paper_execution_admission import ( + PAPER_RISK_ADMISSION_RECEIPT_INTENT_FIELD, + PaperRiskAdmissionDisposition, + build_paper_risk_admission_receipt, +) +from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt + + +def _release_identity() -> dict[str, str]: + return { + "release_id": "soxl-p2-v3.20260824", + "manifest_sha256": "a" * 64, + "strategy_revision": "soxl-p2-v3", + "config_sha256": "b" * 64, + "risk_policy_sha256": "c" * 64, + "evidence_sha256": "d" * 64, + "plugin_bundle_sha256": "e" * 64, + "effective_session": "2026-08-25", + } + + +def _command( + *, + disposition: PaperRiskAdmissionDisposition = PaperRiskAdmissionDisposition.ALLOW_NEW_RISK, +): + release = _release_identity() + receipt = build_paper_risk_admission_receipt( + strategy_profile="soxl_soxx_trend_income", + release_id=release["release_id"], + risk_policy_sha256=release["risk_policy_sha256"], + decision_digest="f" * 64, + effective_session="2026-08-25", + disposition=disposition, + reason_codes=() if disposition is PaperRiskAdmissionDisposition.ALLOW_NEW_RISK else ("DATA_STALE",), + ) + return ExecutionCommand.from_decision( + platform="ibkr", + account_scope="paper", + strategy_profile="soxl_soxx_trend_income", + execution_mode="paper", + signal_date="2026-08-24", + effective_date="2026-08-25", + execution_timing_contract="next_trading_day", + decision_digest="f" * 64, + intent={ + "strategy_release": release, + PAPER_RISK_ADMISSION_RECEIPT_INTENT_FIELD: receipt.to_dict(), + }, + ) + + +def _evaluate(command: ExecutionCommand | None): + release = _release_identity() + metadata = {"effective_date": "2026-08-25"} + if command is not None: + metadata[PAPER_EXECUTION_COMMAND_SIGNAL_FIELD] = command.to_dict() + return evaluate_ibkr_paper_execution_admission( + signal_metadata=metadata, + strategy_profile="soxl_soxx_trend_income", + account_scope="paper", + positions={"SOXL": {"quantity": 1.0}}, + prices={"SOXL": 100.0}, + target_market_values={"SOXL": 200.0}, + runtime_release_receipt=build_runtime_loaded_receipt(strategy_release=release), + expected_strategy_release=release, + ) + + +def test_paper_admission_is_opt_in_and_rejects_non_paper_enablement(): + assert not resolve_paper_execution_admission_enabled( + env_reader=lambda _name, _default: "", + dry_run_only=False, + execution_mode="paper", + ) + assert resolve_paper_execution_admission_enabled( + env_reader=lambda _name, _default: "true", + dry_run_only=False, + execution_mode="paper", + ) + with pytest.raises(RuntimeError, match="execution_mode=paper"): + resolve_paper_execution_admission_enabled( + env_reader=lambda _name, _default: "true", + dry_run_only=False, + execution_mode="live", + ) + + +def test_paper_admission_blocks_missing_immutable_command_before_a_broker_write(): + observation = _evaluate(None) + + assert observation["broker_write_allowed"] is False + assert observation["command_id"] is None + receipt = observation["runtime_command_gate_receipts"][0] + assert receipt["enforcement"] == "enforce" + assert receipt["broker_write_allowed"] is False + assert "paper_risk_admission_receipt_missing" in receipt["reasons"] + + +def test_paper_admission_persists_a_bound_risk_receipt_and_uses_quote_position_facts(): + command = _command() + + observation = _evaluate(command) + + assert observation["broker_write_allowed"] is True + assert observation["command_id"] == command.command_id + assert observation["risk_admission_receipt"]["receipt_sha256"] == observation[ + "risk_admission_receipt_sha256" + ] + assert observation["exposure_facts"] == [ + { + "symbol": "SOXL", + "position_quantity": 1.0, + "quote_price": 100.0, + "current_market_value": 100.0, + "target_market_value": 200.0, + "exposure_effect": "increases", + } + ] + assert observation["runtime_command_gate_receipts"][0]["broker_write_allowed"] is True + + +def test_reducing_only_receipt_blocks_an_increase_from_reconciled_facts(): + command = _command(disposition=PaperRiskAdmissionDisposition.REDUCING_ONLY) + + observation = _evaluate(command) + + assert observation["risk_disposition"] == "reducing_only" + assert observation["broker_write_allowed"] is False + receipt = observation["runtime_command_gate_receipts"][0] + assert receipt["mode"] == "reducing" + assert receipt["exposure_effect"] == "increases" + assert receipt["broker_write_allowed"] is False diff --git a/uv.lock b/uv.lock index 0b82e0b..cbde2f2 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=54d4ba901ae4e72e09c143c051747b900de55022" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9f7e7f8335e97f83f66677ad5e73254a1a421759" }] [[package]] name = "beautifulsoup4" @@ -791,7 +791,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=54d4ba901ae4e72e09c143c051747b900de55022" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9f7e7f8335e97f83f66677ad5e73254a1a421759" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=cec2a6a7aac02bd06ff9c83703b14c61166b245a" }, @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "0.10.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=54d4ba901ae4e72e09c143c051747b900de55022#54d4ba901ae4e72e09c143c051747b900de55022" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=9f7e7f8335e97f83f66677ad5e73254a1a421759#9f7e7f8335e97f83f66677ad5e73254a1a421759" } [[package]] name = "requests"