diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 5c8aacb..c6ad29b 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -25,6 +25,7 @@ pub mod risk_control; pub mod rules; pub mod scheduler; pub mod strategy; +pub mod signal_contract; pub mod strategy_ai; pub mod universe; diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index dbc71d1..f7441bb 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -525,6 +525,7 @@ pub enum PlatformAccountActionKind { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlatformTradeAction { + ConsumeSignal, Order { kind: PlatformExplicitOrderKind, symbol: String, @@ -607,6 +608,7 @@ pub struct PlatformPositionTargetRule { #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { + pub signal_book: Option>, pub strategy_name: String, pub market: String, pub benchmark_symbol: String, @@ -689,6 +691,7 @@ pub struct PlatformExprStrategyConfig { impl PlatformExprStrategyConfig { pub fn generic() -> Self { Self { + signal_book: None, strategy_name: "platform-expression".to_string(), market: "CN_A".to_string(), benchmark_symbol: String::new(), @@ -1913,6 +1916,7 @@ impl PlatformExprStrategy { } for (index, action) in self.config.explicit_actions.iter().enumerate() { match action { + PlatformTradeAction::ConsumeSignal => {} PlatformTradeAction::Order { amount_expr, limit_price_expr, @@ -9118,7 +9122,11 @@ impl PlatformExprStrategy { self.stock_state(ctx, date, symbol).map(Some) } - fn unscheduled_explicit_actions_are_due(&self, decision_date: NaiveDate) -> bool { + fn unscheduled_explicit_actions_are_due(&self, ctx: &StrategyContext<'_>) -> bool { + if let Some(book) = &self.config.signal_book { + return book.is_due_on(ctx); + } + let decision_date = ctx.decision_date; self.config.signal_rebalance_dates.is_empty() || self.config.signal_rebalance_dates.contains(&decision_date) } @@ -9146,6 +9154,12 @@ impl PlatformExprStrategy { let mut diagnostics = Vec::new(); for action in &self.config.explicit_actions { match action { + PlatformTradeAction::ConsumeSignal => { + let book = self.config.signal_book.as_ref().ok_or_else(|| + BacktestError::Execution("signal_book_not_loaded".into()))?; + intents.extend(book.intents(ctx).map_err(BacktestError::Execution)?); + diagnostics.push(format!("signal_book_consumed version={} decision_date={}", book.version_sha256(), ctx.decision_date)); + } PlatformTradeAction::Order { kind, symbol, @@ -11392,6 +11406,7 @@ impl PlatformExprStrategy { matches!( action, PlatformTradeAction::Order { .. } + | PlatformTradeAction::ConsumeSignal | PlatformTradeAction::TargetPortfolioSmart { .. } | PlatformTradeAction::Modify { .. } ) @@ -12403,7 +12418,7 @@ impl Strategy for PlatformExprStrategy { if self.config.explicit_action_stage == PlatformExplicitActionStage::OpenAuction && !self.config.explicit_actions.is_empty() && self.config.explicit_action_schedule.is_none() - && self.unscheduled_explicit_actions_are_due(ctx.decision_date) + && self.unscheduled_explicit_actions_are_due(ctx) { let mut decision = self.explicit_action_decision(ctx)?; self.attach_buy_denials(ctx, &mut decision)?; @@ -12423,13 +12438,16 @@ impl Strategy for PlatformExprStrategy { impl PlatformExprStrategy { fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> { - if self.config.buy_filter_expr.trim().is_empty() { - return Ok(()); - } let symbols = decision.potential_buy_symbols(ctx.open_orders); if symbols.is_empty() { return Ok(()); } + if let Some(book) = &self.config.signal_book { + decision.buy_denials.extend(book.buy_denials(ctx).map_err(BacktestError::Execution)?); + } + if self.config.buy_filter_expr.trim().is_empty() { + return Ok(()); + } let day = self.day_state(ctx, ctx.decision_date)?; let (market_date, _, factor_date) = self.selection_dates(ctx); let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date) @@ -12541,7 +12559,7 @@ impl PlatformExprStrategy { let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window && self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay && self.config.explicit_action_schedule.is_none() - && self.unscheduled_explicit_actions_are_due(decision_date) + && self.unscheduled_explicit_actions_are_due(ctx) { self.explicit_action_intents(ctx, decision_date, &day)? } else { diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index cc5ff87..ecc75ad 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -17,6 +17,8 @@ use crate::{ #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyRuntimeSpec { + #[serde(default)] + pub signal_book: Option, #[serde(default, alias = "strategy_id")] pub strategy_id: Option, #[serde(default)] @@ -2599,6 +2601,22 @@ pub fn platform_expr_config_from_spec( } cfg.strict_value_budget = true; + if let Some(raw) = &spec.signal_book { + let book = raw.clone().validate()?; + if cfg.explicit_actions.len() != 1 || !matches!(cfg.explicit_actions[0], PlatformTradeAction::ConsumeSignal) { + return Err("signal_book_requires_one_consume_signal_action".into()); + } + if !cfg.signal_rebalance_dates.is_empty() && cfg.signal_rebalance_dates != book.decision_dates() { + return Err("signal_book_schedule_does_not_match_strategy".into()); + } + cfg.rotation_enabled = false; + cfg.signal_rebalance_dates = book.decision_dates(); + cfg.initial_subscriptions.extend(book.symbols()); + cfg.signal_book = Some(std::sync::Arc::new(book)); + } else if cfg.explicit_actions.iter().any(|action| matches!(action, PlatformTradeAction::ConsumeSignal)) { + return Err("consume_signal_requires_verified_signal_book".into()); + } + Ok(cfg) } @@ -2751,6 +2769,7 @@ fn parse_platform_trade_action( None => None, }; match kind.as_str() { + "consume_signal" if when_expr.is_none() && time_in_force.is_none() => Some(PlatformTradeAction::ConsumeSignal), "target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart { target_weights_expr: action .target_weights_expr diff --git a/crates/fidc-core/src/signal_contract.rs b/crates/fidc-core/src/signal_contract.rs new file mode 100644 index 0000000..5b61914 --- /dev/null +++ b/crates/fidc-core/src/signal_contract.rs @@ -0,0 +1,386 @@ +//! 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 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 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); + } + 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) > ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session") { + return Err("next_open_signal_contains_execution_session_inputs".into()); + } + Ok(snapshot) + } + + pub fn is_due_on(&self, ctx: &StrategyContext<'_>) -> bool { + self.index.range(ctx.execution_date.and_hms_opt(0,0,0).expect("session start")..) + .next().is_some_and(|(at,_)|at.date()==ctx.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(); + 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 }], + }], + } + } + + #[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; + 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 = 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 = 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])); + } +}