//! Immutable, account-independent trading signals. Quantity and execution //! prices are intentionally absent; the existing broker owns those decisions. use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex, OnceLock, Weak}; 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; pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v2"; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SignalBookReference { pub book_id: String, pub version_sha256: String, pub artifact_sha256: String, } impl SignalBookReference { pub fn validate(&self) -> Result<(), String> { if !valid_sha(&self.version_sha256) || !valid_sha(&self.artifact_sha256) || self.book_id != format!("signal_book_{}",self.version_sha256) { return Err("signal_book_reference_invalid".into()); } Ok(()) } } #[derive(Default)] struct SignalCache { entries: BTreeMap>, retained: std::collections::VecDeque<(String,Arc,usize)>, } fn signal_cache() -> &'static Mutex { static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(||Mutex::new(SignalCache::default())) } pub fn cached_signal_book(reference: &SignalBookReference) -> Result>,String> { reference.validate()?; let cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?; let book=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade); if book.as_ref().is_some_and(|book|book.version_sha256()!=reference.version_sha256) { return Err("signal_book_cached_version_mismatch".into()); } Ok(book) } pub fn register_signal_book(reference: &SignalBookReference, body: &[u8]) -> Result,String> { use sha2::{Digest,Sha256}; reference.validate()?; if body.len()>64*1024*1024 || format!("{:x}",Sha256::digest(body))!=reference.artifact_sha256 { return Err("signal_book_artifact_hash_or_size_invalid".into()); } let raw:SignalBook=serde_json::from_slice(body).map_err(|error|format!("signal_book_decode_failed: {error}"))?; if raw.version_sha256!=reference.version_sha256 { return Err("signal_book_version_mismatch".into()); } let book=Arc::new(raw.validate()?); let mut cache=signal_cache().lock().map_err(|_|"signal_cache_lock_failed")?; cache.entries.retain(|_,value|value.strong_count()>0); if let Some(existing)=cache.entries.get(&reference.artifact_sha256).and_then(Weak::upgrade) { return Ok(existing); } cache.entries.insert(reference.artifact_sha256.clone(),Arc::downgrade(&book)); let estimated=body.len().saturating_mul(4); if estimated<=128*1024*1024 { cache.retained.push_back((reference.artifact_sha256.clone(),book.clone(),estimated)); while cache.retained.len()>4 || cache.retained.iter().map(|entry|entry.2).sum::()>128*1024*1024 { cache.retained.pop_front(); } } Ok(book) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SignalProvenance { Observed, Reconstructed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum SignalFrequency { Daily, Minute, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum SignalAction { TargetWeight { symbol: String, weight: f64 }, BuyCondition { symbol: String, allowed: bool }, Exit { symbol: String }, Reduce { symbol: String, remaining_ratio: f64 }, } impl SignalAction { fn symbol(&self) -> &str { match self { Self::TargetWeight { symbol, .. } | Self::BuyCondition { symbol, .. } | Self::Exit { symbol } | Self::Reduce { symbol, .. } => symbol, } } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SignalSnapshot { pub signal_at: DateTime, pub decision_at: DateTime, pub input_as_of: DateTime, pub input_available_at: DateTime, pub generated_at: DateTime, pub published_at: DateTime, pub input_sha256: String, pub complete_targets: bool, pub actions: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SignalBook { pub schema: String, pub version_sha256: String, pub generator_sha256: String, pub model_sha256: Option, pub knowledge_cutoff: Option>, pub provenance: SignalProvenance, pub frequency: SignalFrequency, pub expected_decisions: Vec>, pub snapshots: Vec, } #[derive(Debug, Clone)] pub struct ValidatedSignalBook { book: SignalBook, index: BTreeMap, } fn valid_sha(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } fn shanghai(value: DateTime) -> NaiveDateTime { value.with_timezone(&FixedOffset::east_opt(8 * 3600).expect("Shanghai offset")).naive_local() } 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"); value["knowledgeCutoff"]=self.knowledge_cutoff.map(|at|serde_json::json!(at.timestamp_micros())).unwrap_or(serde_json::Value::Null); value["expectedDecisions"]=serde_json::json!(self.expected_decisions.iter().map(DateTime::timestamp_micros).collect::>()); for (raw,snapshot) in value["snapshots"].as_array_mut().ok_or("signal_snapshots_required")?.iter_mut().zip(&self.snapshots) { let object=raw.as_object_mut().ok_or("signal_snapshot_required")?; object.remove("generatedAt"); object.remove("publishedAt"); for (key,at) in [("signalAt",snapshot.signal_at),("decisionAt",snapshot.decision_at), ("inputAsOf",snapshot.input_as_of),("inputAvailableAt",snapshot.input_available_at)] { object.insert(key.into(),serde_json::json!(at.timestamp_micros())); } for (raw,action) in object.get_mut("actions").and_then(serde_json::Value::as_array_mut).ok_or("signal_actions_required")?.iter_mut().zip(&snapshot.actions) { match action { SignalAction::TargetWeight{weight,..}=>raw["weight"]=serde_json::json!(format!("{:016x}",weight.to_bits())), SignalAction::Reduce{remaining_ratio,..}=>raw["remaining_ratio"]=serde_json::json!(format!("{:016x}",remaining_ratio.to_bits())), _=>{} } } } fn sorted(value:serde_json::Value)->serde_json::Value { match value { serde_json::Value::Object(map)=>serde_json::Value::Object(map.into_iter().map(|(key,value)|(key,sorted(value))) .collect::>().into_iter().collect()), serde_json::Value::Array(rows)=>serde_json::Value::Array(rows.into_iter().map(sorted).collect()), other=>other, } } let raw=serde_json::to_vec(&sorted(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) { return Err("signal_book_identity_invalid".into()); } if self.model_sha256.as_ref().is_some_and(|value| !valid_sha(value)) || self.model_sha256.is_some() != self.knowledge_cutoff.is_some() { return Err("signal_model_training_identity_incomplete".into()); } if self.expected_decisions.is_empty() || self.expected_decisions.len() > 100_000 || self.expected_decisions.len() != self.snapshots.len() { return Err("signal_book_decision_coverage_incomplete".into()); } let mut index = BTreeMap::new(); let mut previous = None; let mut total_actions = 0usize; for (number, (expected, snapshot)) in self.expected_decisions.iter().zip(&self.snapshots).enumerate() { if [*expected,snapshot.signal_at,snapshot.input_as_of,snapshot.input_available_at,snapshot.generated_at,snapshot.published_at] .iter().any(|at|at.timestamp_subsec_nanos()%1000!=0) || self.knowledge_cutoff.is_some_and(|at|at.timestamp_subsec_nanos()%1000!=0) { return Err("signal_timestamp_requires_microsecond_precision".into()); } if snapshot.decision_at != *expected || previous.is_some_and(|value| value >= *expected) { return Err("signal_book_decisions_duplicate_or_unordered".into()); } previous = Some(*expected); if self.knowledge_cutoff.is_some_and(|cutoff| cutoff > snapshot.signal_at) || snapshot.signal_at > *expected || snapshot.input_available_at > snapshot.signal_at || snapshot.input_as_of > snapshot.input_available_at || snapshot.published_at < snapshot.generated_at || !valid_sha(&snapshot.input_sha256) || snapshot.generated_at < snapshot.input_available_at || self.knowledge_cutoff.is_some_and(|cutoff| snapshot.generated_at < cutoff) { return Err("signal_book_future_or_invalid_input".into()); } if self.provenance == SignalProvenance::Observed && snapshot.published_at > *expected { return Err("observed_signal_not_available_at_decision".into()); } total_actions = total_actions.checked_add(snapshot.actions.len()).ok_or("signal_book_action_limit")?; if total_actions > 2_000_000 { return Err("signal_book_action_limit".into()); } let mut action_keys = BTreeSet::new(); let mut target_symbols = BTreeSet::new(); let mut reductions = BTreeSet::new(); let mut total_weight = 0.0; for action in &snapshot.actions { let symbol = action.symbol(); if symbol.is_empty() || symbol.trim() != symbol { return Err("signal_symbol_invalid".into()); } let kind = match action { SignalAction::TargetWeight { weight, .. } => { if !weight.is_finite() || !(0.0..=1.0).contains(weight) { return Err("signal_target_weight_invalid".into()); } target_symbols.insert(symbol); total_weight += weight; "target" } SignalAction::BuyCondition { .. } => "buy_condition", SignalAction::Exit { .. } => { reductions.insert(symbol); "exit" } SignalAction::Reduce { remaining_ratio, .. } => { if !remaining_ratio.is_finite() || !(0.0..1.0).contains(remaining_ratio) { return Err("signal_reduction_invalid".into()); } reductions.insert(symbol); "reduce" } }; if !action_keys.insert((symbol, kind)) { return Err("signal_action_duplicate".into()); } } if total_weight > 1.0 + 1e-12 { return Err("signal_target_exposure_exceeds_one".into()); } if snapshot.complete_targets && !reductions.is_empty() { return Err("complete_target_snapshot_cannot_mix_relative_exits".into()); } if !target_symbols.is_disjoint(&reductions) { return Err("signal_target_exit_conflict".into()); } for symbol in &reductions { if action_keys.contains(&(*symbol, "exit")) && action_keys.contains(&(*symbol, "reduce")) { return Err("signal_exit_reduction_conflict".into()); } } 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 }) } } impl ValidatedSignalBook { pub fn require_observed(&self) -> Result<(), String> { if self.book.provenance != SignalProvenance::Observed { return Err("reconstructed_signal_forbidden_in_online_execution".into()); } Ok(()) } pub fn version_sha256(&self) -> &str { &self.book.version_sha256 } pub fn generator_sha256(&self) -> &str { &self.book.generator_sha256 } pub fn decision_dates(&self) -> BTreeSet { self.index.keys().map(|value| value.date()).collect() } pub fn symbols(&self) -> BTreeSet { self.book.snapshots.iter().flat_map(|snapshot| &snapshot.actions) .map(|action| action.symbol().to_owned()).collect() } 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 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 { 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) } pub fn is_due_on(&self, execution_date: NaiveDate) -> bool { self.index.range(execution_date.and_hms_opt(0,0,0).expect("session start")..) .next().is_some_and(|(at,_)|at.date()==execution_date) } fn snapshot_at(&self, execution_date: NaiveDate, current_time: Option, lagged: bool) -> Result<&SignalSnapshot, String> { let at = if self.book.frequency == SignalFrequency::Daily && lagged { execution_date.and_hms_opt(9, 30, 0).expect("next open") } else { execution_date.and_time(current_time.unwrap_or(NaiveTime::from_hms_opt(15, 0, 0).expect("daily close"))) }; self.index.get(&at).map(|index| &self.book.snapshots[*index]) .ok_or_else(|| format!("signal_snapshot_missing_at_decision: {at}")) } pub fn intents(&self, ctx: &StrategyContext<'_>) -> Result, String> { let snapshot = self.snapshot_for(ctx)?; self.snapshot_intents(snapshot, ctx.portfolio) } fn snapshot_intents(&self, snapshot: &SignalSnapshot, portfolio: &PortfolioState) -> Result, String> { let reason = format!("信号执行 version={} decision={}", self.book.version_sha256, snapshot.decision_at); let mut intents = Vec::new(); let mut weights = BTreeMap::new(); for action in &snapshot.actions { match action { SignalAction::TargetWeight { symbol, weight } if snapshot.complete_targets => { weights.insert(symbol.clone(), *weight); } SignalAction::TargetWeight { symbol, weight } => intents.push(OrderIntent::TargetPercent { symbol: symbol.clone(), target_percent: *weight, reason: reason.clone(), }), SignalAction::Exit { symbol } => intents.push(OrderIntent::TargetPercent { symbol: symbol.clone(), target_percent: 0.0, reason: reason.clone(), }), SignalAction::Reduce { symbol, remaining_ratio } => { if let Some(position) = portfolio.position(symbol).filter(|position| position.quantity > 0) { let quantity = (f64::from(position.quantity) * remaining_ratio).floor() as u32; let target_quantity = i32::try_from(quantity).map_err(|_| "signal_reduction_quantity_overflow")?; intents.push(OrderIntent::TargetShares { symbol: symbol.clone(), target_quantity, reason: reason.clone() }); } } SignalAction::BuyCondition { .. } => {} } } if snapshot.complete_targets { if weights.is_empty() { for position in portfolio.positions().values().filter(|position| position.quantity > 0) { intents.push(OrderIntent::TargetPercent { symbol: position.symbol.clone(), target_percent: 0.0, reason: reason.clone() }); } } else { intents.push(OrderIntent::TargetPortfolioSmart { target_weights: weights, order_prices: None, valuation_prices: None, reason }); } } Ok(intents) } pub fn buy_denials(&self, ctx: &StrategyContext<'_>) -> Result, String> { Ok(self.snapshot_for(ctx)?.actions.iter().filter_map(|action| match action { SignalAction::BuyCondition { symbol, allowed: false } => Some((symbol.clone(), "信号买入条件未满足".into())), _ => None, }).collect()) } } #[cfg(test)] mod tests { use super::*; use chrono::Duration; use serde_json::json; 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(); seal(SignalBook { schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64), model_sha256: Some("d".repeat(64)), knowledge_cutoff: Some("2024-12-31T15:00:00+08:00".parse().unwrap()), provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily, expected_decisions: vec![decision], snapshots: vec![SignalSnapshot { signal_at: source, decision_at: decision, input_as_of: source, input_available_at: source, generated_at: decision + Duration::days(10), published_at: decision + Duration::days(10), 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 } 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(); assert!(validated.require_observed().unwrap_err().contains("reconstructed")); let mut observed = book(); observed.provenance = SignalProvenance::Observed; 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; seal(observed).validate().unwrap().require_observed().unwrap(); } #[test] fn rejects_future_inputs_and_model_knowledge() { for field in 0..3 { let mut value = book(); let future = value.snapshots[0].decision_at + Duration::seconds(1); match field { 0 => value.snapshots[0].input_as_of = future, 1 => value.snapshots[0].input_available_at = future, _ => value.knowledge_cutoff = Some(future), } assert!(value.validate().unwrap_err().contains("future")); } } #[test] fn rejects_quantities_prices_and_unknown_signal_fields() { for name in ["quantity", "execution_price", "account_id", "cash"] { let mut action = json!({"kind":"target_weight","symbol":"000001.SZ","weight":0.5}); action[name] = json!(100); assert!(serde_json::from_value::(action).is_err()); } } #[test] fn coverage_and_duplicate_actions_fail_closed() { let mut value = book(); value.expected_decisions.push(value.expected_decisions[0] + Duration::days(1)); assert!(value.validate().unwrap_err().contains("coverage")); let mut value = book(); value.snapshots.push(value.snapshots[0].clone()); value.expected_decisions.push(value.expected_decisions[0]); assert!(value.validate().unwrap_err().contains("duplicate")); let mut value = book(); let repeated = value.snapshots[0].actions[0].clone(); value.snapshots[0].actions.push(repeated); assert!(value.validate().unwrap_err().contains("duplicate")); } #[test] fn rejects_overallocation_nonfinite_and_ambiguous_actions() { for weight in [f64::NAN, f64::INFINITY, -0.1, 1.1] { let mut value = book(); value.snapshots[0].actions[0] = SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight }; assert!(value.validate().is_err()); } let mut value = book(); value.snapshots[0].actions.push(SignalAction::TargetWeight { symbol:"000002.SZ".into(),weight:0.6 }); assert!(value.validate().unwrap_err().contains("exposure")); let mut value = book(); value.snapshots[0].actions.push(SignalAction::Exit {symbol:"000001.SZ".into()}); assert!(value.validate().is_err()); } #[test] fn next_open_uses_decision_session_and_never_nearest_signal() { let value = book().validate().unwrap(); let day = NaiveDate::from_ymd_opt(2025,1,7).unwrap(); assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(9,30,0), true).is_ok()); assert!(value.snapshot_at(day, NaiveTime::from_hms_opt(14,59,0), false).is_err()); assert!(value.snapshot_at(day + Duration::days(1), None, true).is_err()); } #[test] fn reduction_is_resolved_from_each_accounts_actual_position() { 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 = 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); portfolio.position_mut("000001.SZ").buy(day,held,10.0); let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap(); assert!(matches!(result[0],OrderIntent::TargetShares {target_quantity,..} if target_quantity==expected)); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity,held); } assert!(value.snapshot_intents(&value.book.snapshots[0],&PortfolioState::new(10_000.0)).unwrap().is_empty()); } #[test] fn empty_complete_snapshot_clears_only_that_accounts_holdings() { let mut raw = book(); raw.snapshots[0].actions.clear(); 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); let result = value.snapshot_intents(&value.book.snapshots[0],&portfolio).unwrap(); assert!(matches!(&result[0],OrderIntent::TargetPercent {symbol,target_percent,..} if symbol=="000002.SZ" && *target_percent==0.0)); } #[test] fn platform_spec_consumes_book_without_running_another_selection() { let spec = json!({"signalBook":book(),"runtimeExpressions":{"trading":{"actions":[{"kind":"consume_signal"}]}}}); let config = crate::platform_strategy_spec::platform_expr_config_from_value("signal-fixture","000001.SZ",&spec).unwrap(); 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].signal_at=raw.snapshots[0].input_available_at; 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(); } }