perf: compile date comparisons into numeric VM

This commit is contained in:
boris
2026-09-06 17:58:57 +08:00
parent e0b4a3f56c
commit bc228980af
2 changed files with 261 additions and 11 deletions
+87 -6
View File
@@ -314,15 +314,67 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result<Value, Eval
BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)), BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)),
BinaryOp::Divide => 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::Remainder => Ok(Value::Number(number(lhs)? % number(rhs)?)),
BinaryOp::Equal => Ok(Value::Boolean(lhs == rhs)), BinaryOp::Equal => Ok(Value::Boolean(match (lhs, rhs) {
BinaryOp::NotEqual => Ok(Value::Boolean(lhs != rhs)), (Value::Number(lhs), Value::Number(rhs)) => float_equal(lhs, rhs),
BinaryOp::Less => Ok(Value::Boolean(number(lhs)? < number(rhs)?)), (Value::Boolean(lhs), Value::Boolean(rhs)) => lhs == rhs,
BinaryOp::LessEqual => Ok(Value::Boolean(number(lhs)? <= number(rhs)?)), _ => {
BinaryOp::Greater => Ok(Value::Boolean(number(lhs)? > number(rhs)?)), return Err(EvalError::new(
BinaryOp::GreaterEqual => Ok(Value::Boolean(number(lhs)? >= number(rhs)?)), "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<Value, EvalError> { fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result<Value, EvalError> {
let numeric = |index: usize| -> Result<f64, EvalError> { let numeric = |index: usize| -> Result<f64, EvalError> {
args.get(index) 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] #[test]
fn short_circuit_does_not_resolve_unused_variable() { fn short_circuit_does_not_resolve_unused_variable() {
let program = compile("false && missing", |name| { let program = compile("false && missing", |name| {
+174 -5
View File
@@ -5474,6 +5474,9 @@ impl PlatformExprStrategy {
let integer = |value: i64| Some(NumericVmValue::Number(value as f64)); let integer = |value: i64| Some(NumericVmValue::Number(value as f64));
let boolean = |value: bool| Some(NumericVmValue::Boolean(value)); let boolean = |value: bool| Some(NumericVmValue::Boolean(value));
match identifier { 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_open" => number(day.signal_open),
"signal_close" => number(day.signal_close), "signal_close" => number(day.signal_close),
"benchmark_open" => number(day.benchmark_open), "benchmark_open" => number(day.benchmark_open),
@@ -6381,11 +6384,19 @@ impl PlatformExprStrategy {
script.push('\n'); script.push('\n');
} }
script.push_str(&Self::numeric_vm_template_source(expression, &mut bindings).ok()?); script.push_str(&Self::numeric_vm_template_source(expression, &mut bindings).ok()?);
let program = numeric_expr_vm::compile(&script, |identifier| { let (numeric_script, date_literals_rewritten) =
bindings 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) .get(identifier)
.and_then(|binding| Self::numeric_vm_helper_type(&binding.name)) .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()?; .ok()?;
let helper_bindings = program 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( fn numeric_vm_template_source(
template: &Result<RuntimeExpressionTemplate, String>, template: &Result<RuntimeExpressionTemplate, String>,
bindings: &mut AHashMap<String, RuntimeHelperBinding>, bindings: &mut AHashMap<String, RuntimeHelperBinding>,
@@ -34061,7 +34183,8 @@ mod tests {
cfg.explicit_actions = vec![PlatformTradeAction::Order { cfg.explicit_actions = vec![PlatformTradeAction::Order {
kind: PlatformExplicitOrderKind::Value, kind: PlatformExplicitOrderKind::Value,
symbol: "000001.SZ".to_string(), 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, limit_price_expr: None,
time_in_force: None, time_in_force: None,
start_time_expr: None, start_time_expr: None,
@@ -34070,8 +34193,23 @@ mod tests {
reason: "ast_cache_reuse".to_string(), reason: "ast_cache_reuse".to_string(),
}]; }];
let mut strategy = PlatformExprStrategy::new(cfg); 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_hits_after_first = strategy.numeric_vm_hits();
let vm_fallbacks_after_first = strategy.numeric_vm_fallbacks(); let vm_fallbacks_after_first = strategy.numeric_vm_fallbacks();
assert!( 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] #[test]
fn expression_plan_only_executes_prelude_when_expression_depends_on_it() { fn expression_plan_only_executes_prelude_when_expression_depends_on_it() {
let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); let mut cfg = PlatformExprStrategyConfig::microcap_rotation();