//! Immutable, account-independent trading signals. Quantity and execution //! prices are intentionally absent; the existing broker owns those decisions. 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; pub const SIGNAL_BOOK_SCHEMA: &str = "fidc.signal-book/v1"; #[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 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 knowledge_cutoff: DateTime, 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"); 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) { return Err("signal_book_identity_invalid".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 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 >= *expected || snapshot.input_as_of > *expected || snapshot.input_available_at > *expected || 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 || snapshot.generated_at < self.knowledge_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 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 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) } 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), knowledge_cutoff: "2024-12-31T15:00:00+08:00".parse().unwrap(), provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily, expected_decisions: vec![decision], snapshots: vec![SignalSnapshot { 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 } #[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 = 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].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(); } }