From 61a4172bd436adcf2624980110ba78b7effa13a6 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 24 Aug 2026 09:28:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=AD=96=E7=95=A5=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E6=89=A7=E8=A1=8C=E4=B8=8E=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 368 ++++++------------ .../fidc-core/src/platform_strategy_spec.rs | 38 +- 2 files changed, 163 insertions(+), 243 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 518674d..ebe9119 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -401,30 +401,23 @@ pub struct PlatformExprStrategyConfig { } impl PlatformExprStrategyConfig { - pub fn microcap_rotation() -> Self { + pub fn generic() -> Self { Self { - strategy_name: "microcap_rotation".to_string(), + strategy_name: "platform-expression".to_string(), market: "CN_A".to_string(), - benchmark_symbol: "000852.SH".to_string(), - signal_symbol: "000001.SH".to_string(), - refresh_rate: 15, + benchmark_symbol: String::new(), + signal_symbol: String::new(), + refresh_rate: 1, refresh_rate_expr: String::new(), - max_positions: 40, - prelude: r#"let stocknum = 40; -let ma_ratio = 1.0001; -fn band_low(index_close) { - round((index_close - 2000) * 4 / 500 + 7) -}"# - .to_string(), + max_positions: 1, + prelude: String::new(), universe_exclude: Vec::new(), market_cap_field: "market_cap".to_string(), - market_cap_lower_expr: "band_low(signal_close)".to_string(), - market_cap_upper_expr: "band_low(signal_close) + 10".to_string(), - selection_limit_expr: "stocknum".to_string(), + market_cap_lower_expr: "0.0".to_string(), + market_cap_upper_expr: "1.0e30".to_string(), + selection_limit_expr: "1".to_string(), selection_candidate_limit_expr: String::new(), - stock_filter_expr: - "stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long" - .to_string(), + stock_filter_expr: String::new(), buy_scale_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(), portfolio_drawdown_control: None, @@ -434,17 +427,17 @@ fn band_low(index_close) { rank_by: "market_cap".to_string(), rank_expr: String::new(), rank_desc: false, - benchmark_short_ma_days: 5, - benchmark_long_ma_days: 10, - stock_short_ma_days: 5, - stock_mid_ma_days: 10, - stock_long_ma_days: 20, + benchmark_short_ma_days: 1, + benchmark_long_ma_days: 1, + stock_short_ma_days: 1, + stock_mid_ma_days: 1, + stock_long_ma_days: 1, skip_month_day_ranges: Vec::new(), rebalance_schedule: None, signal_rebalance_dates: BTreeSet::new(), rotation_enabled: true, daily_top_up_enabled: false, - daily_position_target_adjust_enabled: true, + daily_position_target_adjust_enabled: false, target_portfolio_daily_enabled: false, rebalance_existing_positions: false, hold_until_exit_enabled: false, @@ -478,6 +471,33 @@ fn band_low(index_close) { } } + pub fn microcap_rotation() -> Self { + let mut config = Self::generic(); + config.strategy_name = "microcap_rotation".to_string(); + config.benchmark_symbol = "000852.SH".to_string(); + config.signal_symbol = "000001.SH".to_string(); + config.refresh_rate = 15; + config.max_positions = 40; + config.prelude = r#"let stocknum = 40; +let ma_ratio = 1.0001; +fn band_low(index_close) { + round((index_close - 2000) * 4 / 500 + 7) +}"# + .to_string(); + config.market_cap_lower_expr = "band_low(signal_close)".to_string(); + config.market_cap_upper_expr = "band_low(signal_close) + 10".to_string(); + config.selection_limit_expr = "stocknum".to_string(); + config.stock_filter_expr = + "stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long".to_string(); + config.benchmark_short_ma_days = 5; + config.benchmark_long_ma_days = 10; + config.stock_short_ma_days = 5; + config.stock_mid_ma_days = 10; + config.stock_long_ma_days = 20; + config.daily_position_target_adjust_enabled = true; + config + } + fn in_skip_window(&self, date: NaiveDate) -> bool { let year = date.year() as u32; let month = date.month(); @@ -791,6 +811,7 @@ impl<'a> SelectiveExpressionScope<'a> { struct ExpressionEvalPlan { identifiers: BTreeSet, runtime_template: Result, + requires_prelude: bool, } struct RuntimeExpressionTemplate { @@ -832,8 +853,6 @@ pub struct PlatformExprStrategy { prelude_runtime_template: Result, prelude_identifier_candidates: BTreeSet, prelude_declared_identifiers: BTreeSet, - prelude_numeric_constants: HashMap, - compact_stock_filter_expr: String, stock_filter_quote_usage: StockFilterQuoteUsage, selection_quote_usage: StockFilterQuoteUsage, stock_rolling_requirements: StockRollingRequirements, @@ -1106,25 +1125,16 @@ impl PlatformExprStrategy { let prelude_identifier_candidates = Self::extract_identifier_candidates(&normalized_prelude); let prelude_declared_identifiers = Self::declared_prelude_identifiers(&config.prelude); - let prelude_numeric_constants = Self::parse_prelude_numeric_constants(&normalized_prelude); let normalized_stock_filter_expr = Self::normalize_expr(&config.stock_filter_expr); - let compact_stock_filter_expr = Self::compact_expr(&normalized_stock_filter_expr); let stock_filter_quote_usage = Self::stock_filter_quote_usage_for_expr(&normalized_stock_filter_expr); let selection_quote_usage = Self::selection_quote_usage_for_config(&config, &normalized_stock_filter_expr); - let stock_rolling_requirements = - Self::stock_rolling_requirements_for_config(&config, &normalized_stock_filter_expr); - let stock_extra_factors_required = Self::stock_extra_factors_required_for_config( - &config, - &normalized_stock_filter_expr, - &prelude_declared_identifiers, - ); - let stock_extra_factor_identifiers = Self::stock_extra_factor_identifiers_for_config( - &config, - &normalized_stock_filter_expr, - &prelude_declared_identifiers, - ); + let stock_rolling_requirements = Self::stock_rolling_requirements_for_config(&config); + let stock_extra_factors_required = + Self::stock_extra_factors_required_for_config(&config, &prelude_declared_identifiers); + let stock_extra_factor_identifiers = + Self::stock_extra_factor_identifiers_for_config(&config, &prelude_declared_identifiers); let stock_text_factors_required = Self::stock_text_factors_required_for_config( &config, &normalized_stock_filter_expr, @@ -1154,8 +1164,6 @@ impl PlatformExprStrategy { prelude_runtime_template, prelude_identifier_candidates, prelude_declared_identifiers, - prelude_numeric_constants, - compact_stock_filter_expr, stock_filter_quote_usage, selection_quote_usage, stock_rolling_requirements, @@ -3879,12 +3887,12 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, identifiers: &BTreeSet, + prelude_identifiers: &BTreeSet, include_day_factors: bool, include_factors_map: bool, include_process_event_counts: bool, ) -> Scope<'static> { - let mut scope = - SelectiveExpressionScope::new(identifiers, &self.prelude_identifier_candidates); + let mut scope = SelectiveExpressionScope::new(identifiers, prelude_identifiers); let trade_date = day.date.format("%Y-%m-%d").to_string(); let decision_date = ctx.decision_date.format("%Y-%m-%d").to_string(); let execution_date = ctx.execution_date.format("%Y-%m-%d").to_string(); @@ -4573,7 +4581,12 @@ impl PlatformExprStrategy { ) -> Result { let expression_plan = self.expression_eval_plan(expr); let normalized_identifiers = &expression_plan.identifiers; - let prelude_identifiers = &self.prelude_identifier_candidates; + let empty_prelude_identifiers = BTreeSet::new(); + let prelude_identifiers = if expression_plan.requires_prelude { + &self.prelude_identifier_candidates + } else { + &empty_prelude_identifiers + }; let include_day_factors = normalized_identifiers.contains("day_factors") || normalized_identifiers.contains("day_factor") || prelude_identifiers.contains("day_factors"); @@ -4590,17 +4603,22 @@ impl PlatformExprStrategy { stock, position, &normalized_identifiers, + prelude_identifiers, include_day_factors, include_factors_map, include_process_event_counts, ); - let expanded_prelude = self.expand_runtime_helper_template( - ctx, - day, - stock, - &self.prelude_runtime_template, - &mut scope, - )?; + let expanded_prelude = if expression_plan.requires_prelude { + self.expand_runtime_helper_template( + ctx, + day, + stock, + &self.prelude_runtime_template, + &mut scope, + )? + } else { + String::new() + }; let expanded_expr = self.expand_runtime_helper_template( ctx, day, @@ -4650,8 +4668,12 @@ impl PlatformExprStrategy { return plan; } let normalized = Self::normalize_expr(expr); + let identifiers = Self::extract_identifier_candidates(&normalized); let plan = Arc::new(ExpressionEvalPlan { - identifiers: Self::extract_identifier_candidates(&normalized), + requires_prelude: identifiers + .iter() + .any(|identifier| self.prelude_declared_identifiers.contains(identifier)), + identifiers, runtime_template: Self::compile_runtime_helper_template(&normalized), }); self.expression_plan_cache @@ -4700,28 +4722,6 @@ impl PlatformExprStrategy { output } - fn prelude_numeric_constant(&self, name: &str) -> Option { - self.prelude_numeric_constants.get(name).copied() - } - - fn parse_prelude_numeric_constants(normalized_prelude: &str) -> HashMap { - let mut constants = HashMap::new(); - for line in normalized_prelude.lines() { - let trimmed = line.trim(); - let Some(body) = trimmed.strip_prefix("let ") else { - continue; - }; - let Some((lhs, rhs)) = body.split_once('=') else { - continue; - }; - let rhs = rhs.trim().trim_end_matches(';').trim(); - if let Ok(value) = rhs.parse::() { - constants.insert(lhs.trim().to_string(), value); - } - } - constants - } - fn normalize_prelude_for_eval(prelude: &str) -> String { prelude .lines() @@ -6101,13 +6101,10 @@ impl PlatformExprStrategy { .lines() .filter_map(|line| { let trimmed = line.trim_start(); - let body = if let Some(rest) = trimmed.strip_prefix("let ") { - rest - } else if let Some(rest) = trimmed.strip_prefix("fn ") { - rest - } else { - return None; - }; + let body = trimmed + .strip_prefix("let ") + .or_else(|| trimmed.strip_prefix("const ")) + .or_else(|| trimmed.strip_prefix("fn "))?; let identifier: String = body .chars() .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') @@ -7611,9 +7608,6 @@ impl PlatformExprStrategy { if self.config.stock_filter_expr.trim().is_empty() { return Ok(true); } - if let Some(value) = self.fast_stock_passes_expr(ctx, day, stock) { - return Ok(value); - } match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) { Ok(value) => Ok(value), Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(false), @@ -7621,79 +7615,6 @@ impl PlatformExprStrategy { } } - fn fast_stock_passes_expr( - &self, - _ctx: &StrategyContext<'_>, - _day: &DayExpressionState, - stock: &StockExpressionState, - ) -> Option { - let compact = self.compact_stock_filter_expr.as_str(); - let ma_ratio = self.prelude_numeric_constant("ma_ratio").unwrap_or(1.0); - if compact == "stock_ma_short>stock_ma_mid*ma_ratio&&stock_ma_mid>stock_ma_long" { - return Some( - stock.stock_ma_short.is_finite() - && stock.stock_ma_mid.is_finite() - && stock.stock_ma_long.is_finite() - && stock.stock_ma_short > stock.stock_ma_mid * ma_ratio - && stock.stock_ma_mid > stock.stock_ma_long, - ); - } - - let mut filter_body = compact; - let requires_min_listed_days = - if let Some(rest) = filter_body.strip_prefix("listed_days>=min_listed_days&&") { - filter_body = rest; - true - } else { - false - }; - let base_microcap_filter = "rolling_mean(\"close\",5)>rolling_mean(\"close\",10)*ma_ratio&&rolling_mean(\"close\",10)>rolling_mean(\"close\",30)*ma_ratio&&rolling_mean(\"volume\",5)0&&rolling_mean(\"volume\",100)>0" - | "&&rolling_mean(\"volume\",5)>0.0&&rolling_mean(\"volume\",100)>0.0" - ) - }) - { - true - } else { - return None; - }; - - let listed_days_pass = if requires_min_listed_days { - let min_listed_days = self - .prelude_numeric_constant("min_listed_days") - .unwrap_or(0.0); - (stock.listed_days as f64) >= min_listed_days - } else { - true - }; - let max_volume_ratio = self - .prelude_numeric_constant("max_volume_ratio") - .unwrap_or(1.0); - let volume_ma100 = stock.stock_volume_ma100; - let positive_volume_pass = - !requires_positive_volume || (stock.stock_volume_ma5 > 0.0 && volume_ma100 > 0.0); - - Some( - listed_days_pass - && stock.stock_ma5.is_finite() - && stock.stock_ma10.is_finite() - && stock.stock_ma30.is_finite() - && stock.stock_volume_ma5.is_finite() - && volume_ma100.is_finite() - && positive_volume_pass - && stock.stock_ma5 > stock.stock_ma10 * ma_ratio - && stock.stock_ma10 > stock.stock_ma30 * ma_ratio - && stock.stock_volume_ma5 < volume_ma100 * max_volume_ratio, - ) - } - fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { match self.config.market_cap_field.as_str() { "market_cap_bn" => row.market_cap_bn, @@ -8397,7 +8318,6 @@ impl PlatformExprStrategy { fn stock_rolling_requirements_for_config( config: &PlatformExprStrategyConfig, - normalized_stock_filter_expr: &str, ) -> StockRollingRequirements { let mut requirements = StockRollingRequirements::default(); let expressions = [ @@ -8418,17 +8338,11 @@ impl PlatformExprStrategy { Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized); Self::require_stock_rollings_for_helper_calls(&mut requirements, &normalized); } - Self::require_stock_rollings_for_fast_filter( - &mut requirements, - config, - normalized_stock_filter_expr, - ); requirements } fn stock_extra_factors_required_for_config( config: &PlatformExprStrategyConfig, - normalized_stock_filter_expr: &str, prelude_declared_identifiers: &BTreeSet, ) -> bool { if !config.explicit_actions.is_empty() { @@ -8439,14 +8353,10 @@ impl PlatformExprStrategy { { return true; } - let stock_filter_has_fast_path = - Self::stock_filter_fast_path_supported(normalized_stock_filter_expr); - if !stock_filter_has_fast_path - && Self::expr_requires_stock_extra_factors( - &config.stock_filter_expr, - prelude_declared_identifiers, - ) - { + if Self::expr_requires_stock_extra_factors( + &config.stock_filter_expr, + prelude_declared_identifiers, + ) { return true; } [ @@ -8461,7 +8371,6 @@ impl PlatformExprStrategy { fn stock_extra_factor_identifiers_for_config( config: &PlatformExprStrategyConfig, - normalized_stock_filter_expr: &str, prelude_declared_identifiers: &BTreeSet, ) -> BTreeSet { let mut identifiers = BTreeSet::new(); @@ -8471,15 +8380,11 @@ impl PlatformExprStrategy { if Self::stock_field_may_use_extra_factors(&config.rank_by) { identifiers.insert(config.rank_by.trim().to_string()); } - let stock_filter_has_fast_path = - Self::stock_filter_fast_path_supported(normalized_stock_filter_expr); - if !stock_filter_has_fast_path { - Self::collect_stock_extra_factor_identifiers( - &mut identifiers, - &config.stock_filter_expr, - prelude_declared_identifiers, - ); - } + Self::collect_stock_extra_factor_identifiers( + &mut identifiers, + &config.stock_filter_expr, + prelude_declared_identifiers, + ); for expr in [ config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), @@ -8793,65 +8698,6 @@ impl PlatformExprStrategy { .filter(|value| *value > 0) } - fn require_stock_rollings_for_fast_filter( - requirements: &mut StockRollingRequirements, - config: &PlatformExprStrategyConfig, - normalized_stock_filter_expr: &str, - ) { - let compact = Self::compact_expr(normalized_stock_filter_expr); - if compact == "stock_ma_short>stock_ma_mid*ma_ratio&&stock_ma_mid>stock_ma_long" { - requirements.require(StockRollingField::Close, config.stock_short_ma_days); - requirements.require(StockRollingField::Close, config.stock_mid_ma_days); - requirements.require(StockRollingField::Close, config.stock_long_ma_days); - return; - } - - let mut filter_body = compact.as_str(); - if let Some(rest) = filter_body.strip_prefix("listed_days>=min_listed_days&&") { - filter_body = rest; - } - let base_microcap_filter = "rolling_mean(\"close\",5)>rolling_mean(\"close\",10)*ma_ratio&&rolling_mean(\"close\",10)>rolling_mean(\"close\",30)*ma_ratio&&rolling_mean(\"volume\",5)0&&rolling_mean(\"volume\",100)>0" - | "&&rolling_mean(\"volume\",5)>0.0&&rolling_mean(\"volume\",100)>0.0" - ) - }); - if matches_microcap_fast_filter { - requirements.require(StockRollingField::Close, 5); - requirements.require(StockRollingField::Close, 10); - requirements.require(StockRollingField::Close, 30); - requirements.require(StockRollingField::Volume, 5); - requirements.require(StockRollingField::Volume, 100); - } - } - - fn stock_filter_fast_path_supported(normalized_stock_filter_expr: &str) -> bool { - let compact = Self::compact_expr(normalized_stock_filter_expr); - if compact == "stock_ma_short>stock_ma_mid*ma_ratio&&stock_ma_mid>stock_ma_long" { - return true; - } - let mut filter_body = compact.as_str(); - if let Some(rest) = filter_body.strip_prefix("listed_days>=min_listed_days&&") { - filter_body = rest; - } - let base_microcap_filter = "rolling_mean(\"close\",5)>rolling_mean(\"close\",10)*ma_ratio&&rolling_mean(\"close\",10)>rolling_mean(\"close\",30)*ma_ratio&&rolling_mean(\"volume\",5)0&&rolling_mean(\"volume\",100)>0" - | "&&rolling_mean(\"volume\",5)>0.0&&rolling_mean(\"volume\",100)>0.0" - ) - }) - } - #[cfg(test)] fn stock_filter_uses_intraday_quote_fields(&self) -> bool { self.stock_filter_quote_usage() != StockFilterQuoteUsage::DailyOnly @@ -31333,6 +31179,44 @@ mod tests { ); } + #[test] + fn expression_plan_only_executes_prelude_when_expression_depends_on_it() { + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.prelude = r#" +let stock_threshold = 10.0; +const constant_threshold = 11.0; +let unrelated_market_signal = rolling_mean_current("signal_close", 60); +fn passes_threshold(value) { value > stock_threshold } +"# + .to_string(); + let strategy = PlatformExprStrategy::new(cfg); + + assert!( + !strategy + .expression_eval_plan("close > 0 && !is_st") + .requires_prelude, + "stock-only expressions must not execute unrelated day-level prelude declarations" + ); + assert!( + strategy + .expression_eval_plan("market_cap > stock_threshold") + .requires_prelude, + "direct prelude variable dependencies must retain the prelude" + ); + assert!( + strategy + .expression_eval_plan("market_cap > constant_threshold") + .requires_prelude, + "const prelude dependencies must retain the prelude" + ); + assert!( + strategy + .expression_eval_plan("passes_threshold(market_cap)") + .requires_prelude, + "user-defined prelude function dependencies must retain the prelude" + ); + } + #[test] fn ast_cache_reuses_rolling_helper_scripts_across_dates() { let dates = [d(2025, 2, 3), d(2025, 2, 4)]; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index b94f30f..426ca71 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1389,7 +1389,7 @@ pub fn platform_expr_config_from_spec( signal_symbol: &str, strategy_spec: Option<&StrategyRuntimeSpec>, ) -> Result { - let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + let mut cfg = PlatformExprStrategyConfig::generic(); cfg.strategy_name = strategy_id.to_string(); if !signal_symbol.trim().is_empty() { cfg.signal_symbol = signal_symbol.trim().to_string(); @@ -1620,6 +1620,17 @@ pub fn platform_expr_config_from_spec( .filter(|value| !value.trim().is_empty()) { cfg.selection_limit_expr = expr.clone(); + if let Ok(limit) = expr.trim().parse::() + && limit > 0 + && spec + .engine_config + .as_ref() + .and_then(|engine| engine.rank_limit) + .filter(|value| *value > 0) + .is_none() + { + cfg.max_positions = limit; + } } if let Some(expr) = selection .candidate_limit_expr @@ -2491,6 +2502,31 @@ mod tests { ); } + #[test] + fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() { + let spec = serde_json::json!({ + "strategyId": "generic_runtime_strategy", + "signalSymbol": "000300.SH", + "benchmark": { "instrumentId": "000300.SH" }, + "runtimeExpressions": { + "selection": { "limitExpr": "7" }, + "trading": { "rotationEnabled": true } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("generic config"); + + assert_eq!(cfg.strategy_name, "generic_runtime_strategy"); + assert_eq!(cfg.max_positions, 7); + assert_eq!(cfg.selection_limit_expr, "7"); + assert_eq!(cfg.market_cap_lower_expr, "0.0"); + assert_eq!(cfg.market_cap_upper_expr, "1.0e30"); + assert!(cfg.stock_filter_expr.is_empty()); + assert!(cfg.prelude.is_empty()); + assert_eq!(cfg.refresh_rate, 1); + assert!(!cfg.daily_position_target_adjust_enabled); + } + #[test] fn engine_config_parses_weak_market_shrink_overweight_threshold() { let spec = serde_json::json!({