增加类型化静态股票池合同

This commit is contained in:
boris
2026-09-07 07:27:44 +08:00
parent c3f88ebf12
commit fc6dea12eb
2 changed files with 104 additions and 1 deletions
@@ -596,6 +596,7 @@ pub struct PlatformExprStrategyConfig {
pub refresh_rate_expr: String, pub refresh_rate_expr: String,
pub max_positions: usize, pub max_positions: usize,
pub prelude: String, pub prelude: String,
pub universe_include: Option<BTreeSet<String>>,
pub universe_exclude: Vec<String>, pub universe_exclude: Vec<String>,
pub market_cap_field: String, pub market_cap_field: String,
pub market_cap_lower_expr: String, pub market_cap_lower_expr: String,
@@ -673,6 +674,7 @@ impl PlatformExprStrategyConfig {
refresh_rate_expr: String::new(), refresh_rate_expr: String::new(),
max_positions: 1, max_positions: 1,
prelude: String::new(), prelude: String::new(),
universe_include: None,
universe_exclude: Vec::new(), universe_exclude: Vec::new(),
market_cap_field: "market_cap".to_string(), market_cap_field: "market_cap".to_string(),
market_cap_lower_expr: "0.0".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(); let factor_symbol_ids = factor_day.factor_symbol_ids();
debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len()); debug_assert_eq!(factor_rows.len(), factor_symbol_ids.len());
for (factor, symbol_id) in factor_rows.iter().zip(factor_symbol_ids.iter().copied()) { 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() if !self.config.candidate_symbols_by_date.is_empty()
&& !self && !self
.config .config
@@ -10495,6 +10505,14 @@ impl PlatformExprStrategy {
let market = ctx.data.require_market(date, symbol)?; let market = ctx.data.require_market(date, symbol)?;
let candidate = ctx.data.require_candidate(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) = if let Some(reason) =
Self::universe_exclude_reason(&self.config.universe_exclude, &market.symbol) 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!(rejection.as_deref(), Some("paused"));
assert_eq!(selected.len(), 1); assert_eq!(selected.len(), 1);
assert_eq!(selected[0].symbol, symbol); 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 { fn sample_calendar() -> TradingCalendar {
+66 -1
View File
@@ -62,6 +62,8 @@ pub struct StrategyBenchmarkSpec {
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StrategyUniverseSpec { pub struct StrategyUniverseSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
pub exclude: Vec<String>, pub exclude: Vec<String>,
} }
@@ -1944,6 +1946,27 @@ pub fn platform_expr_config_from_spec(
} }
} }
if let Some(universe) = spec.universe.as_ref() { 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 cfg.universe_exclude = universe
.exclude .exclude
.iter() .iter()
@@ -3009,6 +3032,15 @@ fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String {
instrument_query_id(trimmed, &normalize_board(trimmed, raw_board)) 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 { fn instrument_query_id(symbol: &str, board: &str) -> String {
if symbol.contains('.') { if symbol.contains('.') {
return symbol.to_ascii_uppercase(); return symbol.to_ascii_uppercase();
@@ -3095,7 +3127,10 @@ mod tests {
"strategyId": "runtime_spec_test", "strategyId": "runtime_spec_test",
"signalSymbol": "000852.SH", "signalSymbol": "000852.SH",
"benchmark": { "instrumentId": "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": { "runtimeExpressions": {
"prelude": "let stocknum = 8;", "prelude": "let stocknum = 8;",
"selection": { "selection": {
@@ -3134,6 +3169,13 @@ mod tests {
assert_eq!(cfg.signal_symbol, "000852.SH"); assert_eq!(cfg.signal_symbol, "000852.SH");
assert_eq!(cfg.selection_limit_expr, "stocknum"); assert_eq!(cfg.selection_limit_expr, "stocknum");
assert_eq!(cfg.refresh_rate_expr, "year >= 2024 ? 5 : 20"); 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_eq!(cfg.universe_exclude, ["paused", "st", "kcb", "one_yuan"]);
assert!(!cfg.rotation_enabled); assert!(!cfg.rotation_enabled);
assert!(cfg.daily_top_up_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] #[test]
fn parses_and_rejects_invalid_position_target_rules() { fn parses_and_rejects_invalid_position_target_rules() {
let spec = serde_json::json!({ let spec = serde_json::json!({