diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 6f40854..040325e 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -596,6 +596,7 @@ pub struct PlatformExprStrategyConfig { pub refresh_rate_expr: String, pub max_positions: usize, pub prelude: String, + pub universe_include: Option>, pub universe_exclude: Vec, pub market_cap_field: String, pub market_cap_lower_expr: String, @@ -673,6 +674,7 @@ impl PlatformExprStrategyConfig { refresh_rate_expr: String::new(), max_positions: 1, prelude: String::new(), + universe_include: None, universe_exclude: Vec::new(), market_cap_field: "market_cap".to_string(), market_cap_lower_expr: "0.0".to_string(), @@ -9974,6 +9976,14 @@ impl PlatformExprStrategy { let factor_symbol_ids = factor_day.factor_symbol_ids(); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { + if self + .config + .universe_include + .as_ref() + .is_some_and(|symbols| !symbols.contains(&factor.symbol)) + { + continue; + } if !self.config.candidate_symbols_by_date.is_empty() && !self .config @@ -10495,6 +10505,14 @@ impl PlatformExprStrategy { let market = ctx.data.require_market(date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; + if self + .config + .universe_include + .as_ref() + .is_some_and(|symbols| !symbols.contains(&market.symbol)) + { + return Ok(Some("universe_not_included".to_string())); + } if let Some(reason) = Self::universe_exclude_reason(&self.config.universe_exclude, &market.symbol) { @@ -24352,6 +24370,26 @@ mod tests { assert_eq!(rejection.as_deref(), Some("paused")); assert_eq!(selected.len(), 1); assert_eq!(selected[0].symbol, symbol); + + let mut restricted_cfg = PlatformExprStrategyConfig::microcap_rotation(); + restricted_cfg.universe_include = Some(BTreeSet::from(["000001.SZ".to_string()])); + restricted_cfg.universe_exclude.clear(); + let restricted_strategy = PlatformExprStrategy::new(restricted_cfg); + let restricted_stock = restricted_strategy + .stock_state(&ctx, date, symbol) + .expect("restricted stock state"); + assert_eq!( + restricted_strategy + .buy_rejection_reason(&ctx, date, symbol, &restricted_stock) + .expect("static universe rejection") + .as_deref(), + Some("universe_not_included") + ); + assert!( + restricted_strategy + .selectable_universe_on(&ctx, date, date) + .is_empty() + ); } fn sample_calendar() -> TradingCalendar { diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index d5ff9ec..c39dfc0 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -62,6 +62,8 @@ pub struct StrategyBenchmarkSpec { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyUniverseSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include: Option>, #[serde(default)] pub exclude: Vec, } @@ -1944,6 +1946,27 @@ pub fn platform_expr_config_from_spec( } } if let Some(universe) = spec.universe.as_ref() { + cfg.universe_include = universe + .include + .as_ref() + .map(|raw_symbols| { + let mut symbols = BTreeSet::new(); + for raw_symbol in raw_symbols { + let symbol = normalize_symbol(raw_symbol, None); + if !is_static_cn_universe_symbol(&symbol) { + return Err(format!( + "universe.include contains invalid CN stock symbol: {raw_symbol}" + )); + } + if !symbols.insert(symbol.clone()) { + return Err(format!( + "universe.include contains duplicate normalized symbol: {symbol}" + )); + } + } + Ok(symbols) + }) + .transpose()?; cfg.universe_exclude = universe .exclude .iter() @@ -3009,6 +3032,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String { instrument_query_id(trimmed, &normalize_board(trimmed, raw_board)) } +fn is_static_cn_universe_symbol(symbol: &str) -> bool { + let Some((code, exchange)) = symbol.rsplit_once('.') else { + return false; + }; + code.len() == 6 + && code.bytes().all(|byte| byte.is_ascii_digit()) + && matches!(exchange, "SH" | "SZ" | "BJ") +} + fn instrument_query_id(symbol: &str, board: &str) -> String { if symbol.contains('.') { return symbol.to_ascii_uppercase(); @@ -3095,7 +3127,10 @@ mod tests { "strategyId": "runtime_spec_test", "signalSymbol": "000852.SH", "benchmark": { "instrumentId": "000852.SH" }, - "universe": { "exclude": ["paused", "st", "kcb", "one_yuan"] }, + "universe": { + "include": ["600000.sh", "000001.SZ"], + "exclude": ["paused", "st", "kcb", "one_yuan"] + }, "runtimeExpressions": { "prelude": "let stocknum = 8;", "selection": { @@ -3134,6 +3169,13 @@ mod tests { assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.selection_limit_expr, "stocknum"); assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20"); + assert_eq!( + cfg.universe_include, + Some(BTreeSet::from([ + "000001.SZ".to_string(), + "600000.SH".to_string() + ])) + ); assert_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]); assert!(!cfg.rotation_enabled); assert!(cfg.daily_top_up_enabled); @@ -3155,6 +3197,29 @@ mod tests { ); } + #[test] + fn rejects_invalid_or_duplicate_static_universe_symbols() { + let invalid = serde_json::json!({ + "universe": {"include": ["not-a-stock"]} + }); + assert!( + platform_expr_config_from_value("", "", &invalid) + .unwrap_err() + .to_string() + .contains("invalid CN stock symbol") + ); + + let duplicate = serde_json::json!({ + "universe": {"include": ["600000.sh", "600000.SH"]} + }); + assert!( + platform_expr_config_from_value("", "", &duplicate) + .unwrap_err() + .to_string() + .contains("duplicate normalized symbol") + ); + } + #[test] fn parses_and_rejects_invalid_position_target_rules() { let spec = serde_json::json!({