Support sparse factor aliases in platform runtime
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user