diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9054cf1..b9d0fe2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -39,6 +39,28 @@ pub struct PlatformRebalanceSchedule { pub time_rule: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SelectionRiskDeferral { + None, + LimitState, + All, +} + +impl SelectionRiskDeferral { + fn should_defer_rejection(self, reason: &str) -> bool { + match self { + SelectionRiskDeferral::None => false, + SelectionRiskDeferral::LimitState => matches!(reason, "upper_limit" | "lower_limit"), + SelectionRiskDeferral::All => true, + } + } + + fn should_suppress_diagnostic(self, rule_code: &str) -> bool { + matches!(self, SelectionRiskDeferral::LimitState) + && matches!(rule_code, "upper_limit" | "lower_limit") + } +} + impl PlatformRebalanceSchedule { fn as_schedule_rule(&self, stage: ScheduleStage) -> ScheduleRule { let rule = match self.frequency { @@ -6011,7 +6033,12 @@ impl PlatformExprStrategy { date: NaiveDate, factor_date: NaiveDate, ) -> Vec { - self.selectable_universe_on_with_options(ctx, date, factor_date, false) + self.selectable_universe_on_with_options( + ctx, + date, + factor_date, + SelectionRiskDeferral::None, + ) } fn selectable_universe_on_with_options( @@ -6019,7 +6046,7 @@ impl PlatformExprStrategy { ctx: &StrategyContext<'_>, date: NaiveDate, factor_date: NaiveDate, - defer_all_selection_risk: bool, + selection_risk_deferral: SelectionRiskDeferral, ) -> Vec { let mut rows = Vec::new(); for factor in ctx.data.factor_snapshots_on(factor_date) { @@ -6038,7 +6065,7 @@ impl PlatformExprStrategy { if let Some(_reason) = self.selection_risk_rejection_reason(ctx, date, &factor.symbol, candidate, market) { - if !defer_all_selection_risk { + if !selection_risk_deferral.should_defer_rejection(_reason) { continue; } } @@ -6071,7 +6098,12 @@ impl PlatformExprStrategy { date: NaiveDate, factor_date: NaiveDate, ) -> Vec { - self.selection_risk_decisions_with_options(ctx, date, factor_date, false) + self.selection_risk_decisions_with_options( + ctx, + date, + factor_date, + SelectionRiskDeferral::None, + ) } fn selection_risk_decisions_with_options( @@ -6079,7 +6111,7 @@ impl PlatformExprStrategy { ctx: &StrategyContext<'_>, date: NaiveDate, factor_date: NaiveDate, - _defer_all_selection_risk: bool, + selection_risk_deferral: SelectionRiskDeferral, ) -> Vec { let mut decisions = Vec::new(); for factor in ctx.data.factor_snapshots_on(factor_date) { @@ -6099,6 +6131,9 @@ impl PlatformExprStrategy { ctx.data.instrument(&factor.symbol), &self.config.risk_config, ) { + if selection_risk_deferral.should_suppress_diagnostic(&decision.rule_code) { + continue; + } decisions.push(decision); } } @@ -6457,14 +6492,28 @@ impl PlatformExprStrategy { band_high: f64, limit: usize, ) -> Result<(Vec, Vec, Vec), BacktestError> { - let defer_all_selection_risk = ctx.is_lagged_execution(); - let universe = if defer_all_selection_risk { - self.selectable_universe_on_with_options(ctx, date, universe_factor_date, true) + let selection_risk_deferral = if ctx.is_lagged_execution() { + SelectionRiskDeferral::All + } else { + SelectionRiskDeferral::None + }; + let universe = if selection_risk_deferral != SelectionRiskDeferral::None { + self.selectable_universe_on_with_options( + ctx, + date, + universe_factor_date, + selection_risk_deferral, + ) } else { self.selectable_universe_on(ctx, date, universe_factor_date) }; - let risk_decisions = if defer_all_selection_risk { - self.selection_risk_decisions_with_options(ctx, date, universe_factor_date, true) + let risk_decisions = if selection_risk_deferral != SelectionRiskDeferral::None { + self.selection_risk_decisions_with_options( + ctx, + date, + universe_factor_date, + selection_risk_deferral, + ) } else { self.selection_risk_decisions(ctx, date, universe_factor_date) }; @@ -7150,13 +7199,20 @@ impl PlatformExprStrategy { let mut diagnostics = Vec::new(); let mut candidates = Vec::new(); let quote_usage = self.stock_filter_quote_usage(); + let selection_risk_deferral = if ctx.is_lagged_execution() { + SelectionRiskDeferral::All + } else if self.selection_uses_intraday_quote_fields() + || quote_usage != StockFilterQuoteUsage::DailyOnly + { + SelectionRiskDeferral::LimitState + } else { + SelectionRiskDeferral::None + }; let universe = self.selectable_universe_on_with_options( ctx, date, universe_factor_date, - ctx.is_lagged_execution() - || self.selection_uses_intraday_quote_fields() - || quote_usage != StockFilterQuoteUsage::DailyOnly, + selection_risk_deferral, ); let quote_candidate_limit = self.quote_plan_candidate_limit(selection_limit); for candidate in universe { @@ -23718,4 +23774,133 @@ mod tests { vec!["000003.SZ".to_string(), "000004.SZ".to_string()] ); } + + #[test] + fn selection_quote_plan_keeps_configured_static_selection_risk_for_intraday_fields() { + let date = d(2025, 1, 3); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| { + let paused = *symbol == "000002.SZ"; + DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-01-03 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + } + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: index as f64 + 1.0, + free_float_cap_bn: index as f64 + 1.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + symbols + .iter() + .map(|symbol| { + let paused = *symbol == "000002.SZ"; + CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: paused, + allow_buy: !paused, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + } + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: Some(date.and_hms_opt(10, 18, 0).unwrap()), + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.max_positions = 3; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "3".to_string(); + cfg.stock_filter_expr = "last_price > 0".to_string(); + cfg.rank_by = "market_cap".to_string(); + cfg.risk_config.static_rules.reject_paused_selection = true; + let strategy = PlatformExprStrategy::new(cfg); + + let plan = strategy.selection_quote_plan(&ctx, 0).expect("quote plan"); + + assert!(plan.requires_intraday_selection_quotes); + assert!(!plan.candidate_symbols.contains(&"000002.SZ".to_string())); + assert!(!plan.order_symbols.contains(&"000002.SZ".to_string())); + assert_eq!( + plan.order_symbols, + vec!["000001.SZ".to_string(), "000003.SZ".to_string()] + ); + } } diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 67ee2e3..7f78236 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -584,8 +584,7 @@ fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlCon || config.static_rules.reject_bjse_selection || config.static_rules.reject_one_yuan_selection || config.static_rules.reject_upper_limit_selection - || config.static_rules.reject_lower_limit_selection - || config.static_rules.respect_allow_buy_sell; + || config.static_rules.reject_lower_limit_selection; } missing_field_rejected(&fields, config, RiskCheckScope::Selection) } @@ -681,15 +680,13 @@ fn missing_single_field_rejected( RiskCheckScope::Sell => false, }, "allow_buy" => match scope { - RiskCheckScope::Selection | RiskCheckScope::Buy => { - config.static_rules.respect_allow_buy_sell - } + RiskCheckScope::Selection => false, + RiskCheckScope::Buy => config.static_rules.respect_allow_buy_sell, RiskCheckScope::Sell => false, }, "allow_sell" => match scope { - RiskCheckScope::Selection | RiskCheckScope::Sell => { - config.static_rules.respect_allow_buy_sell - } + RiskCheckScope::Selection => false, + RiskCheckScope::Sell => config.static_rules.respect_allow_buy_sell, RiskCheckScope::Buy => false, }, "upper_limit" | "upper_limit_price" | "high_limit" | "high_limit_price" => match scope { @@ -1086,7 +1083,7 @@ mod tests { } #[test] - fn missing_risk_state_rejects_selection_and_buy_when_static_filters_enabled() { + fn missing_risk_state_default_selection_ignores_allow_flags_but_buy_rejects() { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.allow_sell = true; @@ -1106,11 +1103,28 @@ mod tests { 6.27, ); - assert_eq!(selection_reason, Some("missing_risk_state")); + assert_eq!(selection_reason, None); assert_eq!(buy_reason, Some("missing_risk_state")); assert_eq!(sell_reason, None); } + #[test] + fn missing_risk_state_selection_still_respects_configured_static_filters() { + let date = d(2025, 1, 2); + let mut candidate = candidate(date); + candidate.allow_sell = true; + candidate.risk_level_code = Some("missing_risk_state:is_st,allow_buy".to_string()); + let market = market(date, 6.27, 5.63); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_st_selection = true; + + let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( + date, &candidate, &market, None, &config, + ); + + assert_eq!(selection_reason, Some("missing_risk_state")); + } + #[test] fn missing_risk_state_selection_audit_keeps_missing_fields_in_reason() { let date = d(2025, 1, 2); @@ -1119,12 +1133,11 @@ mod tests { Some("missing_risk_state:is_st,allow_buy,upper_limit_price".to_string()); let market = market(date, 6.27, 5.63); + let mut config = FidcRiskControlConfig::default(); + config.static_rules.reject_st_selection = true; + config.static_rules.reject_upper_limit_selection = true; let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config( - date, - &candidate, - &market, - None, - &FidcRiskControlConfig::default(), + date, &candidate, &market, None, &config, ) .expect("missing risk state rejection"); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 9da60b1..b8e81af 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -2081,7 +2081,7 @@ impl OmniMicroCapStrategy { let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0); if constraints.volume_limit_enabled { - let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).round() + let raw_limit = ((snapshot.minute_volume as f64) * constraints.volume_percent).floor() as i64 - consumed_turnover as i64; if raw_limit <= 0 {