perf: compile date comparisons into numeric VM
This commit is contained in:
@@ -314,13 +314,65 @@ 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> {
|
||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user