From 5f2697540a1627345d33b7f05c3547b8d60de87a Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 4 Jul 2026 03:02:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BB=B6=E8=BF=9F=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E9=80=89=E8=82=A1=E9=A3=8E=E6=8E=A7=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 39 ++++++++++++ crates/fidc-core/src/strategy.rs | 100 +++++++++++++++++++++++-------- crates/fidc-core/src/universe.rs | 19 ++++-- 3 files changed, 128 insertions(+), 30 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 9041e7c..9a0015d 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2506,6 +2506,10 @@ impl DataSet { .unwrap_or(&[]) } + pub fn fundamental_universe_on(&self, date: NaiveDate) -> Vec { + build_fundamental_universe_for_date(date, &self.factor_by_date, &self.market_by_date) + } + pub fn eligible_universe_on_with_risk_config( &self, date: NaiveDate, @@ -3051,6 +3055,41 @@ fn build_eligible_universe( per_date } +fn build_fundamental_universe_for_date( + date: NaiveDate, + factor_by_date: &BTreeMap>>, + market_by_date: &BTreeMap>>, +) -> Vec { + let mut rows = Vec::new(); + let Some(factors) = factor_by_date.get(&date) else { + return rows; + }; + for factor in factors { + let Some(market) = market_by_date + .get(&date) + .and_then(|rows| find_arc_by_symbol(rows, &factor.symbol, |row| row.symbol.as_str())) + else { + continue; + }; + let market_cap_bn = decision_market_cap_bn(factor, market); + if market_cap_bn <= 0.0 || !market_cap_bn.is_finite() { + continue; + } + rows.push(EligibleUniverseSnapshot { + symbol: factor.symbol.clone(), + market_cap_bn, + free_float_cap_bn: decision_free_float_cap_bn(factor, market), + }); + } + rows.sort_by(|left, right| { + left.market_cap_bn + .partial_cmp(&right.market_cap_bn) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.symbol.cmp(&right.symbol)) + }); + rows +} + fn build_eligible_universe_for_date( date: NaiveDate, factor_by_date: &BTreeMap>>, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 461f50c..2cada3f 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -156,6 +156,10 @@ pub struct StrategyContext<'a> { } impl StrategyContext<'_> { + pub fn is_lagged_execution(&self) -> bool { + self.execution_date != self.decision_date + } + pub fn current_datetime(&self) -> Option { self.active_datetime } @@ -525,6 +529,20 @@ impl StrategyContext<'_> { } } + pub fn fundamental_universe_on( + &self, + date: NaiveDate, + ) -> Vec { + let eligible = self.data.fundamental_universe_on(date); + match self.dynamic_universe { + Some(symbols) if !symbols.is_empty() => eligible + .into_iter() + .filter(|row| symbols.contains(&row.symbol)) + .collect(), + _ => eligible, + } + } + pub fn current_snapshot(&self, symbol: &str) -> Option<&DailyMarketSnapshot> { self.data.market(self.execution_date, symbol) } @@ -1373,14 +1391,14 @@ impl Strategy for CnSmallCapRotationStrategy { date: ctx.decision_date, })?; - if self.config.in_skip_window(ctx.execution_date) { + if self.config.in_skip_window(ctx.decision_date) { self.last_gross_exposure = Some(0.0); return Ok(StrategyDecision { rebalance: true, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), order_intents: Vec::new(), - notes: vec![format!("skip-window active on {}", ctx.execution_date)], + notes: vec![format!("skip-window active on {}", ctx.decision_date)], diagnostics: vec![ "seasonal stop window approximated at daily granularity".to_string(), "run_daily(10:17/10:18) mapped to T-1 decision and T open execution" @@ -1454,6 +1472,7 @@ impl Strategy for CnSmallCapRotationStrategy { data: ctx.data, dynamic_universe: ctx.dynamic_universe, risk_config: Some(&self.config.risk_config), + defer_selection_risk: ctx.is_lagged_execution(), }); let before_ma_count = selected_before_ma.len(); let mut ma_rejects = Vec::new(); @@ -2421,7 +2440,11 @@ impl OmniMicroCapStrategy { &self, ctx: &StrategyContext<'_>, date: NaiveDate, + defer_selection_risk: bool, ) -> Vec { + if defer_selection_risk { + return Vec::new(); + } let mut decisions = Vec::new(); for factor in ctx.data.factor_snapshots_on(date) { if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { @@ -2481,8 +2504,13 @@ impl OmniMicroCapStrategy { date: NaiveDate, band_low: f64, band_high: f64, + defer_selection_risk: bool, ) -> Result<(Vec, Vec), BacktestError> { - let universe = ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config); + let universe = if defer_selection_risk { + ctx.fundamental_universe_on(date) + } else { + ctx.eligible_universe_on_with_risk_config(date, &self.config.risk_config) + }; let mut diagnostics = Vec::new(); let mut selected = Vec::new(); let start = lower_bound_eligible(&universe, band_low); @@ -2491,7 +2519,13 @@ impl OmniMicroCapStrategy { if candidate.market_cap_bn > band_high { break; } - if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? { + let rejection = if defer_selection_risk { + (!self.stock_passes_ma_filter(ctx, date, &candidate.symbol)) + .then_some("ma_filter".to_string()) + } else { + self.buy_rejection_reason(ctx, date, &candidate.symbol)? + }; + if let Some(reason) = rejection { if diagnostics.len() < 12 { diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); } @@ -2514,8 +2548,10 @@ impl Strategy for OmniMicroCapStrategy { } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { - let date = ctx.execution_date; - if self.config.in_skip_window(date) { + let signal_date = ctx.decision_date; + let execution_date = ctx.execution_date; + let defer_selection_risk = ctx.is_lagged_execution(); + if self.config.in_skip_window(signal_date) { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), @@ -2531,14 +2567,14 @@ impl Strategy for OmniMicroCapStrategy { reason: "seasonal_stop_window".to_string(), }) .collect(), - notes: vec![format!("seasonal stop window on {}", date)], + notes: vec![format!("seasonal stop window on {}", signal_date)], diagnostics: vec!["platform-native skip window forced all cash".to_string()], risk_decisions: Vec::new(), }); } let (index_level, prev_index_level, ma_short, ma_long, trading_ratio) = - match self.trading_ratio(ctx, date) { + match self.trading_ratio(ctx, signal_date) { Ok(value) => value, Err(BacktestError::Execution(message)) if message.contains("insufficient benchmark") => @@ -2559,9 +2595,11 @@ impl Strategy for OmniMicroCapStrategy { }; // 使用前一交易日的指数价格计算市值区间(模拟实盘场景) let (band_low, band_high) = self.market_cap_band(prev_index_level); - let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?; - let risk_decisions = self.selection_risk_decisions(ctx, date); + let (stock_list, selection_notes) = + self.select_symbols(ctx, signal_date, band_low, band_high, defer_selection_risk)?; + let risk_decisions = self.selection_risk_decisions(ctx, signal_date, defer_selection_risk); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; + let projection_date = signal_date; let mut projected = ctx.portfolio.clone(); let mut projected_execution_state = ProjectedExecutionState::default(); let mut order_intents = Vec::new(); @@ -2571,18 +2609,21 @@ impl Strategy for OmniMicroCapStrategy { if position.quantity == 0 || position.average_cost <= 0.0 { continue; } - let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last) + let Some(current_price) = + ctx.data + .price(signal_date, &position.symbol, PriceField::Last) else { continue; }; - let Some(market) = ctx.data.market(date, &position.symbol) else { + let Some(market) = ctx.data.market(signal_date, &position.symbol) else { continue; }; let stop_hit = current_price <= position.average_cost * self.config.stop_loss_ratio + self.stop_loss_tolerance(market); let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio; - let can_sell = self.can_sell_position(ctx, date, &position.symbol); + let can_sell = defer_selection_risk + || self.can_sell_position(ctx, execution_date, &position.symbol); let at_upper_limit = market.is_at_upper_limit_price(current_price); if stop_hit || (profit_hit && !at_upper_limit) { let sell_reason = if stop_hit { @@ -2600,7 +2641,7 @@ impl Strategy for OmniMicroCapStrategy { self.project_target_zero( ctx, &mut projected, - date, + projection_date, &position.symbol, sell_reason, &mut projected_execution_state, @@ -2618,7 +2659,11 @@ impl Strategy for OmniMicroCapStrategy { { continue; } - if self.buy_rejection_reason(ctx, date, symbol)?.is_some() { + if !defer_selection_risk + && self + .buy_rejection_reason(ctx, execution_date, symbol)? + .is_some() + { continue; } order_intents.push(OrderIntent::Value { @@ -2629,7 +2674,7 @@ impl Strategy for OmniMicroCapStrategy { self.project_order_value( ctx, &mut projected, - date, + projection_date, symbol, replacement_cash, &format!("replacement_after_{}", sell_reason), @@ -2652,7 +2697,7 @@ impl Strategy for OmniMicroCapStrategy { if stock_list.iter().any(|candidate| candidate == symbol) { continue; } - if !self.can_sell_position(ctx, date, symbol) { + if !defer_selection_risk && !self.can_sell_position(ctx, execution_date, symbol) { continue; } order_intents.push(OrderIntent::TargetValue { @@ -2663,7 +2708,7 @@ impl Strategy for OmniMicroCapStrategy { self.project_target_zero( ctx, &mut projected, - date, + projection_date, symbol, "periodic_rebalance_sell", &mut projected_execution_state, @@ -2680,7 +2725,11 @@ impl Strategy for OmniMicroCapStrategy { { continue; } - if self.buy_rejection_reason(ctx, date, symbol)?.is_some() { + if !defer_selection_risk + && self + .buy_rejection_reason(ctx, execution_date, symbol)? + .is_some() + { continue; } order_intents.push(OrderIntent::Value { @@ -2691,7 +2740,7 @@ impl Strategy for OmniMicroCapStrategy { self.project_order_value( ctx, &mut projected, - date, + projection_date, symbol, fixed_buy_cash, "periodic_rebalance_buy", @@ -2713,7 +2762,7 @@ impl Strategy for OmniMicroCapStrategy { projected.positions().len(), order_intents.len() ), - "platform schedule 10:17/10:18 approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(), + "platform schedule signal uses decision_date data; broker applies execution_date price and risk checks".to_string(), ]; if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER") .map(|value| value == "1") @@ -2907,10 +2956,11 @@ mod tests { default_cfg.stock_long_ma_days = 3; let default_strategy = OmniMicroCapStrategy::new(default_cfg.clone()); let (default_selected, _) = default_strategy - .select_symbols(&ctx, dates[2], 0.0, 100.0) + .select_symbols(&ctx, dates[2], 0.0, 100.0, false) .expect("default selection"); assert!(default_selected.is_empty()); - let default_risk_decisions = default_strategy.selection_risk_decisions(&ctx, dates[2]); + let default_risk_decisions = + default_strategy.selection_risk_decisions(&ctx, dates[2], false); assert_eq!(default_risk_decisions.len(), 1); assert_eq!(default_risk_decisions[0].symbol, symbol); assert_eq!(default_risk_decisions[0].rule_code, "kcb"); @@ -2925,11 +2975,11 @@ mod tests { cfg.risk_config.static_rules.reject_kcb_buy = false; let configured_strategy = OmniMicroCapStrategy::new(cfg); let (selected, _) = configured_strategy - .select_symbols(&ctx, dates[2], 0.0, 100.0) + .select_symbols(&ctx, dates[2], 0.0, 100.0, false) .expect("configured selection"); assert!( configured_strategy - .selection_risk_decisions(&ctx, dates[2]) + .selection_risk_decisions(&ctx, dates[2], false) .is_empty() ); diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index e9162b4..af57370 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -50,15 +50,20 @@ pub struct SelectionContext<'a> { pub data: &'a DataSet, pub dynamic_universe: Option<&'a BTreeSet>, pub risk_config: Option<&'a FidcRiskControlConfig>, + pub defer_selection_risk: bool, } impl SelectionContext<'_> { fn eligible_universe(&self) -> Vec { - let eligible = match self.risk_config { - Some(risk_config) => self - .data - .eligible_universe_on_with_risk_config(self.decision_date, risk_config), - None => self.data.eligible_universe_on(self.decision_date).to_vec(), + let eligible = if self.defer_selection_risk { + self.data.fundamental_universe_on(self.decision_date) + } else { + match self.risk_config { + Some(risk_config) => self + .data + .eligible_universe_on_with_risk_config(self.decision_date, risk_config), + None => self.data.eligible_universe_on(self.decision_date).to_vec(), + } }; match self.dynamic_universe { Some(symbols) if !symbols.is_empty() => eligible @@ -70,6 +75,9 @@ impl SelectionContext<'_> { } fn selection_risk_decisions(&self) -> Vec { + if self.defer_selection_risk { + return Vec::new(); + } let default_risk_config; let risk_config = match self.risk_config { Some(value) => value, @@ -403,6 +411,7 @@ mod tests { data: &data, dynamic_universe: None, risk_config: Some(&FidcRiskControlConfig::default()), + defer_selection_risk: false, }); let rules = diagnostics