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

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 max_positions: usize,
pub prelude: String,
pub universe_include: Option<BTreeSet<String>>,
pub universe_exclude: Vec<String>,
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 {
+66 -1
View File
@@ -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<Vec<String>>,
#[serde(default)]
pub exclude: Vec<String>,
}
@@ -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!({