From 3b033fd29465d044856d38effa916f3e4936b313 Mon Sep 17 00:00:00 2001 From: boris Date: Sat, 9 May 2026 02:08:36 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20core=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=B1=82=E9=BB=98=E8=AE=A4=E6=B7=BB=E5=8A=A0=20new=5Flisting?= =?UTF-8?q?=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - platform expr 选股从 eligible_universe_on 开始 - eligible_universe_on 无条件过滤新股 - 导致即使 strategy_spec.universe.exclude 不含 new_listing,仍会过滤新股 修复: - StrategyRuntimeSpec 补 universe_exclude 字段 - platform expr 选股从 factor/candidate/market 合并开始 - 按 strategy_spec.universe.exclude 自己决定是否排除 new_listing - 补回归测试 相关: - 保持旧策略默认排除不变 - 新策略可以显式不排除新股 --- .../fidc-core/src/platform_expr_strategy.rs | 219 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 19 ++ 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c1e76b2..e5ae557 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -4190,6 +4190,77 @@ impl PlatformExprStrategy { } } + fn selectable_universe_on( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + ) -> Vec { + let mut rows = Vec::new(); + for factor in ctx.data.factor_snapshots_on(date) { + if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { + continue; + } + if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(&factor.symbol) { + continue; + } + let Some(candidate) = ctx.data.candidate(date, &factor.symbol) else { + continue; + }; + let Some(market) = ctx.data.market(date, &factor.symbol) else { + continue; + }; + if market.paused { + continue; + } + if !self.stock_passes_universe_exclude( + candidate, + market, + self.special_name(ctx, &factor.symbol), + ) { + continue; + } + rows.push(EligibleUniverseSnapshot { + symbol: factor.symbol.clone(), + market_cap_bn: factor.market_cap_bn, + free_float_cap_bn: factor.free_float_cap_bn, + }); + } + 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 stock_passes_universe_exclude( + &self, + candidate: &crate::data::CandidateEligibility, + market: &DailyMarketSnapshot, + has_special_name: bool, + ) -> bool { + let excludes = &self.config.universe_exclude; + if excludes.iter().any(|item| item == "paused") && (market.paused || candidate.is_paused) { + return false; + } + if excludes.iter().any(|item| item == "st") && (candidate.is_st || has_special_name) { + return false; + } + if excludes.iter().any(|item| item == "kcb") && candidate.is_kcb { + return false; + } + if excludes.iter().any(|item| item == "new_listing") && candidate.is_new_listing { + return false; + } + if excludes.iter().any(|item| item == "one_yuan") + && (candidate.is_one_yuan || market.day_open <= 1.0) + { + return false; + } + candidate.allow_buy && candidate.allow_sell + } + fn stock_numeric_field_value( &self, candidate: &EligibleUniverseSnapshot, @@ -4353,7 +4424,7 @@ impl PlatformExprStrategy { band_high: f64, limit: usize, ) -> Result<(Vec, Vec), BacktestError> { - let universe = ctx.eligible_universe_on(date); + let universe = self.selectable_universe_on(ctx, date); let mut diagnostics = Vec::new(); let mut candidates = Vec::new(); for candidate in universe { @@ -5845,6 +5916,152 @@ mod tests { ); } + #[test] + fn platform_strategy_honors_configured_universe_excludes_for_new_listings() { + let date = d(2025, 2, 3); + let symbols = ["301001.SZ", "000001.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(2025, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-02-03 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + vec![ + DailyFactorSnapshot { + date, + symbol: "301001.SZ".to_string(), + market_cap_bn: 8.0, + free_float_cap_bn: 7.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date, + symbol: "301001.SZ".to_string(), + is_st: false, + is_new_listing: true, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + 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: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 1; + cfg.max_positions = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.stock_short_ma_days = 1; + cfg.stock_mid_ma_days = 1; + cfg.stock_long_ma_days = 1; + cfg.universe_exclude = vec![ + "paused".to_string(), + "st".to_string(), + "kcb".to_string(), + "one_yuan".to_string(), + ]; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "true".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + crate::strategy::OrderIntent::Value { symbol, .. } if symbol == "301001.SZ" + ))); + } + #[test] fn platform_helpers_support_generic_rolling_stats_and_normalized_factors() { let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)]; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 857deb8..ada8137 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -20,6 +20,8 @@ pub struct StrategyRuntimeSpec { #[serde(default)] pub benchmark: Option, #[serde(default)] + pub universe: Option, + #[serde(default)] pub signal_symbol: Option, #[serde(default)] pub execution: Option, @@ -40,6 +42,13 @@ pub struct StrategyBenchmarkSpec { pub fallback_instrument_id: Option, } +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyUniverseSpec { + #[serde(default)] + pub exclude: Vec, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExecutionSpec { @@ -424,6 +433,14 @@ pub fn platform_expr_config_from_spec( cfg.signal_symbol = spec_signal_symbol.clone(); } } + if let Some(universe) = spec.universe.as_ref() { + cfg.universe_exclude = universe + .exclude + .iter() + .map(|item| item.trim().to_ascii_lowercase()) + .filter(|item| !item.is_empty()) + .collect(); + } let mut prelude_parts = Vec::new(); if let Some(runtime_expr) = spec.runtime_expressions.as_ref() @@ -1024,6 +1041,7 @@ mod tests { "strategyId": "runtime_spec_test", "signalSymbol": "000852.SH", "benchmark": { "instrumentId": "000852.SH" }, + "universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] }, "runtimeExpressions": { "prelude": "let stocknum = 8;", "selection": { @@ -1054,6 +1072,7 @@ mod tests { assert_eq!(cfg.strategy_name, "runtime_spec_test"); assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.selection_limit_expr, "stocknum"); + assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]); assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); assert!(cfg.retry_empty_rebalance);