Support sparse factor aliases in platform runtime

This commit is contained in:
boris
2026-04-22 00:31:42 -07:00
parent e838629c58
commit 6d3017ca56

View File

@@ -135,6 +135,7 @@ struct DayExpressionState {
weekday: i64,
is_month_start: bool,
is_month_end: bool,
available_factor_names: BTreeSet<String>,
}
#[derive(Debug, Clone)]
@@ -143,6 +144,10 @@ struct StockExpressionState {
market_cap: f64,
free_float_cap: f64,
pe_ttm: f64,
volume: i64,
tick_volume: i64,
bid1_volume: i64,
ask1_volume: i64,
turnover_ratio: f64,
effective_turnover_ratio: f64,
open: f64,
@@ -221,6 +226,107 @@ impl PlatformExprStrategy {
Self { config, engine }
}
fn is_expression_identifier(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first == '_' || first.is_ascii_alphabetic()) {
return false;
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn reserved_scope_names() -> BTreeSet<&'static str> {
BTreeSet::from([
"signal_close",
"benchmark_close",
"signal_ma5",
"signal_ma10",
"signal_ma20",
"signal_ma30",
"benchmark_ma5",
"benchmark_ma10",
"benchmark_ma20",
"benchmark_ma30",
"benchmark_ma_short",
"benchmark_ma_long",
"cash",
"available_cash",
"market_value",
"total_equity",
"current_exposure",
"position_count",
"max_positions",
"refresh_rate",
"year",
"month",
"quarter",
"day_of_month",
"day_of_year",
"week_of_year",
"weekday",
"is_month_start",
"is_month_end",
"day_factors",
"symbol",
"market_cap",
"free_float_cap",
"pe_ttm",
"volume",
"tick_volume",
"bid1_volume",
"ask1_volume",
"turnover_ratio",
"effective_turnover_ratio",
"open",
"close",
"last",
"last_price",
"prev_close",
"upper_limit",
"lower_limit",
"price_tick",
"round_lot",
"paused",
"is_st",
"is_kcb",
"is_one_yuan",
"is_new_listing",
"allow_buy",
"allow_sell",
"listed_days",
"stock_ma_short",
"stock_ma_mid",
"stock_ma_long",
"stock_ma5",
"stock_ma10",
"stock_ma20",
"stock_ma30",
"ma5",
"ma10",
"ma20",
"ma30",
"factors",
"avg_cost",
"current_price",
"holding_return",
"quantity",
"sellable_qty",
"profit_pct",
"at_upper_limit",
"at_lower_limit",
])
}
fn price_is_at_limit(price: f64, limit: f64, tick: f64) -> bool {
if !price.is_finite() || !limit.is_finite() {
return false;
}
let tolerance = tick.abs().max(1e-6);
(price - limit).abs() <= tolerance
}
fn intraday_execution_start_time(&self) -> NaiveTime {
NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
}
@@ -619,6 +725,12 @@ impl PlatformExprStrategy {
weekday: date.weekday().number_from_monday() as i64,
is_month_start: date.day() == 1,
is_month_end,
available_factor_names: ctx
.data
.factor_snapshots_on(date)
.into_iter()
.flat_map(|row| row.extra_factors.keys().cloned())
.collect(),
})
}
@@ -666,6 +778,10 @@ impl PlatformExprStrategy {
market_cap: factor.market_cap_bn,
free_float_cap: factor.free_float_cap_bn,
pe_ttm: factor.pe_ttm,
volume: market.volume as i64,
tick_volume: market.tick_volume as i64,
bid1_volume: market.bid1_volume as i64,
ask1_volume: market.ask1_volume as i64,
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
open: market.day_open,
@@ -762,10 +878,16 @@ impl PlatformExprStrategy {
day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end));
scope.push("day_factors", day_factors);
if let Some(stock) = stock {
let at_upper_limit = Self::price_is_at_limit(stock.last, stock.upper_limit, stock.price_tick);
let at_lower_limit = Self::price_is_at_limit(stock.last, stock.lower_limit, stock.price_tick);
scope.push("symbol", stock.symbol.clone());
scope.push("market_cap", stock.market_cap);
scope.push("free_float_cap", stock.free_float_cap);
scope.push("pe_ttm", stock.pe_ttm);
scope.push("volume", stock.volume);
scope.push("tick_volume", stock.tick_volume);
scope.push("bid1_volume", stock.bid1_volume);
scope.push("ask1_volume", stock.ask1_volume);
scope.push("turnover_ratio", stock.turnover_ratio);
scope.push("effective_turnover_ratio", stock.effective_turnover_ratio);
scope.push("open", stock.open);
@@ -785,6 +907,8 @@ impl PlatformExprStrategy {
scope.push("allow_buy", stock.allow_buy);
scope.push("allow_sell", stock.allow_sell);
scope.push("listed_days", stock.listed_days);
scope.push("at_upper_limit", at_upper_limit);
scope.push("at_lower_limit", at_lower_limit);
scope.push("stock_ma_short", stock.stock_ma_short);
scope.push("stock_ma_mid", stock.stock_ma_mid);
scope.push("stock_ma_long", stock.stock_ma_long);
@@ -801,6 +925,10 @@ impl PlatformExprStrategy {
factors.insert("market_cap".into(), Dynamic::from(stock.market_cap));
factors.insert("free_float_cap".into(), Dynamic::from(stock.free_float_cap));
factors.insert("pe_ttm".into(), Dynamic::from(stock.pe_ttm));
factors.insert("volume".into(), Dynamic::from(stock.volume));
factors.insert("tick_volume".into(), Dynamic::from(stock.tick_volume));
factors.insert("bid1_volume".into(), Dynamic::from(stock.bid1_volume));
factors.insert("ask1_volume".into(), Dynamic::from(stock.ask1_volume));
factors.insert("turnover_ratio".into(), Dynamic::from(stock.turnover_ratio));
factors.insert(
"effective_turnover_ratio".into(),
@@ -822,6 +950,8 @@ impl PlatformExprStrategy {
factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy));
factors.insert("allow_sell".into(), Dynamic::from(stock.allow_sell));
factors.insert("listed_days".into(), Dynamic::from(stock.listed_days));
factors.insert("at_upper_limit".into(), Dynamic::from(at_upper_limit));
factors.insert("at_lower_limit".into(), Dynamic::from(at_lower_limit));
factors.insert("stock_ma5".into(), Dynamic::from(stock.stock_ma5));
factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10));
factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20));
@@ -834,6 +964,20 @@ impl PlatformExprStrategy {
factors.insert(key.clone().into(), Dynamic::from(*value));
}
scope.push("factors", factors);
let reserved_names = Self::reserved_scope_names();
for (key, value) in &stock.extra_factors {
if Self::is_expression_identifier(key) && !reserved_names.contains(key.as_str()) {
scope.push_dynamic(key.clone(), Dynamic::from(*value));
}
}
for key in &day.available_factor_names {
if Self::is_expression_identifier(key)
&& !reserved_names.contains(key.as_str())
&& !stock.extra_factors.contains_key(key)
{
scope.push_dynamic(key.clone(), Dynamic::from(0.0));
}
}
}
if let Some(position) = position {
scope.push("avg_cost", position.avg_cost);
@@ -855,11 +999,42 @@ impl PlatformExprStrategy {
) -> Result<Dynamic, BacktestError> {
let mut scope = self.eval_scope(day, stock, position);
let normalized_expr = Self::normalize_expr(expr);
let script = if self.config.prelude.trim().is_empty() {
normalized_expr
} else {
format!("{}\n{}", self.config.prelude, normalized_expr)
};
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude);
if let Some(item) = stock {
let reserved_names = Self::reserved_scope_names();
for identifier in Self::extract_identifier_candidates(&normalized_expr) {
if reserved_names.contains(identifier.as_str())
|| prelude_declared_identifiers.contains(&identifier)
|| !day.available_factor_names.contains(&identifier)
{
continue;
}
let value = item.extra_factors.get(&identifier).copied().unwrap_or(0.0);
scope.push_dynamic(identifier, Dynamic::from(value));
}
}
let factor_alias_prelude = stock
.map(|item| {
let reserved_names = Self::reserved_scope_names();
item.extra_factors
.keys()
.filter(|key| {
Self::is_expression_identifier(key) && !reserved_names.contains(key.as_str())
})
.map(|key| format!("let {key} = factors[\"{key}\"];"))
.collect::<Vec<_>>()
.join("\n")
})
.filter(|value| !value.trim().is_empty());
let mut script_parts = Vec::new();
if !self.config.prelude.trim().is_empty() {
script_parts.push(self.config.prelude.trim().to_string());
}
if let Some(alias_prelude) = factor_alias_prelude {
script_parts.push(alias_prelude);
}
script_parts.push(normalized_expr);
let script = script_parts.join("\n");
self.engine
.eval_with_scope::<Dynamic>(&mut scope, &script)
.map_err(|error| BacktestError::Execution(format!("platform expr eval failed: {}", error)))
@@ -869,6 +1044,69 @@ impl PlatformExprStrategy {
Self::rewrite_ternary(expr.trim())
}
fn declared_prelude_identifiers(prelude: &str) -> BTreeSet<String> {
prelude
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
let body = if let Some(rest) = trimmed.strip_prefix("let ") {
rest
} else if let Some(rest) = trimmed.strip_prefix("fn ") {
rest
} else {
return None;
};
let identifier: String = body
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.collect();
(!identifier.is_empty()).then_some(identifier)
})
.collect()
}
fn extract_identifier_candidates(expr: &str) -> BTreeSet<String> {
let mut identifiers = BTreeSet::new();
let mut chars = expr.chars().peekable();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' && (in_single_quote || in_double_quote) {
escaped = true;
continue;
}
if ch == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
continue;
}
if ch == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
continue;
}
if in_single_quote || in_double_quote {
continue;
}
if ch.is_ascii_alphabetic() || ch == '_' {
let mut identifier = String::from(ch);
while let Some(next) = chars.peek().copied() {
if next.is_ascii_alphanumeric() || next == '_' {
identifier.push(next);
chars.next();
} else {
break;
}
}
identifiers.insert(identifier);
}
}
identifiers
}
fn rewrite_ternary(expr: &str) -> String {
let Some((question_idx, colon_idx)) = Self::find_top_level_ternary(expr) else {
return expr.trim().to_string();
@@ -1025,6 +1263,10 @@ impl PlatformExprStrategy {
"market_cap" => Some(stock.market_cap),
"free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap),
"pe_ttm" => Some(stock.pe_ttm),
"volume" => Some(stock.volume as f64),
"tick_volume" => Some(stock.tick_volume as f64),
"bid1_volume" => Some(stock.bid1_volume as f64),
"ask1_volume" => Some(stock.ask1_volume as f64),
"turnover_ratio" => Some(stock.turnover_ratio),
"effective_turnover_ratio" => Some(stock.effective_turnover_ratio),
"open" => Some(stock.open),