From 4b577517a95bd862cd304feeee7db875d7532bf3 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 24 Aug 2026 13:46:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=95=B0=E5=80=BC=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=E5=BC=8F=E5=AD=97=E8=8A=82=E7=A0=81=E8=99=9A=E6=8B=9F?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/lib.rs | 1 + crates/fidc-core/src/numeric_expr_vm.rs | 1512 +++++++++++++++++ .../fidc-core/src/platform_expr_strategy.rs | 660 ++++++- 3 files changed, 2144 insertions(+), 29 deletions(-) create mode 100644 crates/fidc-core/src/numeric_expr_vm.rs diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 474805d..c25cafe 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod events; pub mod futures; pub mod instrument; pub mod metrics; +mod numeric_expr_vm; pub mod platform_expr_strategy; pub mod platform_runtime_schema; pub mod platform_strategy_spec; diff --git a/crates/fidc-core/src/numeric_expr_vm.rs b/crates/fidc-core/src/numeric_expr_vm.rs new file mode 100644 index 0000000..bfd4756 --- /dev/null +++ b/crates/fidc-core/src/numeric_expr_vm.rs @@ -0,0 +1,1512 @@ +use std::collections::BTreeMap; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueType { + Number, + Boolean, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Value { + Number(f64), + Boolean(bool), +} + +impl Value { + fn value_type(self) -> ValueType { + match self { + Self::Number(_) => ValueType::Number, + Self::Boolean(_) => ValueType::Boolean, + } + } + + pub(crate) fn as_number(self) -> Option { + match self { + Self::Number(value) => Some(value), + Self::Boolean(_) => None, + } + } + + pub(crate) fn as_bool(self) -> Option { + match self { + Self::Boolean(value) => Some(value), + Self::Number(_) => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompileError { + position: usize, + message: String, +} + +impl CompileError { + fn new(position: usize, message: impl Into) -> Self { + Self { + position, + message: message.into(), + } + } +} + +impl fmt::Display for CompileError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "numeric expression compile error at byte {}: {}", + self.position, self.message + ) + } +} + +impl std::error::Error for CompileError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EvalError { + message: String, +} + +impl EvalError { + pub(crate) fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for EvalError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "numeric expression eval error: {}", self.message) + } +} + +impl std::error::Error for EvalError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnaryOp { + Negate, + Not, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BinaryOp { + Add, + Subtract, + Multiply, + Divide, + Remainder, + Equal, + NotEqual, + Less, + LessEqual, + Greater, + GreaterEqual, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Builtin { + Round, + Floor, + Ceil, + Abs, + Min, + Max, + Sqrt, + Pow, + Log, + Exp, + Clamp, + Between, + Nz, + SafeDiv, + Iff, +} + +#[derive(Debug, Clone, PartialEq)] +enum Instruction { + Push(Value), + LoadVariable(u16), + LoadLocal(u16), + StoreLocal(u16), + Unary(UnaryOp), + Binary(BinaryOp), + Call { builtin: Builtin, argc: u8 }, + JumpIfFalse(usize), + JumpIfTrue(usize), + Jump(usize), + Return, +} + +#[derive(Debug, Clone)] +pub(crate) struct Program { + instructions: Vec, + variables: Vec, + variable_types: Vec, + local_count: usize, + result_type: ValueType, +} + +impl Program { + pub(crate) fn variables(&self) -> &[String] { + &self.variables + } + + pub(crate) fn evaluate( + &self, + scratch: &mut Scratch, + mut resolve: F, + ) -> Result + where + F: FnMut(usize, &str, ValueType) -> Result, + { + scratch.prepare(self); + let mut pc = 0usize; + while let Some(instruction) = self.instructions.get(pc) { + match *instruction { + Instruction::Push(value) => scratch.stack.push(value), + Instruction::LoadVariable(index) => { + let index = usize::from(index); + let cached = scratch.variables[index]; + let value = match cached { + Some(value) => value, + None => { + let expected_type = self.variable_types[index]; + let value = resolve(index, &self.variables[index], expected_type)?; + if value.value_type() != expected_type { + return Err(EvalError::new(format!( + "variable {} expected {:?}, got {:?}", + self.variables[index], + expected_type, + value.value_type() + ))); + } + scratch.variables[index] = Some(value); + value + } + }; + scratch.stack.push(value); + } + Instruction::LoadLocal(index) => { + let index = usize::from(index); + let value = scratch.locals[index].ok_or_else(|| { + EvalError::new(format!("local slot {index} was not initialized")) + })?; + scratch.stack.push(value); + } + Instruction::StoreLocal(index) => { + let value = pop(&mut scratch.stack)?; + scratch.locals[usize::from(index)] = Some(value); + } + Instruction::Unary(operator) => { + let value = pop(&mut scratch.stack)?; + scratch.stack.push(eval_unary(operator, value)?); + } + Instruction::Binary(operator) => { + let rhs = pop(&mut scratch.stack)?; + let lhs = pop(&mut scratch.stack)?; + scratch.stack.push(eval_binary(operator, lhs, rhs)?); + } + Instruction::Call { builtin, argc } => { + let argc = usize::from(argc); + if scratch.stack.len() < argc { + return Err(EvalError::new("stack underflow during builtin call")); + } + let start = scratch.stack.len() - argc; + let value = eval_builtin(builtin, &scratch.stack[start..])?; + scratch.stack.truncate(start); + scratch.stack.push(value); + } + Instruction::JumpIfFalse(target) => { + let condition = pop_bool(&mut scratch.stack)?; + if !condition { + pc = target; + continue; + } + } + Instruction::JumpIfTrue(target) => { + let condition = pop_bool(&mut scratch.stack)?; + if condition { + pc = target; + continue; + } + } + Instruction::Jump(target) => { + pc = target; + continue; + } + Instruction::Return => { + let result = pop(&mut scratch.stack)?; + if result.value_type() != self.result_type { + return Err(EvalError::new(format!( + "program expected {:?}, got {:?}", + self.result_type, + result.value_type() + ))); + } + if !scratch.stack.is_empty() { + return Err(EvalError::new("program returned with a dirty stack")); + } + return Ok(result); + } + } + pc += 1; + } + Err(EvalError::new("program terminated without return")) + } +} + +#[derive(Debug, Default)] +pub(crate) struct Scratch { + stack: Vec, + variables: Vec>, + locals: Vec>, +} + +impl Scratch { + fn prepare(&mut self, program: &Program) { + self.stack.clear(); + self.stack.reserve( + program + .instructions + .len() + .saturating_sub(self.stack.capacity()), + ); + self.variables.clear(); + self.variables.resize(program.variables.len(), None); + self.locals.clear(); + self.locals.resize(program.local_count, None); + } +} + +fn pop(stack: &mut Vec) -> Result { + stack.pop().ok_or_else(|| EvalError::new("stack underflow")) +} + +fn pop_bool(stack: &mut Vec) -> Result { + pop(stack)? + .as_bool() + .ok_or_else(|| EvalError::new("boolean operand required")) +} + +fn number(value: Value) -> Result { + value + .as_number() + .ok_or_else(|| EvalError::new("numeric operand required")) +} + +fn eval_unary(operator: UnaryOp, value: Value) -> Result { + match operator { + UnaryOp::Negate => Ok(Value::Number(-number(value)?)), + UnaryOp::Not => { + Ok(Value::Boolean(!value.as_bool().ok_or_else(|| { + EvalError::new("boolean operand required for !") + })?)) + } + } +} + +fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result { + match operator { + BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)), + BinaryOp::Subtract => 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::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)?)), + } +} + +fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { + let numeric = |index: usize| -> Result { + args.get(index) + .copied() + .ok_or_else(|| EvalError::new("missing builtin argument")) + .and_then(number) + }; + Ok(match builtin { + Builtin::Round => Value::Number(numeric(0)?.round()), + Builtin::Floor => Value::Number(numeric(0)?.floor()), + Builtin::Ceil => Value::Number(numeric(0)?.ceil()), + Builtin::Abs => Value::Number(numeric(0)?.abs()), + Builtin::Min => Value::Number(numeric(0)?.min(numeric(1)?)), + Builtin::Max => Value::Number(numeric(0)?.max(numeric(1)?)), + Builtin::Sqrt => Value::Number(numeric(0)?.sqrt()), + Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)), + Builtin::Log => Value::Number(numeric(0)?.ln()), + Builtin::Exp => Value::Number(numeric(0)?.exp()), + Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)), + Builtin::Between => { + let value = numeric(0)?; + Value::Boolean(value >= numeric(1)? && value <= numeric(2)?) + } + Builtin::Nz => { + let value = numeric(0)?; + Value::Number(if value.is_finite() { + value + } else { + numeric(1)? + }) + } + Builtin::SafeDiv => { + let lhs = numeric(0)?; + let rhs = numeric(1)?; + let fallback = if args.len() == 3 { numeric(2)? } else { 0.0 }; + Value::Number(if rhs.abs() <= f64::EPSILON { + fallback + } else { + lhs / rhs + }) + } + Builtin::Iff => { + let condition = args + .first() + .and_then(|value| value.as_bool()) + .ok_or_else(|| EvalError::new("iff condition must be boolean"))?; + if condition { args[1] } else { args[2] } + } + }) +} + +#[derive(Debug, Clone, PartialEq)] +enum Expr { + Number(f64, usize), + Boolean(bool, usize), + Variable(String, usize), + Unary { + operator: UnaryOp, + operand: Box, + position: usize, + }, + Binary { + operator: ParsedBinaryOp, + lhs: Box, + rhs: Box, + position: usize, + }, + Call { + name: String, + args: Vec, + position: usize, + }, + If { + condition: Box, + when_true: Box, + when_false: Box, + position: usize, + }, +} + +impl Expr { + fn position(&self) -> usize { + match self { + Self::Number(_, position) + | Self::Boolean(_, position) + | Self::Variable(_, position) => *position, + Self::Unary { position, .. } + | Self::Binary { position, .. } + | Self::Call { position, .. } + | Self::If { position, .. } => *position, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ParsedBinaryOp { + Add, + Subtract, + Multiply, + Divide, + Remainder, + Equal, + NotEqual, + Less, + LessEqual, + Greater, + GreaterEqual, + And, + Or, +} + +#[derive(Debug, Clone, PartialEq)] +struct LetStatement { + name: String, + value: Expr, + position: usize, +} + +#[derive(Debug, Clone, PartialEq)] +struct ParsedProgram { + statements: Vec, + result: Expr, +} + +#[derive(Debug, Clone, PartialEq)] +struct Token { + kind: TokenKind, + position: usize, +} + +#[derive(Debug, Clone, PartialEq)] +enum TokenKind { + Number(f64), + Identifier(String), + True, + False, + Let, + Const, + If, + Else, + Plus, + Minus, + Star, + Slash, + Percent, + Bang, + AndAnd, + OrOr, + Equal, + EqualEqual, + BangEqual, + Less, + LessEqual, + Greater, + GreaterEqual, + LeftParen, + RightParen, + LeftBrace, + RightBrace, + Comma, + Semicolon, + Eof, +} + +struct Lexer<'a> { + source: &'a str, + cursor: usize, +} + +impl<'a> Lexer<'a> { + fn new(source: &'a str) -> Self { + Self { source, cursor: 0 } + } + + fn tokenize(mut self) -> Result, CompileError> { + let mut tokens = Vec::new(); + loop { + self.skip_space_and_comments()?; + let position = self.cursor; + let Some(ch) = self.peek() else { + tokens.push(Token { + kind: TokenKind::Eof, + position, + }); + return Ok(tokens); + }; + let kind = match ch { + '0'..='9' | '.' if self.number_starts_here() => self.number()?, + 'a'..='z' | 'A'..='Z' | '_' => self.identifier(), + '+' => { + self.advance(); + TokenKind::Plus + } + '-' => { + self.advance(); + TokenKind::Minus + } + '*' => { + self.advance(); + TokenKind::Star + } + '/' => { + self.advance(); + TokenKind::Slash + } + '%' => { + self.advance(); + TokenKind::Percent + } + '!' => { + self.advance(); + if self.consume('=') { + TokenKind::BangEqual + } else { + TokenKind::Bang + } + } + '&' => { + self.advance(); + if !self.consume('&') { + return Err(CompileError::new(position, "single & is not supported")); + } + TokenKind::AndAnd + } + '|' => { + self.advance(); + if !self.consume('|') { + return Err(CompileError::new(position, "single | is not supported")); + } + TokenKind::OrOr + } + '=' => { + self.advance(); + if self.consume('=') { + TokenKind::EqualEqual + } else { + TokenKind::Equal + } + } + '<' => { + self.advance(); + if self.consume('=') { + TokenKind::LessEqual + } else { + TokenKind::Less + } + } + '>' => { + self.advance(); + if self.consume('=') { + TokenKind::GreaterEqual + } else { + TokenKind::Greater + } + } + '(' => { + self.advance(); + TokenKind::LeftParen + } + ')' => { + self.advance(); + TokenKind::RightParen + } + '{' => { + self.advance(); + TokenKind::LeftBrace + } + '}' => { + self.advance(); + TokenKind::RightBrace + } + ',' => { + self.advance(); + TokenKind::Comma + } + ';' => { + self.advance(); + TokenKind::Semicolon + } + '\'' | '"' => { + return Err(CompileError::new( + position, + "string expressions are not supported by the numeric VM", + )); + } + '[' | ']' | ':' => { + return Err(CompileError::new( + position, + "maps, arrays, and indexing are not supported by the numeric VM", + )); + } + _ => { + return Err(CompileError::new( + position, + format!("unsupported character {ch:?}"), + )); + } + }; + tokens.push(Token { kind, position }); + } + } + + fn skip_space_and_comments(&mut self) -> Result<(), CompileError> { + loop { + while self.peek().is_some_and(char::is_whitespace) { + self.advance(); + } + if self.rest().starts_with("//") { + while self.peek().is_some_and(|ch| ch != '\n') { + self.advance(); + } + continue; + } + if self.rest().starts_with("/*") { + let start = self.cursor; + self.cursor += 2; + while !self.rest().starts_with("*/") { + if self.peek().is_none() { + return Err(CompileError::new(start, "unterminated block comment")); + } + self.advance(); + } + self.cursor += 2; + continue; + } + return Ok(()); + } + } + + fn number_starts_here(&self) -> bool { + self.peek().is_some_and(|ch| ch.is_ascii_digit()) + || (self.peek() == Some('.') + && self + .rest() + .chars() + .nth(1) + .is_some_and(|ch| ch.is_ascii_digit())) + } + + fn number(&mut self) -> Result { + let start = self.cursor; + let mut seen_dot = false; + let mut seen_exponent = false; + while let Some(ch) = self.peek() { + match ch { + '0'..='9' | '_' => { + self.advance(); + } + '.' if !seen_dot && !seen_exponent => { + seen_dot = true; + self.advance(); + } + 'e' | 'E' if !seen_exponent => { + seen_exponent = true; + self.advance(); + if self.peek().is_some_and(|next| matches!(next, '+' | '-')) { + self.advance(); + } + } + _ => break, + } + } + let raw = self.source[start..self.cursor].replace('_', ""); + let value = raw + .parse::() + .map_err(|_| CompileError::new(start, format!("invalid numeric literal {raw:?}")))?; + Ok(TokenKind::Number(value)) + } + + fn identifier(&mut self) -> TokenKind { + let start = self.cursor; + self.advance(); + while self + .peek() + .is_some_and(|ch| ch == '_' || ch.is_ascii_alphanumeric()) + { + self.advance(); + } + match &self.source[start..self.cursor] { + "true" => TokenKind::True, + "false" => TokenKind::False, + "let" => TokenKind::Let, + "const" => TokenKind::Const, + "if" => TokenKind::If, + "else" => TokenKind::Else, + identifier => TokenKind::Identifier(identifier.to_string()), + } + } + + fn rest(&self) -> &'a str { + &self.source[self.cursor..] + } + + fn peek(&self) -> Option { + self.rest().chars().next() + } + + fn advance(&mut self) -> Option { + let ch = self.peek()?; + self.cursor += ch.len_utf8(); + Some(ch) + } + + fn consume(&mut self, expected: char) -> bool { + if self.peek() == Some(expected) { + self.advance(); + true + } else { + false + } + } +} + +struct Parser { + tokens: Vec, + cursor: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, cursor: 0 } + } + + fn parse(mut self) -> Result { + let mut statements = Vec::new(); + while matches!(self.current().kind, TokenKind::Let | TokenKind::Const) { + let position = self.advance().position; + let name = match self.advance().kind { + TokenKind::Identifier(name) => name, + _ => return Err(self.error("expected identifier after let/const")), + }; + self.expect(TokenKind::Equal, "expected = in local declaration")?; + let value = self.expression(0)?; + self.expect(TokenKind::Semicolon, "expected ; after local declaration")?; + statements.push(LetStatement { + name, + value, + position, + }); + } + if matches!(self.current().kind, TokenKind::Eof) { + return Err(self.error("numeric VM program requires a result expression")); + } + let result = self.expression(0)?; + while matches!(self.current().kind, TokenKind::Semicolon) { + self.advance(); + } + if !matches!(self.current().kind, TokenKind::Eof) { + return Err(self.error("unexpected token after result expression")); + } + Ok(ParsedProgram { statements, result }) + } + + fn expression(&mut self, min_precedence: u8) -> Result { + let mut lhs = self.prefix()?; + loop { + let Some((operator, precedence)) = self.binary_operator() else { + break; + }; + if precedence < min_precedence { + break; + } + let position = self.advance().position; + let rhs = self.expression(precedence + 1)?; + lhs = Expr::Binary { + operator, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + position, + }; + } + Ok(lhs) + } + + fn prefix(&mut self) -> Result { + let token = self.advance(); + match token.kind { + TokenKind::Number(value) => Ok(Expr::Number(value, token.position)), + TokenKind::True => Ok(Expr::Boolean(true, token.position)), + TokenKind::False => Ok(Expr::Boolean(false, token.position)), + TokenKind::Identifier(name) => { + if matches!(self.current().kind, TokenKind::LeftParen) { + self.advance(); + let mut args = Vec::new(); + if !matches!(self.current().kind, TokenKind::RightParen) { + loop { + args.push(self.expression(0)?); + if matches!(self.current().kind, TokenKind::Comma) { + self.advance(); + } else { + break; + } + } + } + self.expect(TokenKind::RightParen, "expected ) after function arguments")?; + Ok(Expr::Call { + name, + args, + position: token.position, + }) + } else { + Ok(Expr::Variable(name, token.position)) + } + } + TokenKind::Minus => Ok(Expr::Unary { + operator: UnaryOp::Negate, + operand: Box::new(self.expression(7)?), + position: token.position, + }), + TokenKind::Plus => self.expression(7), + TokenKind::Bang => Ok(Expr::Unary { + operator: UnaryOp::Not, + operand: Box::new(self.expression(7)?), + position: token.position, + }), + TokenKind::LeftParen => { + let expression = self.expression(0)?; + self.expect(TokenKind::RightParen, "expected )")?; + Ok(expression) + } + TokenKind::If => self.if_expression(token.position), + _ => Err(CompileError::new( + token.position, + "expected numeric or boolean expression", + )), + } + } + + fn if_expression(&mut self, position: usize) -> Result { + let condition = self.expression(0)?; + self.expect(TokenKind::LeftBrace, "expected { after if condition")?; + let when_true = self.expression(0)?; + while matches!(self.current().kind, TokenKind::Semicolon) { + self.advance(); + } + self.expect(TokenKind::RightBrace, "expected } after true branch")?; + self.expect(TokenKind::Else, "expected else after if branch")?; + self.expect(TokenKind::LeftBrace, "expected { after else")?; + let when_false = self.expression(0)?; + while matches!(self.current().kind, TokenKind::Semicolon) { + self.advance(); + } + self.expect(TokenKind::RightBrace, "expected } after false branch")?; + Ok(Expr::If { + condition: Box::new(condition), + when_true: Box::new(when_true), + when_false: Box::new(when_false), + position, + }) + } + + fn binary_operator(&self) -> Option<(ParsedBinaryOp, u8)> { + Some(match self.current().kind { + TokenKind::OrOr => (ParsedBinaryOp::Or, 1), + TokenKind::AndAnd => (ParsedBinaryOp::And, 2), + TokenKind::EqualEqual => (ParsedBinaryOp::Equal, 3), + TokenKind::BangEqual => (ParsedBinaryOp::NotEqual, 3), + TokenKind::Less => (ParsedBinaryOp::Less, 4), + TokenKind::LessEqual => (ParsedBinaryOp::LessEqual, 4), + TokenKind::Greater => (ParsedBinaryOp::Greater, 4), + TokenKind::GreaterEqual => (ParsedBinaryOp::GreaterEqual, 4), + TokenKind::Plus => (ParsedBinaryOp::Add, 5), + TokenKind::Minus => (ParsedBinaryOp::Subtract, 5), + TokenKind::Star => (ParsedBinaryOp::Multiply, 6), + TokenKind::Slash => (ParsedBinaryOp::Divide, 6), + TokenKind::Percent => (ParsedBinaryOp::Remainder, 6), + _ => return None, + }) + } + + fn expect(&mut self, expected: TokenKind, message: &str) -> Result<(), CompileError> { + if std::mem::discriminant(&self.current().kind) == std::mem::discriminant(&expected) { + self.advance(); + Ok(()) + } else { + Err(self.error(message)) + } + } + + fn current(&self) -> &Token { + &self.tokens[self.cursor] + } + + fn advance(&mut self) -> Token { + let token = self.tokens[self.cursor].clone(); + if !matches!(token.kind, TokenKind::Eof) { + self.cursor += 1; + } + token + } + + fn error(&self, message: impl Into) -> CompileError { + CompileError::new(self.current().position, message) + } +} + +struct Compiler<'a, F> +where + F: Fn(&str) -> Option, +{ + resolve_type: &'a F, + instructions: Vec, + variables: Vec, + variable_types: Vec, + variable_indices: BTreeMap, + locals: BTreeMap, +} + +impl<'a, F> Compiler<'a, F> +where + F: Fn(&str) -> Option, +{ + fn new(resolve_type: &'a F) -> Self { + Self { + resolve_type, + instructions: Vec::new(), + variables: Vec::new(), + variable_types: Vec::new(), + variable_indices: BTreeMap::new(), + locals: BTreeMap::new(), + } + } + + fn compile(mut self, parsed: ParsedProgram) -> Result { + for statement in parsed.statements { + if self.locals.contains_key(&statement.name) { + return Err(CompileError::new( + statement.position, + format!("duplicate local variable {}", statement.name), + )); + } + let value_type = self.expression(&statement.value)?; + let slot = u16::try_from(self.locals.len()) + .map_err(|_| CompileError::new(statement.position, "too many local variables"))?; + self.instructions.push(Instruction::StoreLocal(slot)); + self.locals.insert(statement.name, (slot, value_type)); + } + let result_type = self.expression(&parsed.result)?; + self.instructions.push(Instruction::Return); + Ok(Program { + instructions: self.instructions, + variables: self.variables, + variable_types: self.variable_types, + local_count: self.locals.len(), + result_type, + }) + } + + fn expression(&mut self, expression: &Expr) -> Result { + match expression { + Expr::Number(value, _) => { + self.instructions + .push(Instruction::Push(Value::Number(*value))); + Ok(ValueType::Number) + } + Expr::Boolean(value, _) => { + self.instructions + .push(Instruction::Push(Value::Boolean(*value))); + Ok(ValueType::Boolean) + } + Expr::Variable(name, position) => { + if let Some((slot, value_type)) = self.locals.get(name).copied() { + self.instructions.push(Instruction::LoadLocal(slot)); + return Ok(value_type); + } + let value_type = (self.resolve_type)(name).ok_or_else(|| { + CompileError::new( + *position, + format!("identifier {name} is not numeric or boolean"), + ) + })?; + let index = if let Some(index) = self.variable_indices.get(name).copied() { + index + } else { + let index = u16::try_from(self.variables.len()) + .map_err(|_| CompileError::new(*position, "too many external variables"))?; + self.variables.push(name.clone()); + self.variable_types.push(value_type); + self.variable_indices.insert(name.clone(), index); + index + }; + self.instructions.push(Instruction::LoadVariable(index)); + Ok(value_type) + } + Expr::Unary { + operator, + operand, + position, + } => { + let operand_type = self.expression(operand)?; + let required = match operator { + UnaryOp::Negate => ValueType::Number, + UnaryOp::Not => ValueType::Boolean, + }; + require_type(operand_type, required, *position)?; + self.instructions.push(Instruction::Unary(*operator)); + Ok(required) + } + Expr::Binary { + operator, + lhs, + rhs, + position, + } => self.binary(*operator, lhs, rhs, *position), + Expr::Call { + name, + args, + position, + } => self.call(name, args, *position), + Expr::If { + condition, + when_true, + when_false, + position, + } => { + let condition_type = self.expression(condition)?; + require_type(condition_type, ValueType::Boolean, *position)?; + let false_jump = self.instructions.len(); + self.instructions.push(Instruction::JumpIfFalse(usize::MAX)); + let true_type = self.expression(when_true)?; + let end_jump = self.instructions.len(); + self.instructions.push(Instruction::Jump(usize::MAX)); + let false_target = self.instructions.len(); + patch_jump(&mut self.instructions, false_jump, false_target)?; + let false_type = self.expression(when_false)?; + require_type(false_type, true_type, when_false.position())?; + let end_target = self.instructions.len(); + patch_jump(&mut self.instructions, end_jump, end_target)?; + Ok(true_type) + } + } + } + + fn binary( + &mut self, + operator: ParsedBinaryOp, + lhs: &Expr, + rhs: &Expr, + position: usize, + ) -> Result { + if matches!(operator, ParsedBinaryOp::And | ParsedBinaryOp::Or) { + let lhs_type = self.expression(lhs)?; + require_type(lhs_type, ValueType::Boolean, position)?; + let branch = self.instructions.len(); + self.instructions.push(match operator { + ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX), + ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX), + _ => unreachable!(), + }); + let rhs_type = self.expression(rhs)?; + require_type(rhs_type, ValueType::Boolean, rhs.position())?; + let end_jump = self.instructions.len(); + self.instructions.push(Instruction::Jump(usize::MAX)); + let short_target = self.instructions.len(); + self.instructions + .push(Instruction::Push(Value::Boolean(matches!( + operator, + ParsedBinaryOp::Or + )))); + let end_target = self.instructions.len(); + patch_jump(&mut self.instructions, branch, short_target)?; + patch_jump(&mut self.instructions, end_jump, end_target)?; + return Ok(ValueType::Boolean); + } + + let lhs_type = self.expression(lhs)?; + let rhs_type = self.expression(rhs)?; + let (required, result, instruction) = match operator { + ParsedBinaryOp::Add => (ValueType::Number, ValueType::Number, BinaryOp::Add), + ParsedBinaryOp::Subtract => (ValueType::Number, ValueType::Number, BinaryOp::Subtract), + ParsedBinaryOp::Multiply => (ValueType::Number, ValueType::Number, BinaryOp::Multiply), + ParsedBinaryOp::Divide => (ValueType::Number, ValueType::Number, BinaryOp::Divide), + ParsedBinaryOp::Remainder => { + (ValueType::Number, ValueType::Number, BinaryOp::Remainder) + } + ParsedBinaryOp::Less => (ValueType::Number, ValueType::Boolean, BinaryOp::Less), + ParsedBinaryOp::LessEqual => { + (ValueType::Number, ValueType::Boolean, BinaryOp::LessEqual) + } + ParsedBinaryOp::Greater => (ValueType::Number, ValueType::Boolean, BinaryOp::Greater), + ParsedBinaryOp::GreaterEqual => ( + ValueType::Number, + ValueType::Boolean, + BinaryOp::GreaterEqual, + ), + ParsedBinaryOp::Equal => { + require_type(rhs_type, lhs_type, position)?; + self.instructions.push(Instruction::Binary(BinaryOp::Equal)); + return Ok(ValueType::Boolean); + } + ParsedBinaryOp::NotEqual => { + require_type(rhs_type, lhs_type, position)?; + self.instructions + .push(Instruction::Binary(BinaryOp::NotEqual)); + return Ok(ValueType::Boolean); + } + ParsedBinaryOp::And | ParsedBinaryOp::Or => unreachable!(), + }; + require_type(lhs_type, required, lhs.position())?; + require_type(rhs_type, required, rhs.position())?; + self.instructions.push(Instruction::Binary(instruction)); + Ok(result) + } + + fn call( + &mut self, + name: &str, + args: &[Expr], + position: usize, + ) -> Result { + let (builtin, expected_args, result_type) = match name { + "round" => (Builtin::Round, &[ValueType::Number][..], ValueType::Number), + "floor" => (Builtin::Floor, &[ValueType::Number][..], ValueType::Number), + "ceil" => (Builtin::Ceil, &[ValueType::Number][..], ValueType::Number), + "abs" => (Builtin::Abs, &[ValueType::Number][..], ValueType::Number), + "min" => ( + Builtin::Min, + &[ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "max" => ( + Builtin::Max, + &[ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "sqrt" => (Builtin::Sqrt, &[ValueType::Number][..], ValueType::Number), + "pow" => ( + Builtin::Pow, + &[ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "log" => (Builtin::Log, &[ValueType::Number][..], ValueType::Number), + "exp" => (Builtin::Exp, &[ValueType::Number][..], ValueType::Number), + "clamp" => ( + Builtin::Clamp, + &[ValueType::Number, ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "between" => ( + Builtin::Between, + &[ValueType::Number, ValueType::Number, ValueType::Number][..], + ValueType::Boolean, + ), + "nz" => ( + Builtin::Nz, + &[ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "safe_div" if args.len() == 2 => ( + Builtin::SafeDiv, + &[ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "safe_div" if args.len() == 3 => ( + Builtin::SafeDiv, + &[ValueType::Number, ValueType::Number, ValueType::Number][..], + ValueType::Number, + ), + "iff" if args.len() == 3 => { + let condition_type = self.expression(&args[0])?; + require_type(condition_type, ValueType::Boolean, args[0].position())?; + let true_type = self.expression(&args[1])?; + let false_type = self.expression(&args[2])?; + require_type(false_type, true_type, args[2].position())?; + self.instructions.push(Instruction::Call { + builtin: Builtin::Iff, + argc: 3, + }); + return Ok(true_type); + } + _ => { + return Err(CompileError::new( + position, + format!("function {name} is not supported by the numeric VM"), + )); + } + }; + if args.len() != expected_args.len() { + return Err(CompileError::new( + position, + format!( + "function {name} expects {} arguments, got {}", + expected_args.len(), + args.len() + ), + )); + } + for (argument, expected_type) in args.iter().zip(expected_args) { + let actual_type = self.expression(argument)?; + require_type(actual_type, *expected_type, argument.position())?; + } + self.instructions.push(Instruction::Call { + builtin, + argc: u8::try_from(args.len()) + .map_err(|_| CompileError::new(position, "too many function arguments"))?, + }); + Ok(result_type) + } +} + +fn patch_jump( + instructions: &mut [Instruction], + index: usize, + target: usize, +) -> Result<(), CompileError> { + let Some(instruction) = instructions.get_mut(index) else { + return Err(CompileError::new(0, "invalid jump patch index")); + }; + match instruction { + Instruction::JumpIfFalse(value) + | Instruction::JumpIfTrue(value) + | Instruction::Jump(value) => { + *value = target; + Ok(()) + } + _ => Err(CompileError::new(0, "instruction is not a jump")), + } +} + +fn require_type( + actual: ValueType, + expected: ValueType, + position: usize, +) -> Result<(), CompileError> { + if actual == expected { + Ok(()) + } else { + Err(CompileError::new( + position, + format!("expected {expected:?}, got {actual:?}"), + )) + } +} + +pub(crate) fn compile(source: &str, resolve_type: F) -> Result +where + F: Fn(&str) -> Option, +{ + let tokens = Lexer::new(source).tokenize()?; + let parsed = Parser::new(tokens).parse()?; + Compiler::new(&resolve_type).compile(parsed) +} + +#[cfg(test)] +mod tests { + use std::hint::black_box; + use std::time::Instant; + + use rhai::{Engine, Scope}; + + use super::{EvalError, Scratch, Value, ValueType, compile}; + + fn evaluate(source: &str, values: &[(&str, Value)]) -> Value { + let program = compile(source, |name| { + values + .iter() + .find_map(|(key, value)| (*key == name).then_some(value.value_type())) + }) + .expect("compile"); + let mut scratch = Scratch::default(); + program + .evaluate(&mut scratch, |_index, name, _expected| { + values + .iter() + .find_map(|(key, value)| (*key == name).then_some(*value)) + .ok_or_else(|| EvalError::new(format!("missing {name}"))) + }) + .expect("evaluate") + } + + #[test] + fn evaluates_numeric_boolean_and_if_expressions() { + let source = r#" + let clamped = clamp(close, 2.0, 20.0); + let ratio = safe_div(clamped, previous, 0.0); + if ratio > 1.0 && !paused { min(ratio, 2.0) } else { 0.5 } + "#; + assert_eq!( + evaluate( + source, + &[ + ("close", Value::Number(12.0)), + ("previous", Value::Number(10.0)), + ("paused", Value::Boolean(false)), + ], + ), + Value::Number(1.2) + ); + } + + #[test] + fn short_circuit_does_not_resolve_unused_variable() { + let program = compile("false && missing", |name| { + (name == "missing").then_some(ValueType::Boolean) + }) + .expect("compile"); + let mut scratch = Scratch::default(); + let result = program + .evaluate(&mut scratch, |_index, name, _expected| { + Err(EvalError::new(format!("unexpected resolution of {name}"))) + }) + .expect("short circuit"); + assert_eq!(result, Value::Boolean(false)); + } + + #[test] + fn supports_numeric_builtins_and_local_reuse() { + let source = r#" + const scale = pow(2.0, 3.0); + let adjusted = nz(value, 4.0) * scale; + between(adjusted, 31.0, 33.0) + "#; + assert_eq!( + evaluate(source, &[("value", Value::Number(4.0))]), + Value::Boolean(true) + ); + } + + #[test] + fn rejects_dynamic_language_features() { + assert!(compile("factors[\"close\"]", |_| Some(ValueType::Number)).is_err()); + assert!(compile("contains(symbol, \"SZ\")", |_| None).is_err()); + assert!(compile("custom_score(close)", |_| Some(ValueType::Number)).is_err()); + } + + #[test] + fn scratch_reuses_allocations_across_program_runs() { + let program = compile("left + right * 2.0", |_| Some(ValueType::Number)).expect("compile"); + let mut scratch = Scratch::default(); + for _ in 0..10 { + let value = program + .evaluate(&mut scratch, |_index, name, _expected| { + Ok(Value::Number(if name == "left" { 1.0 } else { 2.0 })) + }) + .expect("evaluate"); + assert_eq!(value, Value::Number(5.0)); + } + } + + #[test] + fn matches_rhai_for_representative_numeric_boolean_corpus() { + let source = r#" + let clamped = clamp(signal_close, 2000.0, 3000.0); + let ratio = safe_div(clamped - 2000.0, 1000.0, 0.0); + let trend = ma10 > ma30 ? 1.0 : 0.3; + let exposure = drawdown >= 0.08 ? min(trend, 0.2) : trend; + ready && !paused ? exposure * (1.0 + ratio) : 0.0 + "#; + let normalized = source + .replace( + "ma10 > ma30 ? 1.0 : 0.3", + "if ma10 > ma30 { 1.0 } else { 0.3 }", + ) + .replace( + "drawdown >= 0.08 ? min(trend, 0.2) : trend", + "if drawdown >= 0.08 { min(trend, 0.2) } else { trend }", + ) + .replace( + "ready && !paused ? exposure * (1.0 + ratio) : 0.0", + "if ready && !paused { exposure * (1.0 + ratio) } else { 0.0 }", + ); + let program = compile(&normalized, |name| match name { + "ready" | "paused" => Some(ValueType::Boolean), + _ => Some(ValueType::Number), + }) + .expect("compile VM"); + let mut engine = Engine::new(); + engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs)); + engine.register_fn("clamp", |value: f64, low: f64, high: f64| { + value.clamp(low, high) + }); + engine.register_fn("safe_div", |lhs: f64, rhs: f64, fallback: f64| { + if rhs.abs() <= f64::EPSILON { + fallback + } else { + lhs / rhs + } + }); + let ast = engine.compile(&normalized).expect("compile Rhai"); + let mut scratch = Scratch::default(); + for index in 0..256 { + let signal_close = 1800.0 + index as f64 * 7.0; + let ma10 = 10.0 + (index % 9) as f64; + let ma30 = 12.0 + (index % 7) as f64; + let drawdown = (index % 13) as f64 / 100.0; + let ready = index % 5 != 0; + let paused = index % 17 == 0; + let values = [ + ("signal_close", Value::Number(signal_close)), + ("ma10", Value::Number(ma10)), + ("ma30", Value::Number(ma30)), + ("drawdown", Value::Number(drawdown)), + ("ready", Value::Boolean(ready)), + ("paused", Value::Boolean(paused)), + ]; + let vm_value = program + .evaluate(&mut scratch, |_slot, name, _expected| { + values + .iter() + .find_map(|(key, value)| (*key == name).then_some(*value)) + .ok_or_else(|| EvalError::new(format!("missing {name}"))) + }) + .expect("evaluate VM") + .as_number() + .expect("numeric VM result"); + let mut scope = Scope::new(); + scope.push("signal_close", signal_close); + scope.push("ma10", ma10); + scope.push("ma30", ma30); + scope.push("drawdown", drawdown); + scope.push("ready", ready); + scope.push("paused", paused); + let rhai_value = engine + .eval_ast_with_scope::(&mut scope, &ast) + .expect("evaluate Rhai"); + assert_eq!(vm_value.to_bits(), rhai_value.to_bits(), "case {index}"); + } + } + + #[test] + #[ignore = "manual release-mode performance evidence"] + fn benchmark_numeric_vm_against_rhai() { + let source = "!is_st && !is_star_st && close > ma5 && ma5 > ma10 && ma10 > ma30 && volume_ma5 < volume_ma100"; + let program = compile(source, |name| match name { + "is_st" | "is_star_st" => Some(ValueType::Boolean), + _ => Some(ValueType::Number), + }) + .expect("compile VM"); + let values = [ + ("is_st", Value::Boolean(false)), + ("is_star_st", Value::Boolean(false)), + ("close", Value::Number(12.0)), + ("ma5", Value::Number(11.5)), + ("ma10", Value::Number(11.0)), + ("ma30", Value::Number(10.0)), + ("volume_ma5", Value::Number(8_000.0)), + ("volume_ma100", Value::Number(10_000.0)), + ]; + let iterations = 2_000_000usize; + let mut scratch = Scratch::default(); + let vm_started = Instant::now(); + let mut vm_true = 0usize; + for _ in 0..iterations { + let result = program + .evaluate(&mut scratch, |_slot, name, _expected| { + values + .iter() + .find_map(|(key, value)| (*key == name).then_some(*value)) + .ok_or_else(|| EvalError::new(format!("missing {name}"))) + }) + .expect("VM evaluation") + .as_bool() + .expect("boolean VM result"); + vm_true += usize::from(black_box(result)); + } + let vm_elapsed = vm_started.elapsed(); + + let engine = Engine::new(); + let ast = engine.compile(source).expect("compile Rhai"); + let mut scope = Scope::new(); + scope.push("is_st", false); + scope.push("is_star_st", false); + scope.push("close", 12.0_f64); + scope.push("ma5", 11.5_f64); + scope.push("ma10", 11.0_f64); + scope.push("ma30", 10.0_f64); + scope.push("volume_ma5", 8_000.0_f64); + scope.push("volume_ma100", 10_000.0_f64); + let rhai_started = Instant::now(); + let mut rhai_true = 0usize; + for _ in 0..iterations { + let result = engine + .eval_ast_with_scope::(&mut scope, &ast) + .expect("Rhai evaluation"); + rhai_true += usize::from(black_box(result)); + } + let rhai_elapsed = rhai_started.elapsed(); + assert_eq!(vm_true, rhai_true); + println!( + "numeric_vm_benchmark iterations={} vm_ns_per_eval={:.3} rhai_ns_per_eval={:.3} speedup={:.3}", + iterations, + vm_elapsed.as_nanos() as f64 / iterations as f64, + rhai_elapsed.as_nanos() as f64 / iterations as f64, + rhai_elapsed.as_secs_f64() / vm_elapsed.as_secs_f64(), + ); + } +} diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 18ce2c4..1085e49 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -14,6 +14,10 @@ use crate::data::{ }; use crate::engine::BacktestError; use crate::events::OrderSide; +use crate::numeric_expr_vm::{ + self, EvalError as NumericVmEvalError, Program as NumericVmProgram, + Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType, +}; use crate::portfolio::PortfolioState; use crate::risk_control::{ChinaAShareRiskControl, FidcRiskControlConfig, FidcRiskDecisionAudit}; use crate::scheduler::{ @@ -812,6 +816,12 @@ struct ExpressionEvalPlan { prelude_source: String, prelude_identifiers: BTreeSet, prelude_runtime_template: Option>, + numeric_vm: Option, +} + +struct NumericExpressionPlan { + program: NumericVmProgram, + helper_bindings: Vec>, } struct PreludeDependencyPlan { @@ -876,10 +886,12 @@ impl PreludeDependencyPlan { } } +#[derive(Clone)] struct RuntimeExpressionTemplate { segments: Vec, } +#[derive(Clone)] enum RuntimeExpressionSegment { Literal(String), Helper { @@ -889,6 +901,13 @@ enum RuntimeExpressionSegment { }, } +#[derive(Clone)] +struct RuntimeHelperBinding { + name: String, + args: Vec, + scope_name: String, +} + pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, @@ -912,6 +931,9 @@ pub struct PlatformExprStrategy { cache_hits: RefCell, cache_misses: RefCell, expression_plan_cache: RefCell>>, + numeric_vm_scratch: RefCell, + numeric_vm_hits: RefCell, + numeric_vm_fallbacks: RefCell, prelude_dependency_plan: PreludeDependencyPlan, prelude_identifier_candidates: BTreeSet, prelude_declared_identifiers: BTreeSet, @@ -1223,6 +1245,9 @@ impl PlatformExprStrategy { cache_hits: RefCell::new(0), cache_misses: RefCell::new(0), expression_plan_cache: RefCell::new(AHashMap::new()), + numeric_vm_scratch: RefCell::new(NumericVmScratch::default()), + numeric_vm_hits: RefCell::new(0), + numeric_vm_fallbacks: RefCell::new(0), prelude_dependency_plan, prelude_identifier_candidates, prelude_declared_identifiers, @@ -1252,6 +1277,14 @@ impl PlatformExprStrategy { self.compiled_cache.borrow().len() } + pub fn numeric_vm_hits(&self) -> u64 { + *self.numeric_vm_hits.borrow() + } + + pub fn numeric_vm_fallbacks(&self) -> u64 { + *self.numeric_vm_fallbacks.borrow() + } + /// 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. @@ -4658,6 +4691,353 @@ impl PlatformExprStrategy { scope.into_inner() } + fn eval_numeric_vm( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + ) -> Result, BacktestError> { + let expression_plan = self.expression_eval_plan(expr); + let Some(vm_plan) = expression_plan.numeric_vm.as_ref() else { + *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, + ) + .map_err(|error| NumericVmEvalError::new(error.to_string())); + } + self.numeric_vm_identifier_value(ctx, day, stock, position, identifier) + .ok_or_else(|| { + NumericVmEvalError::new(format!( + "missing numeric/boolean identifier {identifier}" + )) + }) + }) + .map_err(|error| { + BacktestError::Execution(format!( + "platform numeric VM failed for expression {expr:?}: {error}" + )) + })?; + *self.numeric_vm_hits.borrow_mut() += 1; + Ok(Some(value)) + } + + fn numeric_vm_runtime_helper_value( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + 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) + .map(NumericVmValue::Number) + .or_else(|| { + scope + .get_value::(&binding.scope_name) + .map(|value| NumericVmValue::Number(value as f64)) + }) + .ok_or_else(|| { + BacktestError::Execution(format!( + "runtime helper {} did not bind a numeric value", + 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:?}", + 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 + ))), + }, + } + } + + fn numeric_vm_identifier_value( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, + identifier: &str, + ) -> Option { + let number = |value: f64| Some(NumericVmValue::Number(value)); + let integer = |value: i64| Some(NumericVmValue::Number(value as f64)); + let boolean = |value: bool| Some(NumericVmValue::Boolean(value)); + match identifier { + "signal_open" => number(day.signal_open), + "signal_close" => number(day.signal_close), + "benchmark_open" => number(day.benchmark_open), + "benchmark_close" => number(day.benchmark_close), + "benchmark_signal_close" => number(day.benchmark_signal_close), + "signal_ma5" => number(day.signal_ma5), + "signal_ma10" => number(day.signal_ma10), + "signal_ma20" => number(day.signal_ma20), + "signal_ma30" => number(day.signal_ma30), + "signal_ma_short" => number(day.signal_ma_short), + "signal_ma_long" => number(day.signal_ma_long), + "benchmark_ma5" => number(day.benchmark_ma5), + "benchmark_ma10" => number(day.benchmark_ma10), + "benchmark_ma20" => number(day.benchmark_ma20), + "benchmark_ma30" => number(day.benchmark_ma30), + "benchmark_ma_short" => number(day.benchmark_ma_short), + "benchmark_ma_long" => number(day.benchmark_ma_long), + "cash" => number(day.cash), + "available_cash" => number(day.available_cash), + "frozen_cash" => number(day.frozen_cash), + "market_value" => number(day.market_value), + "total_equity" => number(day.total_equity), + "total_value" => number(day.total_value), + "portfolio_value" => number(day.portfolio_value), + "starting_cash" => number(day.starting_cash), + "unit_net_value" => number(day.unit_net_value), + "static_unit_net_value" => number(day.static_unit_net_value), + "daily_pnl" => number(day.daily_pnl), + "daily_returns" => number(day.daily_returns), + "total_returns" => number(day.total_returns), + "transaction_cost" => { + number(position.map_or(day.transaction_cost, |value| value.transaction_cost)) + } + "trading_pnl" => number(position.map_or(day.trading_pnl, |value| value.trading_pnl)), + "position_pnl" => number(position.map_or(day.position_pnl, |value| value.position_pnl)), + "cash_liabilities" => number(day.cash_liabilities), + "management_fee_rate" => number(day.management_fee_rate), + "management_fees" => number(day.management_fees), + "current_exposure" => number(day.current_exposure), + "position_count" => integer(day.position_count), + "max_positions" => integer(day.max_positions), + "refresh_rate" => integer(day.refresh_rate), + "year" => integer(day.year), + "month" => integer(day.month), + "quarter" => integer(day.quarter), + "day_of_month" => integer(day.day_of_month), + "day_of_year" => integer(day.day_of_year), + "week_of_year" => integer(day.week_of_year), + "weekday" => integer(day.weekday), + "is_month_start" => boolean(day.is_month_start), + "is_month_end" => boolean(day.is_month_end), + "has_open_orders" => boolean(ctx.has_open_orders()), + "open_order_count" => integer(ctx.open_order_count() as i64), + "open_buy_order_count" => integer(ctx.open_buy_order_count() as i64), + "open_sell_order_count" => integer(ctx.open_sell_order_count() as i64), + "open_buy_qty" => integer(ctx.open_buy_quantity() as i64), + "open_sell_qty" => integer(ctx.open_sell_quantity() as i64), + "latest_open_order_id" => integer(ctx.latest_open_order_id() as i64), + "latest_open_order_unfilled_qty" => { + integer(ctx.latest_open_order_unfilled_quantity() as i64) + } + "has_dynamic_universe" => boolean(ctx.has_dynamic_universe()), + "dynamic_universe_count" => integer(ctx.dynamic_universe_count() as i64), + "has_subscriptions" => boolean(ctx.has_subscriptions()), + "subscription_count" => integer(ctx.subscription_count() as i64), + "subscription_guard_required" => boolean(self.config.subscription_guard_required), + "has_process_events" => boolean(ctx.has_process_events()), + "process_event_count" => integer(ctx.process_event_count() as i64), + "current_process_order_id" => integer(ctx.current_process_event_order_id() as i64), + "latest_process_order_id" => integer(ctx.latest_process_event_order_id() as i64), + _ => { + if let Some(stock) = stock { + let at_upper_limit = Self::price_is_at_or_above_upper_limit( + stock.last, + stock.upper_limit, + stock.price_tick, + ); + let at_lower_limit = Self::price_is_at_or_below_lower_limit( + stock.last, + stock.lower_limit, + stock.price_tick, + ); + let stock_value = match identifier { + "market_cap" => number(stock.market_cap), + "market_cap_bn" => number(stock.market_cap_bn), + "free_float_cap" | "free_float_market_cap" => number(stock.free_float_cap), + "free_float_cap_bn" => number(stock.free_float_cap_bn), + "free_float_cap_or_market_cap" => number(if stock.free_float_cap > 0.0 { + stock.free_float_cap + } else { + stock.market_cap + }), + "pe_ttm" => number(stock.pe_ttm), + "volume" => number(stock.volume), + "minute_volume" | "intraday_volume" => integer(stock.minute_volume), + "bid1_volume" => integer(stock.bid1_volume), + "ask1_volume" => integer(stock.ask1_volume), + "turnover" | "turnover_ratio" => number(stock.turnover_ratio), + "effective_turnover_ratio" => number(stock.effective_turnover_ratio), + "open" => number(stock.open), + "high" => number(stock.high), + "low" => number(stock.low), + "close" => number(stock.close), + "last" | "last_price" => number(stock.last), + "prev_close" => number(stock.prev_close), + "amount" => number(stock.amount), + "upper_limit" => number(stock.upper_limit), + "lower_limit" => number(stock.lower_limit), + "price_tick" => number(stock.price_tick), + "round_lot" => integer(stock.round_lot), + "minimum_order_quantity" => integer(stock.minimum_order_quantity), + "order_step_size" => integer(stock.order_step_size), + "paused" => boolean(stock.paused), + "is_st" => boolean(stock.is_st), + "is_star_st" => boolean(stock.is_star_st), + "is_kcb" => boolean(stock.is_kcb), + "is_bjse" => boolean(stock.is_bjse), + "is_one_yuan" => boolean(stock.is_one_yuan), + "is_new_listing" => boolean(stock.is_new_listing), + "allow_buy" => boolean(stock.allow_buy), + "allow_sell" => boolean(stock.allow_sell), + "touched_upper_limit" | "hit_upper_limit" => { + boolean(stock.touched_upper_limit) + } + "touched_lower_limit" | "hit_lower_limit" => { + boolean(stock.touched_lower_limit) + } + "listed_days" => integer(stock.listed_days), + "at_upper_limit" => boolean(at_upper_limit), + "at_lower_limit" => boolean(at_lower_limit), + "symbol_open_order_count" => { + integer(ctx.symbol_open_order_count(&stock.symbol) as i64) + } + "symbol_open_buy_qty" => { + integer(ctx.symbol_open_buy_quantity(&stock.symbol) as i64) + } + "symbol_open_sell_qty" => { + integer(ctx.symbol_open_sell_quantity(&stock.symbol) as i64) + } + "latest_symbol_open_order_id" => { + integer(ctx.latest_symbol_open_order_id(&stock.symbol) as i64) + } + "latest_symbol_open_order_unfilled_qty" => integer( + ctx.latest_symbol_open_order_unfilled_quantity(&stock.symbol) as i64, + ), + "in_dynamic_universe" => { + boolean(ctx.dynamic_universe_contains(&stock.symbol)) + } + "is_subscribed" => boolean(ctx.is_subscribed(&stock.symbol)), + "stock_ma_short" => number(stock.stock_ma_short), + "stock_ma_mid" => number(stock.stock_ma_mid), + "stock_ma_long" => number(stock.stock_ma_long), + "stock_ma5" | "ma5" => number(stock.stock_ma5), + "stock_ma10" | "ma10" => number(stock.stock_ma10), + "stock_ma20" | "ma20" => number(stock.stock_ma20), + "stock_ma30" | "ma30" => number(stock.stock_ma30), + "stock_volume_ma5" | "volume_ma5" => number(stock.stock_volume_ma5), + "stock_volume_ma10" | "volume_ma10" => number(stock.stock_volume_ma10), + "stock_volume_ma20" | "volume_ma20" => number(stock.stock_volume_ma20), + "stock_volume_ma60" | "volume_ma60" => number(stock.stock_volume_ma60), + "stock_volume_ma100" | "volume_ma100" => number(stock.stock_volume_ma100), + _ => stock + .extra_factors + .get(identifier) + .copied() + .map(NumericVmValue::Number), + }; + if stock_value.is_some() { + return stock_value; + } + if day.available_factor_names.contains(identifier) { + return number(f64::NAN); + } + } + let position = position?; + match identifier { + "avg_cost" => number(position.avg_cost), + "avg_price" => number(position.avg_price), + "current_price" => number(position.current_price), + "position_prev_close" | "prev_position_close" => number(position.prev_close), + "holding_return" => number(position.holding_return), + "quantity" => integer(position.quantity), + "sellable_qty" => integer(position.sellable_qty), + "sellable" => integer(position.sellable), + "closable" => integer(position.closable), + "old_quantity" => integer(position.old_quantity), + "buy_quantity" | "bought_quantity" => integer(position.bought_quantity), + "sell_quantity" | "sold_quantity" => integer(position.sold_quantity), + "buy_avg_price" => number(position.buy_avg_price), + "sell_avg_price" => number(position.sell_avg_price), + "bought_value" => number(position.bought_value), + "sold_value" => number(position.sold_value), + "position_market_value" => number(position.market_value), + "equity" => number(position.equity), + "value_percent" => number(position.value_percent), + "unrealized_pnl" => number(position.unrealized_pnl), + "realized_pnl" => number(position.realized_pnl), + "pnl" => number(position.pnl), + "day_trade_quantity_delta" => integer(position.day_trade_quantity_delta), + "dividend_receivable" => number(position.dividend_receivable), + "available_sellable_qty" => integer( + stock + .map(|stock| { + ctx.available_sellable_qty( + &stock.symbol, + position.sellable_qty as u32, + ) as i64 + }) + .unwrap_or(position.sellable_qty.max(0)), + ), + "reserved_open_sell_qty" => integer( + stock + .map(|stock| ctx.symbol_open_sell_quantity(&stock.symbol) as i64) + .unwrap_or(0), + ), + "profit_pct" => number(position.holding_return * 100.0), + _ => None, + } + } + } + } + fn eval_dynamic( &self, ctx: &StrategyContext<'_>, @@ -4762,13 +5142,19 @@ impl PlatformExprStrategy { .collect::>(); let prelude_runtime_template = (!prelude_source.trim().is_empty()) .then(|| Self::compile_runtime_helper_template(&prelude_source)); + let runtime_template = Self::compile_runtime_helper_template(&normalized); + let numeric_vm = Self::compile_numeric_expression_plan( + prelude_runtime_template.as_ref(), + &runtime_template, + ); let plan = Arc::new(ExpressionEvalPlan { identifiers, scope_identifiers, - runtime_template: Self::compile_runtime_helper_template(&normalized), + runtime_template, prelude_source, prelude_identifiers, prelude_runtime_template, + numeric_vm, }); self.expression_plan_cache .borrow_mut() @@ -5326,6 +5712,168 @@ impl PlatformExprStrategy { Ok(RuntimeExpressionTemplate { segments }) } + fn compile_numeric_expression_plan( + prelude: Option<&Result>, + expression: &Result, + ) -> Option { + let mut bindings = AHashMap::::new(); + let mut script = String::new(); + if let Some(prelude) = prelude { + script.push_str(&Self::numeric_vm_template_source(prelude, &mut bindings).ok()?); + script.push('\n'); + } + script.push_str(&Self::numeric_vm_template_source(expression, &mut bindings).ok()?); + let program = numeric_expr_vm::compile(&script, |identifier| { + bindings + .get(identifier) + .and_then(|binding| Self::numeric_vm_helper_type(&binding.name)) + .or_else(|| Self::numeric_vm_identifier_type(identifier)) + }) + .ok()?; + let helper_bindings = program + .variables() + .iter() + .map(|identifier| bindings.get(identifier).cloned()) + .collect(); + Some(NumericExpressionPlan { + program, + helper_bindings, + }) + } + + fn numeric_vm_template_source( + template: &Result, + bindings: &mut AHashMap, + ) -> Result { + let template = template.as_ref().map_err(Clone::clone)?; + let mut output = String::new(); + for segment in &template.segments { + match segment { + RuntimeExpressionSegment::Literal(literal) => output.push_str(literal), + RuntimeExpressionSegment::Helper { + name, + args, + scope_name, + } => { + Self::numeric_vm_helper_type(name).ok_or_else(|| { + format!("runtime helper {name} is not numeric-VM compatible") + })?; + 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) + { + return Err(format!("runtime helper scope collision for {scope_name}")); + } + bindings.insert(scope_name.clone(), binding); + output.push_str(scope_name); + } + } + } + Ok(output) + } + + fn numeric_vm_helper_type(helper: &str) -> Option { + match helper { + "has_dividend" | "has_split" | "is_margin_stock" => Some(NumericVmValueType::Boolean), + "rolling_mean" + | "sma" + | "ma" + | "rolling_mean_current" + | "rolling_max_current" + | "rolling_return_stddev_current" + | "vma" + | "rolling_sum" + | "rolling_min" + | "rolling_max" + | "rolling_stddev" + | "stddev" + | "rolling_zscore" + | "pct_change" + | "factor_value" + | "get_factor_value" + | "dividend_cash" + | "split_ratio" + | "securities_margin" + | "get_securities_margin_value" + | "shares" + | "get_shares_value" + | "turnover_rate" + | "get_turnover_rate_value" + | "price_change_rate" + | "get_price_change_rate_value" + | "stock_connect" + | "get_stock_connect_value" + | "current_performance" + | "fundamental" + | "get_fundamentals_value" + | "financial" + | "get_financials_value" + | "pit_financial" + | "get_pit_financials_value" + | "industry_code" + | "get_industry_code" + | "yield_curve" + | "get_yield_curve_value" + | "dominant_future_price" + | "get_dominant_future_price_value" => Some(NumericVmValueType::Number), + _ => None, + } + } + + fn numeric_vm_identifier_type(identifier: &str) -> Option { + match identifier { + "trade_date" + | "current_date" + | "date" + | "decision_date" + | "execution_date" + | "symbol" + | "order_book_id" + | "latest_open_order_status" + | "latest_symbol_open_order_status" + | "current_process_kind" + | "current_process_symbol" + | "current_process_side" + | "current_process_detail" + | "latest_process_kind" + | "latest_process_symbol" + | "latest_process_side" + | "latest_process_detail" + | "day_factors" + | "factors" + | "process_event_counts" => None, + "is_month_start" + | "is_month_end" + | "has_open_orders" + | "has_dynamic_universe" + | "has_subscriptions" + | "subscription_guard_required" + | "has_process_events" + | "paused" + | "is_st" + | "is_star_st" + | "is_kcb" + | "is_bjse" + | "is_one_yuan" + | "is_new_listing" + | "allow_buy" + | "allow_sell" + | "touched_upper_limit" + | "touched_lower_limit" + | "hit_upper_limit" + | "hit_lower_limit" + | "at_upper_limit" + | "at_lower_limit" + | "in_dynamic_universe" + | "is_subscribed" => Some(NumericVmValueType::Boolean), + _ => Some(NumericVmValueType::Number), + } + } + fn expand_runtime_helper_template( &self, ctx: &StrategyContext<'_>, @@ -6217,9 +6765,9 @@ impl PlatformExprStrategy { matches!( error, BacktestError::Execution(message) - if message.starts_with("missing rolling mean for field ") - || message.starts_with("missing current rolling mean for field ") - || message.starts_with("missing current rolling values for field ") + if message.contains("missing rolling mean for field ") + || message.contains("missing current rolling mean for field ") + || message.contains("missing current rolling values for field ") ) } @@ -6601,6 +7149,12 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, ) -> Result { + if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? { + return match value { + NumericVmValue::Number(number) => Ok(number), + NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }), + }; + } let value = self.eval_dynamic(ctx, expr, day, stock, position)?; if let Some(number) = value.clone().try_cast::() { return Ok(number); @@ -6625,6 +7179,12 @@ impl PlatformExprStrategy { stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, ) -> Result { + if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? { + return match value { + NumericVmValue::Boolean(boolean) => Ok(boolean), + NumericVmValue::Number(number) => Ok(number != 0.0), + }; + } let value = self.eval_dynamic(ctx, expr, day, stock, position)?; if let Some(boolean) = value.clone().try_cast::() { return Ok(boolean); @@ -31365,7 +31925,7 @@ mod tests { } #[test] - fn ast_cache_reuses_compiled_ast_across_invocations() { + fn numeric_vm_reuses_compiled_program_across_invocations() { let date = d(2025, 2, 3); let data = DataSet::from_components( vec![Instrument { @@ -31468,30 +32028,23 @@ mod tests { }]; let mut strategy = PlatformExprStrategy::new(cfg); - // 第一次调用:所有表达式 cache miss。 let _ = strategy.on_day(&ctx).expect("first decision"); - let misses_after_first = strategy.ast_cache_misses(); - let hits_after_first = strategy.ast_cache_hits(); + let vm_hits_after_first = strategy.numeric_vm_hits(); + let vm_fallbacks_after_first = strategy.numeric_vm_fallbacks(); assert!( - misses_after_first > 0, - "first run should populate cache, misses={}", - misses_after_first + vm_hits_after_first > 0, + "first run should execute compiled numeric expressions" ); - // 第二次调用:相同表达式,cache hit 数应当 > 第一次。 let _ = strategy.on_day(&ctx).expect("second decision"); - let misses_after_second = strategy.ast_cache_misses(); - let hits_after_second = strategy.ast_cache_hits(); assert!( - hits_after_second > hits_after_first, - "second run should reuse cached AST, hits {} -> {}", - hits_after_first, - hits_after_second + strategy.numeric_vm_hits() > vm_hits_after_first, + "second run should reuse the compiled numeric VM plan" ); - // 缓存条目数不应该再增长(相同 script):misses 不再增加。 assert_eq!( - misses_after_second, misses_after_first, - "second run should not introduce new misses for same scripts" + strategy.numeric_vm_fallbacks(), + vm_fallbacks_after_first, + "supported numeric expressions must not fall back to Rhai" ); } @@ -31574,7 +32127,54 @@ fn passes_threshold(value) { value > stock_threshold } } #[test] - fn ast_cache_reuses_rolling_helper_scripts_across_dates() { + fn strategy105_numeric_expressions_compile_to_vm() { + let mut config = PlatformExprStrategyConfig::microcap_rotation(); + config.prelude = r#" +let csi_close = rolling_mean_current("signal_close", 1); +let csi_ma10 = rolling_mean_current("signal_close", 10); +let csi_ma30 = rolling_mean_current("signal_close", 30); +let csi_vol20 = rolling_return_stddev_current("signal_close", 20); +let csi_high60 = rolling_max_current("signal_close", 60); +let csi_mean60 = rolling_mean_current("signal_close", 60); +let csi_drawdown60 = csi_high60 > 0.0 ? 1.0 - csi_close / csi_high60 : 0.0; +let csi_ready = csi_close > 0.0 && csi_ma10 > 0.0 && csi_ma30 > 0.0 && csi_high60 > 0.0 && csi_mean60 > 0.0; +let csi_clamped = clamp(csi_close, 2000.0, 3000.0); +let csi_t = (csi_clamped - 2000.0) / 1000.0; +let lower_market_cap = csi_ready ? 12.0 + csi_t * 5.0 : 1000000000.0; +let upper_market_cap = csi_ready ? 40.0 + csi_t * 5.0 : 0.0; +let base_exposure = csi_ma10 > csi_ma30 ? 1.0 : 0.3; +let volatility_exposure = csi_vol20 >= 0.025 ? min(base_exposure, 0.3) : base_exposure; +let dynamic_exposure = csi_drawdown60 >= 0.08 ? min(volatility_exposure, 0.2) : volatility_exposure; +let target_exposure = csi_ready ? dynamic_exposure : 0.0; +"# + .to_string(); + let strategy = PlatformExprStrategy::new(config); + for expression in [ + "lower_market_cap", + "upper_market_cap", + "target_exposure", + "30.0 / 31.0", + "!is_st && !is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)", + ] { + assert!( + strategy + .expression_eval_plan(expression) + .numeric_vm + .is_some(), + "expression should compile to numeric VM: {expression}" + ); + } + assert!( + strategy + .expression_eval_plan("contains(symbol, \"SZ\")") + .numeric_vm + .is_none(), + "string expressions must remain on the Rhai path" + ); + } + + #[test] + fn numeric_vm_reuses_rolling_helper_program_across_dates() { let dates = [d(2025, 2, 3), d(2025, 2, 4)]; let data = DataSet::from_components( vec![Instrument { @@ -31680,7 +32280,8 @@ fn passes_threshold(value) { value > stock_threshold } }]; let mut strategy = PlatformExprStrategy::new(cfg); - let mut misses_after_first = 0; + let mut vm_hits_after_first = 0; + let mut vm_fallbacks_after_first = 0; for (index, date) in dates.iter().enumerate() { let ctx = StrategyContext { execution_date: *date, @@ -31700,18 +32301,19 @@ fn passes_threshold(value) { value > stock_threshold } }; let _ = strategy.on_day(&ctx).expect("platform decision"); if index == 0 { - misses_after_first = strategy.ast_cache_misses(); + vm_hits_after_first = strategy.numeric_vm_hits(); + vm_fallbacks_after_first = strategy.numeric_vm_fallbacks(); } } assert!( - strategy.ast_cache_hits() > 0, - "second date should reuse helper-expanded scripts" + strategy.numeric_vm_hits() > vm_hits_after_first, + "second date should reuse the helper-slot VM program" ); assert_eq!( - strategy.ast_cache_misses(), - misses_after_first, - "rolling helper values must not change cached script identity across dates" + strategy.numeric_vm_fallbacks(), + vm_fallbacks_after_first, + "rolling helper values must not force a Rhai fallback" ); }