增加数值表达式字节码虚拟机

This commit is contained in:
boris
2026-08-24 13:46:45 +08:00
parent c52478708f
commit 4b577517a9
3 changed files with 2144 additions and 29 deletions
+1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+631 -29
View File
@@ -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<String>,
prelude_runtime_template: Option<Result<RuntimeExpressionTemplate, String>>,
numeric_vm: Option<NumericExpressionPlan>,
}
struct NumericExpressionPlan {
program: NumericVmProgram,
helper_bindings: Vec<Option<RuntimeHelperBinding>>,
}
struct PreludeDependencyPlan {
@@ -876,10 +886,12 @@ impl PreludeDependencyPlan {
}
}
#[derive(Clone)]
struct RuntimeExpressionTemplate {
segments: Vec<RuntimeExpressionSegment>,
}
#[derive(Clone)]
enum RuntimeExpressionSegment {
Literal(String),
Helper {
@@ -889,6 +901,13 @@ enum RuntimeExpressionSegment {
},
}
#[derive(Clone)]
struct RuntimeHelperBinding {
name: String,
args: Vec<String>,
scope_name: String,
}
pub struct PlatformExprStrategy {
config: PlatformExprStrategyConfig,
engine: Engine,
@@ -912,6 +931,9 @@ pub struct PlatformExprStrategy {
cache_hits: RefCell<u64>,
cache_misses: RefCell<u64>,
expression_plan_cache: RefCell<AHashMap<String, Arc<ExpressionEvalPlan>>>,
numeric_vm_scratch: RefCell<NumericVmScratch>,
numeric_vm_hits: RefCell<u64>,
numeric_vm_fallbacks: RefCell<u64>,
prelude_dependency_plan: PreludeDependencyPlan,
prelude_identifier_candidates: BTreeSet<String>,
prelude_declared_identifiers: BTreeSet<String>,
@@ -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<Option<NumericVmValue>, 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<NumericVmValue, BacktestError> {
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::<f64>(&binding.scope_name)
.map(NumericVmValue::Number)
.or_else(|| {
scope
.get_value::<i64>(&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::<bool>(&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::<f64>()
.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<NumericVmValue> {
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::<AHashSet<_>>();
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<RuntimeExpressionTemplate, String>>,
expression: &Result<RuntimeExpressionTemplate, String>,
) -> Option<NumericExpressionPlan> {
let mut bindings = AHashMap::<String, RuntimeHelperBinding>::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<RuntimeExpressionTemplate, String>,
bindings: &mut AHashMap<String, RuntimeHelperBinding>,
) -> Result<String, String> {
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<NumericVmValueType> {
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<NumericVmValueType> {
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<f64, BacktestError> {
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::<f64>() {
return Ok(number);
@@ -6625,6 +7179,12 @@ impl PlatformExprStrategy {
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<bool, BacktestError> {
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::<bool>() {
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"
);
}