From bc228980afc02cde483b1a6fe69da4048120cbc4 Mon Sep 17 00:00:00 2001 From: boris Date: Sun, 6 Sep 2026 17:58:57 +0800 Subject: [PATCH] perf: compile date comparisons into numeric VM --- crates/fidc-core/src/numeric_expr_vm.rs | 93 ++++++++- .../fidc-core/src/platform_expr_strategy.rs | 179 +++++++++++++++++- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/crates/fidc-core/src/numeric_expr_vm.rs b/crates/fidc-core/src/numeric_expr_vm.rs index bfd4756..e8d4a38 100644 --- a/crates/fidc-core/src/numeric_expr_vm.rs +++ b/crates/fidc-core/src/numeric_expr_vm.rs @@ -314,15 +314,67 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result Ok(Value::Number(number(lhs)? * number(rhs)?)), BinaryOp::Divide => Ok(Value::Number(number(lhs)? / number(rhs)?)), BinaryOp::Remainder => Ok(Value::Number(number(lhs)? % number(rhs)?)), - BinaryOp::Equal => Ok(Value::Boolean(lhs == rhs)), - BinaryOp::NotEqual => Ok(Value::Boolean(lhs != rhs)), - BinaryOp::Less => Ok(Value::Boolean(number(lhs)? < number(rhs)?)), - BinaryOp::LessEqual => Ok(Value::Boolean(number(lhs)? <= number(rhs)?)), - BinaryOp::Greater => Ok(Value::Boolean(number(lhs)? > number(rhs)?)), - BinaryOp::GreaterEqual => Ok(Value::Boolean(number(lhs)? >= number(rhs)?)), + BinaryOp::Equal => Ok(Value::Boolean(match (lhs, rhs) { + (Value::Number(lhs), Value::Number(rhs)) => float_equal(lhs, rhs), + (Value::Boolean(lhs), Value::Boolean(rhs)) => lhs == rhs, + _ => { + return Err(EvalError::new( + "comparison operands must have the same type", + )); + } + })), + BinaryOp::NotEqual => Ok(Value::Boolean(match (lhs, rhs) { + (Value::Number(lhs), Value::Number(rhs)) => float_not_equal(lhs, rhs), + (Value::Boolean(lhs), Value::Boolean(rhs)) => lhs != rhs, + _ => { + return Err(EvalError::new( + "comparison operands must have the same type", + )); + } + })), + BinaryOp::Less => { + let (lhs, rhs) = (number(lhs)?, number(rhs)?); + Ok(Value::Boolean( + (rhs - lhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON, + )) + } + BinaryOp::LessEqual => { + let (lhs, rhs) = (number(lhs)?, number(rhs)?); + Ok(Value::Boolean( + (rhs - lhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON, + )) + } + BinaryOp::Greater => { + let (lhs, rhs) = (number(lhs)?, number(rhs)?); + Ok(Value::Boolean( + (lhs - rhs) / float_comparison_scale(lhs, rhs) > f64::EPSILON, + )) + } + BinaryOp::GreaterEqual => { + let (lhs, rhs) = (number(lhs)?, number(rhs)?); + Ok(Value::Boolean( + (lhs - rhs) / float_comparison_scale(lhs, rhs) > -f64::EPSILON, + )) + } } } +fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 { + if lhs * rhs == 0.0 { + 1.0 + } else { + lhs.abs().max(rhs.abs()) + } +} + +fn float_equal(lhs: f64, rhs: f64) -> bool { + (lhs - rhs).abs() / float_comparison_scale(lhs, rhs) <= f64::EPSILON +} + +fn float_not_equal(lhs: f64, rhs: f64) -> bool { + (lhs - rhs).abs() / float_comparison_scale(lhs, rhs) > f64::EPSILON +} + fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { let numeric = |index: usize| -> Result { args.get(index) @@ -1313,6 +1365,35 @@ mod tests { ); } + #[test] + fn floating_comparisons_match_rhai_epsilon_semantics() { + let adjacent = 11.699999999999998_f64; + assert_eq!( + evaluate("value == 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(true) + ); + assert_eq!( + evaluate("value != 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(false) + ); + assert_eq!( + evaluate("value <= 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(true) + ); + assert_eq!( + evaluate("value >= 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(true) + ); + assert_eq!( + evaluate("value < 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(false) + ); + assert_eq!( + evaluate("value > 11.7", &[("value", Value::Number(adjacent))]), + Value::Boolean(false) + ); + } + #[test] fn short_circuit_does_not_resolve_unused_variable() { let program = compile("false && missing", |name| { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 36d23b0..edbd980 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -5474,6 +5474,9 @@ impl PlatformExprStrategy { let integer = |value: i64| Some(NumericVmValue::Number(value as f64)); let boolean = |value: bool| Some(NumericVmValue::Boolean(value)); match identifier { + "trade_date" | "current_date" | "date" => integer(Self::numeric_date_key(day.date)), + "decision_date" => integer(Self::numeric_date_key(ctx.decision_date)), + "execution_date" => integer(Self::numeric_date_key(ctx.execution_date)), "signal_open" => number(day.signal_open), "signal_close" => number(day.signal_close), "benchmark_open" => number(day.benchmark_open), @@ -6381,11 +6384,19 @@ impl PlatformExprStrategy { script.push('\n'); } script.push_str(&Self::numeric_vm_template_source(expression, &mut bindings).ok()?); - let program = numeric_expr_vm::compile(&script, |identifier| { - bindings + let (numeric_script, date_literals_rewritten) = + Self::rewrite_iso_date_literals_for_numeric_vm(&script); + let program = numeric_expr_vm::compile(&numeric_script, |identifier| { + if let Some(value_type) = bindings .get(identifier) .and_then(|binding| Self::numeric_vm_helper_type(&binding.name)) - .or_else(|| Self::numeric_vm_identifier_type(identifier)) + { + return Some(value_type); + } + if date_literals_rewritten && Self::is_numeric_date_identifier(identifier) { + return Some(NumericVmValueType::Number); + } + Self::numeric_vm_identifier_type(identifier) }) .ok()?; let helper_bindings = program @@ -6399,6 +6410,117 @@ impl PlatformExprStrategy { }) } + fn is_numeric_date_identifier(identifier: &str) -> bool { + matches!( + identifier, + "trade_date" | "current_date" | "date" | "decision_date" | "execution_date" + ) + } + + fn numeric_date_key(value: NaiveDate) -> i64 { + i64::from(value.year()) * 10_000 + i64::from(value.month()) * 100 + i64::from(value.day()) + } + + fn iso_date_literal_has_date_comparison_neighbor( + source: &str, + literal_start: usize, + literal_end: usize, + ) -> bool { + fn trailing_identifier(value: &str) -> &str { + let bytes = value.as_bytes(); + let mut start = bytes.len(); + while start > 0 + && (bytes[start - 1] == b'_' || bytes[start - 1].is_ascii_alphanumeric()) + { + start -= 1; + } + &value[start..] + } + + fn leading_identifier(value: &str) -> &str { + let bytes = value.as_bytes(); + let mut end = 0usize; + while end < bytes.len() && (bytes[end] == b'_' || bytes[end].is_ascii_alphanumeric()) { + end += 1; + } + &value[..end] + } + + const OPERATORS: [&str; 6] = ["==", "!=", ">=", "<=", ">", "<"]; + let left = source[..literal_start].trim_end(); + if OPERATORS.iter().any(|operator| { + left.strip_suffix(operator).is_some_and(|prefix| { + Self::is_numeric_date_identifier(trailing_identifier(prefix.trim_end())) + }) + }) { + return true; + } + let right = source[literal_end..].trim_start(); + OPERATORS.iter().any(|operator| { + right.strip_prefix(operator).is_some_and(|suffix| { + Self::is_numeric_date_identifier(leading_identifier(suffix.trim_start())) + }) + }) + } + + fn rewrite_iso_date_literals_for_numeric_vm(source: &str) -> (String, bool) { + let bytes = source.as_bytes(); + let mut output = String::with_capacity(source.len()); + let mut copied_until = 0usize; + let mut cursor = 0usize; + let mut rewritten = false; + while cursor < bytes.len() { + let quote = bytes[cursor]; + if quote != b'\'' && quote != b'"' { + cursor += 1; + continue; + } + let literal_start = cursor; + cursor += 1; + let content_start = cursor; + let mut escaped = false; + let mut literal_end = None; + while cursor < bytes.len() { + if bytes[cursor] == b'\\' { + escaped = true; + cursor = (cursor + 2).min(bytes.len()); + continue; + } + if bytes[cursor] == quote { + literal_end = Some(cursor); + cursor += 1; + break; + } + cursor += 1; + } + let Some(content_end) = literal_end else { + break; + }; + if escaped || content_end.saturating_sub(content_start) != 10 { + continue; + } + if !Self::iso_date_literal_has_date_comparison_neighbor(source, literal_start, cursor) { + continue; + } + let candidate = &source[content_start..content_end]; + let Ok(date) = NaiveDate::parse_from_str(candidate, "%Y-%m-%d") else { + continue; + }; + if date.to_string() != candidate { + continue; + } + output.push_str(&source[copied_until..literal_start]); + output.push_str(&Self::numeric_date_key(date).to_string()); + copied_until = cursor; + rewritten = true; + } + if !rewritten { + return (source.to_string(), false); + } + output.push_str(&source[copied_until..]); + (output, true) + } + fn numeric_vm_template_source( template: &Result, bindings: &mut AHashMap, @@ -34061,7 +34183,8 @@ mod tests { cfg.explicit_actions = vec![PlatformTradeAction::Order { kind: PlatformExplicitOrderKind::Value, symbol: "000001.SZ".to_string(), - amount_expr: "cash * 0.1".to_string(), + amount_expr: "if decision_date == \"2025-02-03\" { cash * 0.1 } else { 0.0 }" + .to_string(), limit_price_expr: None, time_in_force: None, start_time_expr: None, @@ -34070,8 +34193,23 @@ mod tests { reason: "ast_cache_reuse".to_string(), }]; let mut strategy = PlatformExprStrategy::new(cfg); + let amount_expr = match &strategy.config.explicit_actions[0] { + PlatformTradeAction::Order { amount_expr, .. } => amount_expr, + _ => unreachable!("test action is an order"), + }; + assert!( + strategy + .expression_eval_plan(amount_expr) + .numeric_vm + .is_some(), + "ISO date comparisons must compile to the numeric VM" + ); - let _ = strategy.on_day(&ctx).expect("first decision"); + let first = strategy.on_day(&ctx).expect("first decision"); + assert!(first.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { value, .. } if (*value - 100_000.0).abs() < 1e-8 + ))); let vm_hits_after_first = strategy.numeric_vm_hits(); let vm_fallbacks_after_first = strategy.numeric_vm_fallbacks(); assert!( @@ -34091,6 +34229,37 @@ mod tests { ); } + #[test] + fn numeric_vm_rewrites_only_valid_iso_date_literals() { + let (rewritten, changed) = PlatformExprStrategy::rewrite_iso_date_literals_for_numeric_vm( + "decision_date >= '2025-02-03' && execution_date < \"2025-02-05\"", + ); + assert!(changed); + assert_eq!( + rewritten, + "decision_date >= 20250203 && execution_date < 20250205" + ); + + let invalid = "decision_date == \"2025-02-30\""; + assert_eq!( + PlatformExprStrategy::rewrite_iso_date_literals_for_numeric_vm(invalid), + (invalid.to_string(), false) + ); + let unrelated = "symbol == \"000001.SZ\""; + assert_eq!( + PlatformExprStrategy::rewrite_iso_date_literals_for_numeric_vm(unrelated), + (unrelated.to_string(), false) + ); + let mixed = "pit_financial(\"2025-02-03\") > 0 && \"2025-02-01\" <= decision_date"; + assert_eq!( + PlatformExprStrategy::rewrite_iso_date_literals_for_numeric_vm(mixed), + ( + "pit_financial(\"2025-02-03\") > 0 && 20250201 <= decision_date".to_string(), + true + ) + ); + } + #[test] fn expression_plan_only_executes_prelude_when_expression_depends_on_it() { let mut cfg = PlatformExprStrategyConfig::microcap_rotation();