diff --git a/crates/fidc-core/src/signal_contract.rs b/crates/fidc-core/src/signal_contract.rs index b9ff37f..3c58ff7 100644 --- a/crates/fidc-core/src/signal_contract.rs +++ b/crates/fidc-core/src/signal_contract.rs @@ -288,13 +288,22 @@ impl ValidatedSignalBook { pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> { let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?; - let logical_clock=ctx.current_datetime().filter(|at|at.date()==ctx.decision_date) + if self.book.provenance == SignalProvenance::Observed && ctx.current_datetime().is_none() { + return Err("observed_signal_consumption_clock_missing".into()); + } + let consumption_clock=ctx.current_datetime() .unwrap_or(ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session")); let lagged_daily=ctx.is_lagged_execution() && self.book.frequency==SignalFrequency::Daily; - if (lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date) - || (!lagged_daily && shanghai(snapshot.signal_at)>logical_clock) { + if lagged_daily && shanghai(snapshot.input_as_of).date()>ctx.decision_date { return Err("next_open_signal_contains_execution_session_inputs".into()); } + if shanghai(snapshot.input_available_at)>consumption_clock || shanghai(snapshot.signal_at)>consumption_clock { + return Err("signal_not_available_at_consumption_clock".into()); + } + if self.book.provenance == SignalProvenance::Observed + && (shanghai(snapshot.generated_at)>consumption_clock || shanghai(snapshot.published_at)>consumption_clock) { + return Err("observed_signal_published_after_consumption_clock".into()); + } Ok(snapshot) } @@ -393,6 +402,56 @@ mod tests { book } + fn at_context(at: Option, action: impl FnOnce(&StrategyContext<'_>) -> T) -> T { + let data = crate::DataSet::from_components(vec![], vec![], vec![], vec![], vec![]).unwrap(); + let portfolio = PortfolioState::new(10_000.0); + let symbols = BTreeSet::new(); + action(&StrategyContext { + execution_date: NaiveDate::from_ymd_opt(2025,1,7).unwrap(), + decision_date: NaiveDate::from_ymd_opt(2025,1,6).unwrap(), decision_index:0, + data:&data, portfolio:&portfolio, futures_account:None, open_orders:&[], + dynamic_universe:None, subscriptions:&symbols, process_events:&[], active_process_event:None, + active_datetime:at, order_events:&[], fills:&[], + }) + } + + #[test] + fn observed_next_open_never_backdates_a_morning_publication_into_yesterdays_orders() { + let mut raw = book(); + raw.provenance=SignalProvenance::Observed; + raw.snapshots[0].generated_at="2025-01-07T08:45:00+08:00".parse().unwrap(); + raw.snapshots[0].published_at="2025-01-07T08:46:00+08:00".parse().unwrap(); + let value=seal(raw).validate().unwrap(); + for clock in ["2025-01-06 15:00:00", "2025-01-07 08:45:00"] { + at_context(Some(clock.parse().unwrap()), |ctx| { + assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_published_after_consumption_clock"); + assert!(ctx.portfolio.positions().is_empty()); + }); + } + at_context(Some("2025-01-07 09:30:00".parse().unwrap()), |ctx| { + assert_eq!(value.intents(ctx).unwrap().len(),1); + assert!(ctx.portfolio.positions().is_empty()); + }); + at_context(None, |ctx| assert_eq!(value.intents(ctx).unwrap_err(),"observed_signal_consumption_clock_missing")); + } + + #[test] + fn reconstruction_ignores_research_wall_clock_but_never_early_input_availability() { + let value=book().validate().unwrap(); + at_context(Some("2025-01-06 15:00:00".parse().unwrap()), |ctx| assert!(value.intents(ctx).is_ok())); + at_context(Some("2025-01-06 14:59:59".parse().unwrap()), |ctx| { + assert_eq!(value.intents(ctx).unwrap_err(),"signal_not_available_at_consumption_clock"); + }); + let mut raw=book(); + raw.snapshots[0].input_as_of="2025-01-07T08:30:00+08:00".parse().unwrap(); + raw.snapshots[0].input_available_at=raw.snapshots[0].input_as_of; + raw.snapshots[0].signal_at=raw.snapshots[0].input_as_of; + let value=seal(raw).validate().unwrap(); + at_context(Some("2025-01-07 09:30:00".parse().unwrap()), |ctx| { + assert_eq!(value.intents(ctx).unwrap_err(),"next_open_signal_contains_execution_session_inputs"); + }); + } + #[test] fn historical_reconstruction_is_not_online_publication() { let validated = book().validate().unwrap();