增加数值表达式字节码虚拟机
This commit is contained in:
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user