diff --git a/crates/fidc-core/src/signal_contract.rs b/crates/fidc-core/src/signal_contract.rs index 5784018..07f4247 100644 --- a/crates/fidc-core/src/signal_contract.rs +++ b/crates/fidc-core/src/signal_contract.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::strategy::{OrderIntent, StrategyContext}; use crate::portfolio::PortfolioState; @@ -86,6 +87,13 @@ fn shanghai(value: DateTime) -> NaiveDateTime { } impl SignalBook { + pub fn content_sha256(&self) -> Result { + let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?; + value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256"); + let raw=serde_json::to_vec(&value).map_err(|error|error.to_string())?; + Ok(format!("{:x}",Sha256::digest(raw))) + } + pub fn validate(self) -> Result { if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256) || !valid_sha(&self.generator_sha256) @@ -154,6 +162,9 @@ impl SignalBook { } index.insert(shanghai(*expected), number); } + if self.content_sha256()? != self.version_sha256 { + return Err("signal_book_content_hash_mismatch".into()); + } Ok(ValidatedSignalBook { book: self, index }) } } @@ -179,7 +190,7 @@ 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())?; - if ctx.is_lagged_execution() && shanghai(snapshot.input_as_of) > ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session") { + if ctx.is_lagged_execution() && shanghai(snapshot.input_as_of).date() > ctx.decision_date { return Err("next_open_signal_contains_execution_session_inputs".into()); } Ok(snapshot) @@ -260,7 +271,7 @@ mod tests { fn book() -> SignalBook { let decision: DateTime = "2025-01-07T09:30:00+08:00".parse().unwrap(); let source: DateTime = "2025-01-06T15:00:00+08:00".parse().unwrap(); - SignalBook { + seal(SignalBook { schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64), knowledge_cutoff: "2024-12-31T15:00:00+08:00".parse().unwrap(), provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily, @@ -270,7 +281,12 @@ mod tests { input_sha256: "c".repeat(64), complete_targets: true, actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }], }], - } + }) + } + + fn seal(mut book:SignalBook)->SignalBook { + book.version_sha256=book.content_sha256().unwrap(); + book } #[test] @@ -282,7 +298,7 @@ mod tests { assert!(observed.clone().validate().unwrap_err().contains("not_available")); observed.snapshots[0].generated_at = observed.snapshots[0].decision_at; observed.snapshots[0].published_at = observed.snapshots[0].decision_at; - observed.validate().unwrap().require_observed().unwrap(); + seal(observed).validate().unwrap().require_observed().unwrap(); } #[test] @@ -352,7 +368,7 @@ mod tests { let mut raw = book(); raw.snapshots[0].complete_targets = false; raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}]; - let value = raw.validate().unwrap(); + let value = seal(raw).validate().unwrap(); let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap(); for (held, expected) in [(1000,500),(3000,1500)] { let mut portfolio = PortfolioState::new(100_000.0); @@ -368,7 +384,7 @@ mod tests { fn empty_complete_snapshot_clears_only_that_accounts_holdings() { let mut raw = book(); raw.snapshots[0].actions.clear(); - let value = raw.validate().unwrap(); + let value = seal(raw).validate().unwrap(); let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap(); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut("000002.SZ").buy(day,200,10.0); @@ -383,4 +399,25 @@ mod tests { assert!(!config.rotation_enabled && config.signal_book.is_some()); assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal])); } + + #[test] + fn changed_valid_contents_must_not_reuse_a_version_hash() { + let mut raw=book(); + raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}]; + assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch"); + seal(raw).validate().unwrap(); + } + + #[test] + fn completed_daily_inputs_may_be_published_after_market_close() { + let mut raw=book(); + raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()]; + raw.snapshots[0].decision_at=raw.expected_decisions[0]; + raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap(); + raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap(); + raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at; + raw.snapshots[0].published_at=raw.snapshots[0].generated_at; + raw.provenance=SignalProvenance::Observed; + seal(raw).validate().unwrap().require_observed().unwrap(); + } }