Skip to content

Commit 7068272

Browse files
Pigbibicodex
andauthored
feat: harden forward observation lifecycle evidence (#392)
Co-authored-by: Codex <noreply@openai.com>
1 parent fbfcc75 commit 7068272

6 files changed

Lines changed: 662 additions & 21 deletions

File tree

docs/forward_observation_runtime_contract.zh-CN.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
`quant_platform_kit.strategy_lifecycle.forward_observation` 是策略、插件和平台共用的纯决策层。它不连接券商、不写运行时目标、不部署服务,也不产生订单。
44

5-
每个冻结候选必须显式提供自己的 `ForwardObservationPolicy`:候选 ID、策略 profile、无杠杆基准、前瞻交易日数量、复核里程碑,以及恢复前需要的连续健康周期。控制器没有“252 天”“20/60 天”或某个策略的隐性默认值;新候选缺少这些字段会被拒绝,不能继承 SOXL 的参数。没有已验证的 P3 历史证据和证据引用时,状态固定为 `PARKED`
5+
每个冻结候选必须显式提供自己的 `ForwardObservationPolicy`:候选 ID、策略 profile、无杠杆基准、前瞻交易日数量、复核里程碑、恢复前需要的连续健康周期、市场日历、固定或滚动窗口、窗口起点、窗口理由引用,以及精确的非 Live 证据模式。控制器没有“252 天”“20/60 天”或某个策略的隐性默认值;新候选缺少这些字段会被拒绝,不能继承 SOXL 的参数。没有已验证的 P3 历史证据和证据引用时,状态固定为 `PARKED`
66

7-
P3 通过后,控制器可以自动给出 `start_shadow``start_paper``continue_*` 和在短暂故障恢复后的 `resume_*` 意图。数据过期、Paper/Shadow 不一致或风险门阻断时,它只会给出 `pause_*`,并产生告警;它不会替换数据源、修改参数、授予 IAM 权限或修改仓位
7+
P3 通过后,控制器可以自动给出 `start_shadow``start_paper``continue_*` 和在短暂数据/运行故障恢复后的 `resume_*` 意图。证据模式必须明确为 `shadow_decision + simulated_replay``shadow_decision + broker_paper`,不能把模拟回放、订单预览和券商 Paper 混称为同一种 Paper。数据过期或 Shadow/Paper 不一致时,才会进入可自动恢复的 `PAUSED`;风险阻断、人工冻结、身份不匹配、撤销或被新候选替代,分别进入不可自动恢复的终止状态
88

9-
达到候选自己的完整前瞻窗口(例如 SOXL V7 基于其回测与风险验证采用 252 个交易日)只会进入 `FORWARD_COMPLETE_HUMAN_REVIEW`。不同策略可以采用不同观察期,但必须由冻结候选与回测证据明确证明,不能在运行中自动修改。返回值永久包含:
9+
达到候选自己的完整前瞻窗口(例如 SOXL V7 基于其回测与风险验证采用 252 个交易日)后,控制器会停止两种非 Live 意图,并进入 `FORWARD_COMPLETE_HUMAN_REVIEW`。不同策略可以采用不同观察期,但必须由冻结候选与回测证据明确证明,不能在运行中自动修改。返回值永久包含:
1010

1111
- `no_order=true`
1212
- `live_authority_granted=false`
1313
- `live_action=human_approval_required`
1414

1515
因此调度器可以自动完成非实盘观察、记录和安全暂停/恢复;任何平台适配器都必须把该结果视为非 Live 意图。首次 Live、重新启用 Live、资金扩大和策略参数修改仍须独立人工批准与重新验证。
16+
17+
每个有效观察周期还应输出 `forward_observation_receipt.v1`。收据只保存候选、完整 policy 摘要、观察交易日/序号、前一收据摘要、P1/P2/P3/风控/发布/插件的摘要和精确证据模式;它不保存原始价格、账户、订单或密钥。验证器要求收据连续追加、候选与 policy 一致,且不能越过冻结窗口。

src/quant_platform_kit/strategy_lifecycle/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@
6363
ForwardObservationSnapshot,
6464
evaluate_forward_observation,
6565
)
66+
from quant_platform_kit.strategy_lifecycle.forward_observation_receipt import (
67+
FORWARD_OBSERVATION_DEPENDENCY_DIGESTS,
68+
FORWARD_OBSERVATION_EVIDENCE_MODES,
69+
FORWARD_OBSERVATION_RECEIPT_SCHEMA_VERSION,
70+
InvalidForwardObservationReceipt,
71+
build_forward_observation_receipt,
72+
canonical_forward_observation_receipt_bytes,
73+
forward_observation_policy_sha256,
74+
forward_observation_receipt_sha256,
75+
validate_forward_observation_receipt,
76+
)
6677
from quant_platform_kit.strategy_lifecycle.live_candidate_notifications import (
6778
LiveCandidateNotificationEvent,
6879
build_live_candidate_notification,
@@ -116,6 +127,9 @@
116127
"EvidenceGateResult",
117128
"EvidencePackage",
118129
"FORWARD_OBSERVATION_POLICY_SCHEMA_VERSION",
130+
"FORWARD_OBSERVATION_RECEIPT_SCHEMA_VERSION",
131+
"FORWARD_OBSERVATION_DEPENDENCY_DIGESTS",
132+
"FORWARD_OBSERVATION_EVIDENCE_MODES",
119133
"FORWARD_RISK_SCHEMA_VERSION",
120134
"FORWARD_RISK_TERMINAL_STATUSES",
121135
"P4_OBSERVATION_MODES",
@@ -126,6 +140,7 @@
126140
"ForwardObservationPolicy",
127141
"ForwardObservationPolicyError",
128142
"ForwardObservationSnapshot",
143+
"InvalidForwardObservationReceipt",
129144
"CANONICAL_LIFECYCLE_STATES",
130145
"LEGACY_CATALOG_STATUS_MAP",
131146
"InvalidResearchDriverArtifact",
@@ -140,6 +155,7 @@
140155
"build_strategy_benchmark_catalog",
141156
"assess_strategy_release_readiness",
142157
"build_forward_risk_terminal_artifact",
158+
"build_forward_observation_receipt",
143159
"evaluate_forward_observation",
144160
"build_nonready_forward_risk_stage",
145161
"build_nonready_research_stage",
@@ -149,19 +165,23 @@
149165
"build_research_driver_terminal_artifact",
150166
"canonical_research_driver_terminal_bytes",
151167
"canonical_forward_risk_terminal_bytes",
168+
"canonical_forward_observation_receipt_bytes",
152169
"catalog_status_grants_execution_permission",
153170
"migrate_legacy_lifecycle_status",
154171
"normalize_catalog_lifecycle_status",
155172
"require_canonical_lifecycle_write",
156173
"research_driver_terminal_sha256",
157174
"forward_risk_terminal_sha256",
175+
"forward_observation_policy_sha256",
176+
"forward_observation_receipt_sha256",
158177
"validate_evidence_package",
159178
"validate_evidence_package_file",
160179
"validate_evidence_package_v2",
161180
"validate_optimization_spec",
162181
"validate_research_spec",
163182
"validate_research_driver_terminal_artifact",
164183
"validate_forward_risk_terminal_artifact",
184+
"validate_forward_observation_receipt",
165185
"validate_strategy_spec",
166186
"validate_strategy_spec_file",
167187
]

src/quant_platform_kit/strategy_lifecycle/forward_observation.py

Lines changed: 151 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,40 @@
1010
from __future__ import annotations
1111

1212
from dataclasses import dataclass
13+
from datetime import date
1314
from typing import Any
1415

1516

1617
FORWARD_OBSERVATION_POLICY_SCHEMA_VERSION = "forward_observation_policy.v1"
1718

1819
_NON_LIVE_MODES = frozenset({"shadow", "paper"})
20+
_NON_LIVE_EVIDENCE_MODES = frozenset(
21+
{"shadow_decision", "simulated_replay", "broker_paper"}
22+
)
1923
_DATA_STATUSES = frozenset({"ready", "stale", "unavailable"})
2024
_MODE_STATUSES = frozenset({"healthy", "mismatch", "unavailable"})
2125
_RISK_STATUSES = frozenset({"pass", "blocked"})
22-
_PREVIOUS_STATES = frozenset({"not_started", "active", "paused", "complete"})
26+
_WINDOW_TYPES = frozenset({"fixed", "rolling"})
27+
_CONTROL_STATUSES = frozenset(
28+
{"clear", "manual_hold", "identity_mismatch", "revoked", "superseded"}
29+
)
30+
_PREVIOUS_STATES = frozenset(
31+
{
32+
"not_started",
33+
"active",
34+
"paused",
35+
"complete",
36+
"manual_hold",
37+
"identity_mismatch",
38+
"risk_blocked",
39+
"revoked",
40+
"superseded",
41+
}
42+
)
43+
_STOPPED_ACTIONS = ("keep_shadow_stopped", "keep_paper_stopped")
44+
_PERMANENT_PREVIOUS_STATES = frozenset(
45+
{"manual_hold", "identity_mismatch", "risk_blocked", "revoked", "superseded"}
46+
)
2347

2448

2549
class ForwardObservationPolicyError(ValueError):
@@ -38,6 +62,17 @@ def _non_negative_int(value: object, label: str) -> int:
3862
return value
3963

4064

65+
def _session_date(value: object, label: str) -> str:
66+
if not isinstance(value, str) or not value:
67+
raise ForwardObservationPolicyError(f"{label} must be an ISO-8601 date")
68+
try:
69+
return date.fromisoformat(value).isoformat()
70+
except ValueError as exc:
71+
raise ForwardObservationPolicyError(
72+
f"{label} must be an ISO-8601 date"
73+
) from exc
74+
75+
4176
@dataclass(frozen=True)
4277
class ForwardObservationPolicy:
4378
"""Candidate-specific rules for a no-capital forward-observation window.
@@ -52,12 +87,24 @@ class ForwardObservationPolicy:
5287
domain: str
5388
benchmark_symbol: str
5489
required_trading_sessions: int
55-
review_milestones: tuple[int, ...] = (20, 60)
56-
automatic_non_live_modes: tuple[str, ...] = ("shadow", "paper")
57-
auto_resume_clean_sessions: int = 3
90+
review_milestones: tuple[int, ...]
91+
automatic_non_live_modes: tuple[str, ...]
92+
auto_resume_clean_sessions: int
93+
observation_calendar: str
94+
observation_window_type: str
95+
observation_start_session: str | None
96+
window_rationale_ref: str
97+
non_live_evidence_modes: tuple[str, ...]
5898

5999
def __post_init__(self) -> None:
60-
for field_name in ("candidate_id", "strategy_profile", "domain", "benchmark_symbol"):
100+
for field_name in (
101+
"candidate_id",
102+
"strategy_profile",
103+
"domain",
104+
"benchmark_symbol",
105+
"observation_calendar",
106+
"window_rationale_ref",
107+
):
61108
_required_text(getattr(self, field_name), field_name)
62109
required = _non_negative_int(
63110
self.required_trading_sessions, "required_trading_sessions"
@@ -92,6 +139,33 @@ def __post_init__(self) -> None:
92139
raise ForwardObservationPolicyError(
93140
"review_milestones must be positive integers below required_trading_sessions"
94141
)
142+
window_type = _required_text(
143+
self.observation_window_type, "observation_window_type"
144+
).lower()
145+
if window_type not in _WINDOW_TYPES:
146+
raise ForwardObservationPolicyError(
147+
"observation_window_type must be fixed or rolling"
148+
)
149+
if window_type == "fixed":
150+
_session_date(self.observation_start_session, "observation_start_session")
151+
elif self.observation_start_session is not None:
152+
raise ForwardObservationPolicyError(
153+
"rolling observation_window_type must not set observation_start_session"
154+
)
155+
156+
evidence_modes = tuple(
157+
str(mode).strip().lower() for mode in self.non_live_evidence_modes
158+
)
159+
if (
160+
len(evidence_modes) != 2
161+
or len(set(evidence_modes)) != 2
162+
or set(evidence_modes) - _NON_LIVE_EVIDENCE_MODES
163+
or "shadow_decision" not in evidence_modes
164+
or not ({"simulated_replay", "broker_paper"} & set(evidence_modes))
165+
):
166+
raise ForwardObservationPolicyError(
167+
"non_live_evidence_modes must contain shadow_decision and exactly one paper mode"
168+
)
95169

96170
def to_dict(self) -> dict[str, object]:
97171
return {
@@ -104,6 +178,11 @@ def to_dict(self) -> dict[str, object]:
104178
"review_milestones": list(self.review_milestones),
105179
"automatic_non_live_modes": list(self.automatic_non_live_modes),
106180
"auto_resume_clean_sessions": self.auto_resume_clean_sessions,
181+
"observation_calendar": self.observation_calendar,
182+
"observation_window_type": self.observation_window_type,
183+
"observation_start_session": self.observation_start_session,
184+
"window_rationale_ref": self.window_rationale_ref,
185+
"non_live_evidence_modes": list(self.non_live_evidence_modes),
107186
"live_authority_granted": False,
108187
}
109188

@@ -122,6 +201,7 @@ class ForwardObservationSnapshot:
122201
shadow_status: str = "healthy"
123202
paper_status: str = "healthy"
124203
risk_status: str = "pass"
204+
control_status: str = "clear"
125205

126206
def __post_init__(self) -> None:
127207
if not isinstance(self.historical_evidence_verified, bool):
@@ -152,6 +232,8 @@ def __post_init__(self) -> None:
152232
raise ForwardObservationPolicyError("unsupported paper_status")
153233
if self.risk_status not in _RISK_STATUSES:
154234
raise ForwardObservationPolicyError("unsupported risk_status")
235+
if self.control_status not in _CONTROL_STATUSES:
236+
raise ForwardObservationPolicyError("unsupported control_status")
155237

156238

157239
@dataclass(frozen=True)
@@ -209,11 +291,37 @@ def evaluate_forward_observation(
209291
policy,
210292
snapshot,
211293
state="PARKED",
212-
actions=("keep_shadow_stopped", "keep_paper_stopped"),
294+
actions=_STOPPED_ACTIONS,
213295
notifications=("historical_evidence_required",),
214296
reasons=("verified P3 historical evidence is required before P4",),
215297
)
216298

299+
controlled = _controlled_stop(snapshot)
300+
if controlled is not None:
301+
state, notification, reason = controlled
302+
return _decision(
303+
policy,
304+
snapshot,
305+
state=state,
306+
actions=_STOPPED_ACTIONS,
307+
notifications=(notification,) if notification else (),
308+
reasons=(reason,),
309+
)
310+
311+
if snapshot.risk_status == "blocked":
312+
return _decision(
313+
policy,
314+
snapshot,
315+
state="RISK_BLOCKED",
316+
actions=_STOPPED_ACTIONS,
317+
notifications=(
318+
("forward_observation_risk_blocked",)
319+
if snapshot.previous_state != "risk_blocked"
320+
else ()
321+
),
322+
reasons=("risk_status=blocked; explicit human review is required",),
323+
)
324+
217325
health_reasons = _health_reasons(snapshot)
218326
if health_reasons:
219327
return _decision(
@@ -241,6 +349,21 @@ def evaluate_forward_observation(
241349
),
242350
)
243351

352+
if snapshot.observations_completed >= policy.required_trading_sessions:
353+
notifications = list(_crossed_milestones(policy, snapshot))
354+
if snapshot.previous_observations_completed < policy.required_trading_sessions:
355+
notifications.append("forward_window_complete_human_live_review_required")
356+
return _decision(
357+
policy,
358+
snapshot,
359+
state="FORWARD_COMPLETE_HUMAN_REVIEW",
360+
actions=_STOPPED_ACTIONS,
361+
notifications=tuple(notifications),
362+
reasons=(
363+
"forward window is complete; non-live observation is stopped and live remains blocked pending explicit human approval",
364+
),
365+
)
366+
244367
actions = (
245368
("resume_shadow", "resume_paper")
246369
if snapshot.previous_state == "paused"
@@ -255,13 +378,6 @@ def evaluate_forward_observation(
255378
reasons = [
256379
"P3 evidence is verified; non-live shadow and paper observation may run automatically"
257380
]
258-
if snapshot.observations_completed >= policy.required_trading_sessions:
259-
state = "FORWARD_COMPLETE_HUMAN_REVIEW"
260-
if snapshot.previous_observations_completed < policy.required_trading_sessions:
261-
notifications.append("forward_window_complete_human_live_review_required")
262-
reasons.append(
263-
"forward window is complete; live remains blocked pending explicit human approval"
264-
)
265381
return _decision(
266382
policy,
267383
snapshot,
@@ -280,11 +396,31 @@ def _health_reasons(snapshot: ForwardObservationSnapshot) -> list[str]:
280396
reasons.append(f"shadow_status={snapshot.shadow_status}")
281397
if snapshot.paper_status != "healthy":
282398
reasons.append(f"paper_status={snapshot.paper_status}")
283-
if snapshot.risk_status != "pass":
284-
reasons.append(f"risk_status={snapshot.risk_status}")
285399
return reasons
286400

287401

402+
def _controlled_stop(
403+
snapshot: ForwardObservationSnapshot,
404+
) -> tuple[str, str | None, str] | None:
405+
control = snapshot.control_status
406+
if control == "clear" and snapshot.previous_state in _PERMANENT_PREVIOUS_STATES:
407+
control = snapshot.previous_state
408+
if control == "clear":
409+
return None
410+
state, notification = {
411+
"manual_hold": ("MANUAL_HOLD", "forward_observation_manual_hold"),
412+
"identity_mismatch": (
413+
"IDENTITY_MISMATCH",
414+
"forward_observation_identity_mismatch",
415+
),
416+
"revoked": ("REVOKED", "forward_observation_revoked"),
417+
"superseded": ("SUPERSEDED", "forward_observation_superseded"),
418+
"risk_blocked": ("RISK_BLOCKED", "forward_observation_risk_blocked"),
419+
}[control]
420+
should_notify = control != snapshot.previous_state
421+
return state, notification if should_notify else None, f"control_status={control}"
422+
423+
288424
def _crossed_milestones(
289425
policy: ForwardObservationPolicy, snapshot: ForwardObservationSnapshot
290426
) -> tuple[str, ...]:

0 commit comments

Comments
 (0)