From 9a7e5c79033d2631e58b339d2478014d85a33bce Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 23 Aug 2026 22:58:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=BD=AE=E6=A0=A1=E9=AA=8C=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E8=A1=A8=E8=BE=BE=E5=BC=8F=E8=AF=AD=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2f99a25..c71034b 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1230,6 +1230,180 @@ impl PlatformExprStrategy { self.compiled_cache.borrow().len() } + /// Compile every configured expression before any market data is loaded. + /// This validates syntax only; identifiers and runtime values are resolved + /// later against the point-in-time execution scope. + pub fn validate_expression_syntax(&self) -> Result<(), BacktestError> { + let normalized_prelude = Self::normalize_prelude_for_eval(&self.config.prelude); + let mut expressions = vec![ + ( + "refresh_rate_expr".to_string(), + self.config.refresh_rate_expr.as_str(), + ), + ( + "market_cap_lower_expr".to_string(), + self.config.market_cap_lower_expr.as_str(), + ), + ( + "market_cap_upper_expr".to_string(), + self.config.market_cap_upper_expr.as_str(), + ), + ( + "selection_limit_expr".to_string(), + self.config.selection_limit_expr.as_str(), + ), + ( + "selection_candidate_limit_expr".to_string(), + self.config.selection_candidate_limit_expr.as_str(), + ), + ( + "stock_filter_expr".to_string(), + self.config.stock_filter_expr.as_str(), + ), + ( + "buy_scale_expr".to_string(), + self.config.buy_scale_expr.as_str(), + ), + ( + "exposure_expr".to_string(), + self.config.exposure_expr.as_str(), + ), + ( + "stop_loss_expr".to_string(), + self.config.stop_loss_expr.as_str(), + ), + ( + "take_profit_expr".to_string(), + self.config.take_profit_expr.as_str(), + ), + ("rank_expr".to_string(), self.config.rank_expr.as_str()), + ]; + for (index, action) in self.config.explicit_actions.iter().enumerate() { + match action { + PlatformTradeAction::Order { + amount_expr, + limit_price_expr, + start_time_expr, + end_time_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].amount_expr"), + amount_expr, + )); + for (name, expression) in [ + ("limit_price_expr", limit_price_expr.as_deref()), + ("start_time_expr", start_time_expr.as_deref()), + ("end_time_expr", end_time_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } + PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr, + order_prices_expr, + valuation_prices_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].target_weights_expr"), + target_weights_expr, + )); + for (name, expression) in [ + ("order_prices_expr", order_prices_expr.as_deref()), + ("valuation_prices_expr", valuation_prices_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } + PlatformTradeAction::Universe { + symbols_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].symbols_expr"), + symbols_expr, + )); + if let Some(expression) = when_expr.as_deref() { + expressions + .push((format!("explicit_actions[{index}].when_expr"), expression)); + } + } + PlatformTradeAction::Account { + amount_expr, + receiving_days_expr, + when_expr, + .. + } => { + expressions.push(( + format!("explicit_actions[{index}].amount_expr"), + amount_expr, + )); + for (name, expression) in [ + ("receiving_days_expr", receiving_days_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } + PlatformTradeAction::Cancel { + order_id_expr, + when_expr, + .. + } => { + for (name, expression) in [ + ("order_id_expr", order_id_expr.as_deref()), + ("when_expr", when_expr.as_deref()), + ] { + if let Some(expression) = expression { + expressions + .push((format!("explicit_actions[{index}].{name}"), expression)); + } + } + } + } + } + + if !normalized_prelude.trim().is_empty() { + self.engine.compile(&normalized_prelude).map_err(|error| { + BacktestError::Execution(format!( + "platform expr preflight compile failed field=prelude: {error}" + )) + })?; + } + for (field, expression) in expressions { + if expression.trim().is_empty() { + continue; + } + let normalized_expression = Self::normalize_expr(expression); + let script = if normalized_prelude.trim().is_empty() { + normalized_expression + } else { + format!("{normalized_prelude}\n{normalized_expression}") + }; + self.engine.compile(&script).map_err(|error| { + BacktestError::Execution(format!( + "platform expr preflight compile failed field={field}: {error}" + )) + })?; + } + Ok(()) + } + /// 用 AST 缓存执行 script。命中:直接走 eval_ast_with_scope;未命中:先 /// engine.compile,再插入缓存,再 eval_ast_with_scope。任何编译/执行错误 /// 都按字符串包装为 BacktestError::Execution。 @@ -11211,6 +11385,49 @@ mod tests { }); } + #[test] + fn platform_expr_preflight_accepts_all_configured_expression_categories() { + let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.refresh_rate_expr = "max(1, 5)".to_string(); + config.rank_expr = "market_cap + 1.0".to_string(); + config.explicit_actions = vec![ + PlatformTradeAction::Order { + kind: PlatformExplicitOrderKind::LimitValue, + symbol: "000001.SZ".to_string(), + amount_expr: "cash * 0.1".to_string(), + limit_price_expr: Some("close * 1.01".to_string()), + start_time_expr: Some("\"10:00\"".to_string()), + end_time_expr: Some("\"10:30\"".to_string()), + when_expr: Some("close > 0.0".to_string()), + reason: "preflight".to_string(), + }, + PlatformTradeAction::Cancel { + kind: PlatformExplicitCancelKind::Order, + symbol: None, + order_id_expr: Some("1".to_string()), + when_expr: Some("true".to_string()), + reason: "preflight".to_string(), + }, + ]; + + PlatformExprStrategy::new(config) + .validate_expression_syntax() + .expect("configured expressions should compile"); + } + + #[test] + fn platform_expr_preflight_rejects_malformed_expression_with_field_name() { + let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.exposure_expr = "safe_div(1.0, 2.0".to_string(); + + let error = PlatformExprStrategy::new(config) + .validate_expression_syntax() + .expect_err("malformed expression must fail before data loading"); + let message = error.to_string(); + assert!(message.contains("preflight compile failed"), "{message}"); + assert!(message.contains("field=exposure_expr"), "{message}"); + } + #[test] fn platform_expr_safe_div_supports_two_and_three_arg_forms() { let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());