Skip to content

Commit 8d73d79

Browse files
Pigbibicodex
andcommitted
feat: gate Firstrade paper dry-run admission
Co-Authored-By: Codex <noreply@openai.com>
1 parent 54a2e16 commit 8d73d79

6 files changed

Lines changed: 380 additions & 10 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Fail-closed, opt-in PAPER admission for Firstrade dry-run requests.
2+
3+
The shared QPK contract verifies an immutable execution command, its embedded
4+
deterministic-risk receipt, and the release currently loaded by the runtime.
5+
This adapter intentionally only turns that pure result into a redacted HTTP
6+
audit record. It neither creates commands nor reaches a broker, account,
7+
strategy, deployment, scheduler, or persistence service.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
from collections.abc import Mapping
14+
15+
from quant_platform_kit.common.execution_commands import ExecutionCommand
16+
from quant_platform_kit.common.paper_execution_admission import (
17+
PaperExecutionAdmissionDecision,
18+
evaluate_paper_execution_admission,
19+
)
20+
21+
22+
PAPER_ADMISSION_ENABLED_ENV = "QSL_PAPER_ADMISSION_ENABLED"
23+
PAPER_EXECUTION_COMMAND_ENV = "QSL_PAPER_EXECUTION_COMMAND_JSON"
24+
PAPER_EXECUTION_ADMISSION_AUDIT_SCHEMA_VERSION = "firstrade_paper_execution_admission_audit.v1"
25+
26+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
27+
_FALSE_VALUES = frozenset({"", "0", "false", "no", "off"})
28+
_COMMAND_MISSING = "paper_execution_command_missing"
29+
_COMMAND_INVALID = "paper_execution_command_invalid"
30+
_PLATFORM_MISMATCH = "paper_execution_platform_mismatch"
31+
_ADMISSION_CONFIGURATION_INVALID = "paper_execution_admission_configuration_invalid"
32+
_ADMISSION_EVALUATION_FAILED = "paper_execution_admission_evaluation_failed"
33+
_RUNTIME_MODE_INVALID = "paper_runtime_mode_invalid"
34+
35+
36+
def paper_dry_run_admission_requested(env: Mapping[str, str | None]) -> bool:
37+
"""Return whether this request should be checked instead of using legacy dry-run.
38+
39+
The default is disabled. A malformed non-empty enable value is treated as
40+
requested so the caller receives a fail-closed audit rather than silently
41+
falling back to the ungated legacy preview path.
42+
"""
43+
raw_value = env.get(PAPER_ADMISSION_ENABLED_ENV)
44+
if raw_value is None:
45+
return False
46+
return str(raw_value).strip().lower() not in _FALSE_VALUES
47+
48+
49+
def _enabled(env: Mapping[str, str | None]) -> bool:
50+
return str(env.get(PAPER_ADMISSION_ENABLED_ENV) or "").strip().lower() in _TRUE_VALUES
51+
52+
53+
def _audit(
54+
*,
55+
disposition: str,
56+
findings: tuple[str, ...] | list[str],
57+
command_id: str | None = None,
58+
receipt_sha256: str | None = None,
59+
) -> dict[str, object]:
60+
admitted = disposition == "allow_new_risk" and not findings
61+
return {
62+
"schema_version": PAPER_EXECUTION_ADMISSION_AUDIT_SCHEMA_VERSION,
63+
"admission_enabled": True,
64+
"audit_color": "green" if admitted else "red",
65+
"status": "admitted" if admitted else "blocked",
66+
"command_id": command_id,
67+
"disposition": disposition,
68+
"integrity_findings": list(findings),
69+
"receipt_sha256": receipt_sha256,
70+
}
71+
72+
73+
def _audit_from_decision(decision: PaperExecutionAdmissionDecision) -> dict[str, object]:
74+
"""Return only QPK's stable decision metadata, never raw command intent."""
75+
return _audit(
76+
disposition=decision.disposition.value,
77+
findings=decision.integrity_findings,
78+
command_id=decision.command_id,
79+
receipt_sha256=decision.receipt_sha256,
80+
)
81+
82+
83+
def evaluate_paper_dry_run_admission(
84+
*,
85+
runtime_target: object | None,
86+
env: Mapping[str, str | None],
87+
) -> dict[str, object] | None:
88+
"""Evaluate an optional QPK PAPER admission before any preview can start.
89+
90+
``None`` preserves the existing dry-run route while the feature is disabled.
91+
Once requested, every malformed or incomplete input produces a red audit
92+
record and the caller must avoid invoking the strategy cycle.
93+
"""
94+
if not paper_dry_run_admission_requested(env):
95+
return None
96+
if not _enabled(env):
97+
return _audit(
98+
disposition="halted",
99+
findings=(_ADMISSION_CONFIGURATION_INVALID,),
100+
)
101+
102+
raw_command = env.get(PAPER_EXECUTION_COMMAND_ENV)
103+
if not raw_command:
104+
return _audit(disposition="halted", findings=(_COMMAND_MISSING,))
105+
try:
106+
payload = json.loads(raw_command)
107+
if not isinstance(payload, Mapping):
108+
raise ValueError("command payload must be an object")
109+
command = ExecutionCommand.from_dict(payload)
110+
except (TypeError, ValueError, json.JSONDecodeError):
111+
return _audit(disposition="halted", findings=(_COMMAND_INVALID,))
112+
113+
if command.platform != "firstrade":
114+
return _audit(
115+
disposition="halted",
116+
findings=(_PLATFORM_MISMATCH,),
117+
command_id=command.command_id,
118+
)
119+
if getattr(runtime_target, "dry_run_only", None) is not True:
120+
return _audit(
121+
disposition="halted",
122+
findings=(_RUNTIME_MODE_INVALID,),
123+
command_id=command.command_id,
124+
)
125+
expected_release = getattr(runtime_target, "strategy_release", None)
126+
try:
127+
decision = evaluate_paper_execution_admission(
128+
command=command,
129+
expected_strategy_release=expected_release,
130+
)
131+
except (TypeError, ValueError):
132+
return _audit(
133+
disposition="halted",
134+
findings=(_ADMISSION_EVALUATION_FAILED,),
135+
command_id=command.command_id,
136+
)
137+
return _audit_from_decision(decision)
138+
139+
140+
__all__ = [
141+
"PAPER_ADMISSION_ENABLED_ENV",
142+
"PAPER_EXECUTION_ADMISSION_AUDIT_SCHEMA_VERSION",
143+
"PAPER_EXECUTION_COMMAND_ENV",
144+
"evaluate_paper_dry_run_admission",
145+
"paper_dry_run_admission_requested",
146+
]

main.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
is_live_trading_enabled,
2121
mask_account_id,
2222
)
23+
from application.paper_execution_admission import (
24+
evaluate_paper_dry_run_admission,
25+
paper_dry_run_admission_requested,
26+
)
2327
from application.rebalance_service import run_strategy_cycle
2428
from application.session_check_service import run_session_check
2529
from notifications.telegram import build_sender
@@ -384,6 +388,17 @@ def _run_strategy_cycle_with_report(
384388
raise
385389

386390

391+
def _evaluate_paper_dry_run_admission() -> dict[str, object] | None:
392+
"""Load only release identity needed for an opt-in pre-preview gate."""
393+
if not paper_dry_run_admission_requested(os.environ):
394+
return None
395+
try:
396+
runtime_target = _runtime_settings(dry_run_override=True).runtime_target
397+
except (EnvironmentError, ValueError):
398+
runtime_target = None
399+
return evaluate_paper_dry_run_admission(runtime_target=runtime_target, env=os.environ)
400+
401+
387402
@app.get("/")
388403
def service_info():
389404
return jsonify(
@@ -542,13 +557,29 @@ def dry_run():
542557
if skip_for_market and skip_payload is not None:
543558
return jsonify(skip_payload), 200
544559
try:
545-
return jsonify(
546-
_run_strategy_cycle_with_report(
547-
dry_run_override=True,
548-
send_cycle_notification=False,
549-
dispatch_plugin_alerts=False,
560+
admission_audit = _evaluate_paper_dry_run_admission()
561+
if admission_audit is not None and admission_audit["status"] != "admitted":
562+
return (
563+
jsonify(
564+
{
565+
"ok": False,
566+
"status": "blocked",
567+
"action_done": False,
568+
"submitted_orders": [],
569+
"skipped_orders": [{"reason": "paper_execution_admission_blocked"}],
570+
"paper_execution_admission": admission_audit,
571+
}
572+
),
573+
409,
550574
)
575+
result = _run_strategy_cycle_with_report(
576+
dry_run_override=True,
577+
send_cycle_notification=False,
578+
dispatch_plugin_alerts=False,
551579
)
580+
if admission_audit is not None:
581+
result = {**result, "paper_execution_admission": admission_audit}
582+
return jsonify(result)
552583
except (FirstradePlatformError, EnvironmentError, ValueError) as exc:
553584
notification_attempted = _handle_strategy_run_exception(exc)
554585
return (

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"pytest",
1919
"pytz",
2020
"requests",
21-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@54d4ba901ae4e72e09c143c051747b900de55022",
21+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9f7e7f8335e97f83f66677ad5e73254a1a421759",
2222
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@cec2a6a7aac02bd06ff9c83703b14c61166b245a",
2323
]
2424
license = "MIT"
@@ -82,5 +82,5 @@ show_missing = true
8282

8383
[tool.uv]
8484
override-dependencies = [
85-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@54d4ba901ae4e72e09c143c051747b900de55022",
85+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9f7e7f8335e97f83f66677ad5e73254a1a421759",
8686
]
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from application.paper_execution_admission import (
6+
PAPER_ADMISSION_ENABLED_ENV,
7+
PAPER_EXECUTION_COMMAND_ENV,
8+
evaluate_paper_dry_run_admission,
9+
)
10+
from quant_platform_kit.common.execution_commands import ExecutionCommand
11+
from quant_platform_kit.common.paper_execution_admission import build_paper_risk_admission_receipt
12+
from quant_platform_kit.common.runtime_target import build_runtime_target
13+
14+
15+
def _release() -> dict[str, str]:
16+
return {
17+
"release_id": "soxl-p2-v3.20260825",
18+
"manifest_sha256": "a" * 64,
19+
"strategy_revision": "soxl-p2-v3",
20+
"config_sha256": "b" * 64,
21+
"risk_policy_sha256": "c" * 64,
22+
"evidence_sha256": "d" * 64,
23+
"plugin_bundle_sha256": "e" * 64,
24+
"effective_session": "2026-08-25",
25+
}
26+
27+
28+
def _runtime_target():
29+
return build_runtime_target(
30+
platform_id="firstrade",
31+
strategy_profile="soxl_soxx_trend_income",
32+
dry_run_only=True,
33+
strategy_release=_release(),
34+
)
35+
36+
37+
def _command(
38+
*,
39+
platform: str = "firstrade",
40+
include_receipt: bool = True,
41+
disposition: str = "allow_new_risk",
42+
reason_codes: tuple[str, ...] = (),
43+
command_decision_digest: str = "f" * 64,
44+
) -> dict[str, object]:
45+
release = _release()
46+
intent: dict[str, object] = {"strategy_release": release, "targets": {"SOXL": 0.2}}
47+
if include_receipt:
48+
receipt = build_paper_risk_admission_receipt(
49+
strategy_profile="soxl_soxx_trend_income",
50+
release_id=release["release_id"],
51+
risk_policy_sha256=release["risk_policy_sha256"],
52+
decision_digest="f" * 64,
53+
effective_session="2026-08-25",
54+
disposition=disposition,
55+
reason_codes=reason_codes,
56+
)
57+
intent["paper_risk_admission_receipt"] = receipt.to_dict()
58+
return ExecutionCommand.from_decision(
59+
platform=platform,
60+
account_scope="paper",
61+
strategy_profile="soxl_soxx_trend_income",
62+
execution_mode="paper",
63+
signal_date="2026-08-24",
64+
effective_date="2026-08-25",
65+
execution_timing_contract="next_trading_day",
66+
decision_digest=command_decision_digest,
67+
intent=intent,
68+
created_at="2026-08-24T20:00:00+00:00",
69+
).to_dict()
70+
71+
72+
def test_paper_admission_is_disabled_by_default_even_without_a_command():
73+
assert evaluate_paper_dry_run_admission(runtime_target=None, env={}) is None
74+
75+
76+
def test_paper_admission_requires_an_immutable_command_before_preview():
77+
audit = evaluate_paper_dry_run_admission(
78+
runtime_target=_runtime_target(),
79+
env={PAPER_ADMISSION_ENABLED_ENV: "true"},
80+
)
81+
82+
assert audit == {
83+
"schema_version": "firstrade_paper_execution_admission_audit.v1",
84+
"admission_enabled": True,
85+
"audit_color": "red",
86+
"status": "blocked",
87+
"command_id": None,
88+
"disposition": "halted",
89+
"integrity_findings": ["paper_execution_command_missing"],
90+
"receipt_sha256": None,
91+
}
92+
93+
94+
def test_matching_command_receipt_and_runtime_release_admit_paper_preview():
95+
audit = evaluate_paper_dry_run_admission(
96+
runtime_target=_runtime_target(),
97+
env={
98+
PAPER_ADMISSION_ENABLED_ENV: "true",
99+
PAPER_EXECUTION_COMMAND_ENV: json.dumps(_command()),
100+
},
101+
)
102+
103+
assert audit is not None
104+
assert audit["status"] == "admitted"
105+
assert audit["audit_color"] == "green"
106+
assert audit["disposition"] == "allow_new_risk"
107+
assert audit["integrity_findings"] == []
108+
109+
110+
def test_non_firstrade_or_missing_embedded_receipt_stays_redacted_and_blocked():
111+
for command, finding in (
112+
(_command(platform="longbridge"), "paper_execution_platform_mismatch"),
113+
(_command(include_receipt=False), "paper_risk_admission_receipt_missing"),
114+
):
115+
audit = evaluate_paper_dry_run_admission(
116+
runtime_target=_runtime_target(),
117+
env={
118+
PAPER_ADMISSION_ENABLED_ENV: "true",
119+
PAPER_EXECUTION_COMMAND_ENV: json.dumps(command),
120+
},
121+
)
122+
123+
assert audit is not None
124+
assert audit["status"] == "blocked"
125+
assert audit["audit_color"] == "red"
126+
assert audit["integrity_findings"] == [finding]
127+
assert "targets" not in audit
128+
assert "account_scope" not in audit
129+
130+
131+
def test_reducing_only_receipt_is_blocked_until_a_platform_can_prove_reduction():
132+
audit = evaluate_paper_dry_run_admission(
133+
runtime_target=_runtime_target(),
134+
env={
135+
PAPER_ADMISSION_ENABLED_ENV: "true",
136+
PAPER_EXECUTION_COMMAND_ENV: json.dumps(
137+
_command(
138+
disposition="reducing_only",
139+
reason_codes=("DAILY_LOSS_LIMIT_EXCEEDED",),
140+
)
141+
),
142+
},
143+
)
144+
145+
assert audit is not None
146+
assert audit["status"] == "blocked"
147+
assert audit["audit_color"] == "red"
148+
assert audit["disposition"] == "reducing_only"
149+
assert audit["integrity_findings"] == ["paper_risk_admission_reducing_only"]
150+
151+
152+
def test_mismatched_risk_decision_digest_is_red_and_never_admitted():
153+
audit = evaluate_paper_dry_run_admission(
154+
runtime_target=_runtime_target(),
155+
env={
156+
PAPER_ADMISSION_ENABLED_ENV: "true",
157+
PAPER_EXECUTION_COMMAND_ENV: json.dumps(_command(command_decision_digest="e" * 64)),
158+
},
159+
)
160+
161+
assert audit is not None
162+
assert audit["status"] == "blocked"
163+
assert audit["audit_color"] == "red"
164+
assert audit["disposition"] == "halted"
165+
assert audit["integrity_findings"] == ["paper_risk_admission_command_mismatch"]

0 commit comments

Comments
 (0)