Skip to content

Commit 47a8445

Browse files
Pigbibicodex
andcommitted
feat: stage Longbridge paper execution commands
Co-Authored-By: Codex <noreply@openai.com>
1 parent a4bc7b8 commit 47a8445

9 files changed

Lines changed: 288 additions & 7 deletions
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Paper-only producer for immutable delayed-execution command evidence."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
from collections.abc import Mapping
8+
from typing import Any
9+
10+
from quant_platform_kit.common.execution_commands import (
11+
ExecutionCommand,
12+
ExecutionCommandStore,
13+
build_execution_command_store_from_env as _build_execution_command_store_from_env,
14+
)
15+
16+
17+
PAPER_EXECUTION_INTENT_SCHEMA_VERSION = "longbridge.paper-execution-intent.v1"
18+
19+
20+
def _canonical_json(value: Mapping[str, Any]) -> str:
21+
return json.dumps(dict(value), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
22+
23+
24+
def _normalized_symbols(value: object) -> list[str]:
25+
if not isinstance(value, (list, tuple, set)):
26+
return []
27+
return sorted({str(symbol or "").strip().upper() for symbol in value if str(symbol or "").strip()})
28+
29+
30+
def _normalized_targets(value: object) -> dict[str, float]:
31+
if not isinstance(value, Mapping):
32+
return {}
33+
normalized: dict[str, float] = {}
34+
for symbol, target in value.items():
35+
key = str(symbol or "").strip().upper()
36+
if not key:
37+
continue
38+
try:
39+
normalized[key] = float(target)
40+
except (TypeError, ValueError) as exc:
41+
raise ValueError(f"invalid paper execution target for {key}") from exc
42+
return {symbol: normalized[symbol] for symbol in sorted(normalized)}
43+
44+
45+
def build_paper_execution_command(
46+
*,
47+
platform: str,
48+
account_scope: str,
49+
strategy_profile: str,
50+
execution: Mapping[str, Any],
51+
allocation: Mapping[str, Any],
52+
) -> ExecutionCommand:
53+
"""Bind one paper-only command to immutable timing and target intent."""
54+
execution = dict(execution or {})
55+
allocation = dict(allocation or {})
56+
intent = {
57+
"schema_version": PAPER_EXECUTION_INTENT_SCHEMA_VERSION,
58+
"target_mode": str(allocation.get("target_mode") or "").strip(),
59+
"targets": _normalized_targets(allocation.get("targets")),
60+
"strategy_symbols": _normalized_symbols(allocation.get("strategy_symbols")),
61+
"risk_symbols": _normalized_symbols(allocation.get("risk_symbols")),
62+
"safe_haven_symbols": _normalized_symbols(allocation.get("safe_haven_symbols")),
63+
}
64+
intent_json = _canonical_json(intent)
65+
return ExecutionCommand.from_decision(
66+
platform=platform,
67+
account_scope=account_scope,
68+
strategy_profile=strategy_profile,
69+
execution_mode="paper",
70+
signal_date=execution.get("signal_date"),
71+
effective_date=execution.get("effective_date"),
72+
execution_timing_contract=execution.get("execution_timing_contract"),
73+
decision_digest=hashlib.sha256(intent_json.encode("utf-8")).hexdigest(),
74+
intent=intent,
75+
)
76+
77+
78+
def enqueue_paper_execution_command(
79+
*,
80+
enabled: bool,
81+
dry_run_only: bool,
82+
store: ExecutionCommandStore | None,
83+
platform: str,
84+
account_scope: str,
85+
strategy_profile: str,
86+
execution: Mapping[str, Any],
87+
allocation: Mapping[str, Any],
88+
) -> dict[str, object] | None:
89+
"""Create one command only; this phase never claims or routes it."""
90+
if not enabled:
91+
return None
92+
if not dry_run_only:
93+
raise RuntimeError("durable execution command producer is paper-only")
94+
if store is None or (not store.cloud_prefix_uri and not store.local_dir):
95+
raise RuntimeError("paper durable execution command store is required")
96+
command = build_paper_execution_command(
97+
platform=platform,
98+
account_scope=account_scope,
99+
strategy_profile=strategy_profile,
100+
execution=execution,
101+
allocation=allocation,
102+
)
103+
created = store.enqueue(command)
104+
return {
105+
"schema_version": "longbridge.paper-execution-command-observation.v1",
106+
"command_id": command.command_id,
107+
"decision_digest": command.decision_digest,
108+
"effective_date": command.effective_date,
109+
"status": "QUEUED" if created else "ALREADY_QUEUED",
110+
"consumer_authorized": False,
111+
}
112+
113+
114+
def build_execution_command_store_from_env(
115+
*,
116+
env_reader,
117+
gcp_project_id: str | None = None,
118+
) -> ExecutionCommandStore:
119+
return _build_execution_command_store_from_env(
120+
platform_env_prefix="LONGBRIDGE",
121+
env_reader=env_reader,
122+
project_id=gcp_project_id,
123+
)
124+
125+
126+
def resolve_paper_execution_command_producer_enabled(*, env_reader, dry_run_only: bool) -> bool:
127+
raw_value = str(env_reader("LONGBRIDGE_DURABLE_EXECUTION_COMMAND_PAPER_ENABLED", "") or "").strip().lower()
128+
enabled = raw_value in {"1", "true", "t", "yes", "y", "on"}
129+
if enabled and not dry_run_only:
130+
raise RuntimeError("durable execution command producer is paper-only and cannot be enabled live")
131+
return enabled

application/rebalance_service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from application.execution_service import ExecutionCycleResult, execute_rebalance_cycle
99
from application.execution_state import build_execution_marker_key
10+
from application.durable_execution_commands import enqueue_paper_execution_command
1011
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
1112
from quant_platform_kit.longbridge.market_data import fetch_lot_sizes
1213
from application.signal_snapshot import build_signal_snapshot
@@ -317,6 +318,18 @@ def fetch_replanned_state():
317318
return load_plan(current_snapshot=current_snapshot)
318319

319320
plan, portfolio, execution, allocation = fetch_replanned_state()
321+
paper_command_observation = enqueue_paper_execution_command(
322+
enabled=bool(getattr(config, "durable_execution_command_paper_enabled", False)),
323+
dry_run_only=bool(getattr(config, "dry_run_only", False)),
324+
store=getattr(config, "execution_command_store", None),
325+
platform="longbridge",
326+
account_scope=str(getattr(config, "execution_state_account_scope", "") or "unknown"),
327+
strategy_profile=str(getattr(config, "strategy_profile", "") or "unknown"),
328+
execution=execution,
329+
allocation=allocation,
330+
)
331+
if paper_command_observation is not None:
332+
execution["durable_execution_command"] = paper_command_observation
320333

321334
execution_marker_key = _build_execution_marker_key(config=config, execution=execution)
322335
execution_state_store = getattr(config, "execution_state_store", None)

application/runtime_composer.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
build_execution_marker_store_from_env,
1313
resolve_execution_dedup_enabled,
1414
)
15+
from application.durable_execution_commands import (
16+
build_execution_command_store_from_env,
17+
resolve_paper_execution_command_producer_enabled,
18+
)
1519
from application.runtime_notification_adapters import build_runtime_notification_adapters
1620
from application.runtime_reporting_adapters import build_runtime_reporting_adapters
1721
from quant_platform_kit.common.port_adapters import CallableNotificationPort
@@ -264,6 +268,14 @@ def build_rebalance_config(
264268
gcp_project_id=self.project_id,
265269
),
266270
execution_state_account_scope=self.account_region,
271+
durable_execution_command_paper_enabled=resolve_paper_execution_command_producer_enabled(
272+
env_reader=self.env_reader,
273+
dry_run_only=self.dry_run_only,
274+
),
275+
execution_command_store=build_execution_command_store_from_env(
276+
env_reader=self.env_reader,
277+
gcp_project_id=self.project_id,
278+
),
267279
)
268280

269281
def load_strategy_plugin_signals(self, raw_mounts):

application/runtime_dependencies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class LongBridgeRebalanceConfig:
3737
execution_dedup_enabled: bool = False
3838
execution_state_store: Any = None
3939
execution_state_account_scope: str = ""
40+
durable_execution_command_paper_enabled: bool = False
41+
execution_command_store: Any = None
4042

4143

4244
@dataclass(frozen=True)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies = [
1818
"google-cloud-storage",
1919
"google-auth",
2020
"longport==3.0.23",
21-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@68fcad8c68ab48a1109d99715f8315af449de493",
21+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0393349dabad79de60fdfed6c3580c8193ed9789",
2222
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@291033051fec86586eab223142e5b5b2532a43b5",
2323
"hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@63a3a4e96b1fb2e64b62ef70ff9057496f49ae9c",
2424
]
@@ -61,5 +61,5 @@ include = [
6161

6262
[tool.uv]
6363
override-dependencies = [
64-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@68fcad8c68ab48a1109d99715f8315af449de493",
64+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@0393349dabad79de60fdfed6c3580c8193ed9789",
6565
]

qsl.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ upgrade_ring = "ring_d"
55
allow_legacy = false
66

77
[qsl.requires]
8-
quant_platform_kit = "68fcad8c68ab48a1109d99715f8315af449de493"
8+
quant_platform_kit = "0393349dabad79de60fdfed6c3580c8193ed9789"
99
us_equity_strategies = "291033051fec86586eab223142e5b5b2532a43b5"
1010
hk_equity_strategies = "63a3a4e96b1fb2e64b62ef70ff9057496f49ae9c"
1111

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
6+
7+
ROOT = Path(__file__).resolve().parents[1]
8+
if str(ROOT) not in sys.path:
9+
sys.path.insert(0, str(ROOT))
10+
11+
from application.durable_execution_commands import ( # noqa: E402
12+
build_paper_execution_command,
13+
enqueue_paper_execution_command,
14+
resolve_paper_execution_command_producer_enabled,
15+
)
16+
17+
18+
def _execution() -> dict[str, object]:
19+
return {
20+
"signal_date": "2026-07-17",
21+
"effective_date": "2026-07-20",
22+
"execution_timing_contract": "next_trading_day",
23+
}
24+
25+
26+
def _allocation() -> dict[str, object]:
27+
return {
28+
"target_mode": "value",
29+
"targets": {"SOXL": 350.0, "BOXX": 150.0},
30+
"strategy_symbols": ("SOXL", "BOXX"),
31+
"risk_symbols": ("SOXL",),
32+
"safe_haven_symbols": ("BOXX",),
33+
}
34+
35+
36+
def test_paper_command_is_content_addressed_and_excludes_broker_authority() -> None:
37+
first = build_paper_execution_command(
38+
platform="longbridge",
39+
account_scope="PAPER",
40+
strategy_profile="soxl_soxx_trend_income",
41+
execution=_execution(),
42+
allocation=_allocation(),
43+
)
44+
second = build_paper_execution_command(
45+
platform="longbridge",
46+
account_scope="PAPER",
47+
strategy_profile="soxl_soxx_trend_income",
48+
execution=_execution(),
49+
allocation=_allocation(),
50+
)
51+
52+
assert first.command_id == second.command_id
53+
assert first.execution_mode == "paper"
54+
assert first.effective_date == "2026-07-20"
55+
assert first.intent == {
56+
"schema_version": "longbridge.paper-execution-intent.v1",
57+
"target_mode": "value",
58+
"targets": {"BOXX": 150.0, "SOXL": 350.0},
59+
"strategy_symbols": ["BOXX", "SOXL"],
60+
"risk_symbols": ["SOXL"],
61+
"safe_haven_symbols": ["BOXX"],
62+
}
63+
64+
65+
def test_paper_producer_enqueues_once_and_never_authorizes_consumer() -> None:
66+
observed = []
67+
68+
class Store:
69+
cloud_prefix_uri = "gs://paper/commands"
70+
local_dir = None
71+
72+
def enqueue(self, command):
73+
observed.append(command)
74+
return len(observed) == 1
75+
76+
kwargs = {
77+
"enabled": True,
78+
"dry_run_only": True,
79+
"store": Store(),
80+
"platform": "longbridge",
81+
"account_scope": "PAPER",
82+
"strategy_profile": "soxl_soxx_trend_income",
83+
"execution": _execution(),
84+
"allocation": _allocation(),
85+
}
86+
first = enqueue_paper_execution_command(**kwargs)
87+
second = enqueue_paper_execution_command(**kwargs)
88+
89+
assert first and first["status"] == "QUEUED"
90+
assert second and second["status"] == "ALREADY_QUEUED"
91+
assert first["consumer_authorized"] is False
92+
assert len(observed) == 2
93+
94+
95+
def test_paper_producer_rejects_live_enablement() -> None:
96+
assert resolve_paper_execution_command_producer_enabled(
97+
env_reader=lambda _name, _default="": "true",
98+
dry_run_only=True,
99+
)
100+
try:
101+
resolve_paper_execution_command_producer_enabled(
102+
env_reader=lambda _name, _default="": "true",
103+
dry_run_only=False,
104+
)
105+
except RuntimeError as exc:
106+
assert "paper-only" in str(exc)
107+
else: # pragma: no cover
108+
raise AssertionError("live enablement must fail closed")

tests/test_rebalance_service.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,7 @@ def record_marker(self, *_args, **_kwargs):
13801380
def test_run_strategy_records_execution_marker_after_dry_run_order_preview(self):
13811381
sent_messages = []
13821382
recorded_markers = []
1383+
queued_commands = []
13831384
plan = _build_plan(
13841385
strategy_symbols=("BOXX",),
13851386
safe_haven_symbols=("BOXX",),
@@ -1409,7 +1410,15 @@ def has_marker(self, _marker_key):
14091410
def record_marker(self, marker_key, *, metadata=None):
14101411
recorded_markers.append((marker_key, dict(metadata or {})))
14111412

1412-
rebalance_service.run_strategy(
1413+
class CommandStore:
1414+
cloud_prefix_uri = "gs://paper/commands"
1415+
local_dir = None
1416+
1417+
def enqueue(self, command):
1418+
queued_commands.append(command)
1419+
return True
1420+
1421+
result = rebalance_service.run_strategy(
14131422
runtime=LongBridgeRebalanceRuntime(
14141423
bootstrap=lambda: ("quote-context", "trade-context", {"trend": "ok"}),
14151424
resolve_rebalance_plan=lambda *, indicators, snapshot=None, account_state=None: plan,
@@ -1442,6 +1451,8 @@ def record_marker(self, marker_key, *, metadata=None):
14421451
execution_dedup_enabled=True,
14431452
execution_state_store=FakeStore(),
14441453
execution_state_account_scope="PAPER",
1454+
durable_execution_command_paper_enabled=True,
1455+
execution_command_store=CommandStore(),
14451456
),
14461457
)
14471458

@@ -1450,6 +1461,10 @@ def record_marker(self, marker_key, *, metadata=None):
14501461
self.assertEqual(len(recorded_markers), 1)
14511462
self.assertIn("2026-06-01", recorded_markers[0][0])
14521463
self.assertTrue(recorded_markers[0][1]["dry_run_only"])
1464+
self.assertEqual(len(queued_commands), 1)
1465+
self.assertEqual(queued_commands[0].effective_date, "2026-06-02")
1466+
self.assertEqual(result.execution["durable_execution_command"]["status"], "QUEUED")
1467+
self.assertFalse(result.execution["durable_execution_command"]["consumer_authorized"])
14531468

14541469
def test_append_status_lines_localizes_snapshot_guard_text_for_zh(self):
14551470
lines = []

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)