Skip to content

Commit 573fc9e

Browse files
authored
Require structured runtime target JSON (#34)
1 parent 824ee97 commit 573fc9e

9 files changed

Lines changed: 52 additions & 128 deletions

docs/deployment_model.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- `QuantPlatformKit` remains the shared platform package and is **not deployed as a runtime service**.
66
- The current runtime repositories (`InteractiveBrokersPlatform`, `CharlesSchwabPlatform`, `LongBridgePlatform`, `BinancePlatform`) are the **transitional deployment units**.
7-
- The **target state** is one deployment repository per broker platform, with strategy behavior selected through `RuntimeTarget` / `RUNTIME_TARGET_JSON` and compatibility selectors such as `STRATEGY_PROFILE`.
7+
- The **target state** is one deployment repository per broker platform, with structured runtime identity carried through `RuntimeTarget` / `RUNTIME_TARGET_JSON` and `STRATEGY_PROFILE` retained only as a compatibility routing selector.
88
- Strategy or platform repositories should always depend on a fixed `QuantPlatformKit` Git tag instead of `main`.
99

1010
For the live runtime inventory across repositories, projects, services, schedulers, runtime identities, and current secret names, see [`platform_runtime_inventory.md`](./platform_runtime_inventory.md).
@@ -152,7 +152,7 @@ Within one broker platform repository, selecting a strategy by configuration is
152152
Recommended selector:
153153

154154
- `RUNTIME_TARGET_JSON` for structured runtime identity
155-
- `STRATEGY_PROFILE` for compatibility with existing strategy routing
155+
- `STRATEGY_PROFILE` only for compatibility with existing strategy routing
156156

157157
Good examples:
158158

docs/deployment_model.zh-CN.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- `QuantPlatformKit` 是共享平台代码仓库,**不单独部署**
66
- `InteractiveBrokersPlatform``CharlesSchwabPlatform``LongBridgePlatform``BinancePlatform` 这些仓库才是实际运行单元。
7+
- 目标状态是每个平台一个部署仓库,结构化运行身份通过 `RuntimeTarget` / `RUNTIME_TARGET_JSON` 传递,`STRATEGY_PROFILE` 只作为兼容路由选择器保留。
78
- 策略仓库应该固定依赖某个 Git tag,不要直接依赖 `main`
89

910
如果要看公开 runtime 接线清单,包括仓库、项目、服务、scheduler、runtime identity、selector 和 secret 入口,见 [`platform_runtime_inventory.zh-CN.md`](./platform_runtime_inventory.zh-CN.md)
@@ -122,6 +123,7 @@ Cloud Run 继续只部署运行仓库,不部署 `QuantPlatformKit`。
122123

123124
可以参数化的:
124125

126+
- `RUNTIME_TARGET_JSON`
125127
- `IB_GATEWAY_MODE`
126128
- `ACCOUNT_PREFIX`
127129
- `SERVICE_NAME`
@@ -195,4 +197,5 @@ Cloud Run 继续只部署运行仓库,不部署 `QuantPlatformKit`。
195197
- 平台共享代码进 `QuantPlatformKit`
196198
- 平台运行仓库继续作为部署单元
197199
- GCP / VPS 只部署平台运行仓库
200+
- 运行身份优先看 `RUNTIME_TARGET_JSON``STRATEGY_PROFILE` 只保留兼容路由
198201
- 版本靠固定 tag 管理

docs/platform_runtime_inventory.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -91,25 +91,27 @@ For the platform / strategy-domain / configurable-profile matrix, see [`platform
9191
- **Cloud Run project**
9292
- `longbridgequant`
9393
- **Services**
94-
- HK: `longbridge-quant-hk-service`
94+
- PAPER: `longbridge-quant-paper-service`
95+
- HK: reserved / not yet wired
9596
- SG: `longbridge-quant-sg-service`
9697
- **Runtime service account**
9798
- `longbridge-platform-runtime@longbridgequant.iam.gserviceaccount.com`
9899
- **Schedulers**
99-
- `longbridge-quant-hk-service-scheduler` in `asia-east2`
100+
- `longbridge-quant-paper-service-scheduler` in `asia-east2`
101+
- HK: reserved / not yet wired
100102
- `longbridge-quant-sg-service-scheduler` in `asia-southeast1`
101103
- **Core runtime selectors**
102-
- `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` per regional service
103-
- `ACCOUNT_REGION=HK|SG`
104-
- `LONGPORT_SECRET_NAME=<region token secret>`
104+
- `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` per account service
105+
- `ACCOUNT_REGION=PAPER|HK|SG`
106+
- `LONGPORT_SECRET_NAME=<account token secret>`
105107
- **Runtime secrets**
106108
- Secret Manager refs for LongPort app key / app secret
107-
- region token secrets selected by `LONGPORT_SECRET_NAME`
109+
- account token secrets selected by `LONGPORT_SECRET_NAME`
108110
- runtime Telegram token secret
109111
- **Runtime notes**
110-
- HK and SG keep two independent Cloud Run services, two triggers, and two GitHub Environments.
112+
- PAPER and SG are live today; HK keeps the same deployment pattern when it is added. Each account identity gets its own Cloud Run service, trigger, and GitHub Environment.
111113
- Snapshot-backed profiles require feature snapshot path / manifest envs; direct-runtime profiles do not.
112-
- App key / secret are region-specific Secret Manager refs; Telegram token is shared inside the LongBridge project.
114+
- App key / secret are account-specific Secret Manager refs; Telegram token is shared inside the LongBridge project.
113115
- `SERVICE_NAME` should use the full runtime-facing service names above, not older short prefixes.
114116

115117
### Binance

docs/platform_strategy_matrix.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ For strategy behavior, research status, and archived backtest evidence, see
1818
- There are currently two strategy domains:
1919
- `us_equity`
2020
- `crypto`
21-
- Runtime repositories now expose `RUNTIME_TARGET_JSON` plus `STRATEGY_PROFILE`; this is **not** a full multi-strategy marketplace yet.
21+
- Runtime repositories now expose `RUNTIME_TARGET_JSON` as the primary structured runtime identity, with `STRATEGY_PROFILE` kept as a compatibility routing selector; this is **not** a full multi-strategy marketplace yet.
2222
- Today, each US equity platform can switch among the `runtime_enabled` `us_equity` profiles published by `UsEquityStrategies`, subject to each platform's rollout configuration.
2323
- Platform runtime adapters are generated from strategy input/target-mode declarations plus platform capabilities, so new in-contract profiles should not need per-platform allowlist edits.
2424
- The shared contract is in `QuantPlatformKit`; real `us_equity` strategy implementations now live in `UsEquityStrategies`, while platform repositories own runtime adapters and broker execution.
@@ -29,7 +29,7 @@ For strategy behavior, research status, and archived backtest evidence, see
2929
|---|---|---|---|---|---|---|
3030
| IBKR | `QuantStrategyLab/InteractiveBrokersPlatform` | `us_equity` | `RUNTIME_TARGET_JSON` + `ACCOUNT_GROUP` | `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` | Cloud Run | Yes - controlled by platform rollout config |
3131
| Charles Schwab | `QuantStrategyLab/CharlesSchwabPlatform` | `us_equity` | `RUNTIME_TARGET_JSON` | `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` | Cloud Run | Yes - controlled by platform rollout config |
32-
| LongBridge | `QuantStrategyLab/LongBridgePlatform` | `us_equity` | `RUNTIME_TARGET_JSON` + `ACCOUNT_REGION` | `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` per regional service | Cloud Run | Yes - controlled by platform rollout config |
32+
| LongBridge | `QuantStrategyLab/LongBridgePlatform` | `us_equity` | `RUNTIME_TARGET_JSON` + `ACCOUNT_REGION` | `STRATEGY_PROFILE=<runtime_enabled us_equity profile>` per account service | Cloud Run | Yes - paper and SG today; HK later |
3333
| Binance | `QuantStrategyLab/BinancePlatform` | `crypto` | `RUNTIME_TARGET_JSON` (workflow-local) | `crypto_leader_rotation` | Oracle Cloud + self-hosted runner | No - only this profile is supported today |
3434

3535
## What this means right now
@@ -42,6 +42,8 @@ Platforms currently in this domain:
4242
- `CharlesSchwabPlatform`
4343
- `LongBridgePlatform`
4444

45+
LongBridge account identities are modeled as `paper`, `HK`, and `SG`; today the live services use `paper` and `SG`, and `HK` follows the same contract when it is introduced.
46+
4547
Important limitation:
4648

4749
- This does **not** mean any arbitrary future `us_equity` strategy can run by name alone.

docs/platform_strategy_matrix.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ _核对时间:2026-04-18_
1818
- 当前只有两个策略大类:
1919
- `us_equity`
2020
- `crypto`
21-
- 各个平台仓库现在都已经保留了 `STRATEGY_PROFILE` 入口,但这**还不是**真正的多策略平台。
21+
- 各个平台仓库现在都已经保留了 `RUNTIME_TARGET_JSON` 结构化运行身份,`STRATEGY_PROFILE` 只作为兼容路由入口,但这**还不是**真正的多策略平台。
2222
- 现在每个美股平台仓库都可以在 `UsEquityStrategies` 发布的 `runtime_enabled` `us_equity` profile 之间切换,前提是对应平台 rollout 配置已经放开。
2323
- 平台 runtime adapter 会根据策略输入、target mode 和平台 capability 自动生成;规范内的新 profile 不应该再需要三个平台分别手写 allowlist。
2424
- 共享契约在 `QuantPlatformKit`;真实的 `us_equity` 策略实现现在放在 `UsEquityStrategies`,平台仓库负责运行时适配和券商执行。

docs/us_equity_live_switch_runbook.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ Do not change service names as part of a strategy switch.
7272
| --- | --- | --- |
7373
| IBKR | `interactive-brokers-quant-service` | `ACCOUNT_GROUP` |
7474
| Schwab | `charles-schwab-quant-service` | single service |
75-
| LongBridge HK | `longbridge-quant-hk-service` | `ACCOUNT_REGION=HK` |
75+
| LongBridge PAPER | `longbridge-quant-paper-service` | `ACCOUNT_REGION=PAPER` |
76+
| LongBridge HK | reserved / not yet wired | `ACCOUNT_REGION=HK` |
7677
| LongBridge SG | `longbridge-quant-sg-service` | `ACCOUNT_REGION=SG` |
7778

7879
## Step 1: verify the target profile before touching env
@@ -265,7 +266,7 @@ Do not stop at Cloud Run env.
265266
Verify the first heartbeat or execution notification shows:
266267

267268
- the expected display name
268-
- the expected account prefix (`[HK]` / `[SG]` for LongBridge)
269+
- the expected account prefix (`[PAPER]` / `[HK]` / `[SG]` for LongBridge)
269270
- no stale strategy-specific service-name suffix in the LongBridge notification prefix
270271

271272
If the profile uses feature snapshots, also verify:
@@ -319,12 +320,12 @@ Why:
319320
- `tech_communication_pullback_enhancement` is a feature-snapshot profile
320321
- the strategy has a packaged canonical config; set the env path only when overriding it
321322

322-
### Example C: switch LongBridge HK to `russell_1000_multi_factor_defensive`
323+
### Example C: switch LongBridge PAPER to `russell_1000_multi_factor_defensive`
323324

324325
Keep:
325326

326-
- `ACCOUNT_PREFIX=HK`
327-
- `ACCOUNT_REGION=HK`
327+
- `ACCOUNT_PREFIX=PAPER`
328+
- `ACCOUNT_REGION=PAPER`
328329
- `LONGPORT_SECRET_NAME`
329330
- `LONGPORT_APP_KEY_SECRET_NAME`
330331
- `LONGPORT_APP_SECRET_SECRET_NAME`

src/quant_platform_kit/common/__init__.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@
1515
from .runtime_target import (
1616
build_runtime_context_fields,
1717
RuntimeTarget,
18-
ResolvedRuntimeIdentity,
1918
build_runtime_target,
20-
resolve_runtime_identity_from_env,
2119
resolve_runtime_target_from_env,
22-
resolve_runtime_target_strategy_profile_from_env,
2320
)
2421
from .strategy_plugins import (
2522
PLUGIN_MODE_SHADOW,
@@ -40,7 +37,6 @@
4037
"SUPPORTED_STRATEGY_PLUGIN_MODES",
4138
"localize_notification_text",
4239
"RuntimeTarget",
43-
"ResolvedRuntimeIdentity",
4440
"build_runtime_context_fields",
4541
"build_run_id",
4642
"emit_runtime_log",
@@ -56,9 +52,7 @@
5652
"load_strategy_plugin_signal",
5753
"normalize_strategy_plugin_mode",
5854
"parse_strategy_plugin_mounts",
59-
"resolve_runtime_identity_from_env",
6055
"resolve_runtime_target_from_env",
61-
"resolve_runtime_target_strategy_profile_from_env",
6256
"translator_uses_zh",
6357
"validate_strategy_plugin_signal_payload",
6458
]

src/quant_platform_kit/common/runtime_target.py

Lines changed: 15 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

33
import json
4-
from dataclasses import dataclass, asdict
54
from collections.abc import Iterable
5+
from dataclasses import dataclass, asdict
66
from typing import Any, Mapping
77

88

@@ -26,12 +26,6 @@ def to_dict(self) -> dict[str, object]:
2626
return payload
2727

2828

29-
@dataclass(frozen=True)
30-
class ResolvedRuntimeIdentity:
31-
strategy_profile: str
32-
runtime_target: RuntimeTarget
33-
34-
3529
def build_runtime_context_fields(
3630
extra_context_fields: Mapping[str, Any] | None = None,
3731
*,
@@ -105,25 +99,11 @@ def _coerce_optional_bool(value: object) -> bool | None:
10599
def resolve_runtime_target_from_env(
106100
*,
107101
env: Mapping[str, str | None],
108-
platform_id: str,
109-
strategy_profile: str,
110-
dry_run_only: bool,
111-
deployment_selector: str | None = None,
112-
account_selector: Iterable[str] | str | None = None,
113-
account_scope: str | None = None,
114-
service_name: str | None = None,
102+
expected_platform_id: str | None = None,
115103
) -> RuntimeTarget:
116104
raw_payload = _normalize_optional_string(env.get("RUNTIME_TARGET_JSON"))
117105
if raw_payload is None:
118-
return build_runtime_target(
119-
platform_id=platform_id,
120-
strategy_profile=strategy_profile,
121-
dry_run_only=dry_run_only,
122-
deployment_selector=deployment_selector,
123-
account_selector=account_selector,
124-
account_scope=account_scope,
125-
service_name=service_name,
126-
)
106+
raise EnvironmentError("RUNTIME_TARGET_JSON is required")
127107

128108
try:
129109
payload = json.loads(raw_payload)
@@ -135,15 +115,19 @@ def resolve_runtime_target_from_env(
135115

136116
resolved_platform_id = _normalize_optional_string(payload.get("platform_id"))
137117
if resolved_platform_id is None:
138-
resolved_platform_id = platform_id
139-
elif resolved_platform_id != platform_id:
118+
raise ValueError("RUNTIME_TARGET_JSON.platform_id is required")
119+
if expected_platform_id is not None and resolved_platform_id != expected_platform_id:
140120
raise ValueError(
141121
"RUNTIME_TARGET_JSON.platform_id does not match the runtime platform"
142122
)
143-
resolved_strategy_profile = payload.get("strategy_profile", strategy_profile)
123+
resolved_strategy_profile = _normalize_optional_string(
124+
payload.get("strategy_profile")
125+
)
126+
if resolved_strategy_profile is None:
127+
raise ValueError("RUNTIME_TARGET_JSON.strategy_profile is required")
144128
resolved_dry_run_only = _coerce_optional_bool(payload.get("dry_run_only"))
145129
if resolved_dry_run_only is None:
146-
resolved_dry_run_only = dry_run_only
130+
raise ValueError("RUNTIME_TARGET_JSON.dry_run_only is required")
147131

148132
execution_mode = payload.get("execution_mode")
149133
if execution_mode is not None and str(execution_mode).strip():
@@ -157,63 +141,8 @@ def resolve_runtime_target_from_env(
157141
platform_id=resolved_platform_id,
158142
strategy_profile=resolved_strategy_profile,
159143
dry_run_only=resolved_dry_run_only,
160-
deployment_selector=payload.get("deployment_selector", deployment_selector),
161-
account_selector=payload.get("account_selector", account_selector),
162-
account_scope=payload.get("account_scope", account_scope),
163-
service_name=payload.get("service_name", service_name),
164-
)
165-
166-
167-
def resolve_runtime_target_strategy_profile_from_env(
168-
env: Mapping[str, str | None],
169-
*,
170-
default_strategy_profile: str | None,
171-
) -> str | None:
172-
raw_payload = _normalize_optional_string(env.get("RUNTIME_TARGET_JSON"))
173-
if raw_payload is None:
174-
return _normalize_optional_string(default_strategy_profile)
175-
176-
try:
177-
payload = json.loads(raw_payload)
178-
except json.JSONDecodeError as exc:
179-
raise ValueError("RUNTIME_TARGET_JSON must contain valid JSON") from exc
180-
181-
if not isinstance(payload, dict):
182-
raise ValueError("RUNTIME_TARGET_JSON must decode to an object")
183-
184-
resolved_strategy_profile = _normalize_optional_string(
185-
payload.get("strategy_profile")
186-
)
187-
return resolved_strategy_profile or _normalize_optional_string(default_strategy_profile)
188-
189-
190-
def resolve_runtime_identity_from_env(
191-
env: Mapping[str, str | None],
192-
*,
193-
platform_id: str,
194-
default_strategy_profile: str | None,
195-
dry_run_only: bool,
196-
deployment_selector: str | None = None,
197-
account_selector: Iterable[str] | str | None = None,
198-
account_scope: str | None = None,
199-
service_name: str | None = None,
200-
) -> ResolvedRuntimeIdentity:
201-
strategy_profile = resolve_runtime_target_strategy_profile_from_env(
202-
env,
203-
default_strategy_profile=default_strategy_profile,
204-
)
205-
if strategy_profile is None:
206-
raise EnvironmentError("STRATEGY_PROFILE is required")
207-
return ResolvedRuntimeIdentity(
208-
strategy_profile=strategy_profile,
209-
runtime_target=resolve_runtime_target_from_env(
210-
env=env,
211-
platform_id=platform_id,
212-
strategy_profile=strategy_profile,
213-
dry_run_only=dry_run_only,
214-
deployment_selector=deployment_selector,
215-
account_selector=account_selector,
216-
account_scope=account_scope,
217-
service_name=service_name,
218-
),
144+
deployment_selector=payload.get("deployment_selector"),
145+
account_selector=payload.get("account_selector"),
146+
account_scope=payload.get("account_scope"),
147+
service_name=payload.get("service_name"),
219148
)

tests/test_runtime_target.py

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,6 @@ def test_resolve_runtime_target_from_env_prefers_structured_json(self) -> None:
5353
'"execution_mode":"paper"}'
5454
)
5555
},
56-
platform_id="longbridge",
57-
strategy_profile="fallback_profile",
58-
dry_run_only=False,
59-
deployment_selector="SG",
60-
account_selector=("SG",),
61-
account_scope="SG",
62-
service_name="fallback-service",
6356
)
6457

6558
self.assertEqual(target.platform_id, "longbridge")
@@ -78,12 +71,10 @@ def test_resolve_runtime_target_from_env_rejects_mismatched_execution_mode(self)
7871
"RUNTIME_TARGET_JSON": (
7972
'{"platform_id":"schwab","strategy_profile":"tqqq_growth_income",'
8073
'"dry_run_only":false,"execution_mode":"paper"}'
81-
)
82-
},
83-
platform_id="schwab",
84-
strategy_profile="tqqq_growth_income",
85-
dry_run_only=False,
86-
)
74+
)
75+
},
76+
expected_platform_id="schwab",
77+
)
8778

8879
def test_resolve_runtime_target_from_env_rejects_mismatched_platform(self) -> None:
8980
with self.assertRaisesRegex(
@@ -95,13 +86,15 @@ def test_resolve_runtime_target_from_env_rejects_mismatched_platform(self) -> No
9586
"RUNTIME_TARGET_JSON": (
9687
'{"platform_id":"ibkr","strategy_profile":"global_etf_rotation",'
9788
'"dry_run_only":false}'
98-
)
99-
},
100-
platform_id="longbridge",
101-
strategy_profile="global_etf_rotation",
102-
dry_run_only=False,
89+
)
90+
},
91+
expected_platform_id="longbridge",
10392
)
10493

94+
def test_resolve_runtime_target_from_env_requires_structured_json(self) -> None:
95+
with self.assertRaisesRegex(EnvironmentError, "RUNTIME_TARGET_JSON is required"):
96+
resolve_runtime_target_from_env(env={})
97+
10598
def test_build_runtime_context_fields_merges_runtime_target_without_overwriting_fields(self) -> None:
10699
target = build_runtime_target(
107100
platform_id="longbridge",

0 commit comments

Comments
 (0)