diff --git a/docs/paper_execution_command_consumer.md b/docs/paper_execution_command_consumer.md index 84c4371..97b088e 100644 --- a/docs/paper_execution_command_consumer.md +++ b/docs/paper_execution_command_consumer.md @@ -11,10 +11,12 @@ The shared consumer owns only the rules that must be identical across platforms: 1. validate the runtime-loaded strategy release before touching the queue; -2. atomically claim only due, still-queued commands; -3. validate the immutable paper-risk admission receipt and release binding; -4. apply the enforced runtime command gate to every reconciled proposal; and -5. append a create-only lifecycle: `claimed` → `submitted` → `accepted` → +2. validate the runtime-owned platform, account scope, and strategy-profile + binding before reconciling a claimed command; +3. atomically claim only due, still-queued commands; +4. validate the immutable paper-risk admission receipt and release binding; +5. apply the enforced runtime command gate to every reconciled proposal; and +6. append a create-only lifecycle: `claimed` → `submitted` → `accepted` → `filled`, or `rejected` / `reconciliation_required`. Each platform supplies `reconcile_command(command)`. It owns its broker @@ -39,6 +41,9 @@ execution translation. - Pass the currently loaded release receipt and exact promoted `StrategyReleaseIdentity`. Missing or mismatched evidence blocks before a command is claimed. +- Pass `PaperExecutionCommandBinding` from the runtime target, with the exact + platform, account scope, and strategy profile. A command intended for a + different consumer is rejected without calling the platform reconciler. - Classify exposure from reconciled before/after positions, not an order side. Under a `reducing_only` admission, every proposal must prove `reduces`. - If reconciliation, storage, or lifecycle progression fails after a claim, diff --git a/src/quant_platform_kit/common/__init__.py b/src/quant_platform_kit/common/__init__.py index 7f16ff9..01cbf71 100644 --- a/src/quant_platform_kit/common/__init__.py +++ b/src/quant_platform_kit/common/__init__.py @@ -137,9 +137,11 @@ ) from .paper_execution_command_consumer import ( PAPER_EXECUTION_COMMAND_CONSUMER_SCHEMA_VERSION, + PaperExecutionCommandBinding, PaperExecutionCommandReconciler, PaperExecutionProposal, PaperExecutionReconciliation, + build_paper_execution_command_binding, consume_due_paper_execution_commands, ) from .runtime_logging import ( @@ -404,9 +406,11 @@ "PaperRiskAdmissionDisposition", "PaperRiskAdmissionReceipt", "PAPER_EXECUTION_COMMAND_CONSUMER_SCHEMA_VERSION", + "PaperExecutionCommandBinding", "PaperExecutionCommandReconciler", "PaperExecutionProposal", "PaperExecutionReconciliation", + "build_paper_execution_command_binding", "build_paper_risk_admission_receipt", "calculate_paper_risk_admission_receipt_sha256", "canonical_paper_risk_admission_receipt_json", diff --git a/src/quant_platform_kit/common/paper_execution_command_consumer.py b/src/quant_platform_kit/common/paper_execution_command_consumer.py index 20550b1..3c84f67 100644 --- a/src/quant_platform_kit/common/paper_execution_command_consumer.py +++ b/src/quant_platform_kit/common/paper_execution_command_consumer.py @@ -48,6 +48,7 @@ enforcement=RuntimeCommandGateEnforcement.ENFORCE, ) _SYMBOL_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_COMMAND_BINDING_FIELD_PATTERN = re.compile(r"^[a-z][a-z0-9]*(?:[._=-][a-z0-9]+)*$") def _safe_symbol(value: object) -> str: @@ -57,6 +58,13 @@ def _safe_symbol(value: object) -> str: return symbol +def _command_binding_field(value: object, *, field_name: str) -> str: + field = str(value or "").strip().lower() + if not _COMMAND_BINDING_FIELD_PATTERN.fullmatch(field): + raise ValueError(f"{field_name} must be a lowercase scoped identifier") + return field + + def _json_audit_mapping(value: Mapping[str, object]) -> dict[str, object]: """Normalize a JSON-safe, platform-supplied audit record. @@ -106,6 +114,71 @@ def to_dict(self) -> dict[str, object]: } +@dataclass(frozen=True) +class PaperExecutionCommandBinding: + """The one platform/account/strategy scope allowed to consume a command. + + Release identity proves which approved strategy build was loaded; this + binding proves that a valid command was delivered to the intended platform + runtime. It is supplied from the runtime target, never from a command. + """ + + platform: str + account_scope: str + strategy_profile: str + + def __post_init__(self) -> None: + object.__setattr__(self, "platform", _command_binding_field(self.platform, field_name="platform")) + object.__setattr__( + self, + "account_scope", + _command_binding_field(self.account_scope, field_name="account_scope"), + ) + object.__setattr__( + self, + "strategy_profile", + _command_binding_field(self.strategy_profile, field_name="strategy_profile"), + ) + + def to_dict(self) -> dict[str, str]: + return { + "platform": self.platform, + "account_scope": self.account_scope, + "strategy_profile": self.strategy_profile, + } + + +def build_paper_execution_command_binding( + value: PaperExecutionCommandBinding | Mapping[str, object] | None, +) -> PaperExecutionCommandBinding: + """Build a strict runtime-owned command binding from explicit fields.""" + + if isinstance(value, PaperExecutionCommandBinding): + return value + if not isinstance(value, Mapping) or set(value) != {"platform", "account_scope", "strategy_profile"}: + raise ValueError("paper execution command binding requires platform, account_scope, strategy_profile") + return PaperExecutionCommandBinding( + platform=str(value["platform"]), + account_scope=str(value["account_scope"]), + strategy_profile=str(value["strategy_profile"]), + ) + + +def _command_binding_findings( + command: ExecutionCommand, + *, + expected_binding: PaperExecutionCommandBinding, +) -> tuple[str, ...]: + findings: list[str] = [] + if command.platform != expected_binding.platform: + findings.append("command_platform_mismatch") + if command.account_scope != expected_binding.account_scope: + findings.append("command_account_scope_mismatch") + if command.strategy_profile != expected_binding.strategy_profile: + findings.append("command_strategy_profile_mismatch") + return tuple(findings) + + @dataclass(frozen=True) class PaperExecutionReconciliation: """Platform evidence after reconciling current positions and market data.""" @@ -209,6 +282,7 @@ def consume_due_paper_execution_commands( reconcile_command: PaperExecutionCommandReconciler, runtime_release_receipt: Mapping[str, Any] | None, expected_strategy_release: StrategyReleaseIdentity | Mapping[str, object] | None, + expected_command_binding: PaperExecutionCommandBinding | Mapping[str, object] | None, ) -> dict[str, object]: """Claim and simulate due paper commands without creating broker orders. @@ -221,6 +295,15 @@ def consume_due_paper_execution_commands( raise RuntimeError("paper durable execution command store is required") if not callable(reconcile_command): raise ValueError("reconcile_command must be callable") + try: + expected_binding = build_paper_execution_command_binding(expected_command_binding) + except ValueError: + return { + "schema_version": PAPER_EXECUTION_COMMAND_CONSUMER_SCHEMA_VERSION, + "status": "blocked", + "reason": "command_binding_invalid", + "commands": [], + } try: expected_release = build_strategy_release_identity(expected_strategy_release) except ValueError: @@ -262,9 +345,19 @@ def consume_due_paper_execution_commands( expected_strategy_release=expected_release, ).findings ) - reconciliation = reconcile_command(command) - if not isinstance(reconciliation, PaperExecutionReconciliation): - raise ValueError("reconcile_command must return PaperExecutionReconciliation") + binding_findings = _command_binding_findings( + command, + expected_binding=expected_binding, + ) + if binding_findings: + reconciliation = PaperExecutionReconciliation( + proposals=(), + integrity_findings=binding_findings, + ) + else: + reconciliation = reconcile_command(command) + if not isinstance(reconciliation, PaperExecutionReconciliation): + raise ValueError("reconcile_command must return PaperExecutionReconciliation") integrity_findings.extend(reconciliation.integrity_findings) integrity_findings = list( dict.fromkeys(normalize_runtime_command_integrity_findings(integrity_findings)) @@ -364,8 +457,10 @@ def consume_due_paper_execution_commands( __all__ = [ "PAPER_EXECUTION_COMMAND_CONSUMER_SCHEMA_VERSION", + "PaperExecutionCommandBinding", "PaperExecutionCommandReconciler", "PaperExecutionProposal", "PaperExecutionReconciliation", + "build_paper_execution_command_binding", "consume_due_paper_execution_commands", ] diff --git a/src/quant_platform_kit/common/runtime_command_gate.py b/src/quant_platform_kit/common/runtime_command_gate.py index 4e04a4b..933b31b 100644 --- a/src/quant_platform_kit/common/runtime_command_gate.py +++ b/src/quant_platform_kit/common/runtime_command_gate.py @@ -75,7 +75,11 @@ class RuntimeCommandIntegrityFinding(str, Enum): ACCOUNT_IDENTITY_PLATFORM_MISMATCH = "account_identity_platform_mismatch" ACCOUNT_IDENTITY_TYPE_MISMATCH = "account_identity_type_mismatch" BROKER_OUTCOME_UNKNOWN = "broker_outcome_unknown" + COMMAND_ACCOUNT_SCOPE_MISMATCH = "command_account_scope_mismatch" + COMMAND_BINDING_INVALID = "command_binding_invalid" COMMAND_DIGEST_MISMATCH = "command_digest_mismatch" + COMMAND_PLATFORM_MISMATCH = "command_platform_mismatch" + COMMAND_STRATEGY_PROFILE_MISMATCH = "command_strategy_profile_mismatch" DATA_ARTIFACT_INVALID = "data_artifact_invalid" DATA_STALE = "data_stale" DATA_UNAVAILABLE = "data_unavailable" @@ -114,7 +118,11 @@ class RuntimeCommandIntegrityFinding(str, Enum): RuntimeCommandIntegrityFinding.ACCOUNT_IDENTITY_PLATFORM_MISMATCH.value, RuntimeCommandIntegrityFinding.ACCOUNT_IDENTITY_TYPE_MISMATCH.value, RuntimeCommandIntegrityFinding.BROKER_OUTCOME_UNKNOWN.value, + RuntimeCommandIntegrityFinding.COMMAND_ACCOUNT_SCOPE_MISMATCH.value, + RuntimeCommandIntegrityFinding.COMMAND_BINDING_INVALID.value, RuntimeCommandIntegrityFinding.COMMAND_DIGEST_MISMATCH.value, + RuntimeCommandIntegrityFinding.COMMAND_PLATFORM_MISMATCH.value, + RuntimeCommandIntegrityFinding.COMMAND_STRATEGY_PROFILE_MISMATCH.value, RuntimeCommandIntegrityFinding.DURABLE_EVENT_HISTORY_INVALID.value, RuntimeCommandIntegrityFinding.EXECUTION_REPLAY_DETECTED.value, RuntimeCommandIntegrityFinding.INVALID_EFFECTIVE_SESSION.value, diff --git a/tests/test_paper_execution_command_consumer.py b/tests/test_paper_execution_command_consumer.py index 40a7dbd..b198b5a 100644 --- a/tests/test_paper_execution_command_consumer.py +++ b/tests/test_paper_execution_command_consumer.py @@ -71,6 +71,14 @@ def _runtime_receipt() -> dict[str, object]: return build_runtime_loaded_receipt(strategy_release=_release_identity()) +def _binding() -> dict[str, str]: + return { + "platform": "longbridge", + "account_scope": "paper-sg", + "strategy_profile": "soxl_soxx_trend_income", + } + + def _increasing_reconciliation(_: ExecutionCommand) -> PaperExecutionReconciliation: return PaperExecutionReconciliation( proposals=( @@ -95,6 +103,7 @@ def test_consumer_persists_a_paper_only_lifecycle_for_reconciled_proposals(tmp_p reconcile_command=_increasing_reconciliation, runtime_release_receipt=_runtime_receipt(), expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), ) assert result == { @@ -143,6 +152,7 @@ def test_consumer_requires_runtime_release_before_claiming(tmp_path: Path) -> No reconcile_command=_increasing_reconciliation, runtime_release_receipt=None, expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), ) assert result["status"] == "blocked" @@ -165,6 +175,7 @@ def test_consumer_rejects_new_risk_when_admission_is_reducing_only(tmp_path: Pat reconcile_command=_increasing_reconciliation, runtime_release_receipt=_runtime_receipt(), expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), ) assert result["commands"][0]["status"] == "rejected" @@ -191,6 +202,7 @@ def _failing_reconciliation(_: ExecutionCommand) -> PaperExecutionReconciliation reconcile_command=_failing_reconciliation, runtime_release_receipt=_runtime_receipt(), expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), ) assert result["commands"] == [ @@ -224,9 +236,69 @@ def _unknown_finding(_: ExecutionCommand) -> PaperExecutionReconciliation: reconcile_command=_unknown_finding, runtime_release_receipt=_runtime_receipt(), expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), ) assert result["commands"][0]["status"] == "rejected" receipt = store.events(command)[-1].details["runtime_command_gate_receipts"][0] assert receipt["mode"] == "halted" assert receipt["reasons"] == ["unknown_integrity_finding"] + + +def test_consumer_rejects_a_command_for_another_platform_without_reconciling(tmp_path: Path) -> None: + store = ExecutionCommandStore(local_dir=tmp_path) + command = ExecutionCommand.from_decision( + platform="schwab", + account_scope="paper-sg", + 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="d" * 64, + intent=_command().intent, + created_at="2026-08-24T20:00:00+00:00", + ) + assert store.enqueue(command) + reconciled = False + + def _must_not_reconcile(_: ExecutionCommand) -> PaperExecutionReconciliation: + nonlocal reconciled + reconciled = True + return _increasing_reconciliation(command) + + result = consume_due_paper_execution_commands( + store=store, + as_of_session="2026-08-25", + claimant="paper-command-verify", + reconcile_command=_must_not_reconcile, + runtime_release_receipt=_runtime_receipt(), + expected_strategy_release=_release_identity(), + expected_command_binding=_binding(), + ) + + assert reconciled is False + assert result["commands"][0]["status"] == "rejected" + receipt = store.events(command)[-1].details["runtime_command_gate_receipts"][0] + assert receipt["mode"] == "halted" + assert receipt["reasons"] == ["command_platform_mismatch"] + + +def test_consumer_blocks_before_claiming_when_runtime_binding_is_invalid(tmp_path: Path) -> None: + store = ExecutionCommandStore(local_dir=tmp_path) + command = _command() + assert store.enqueue(command) + + result = consume_due_paper_execution_commands( + store=store, + as_of_session="2026-08-25", + claimant="paper-command-verify", + reconcile_command=_increasing_reconciliation, + runtime_release_receipt=_runtime_receipt(), + expected_strategy_release=_release_identity(), + expected_command_binding={"platform": "longbridge"}, + ) + + assert result["status"] == "blocked" + assert result["reason"] == "command_binding_invalid" + assert store.current_state(command) is ExecutionCommandState.QUEUED