From d071a8a1906c0ea442237d9edf343e5c8f93c50d Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 26 Aug 2026 03:25:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=95=B0=E5=80=BC=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8Fhelper=E6=89=A7=E8=A1=8C=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 260 +++++++++--------- 1 file changed, 127 insertions(+), 133 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 1002c51..f1b6aa2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -926,7 +926,18 @@ enum RuntimeExpressionSegment { struct RuntimeHelperBinding { name: String, args: Vec, - scope_name: String, +} + +/// Typed result of resolving a runtime helper. +/// +/// The Rhai path still binds numeric values into its scope, but the numeric +/// VM consumes these values directly. Keeping the typed value here avoids a +/// per-evaluation `Dynamic -> String -> f64/bool` round trip. +#[derive(Debug)] +enum RuntimeHelperResolution { + Expression(String), + Number(f64), + Boolean(bool), } pub struct PlatformExprStrategy { @@ -4792,21 +4803,13 @@ impl PlatformExprStrategy { *self.numeric_vm_fallbacks.borrow_mut() += 1; return Ok(None); }; - let mut helper_scope = Scope::new(); let mut scratch = self.numeric_vm_scratch.borrow_mut(); let value = vm_plan .program .evaluate(&mut scratch, |index, identifier, expected_type| { if let Some(binding) = vm_plan.helper_bindings[index].as_ref() { return self - .numeric_vm_runtime_helper_value( - ctx, - day, - stock, - binding, - expected_type, - &mut helper_scope, - ) + .numeric_vm_runtime_helper_value(ctx, day, stock, binding, expected_type) .map_err(|error| NumericVmEvalError::new(error.to_string())); } self.numeric_vm_identifier_value(ctx, day, stock, position, identifier) @@ -4832,63 +4835,41 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, binding: &RuntimeHelperBinding, expected_type: NumericVmValueType, - scope: &mut Scope<'_>, ) -> Result { - let resolved = self.resolve_runtime_helper( - ctx, - day, - stock, - &binding.name, - &binding.args, - &binding.scope_name, - scope, - )?; - if resolved == binding.scope_name { - return match expected_type { - NumericVmValueType::Number => scope - .get_value::(&binding.scope_name) + let resolved = + self.resolve_runtime_helper(ctx, day, stock, &binding.name, &binding.args)?; + match (expected_type, resolved) { + (NumericVmValueType::Number, RuntimeHelperResolution::Number(value)) => { + Ok(NumericVmValue::Number(value)) + } + (NumericVmValueType::Boolean, RuntimeHelperResolution::Boolean(value)) => { + Ok(NumericVmValue::Boolean(value)) + } + (expected_type, RuntimeHelperResolution::Expression(expression)) => match expected_type + { + NumericVmValueType::Number => expression + .trim() + .parse::() .map(NumericVmValue::Number) - .or_else(|| { - scope - .get_value::(&binding.scope_name) - .map(|value| NumericVmValue::Number(value as f64)) - }) - .ok_or_else(|| { + .map_err(|_| { BacktestError::Execution(format!( - "runtime helper {} did not bind a numeric value", + "runtime helper {} produced non-numeric expression {expression:?}", binding.name )) }), - NumericVmValueType::Boolean => scope - .get_value::(&binding.scope_name) - .map(NumericVmValue::Boolean) - .ok_or_else(|| { - BacktestError::Execution(format!( - "runtime helper {} did not bind a boolean value", - binding.name - )) - }), - }; - } - match expected_type { - NumericVmValueType::Number => resolved - .trim() - .parse::() - .map(NumericVmValue::Number) - .map_err(|_| { - BacktestError::Execution(format!( - "runtime helper {} produced non-numeric expression {resolved:?}", + NumericVmValueType::Boolean => match expression.trim() { + "true" => Ok(NumericVmValue::Boolean(true)), + "false" => Ok(NumericVmValue::Boolean(false)), + _ => Err(BacktestError::Execution(format!( + "runtime helper {} produced non-boolean expression {expression:?}", binding.name - )) - }), - NumericVmValueType::Boolean => match resolved.trim() { - "true" => Ok(NumericVmValue::Boolean(true)), - "false" => Ok(NumericVmValue::Boolean(false)), - _ => Err(BacktestError::Execution(format!( - "runtime helper {} produced non-boolean expression {resolved:?}", - binding.name - ))), + ))), + }, }, + (expected_type, value) => Err(BacktestError::Execution(format!( + "runtime helper {} returned {:?}, expected {:?}", + binding.name, value, expected_type + ))), } } @@ -5849,7 +5830,6 @@ impl PlatformExprStrategy { let binding = RuntimeHelperBinding { name: name.clone(), args: args.clone(), - scope_name: scope_name.clone(), }; if let Some(existing) = bindings.get(scope_name) && (existing.name != binding.name || existing.args != binding.args) @@ -5981,9 +5961,17 @@ impl PlatformExprStrategy { name, args, scope_name, - } => output.push_str( - &self.resolve_runtime_helper(ctx, day, stock, name, args, scope_name, scope)?, - ), + } => match self.resolve_runtime_helper(ctx, day, stock, name, args)? { + RuntimeHelperResolution::Expression(expression) => output.push_str(&expression), + RuntimeHelperResolution::Number(value) => { + scope.push(scope_name.clone(), value); + output.push_str(scope_name); + } + RuntimeHelperResolution::Boolean(value) => { + scope.push(scope_name.clone(), value); + output.push_str(scope_name); + } + }, } } Ok(output) @@ -5996,9 +5984,7 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, helper: &str, args: &[String], - scope_name: &str, - scope: &mut Scope<'_>, - ) -> Result { + ) -> Result { match helper { "factor" => { let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( @@ -6025,19 +6011,21 @@ impl PlatformExprStrategy { stock.symbol, day.date )) })?; - return Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )); + return Ok(RuntimeHelperResolution::Number(value)); } - Ok(format!("factors[{}]", Self::quote_rhai_string(&key))) + Ok(RuntimeHelperResolution::Expression(format!( + "factors[{}]", + Self::quote_rhai_string(&key) + ))) } "day_factor" => { let key = Self::normalize_runtime_factor_key(&Self::parse_string_or_identifier( args.first().map(String::as_str).unwrap_or_default(), )?); - Ok(format!("day_factors[{}]", Self::quote_rhai_string(&key))) + Ok(RuntimeHelperResolution::Expression(format!( + "day_factors[{}]", + Self::quote_rhai_string(&key) + ))) } "rolling_mean" | "sma" | "ma" => { if args.len() != 2 { @@ -6048,11 +6036,7 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_mean_current" => { if args.len() != 2 { @@ -6063,11 +6047,7 @@ impl PlatformExprStrategy { let field = Self::parse_string_or_identifier(&args[0])?; let lookback = Self::parse_positive_usize(&args[1])?; let value = self.resolve_current_rolling_mean(ctx, day, stock, &field, lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_max_current" => { if args.len() != 2 { @@ -6080,7 +6060,7 @@ impl PlatformExprStrategy { let values = self.resolve_current_rolling_values(ctx, day, stock, &field, lookback)?; let value = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "rolling_return_stddev_current" => { if args.len() != 2 { @@ -6106,7 +6086,9 @@ impl PlatformExprStrategy { "invalid current rolling return for field {field} with count {return_count}" ))); } - Ok(Self::format_rhai_float(rolling_sample_stddev(&returns))) + Ok(Self::normalized_runtime_number(rolling_sample_stddev( + &returns, + ))) } "vma" => { if args.len() != 1 { @@ -6116,11 +6098,7 @@ impl PlatformExprStrategy { } let lookback = Self::parse_positive_usize(&args[0])?; let value = self.resolve_rolling_mean(ctx, day, stock, "volume", lookback)?; - Ok(Self::push_runtime_helper_value( - scope, - scope_name, - Dynamic::from(value), - )) + Ok(RuntimeHelperResolution::Number(value)) } "rolling_sum" | "rolling_min" | "rolling_max" | "rolling_stddev" | "stddev" | "rolling_zscore" => { @@ -6140,7 +6118,7 @@ impl PlatformExprStrategy { "rolling_zscore" => rolling_zscore(&values), _ => 0.0, }; - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "pct_change" => { if args.len() != 2 { @@ -6164,7 +6142,7 @@ impl PlatformExprStrategy { } else { last / first - 1.0 }; - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "factor_value" | "get_factor_value" => { if args.is_empty() || args.len() > 2 { @@ -6183,7 +6161,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "factor_text" | "get_factor_text" => { if args.is_empty() || args.len() > 2 { @@ -6202,7 +6180,9 @@ impl PlatformExprStrategy { .last() .map(|row| row.value.clone()) .unwrap_or_default(); - Ok(Self::quote_rhai_string(&value)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&value), + )) } "dividend_cash" | "has_dividend" => { let (symbol, lookback) = @@ -6215,9 +6195,9 @@ impl PlatformExprStrategy { .map(|row| row.dividend_cash_before_tax) .sum::(); if helper == "has_dividend" { - Ok((total.abs() > f64::EPSILON).to_string()) + Ok(RuntimeHelperResolution::Boolean(total.abs() > f64::EPSILON)) } else { - Ok(Self::format_rhai_float(total)) + Ok(Self::normalized_runtime_number(total)) } } "split_ratio" | "has_split" => { @@ -6227,9 +6207,9 @@ impl PlatformExprStrategy { let splits = ctx.data.get_split(&symbol, start, day.date); let ratio = splits.iter().map(|row| row.split_ratio).product::(); if helper == "has_split" { - Ok((!splits.is_empty()).to_string()) + Ok(RuntimeHelperResolution::Boolean(!splits.is_empty())) } else { - Ok(Self::format_rhai_float(if splits.is_empty() { + Ok(Self::normalized_runtime_number(if splits.is_empty() { 1.0 } else { ratio @@ -6253,7 +6233,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "shares" | "get_shares_value" => { let stock = stock.ok_or_else(|| { @@ -6267,7 +6247,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "turnover_rate" | "get_turnover_rate_value" => { let stock = stock.ok_or_else(|| { @@ -6281,7 +6261,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "price_change_rate" | "get_price_change_rate_value" => { if args.len() > 1 { @@ -6299,7 +6279,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "stock_connect" | "get_stock_connect_value" => { let stock = stock.ok_or_else(|| { @@ -6313,7 +6293,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "current_performance" => { let stock = stock.ok_or_else(|| { @@ -6327,7 +6307,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "fundamental" | "get_fundamentals_value" => { let stock = stock.ok_or_else(|| { @@ -6341,7 +6321,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "financial" | "get_financials_value" => { let stock = stock.ok_or_else(|| { @@ -6355,7 +6335,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "pit_financial" | "get_pit_financials_value" => { let stock = stock.ok_or_else(|| { @@ -6369,7 +6349,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "industry_code" | "get_industry_code" => { if args.len() > 2 { @@ -6390,7 +6370,7 @@ impl PlatformExprStrategy { .get_industry(&stock.symbol, &source, level) .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "industry_name" | "get_industry_name" => { if args.len() > 2 { @@ -6411,7 +6391,9 @@ impl PlatformExprStrategy { .get_industry_name(&stock.symbol, &source, level) .map(|row| row.value) .unwrap_or_default(); - Ok(Self::quote_rhai_string(&value)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&value), + )) } "yield_curve" | "get_yield_curve_value" => { if args.is_empty() || args.len() > 2 { @@ -6427,7 +6409,7 @@ impl PlatformExprStrategy { .last() .map(|row| row.value) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } "is_margin_stock" => { if args.len() > 1 { @@ -6447,7 +6429,7 @@ impl PlatformExprStrategy { .get_margin_stocks(&margin_type) .iter() .any(|symbol| symbol == &stock.symbol); - Ok(matched.to_string()) + Ok(RuntimeHelperResolution::Boolean(matched)) } "dominant_future" | "get_dominant_future" => { if args.len() != 1 { @@ -6457,7 +6439,9 @@ impl PlatformExprStrategy { } let underlying = Self::parse_string_or_identifier(&args[0])?; let symbol = ctx.get_dominant_future(&underlying).unwrap_or_default(); - Ok(Self::quote_rhai_string(&symbol)) + Ok(RuntimeHelperResolution::Expression( + Self::quote_rhai_string(&symbol), + )) } "dominant_future_price" | "get_dominant_future_price_value" => { if args.is_empty() || args.len() > 3 { @@ -6478,7 +6462,7 @@ impl PlatformExprStrategy { .last() .map(|row| Self::price_bar_field(row, &field)) .unwrap_or(0.0); - Ok(Self::format_rhai_float(value)) + Ok(Self::normalized_runtime_number(value)) } other => Err(BacktestError::Execution(format!( "unsupported platform helper: {other}" @@ -6574,6 +6558,17 @@ impl PlatformExprStrategy { )) } + fn normalized_runtime_number(value: f64) -> RuntimeHelperResolution { + let value = if value.is_finite() { + format!("{value:.12}") + .parse::() + .expect("formatted finite runtime helper number must parse") + } else { + 0.0 + }; + RuntimeHelperResolution::Number(value) + } + fn parse_optional_positive_usize( raw: Option<&String>, fallback: usize, @@ -6583,24 +6578,6 @@ impl PlatformExprStrategy { .map(|value| value.unwrap_or(fallback)) } - fn format_rhai_float(value: f64) -> String { - if value.is_finite() { - format!("{value:.12}") - } else { - "0.0".to_string() - } - } - - fn push_runtime_helper_value( - scope: &mut Scope<'_>, - scope_name: &str, - value: Dynamic, - ) -> String { - let name = scope_name.to_string(); - scope.push_dynamic(name.clone(), value); - name - } - fn runtime_helper_scope_name(helper: &str, args: &[String]) -> String { let signature = format!("{helper}({})", args.join(",")); let mut hash = 14_695_981_039_346_656_037u64; @@ -11989,8 +11966,8 @@ mod tests { PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, - PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral, - StockFilterQuoteUsage, framework_stock_rolling_factor_requirement, + PlatformTradeAction, PlatformUniverseActionKind, RuntimeHelperResolution, + SelectionRiskDeferral, StockFilterQuoteUsage, framework_stock_rolling_factor_requirement, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -12058,6 +12035,23 @@ mod tests { assert_eq!(framework_stock_rolling_factor_requirement("alpha001"), None); } + #[test] + fn typed_runtime_numbers_preserve_legacy_rhai_formatting() { + let RuntimeHelperResolution::Number(value) = + PlatformExprStrategy::normalized_runtime_number(1.2345678901235) + else { + panic!("expected numeric helper result"); + }; + assert_eq!(value, "1.234567890123".parse::().unwrap()); + + let RuntimeHelperResolution::Number(value) = + PlatformExprStrategy::normalized_runtime_number(f64::NAN) + else { + panic!("expected numeric helper result"); + }; + assert_eq!(value, 0.0); + } + #[test] fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() { let previous_date = d(2025, 1, 2);