1010from __future__ import annotations
1111
1212from dataclasses import dataclass
13+ from datetime import date
1314from typing import Any
1415
1516
1617FORWARD_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
2549class 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 )
4277class 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+
288424def _crossed_milestones (
289425 policy : ForwardObservationPolicy , snapshot : ForwardObservationSnapshot
290426) -> tuple [str , ...]:
0 commit comments