Support sparse factor aliases in platform runtime
This commit is contained in:
@@ -135,6 +135,7 @@ struct DayExpressionState {
|
|||||||
weekday: i64,
|
weekday: i64,
|
||||||
is_month_start: bool,
|
is_month_start: bool,
|
||||||
is_month_end: bool,
|
is_month_end: bool,
|
||||||
|
available_factor_names: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -143,6 +144,10 @@ struct StockExpressionState {
|
|||||||
market_cap: f64,
|
market_cap: f64,
|
||||||
free_float_cap: f64,
|
free_float_cap: f64,
|
||||||
pe_ttm: f64,
|
pe_ttm: f64,
|
||||||
|
volume: i64,
|
||||||
|
tick_volume: i64,
|
||||||
|
bid1_volume: i64,
|
||||||
|
ask1_volume: i64,
|
||||||
turnover_ratio: f64,
|
turnover_ratio: f64,
|
||||||
effective_turnover_ratio: f64,
|
effective_turnover_ratio: f64,
|
||||||
open: f64,
|
open: f64,
|
||||||
@@ -221,6 +226,107 @@ impl PlatformExprStrategy {
|
|||||||
Self { config, engine }
|
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 {
|
fn intraday_execution_start_time(&self) -> NaiveTime {
|
||||||
NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
|
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,
|
weekday: date.weekday().number_from_monday() as i64,
|
||||||
is_month_start: date.day() == 1,
|
is_month_start: date.day() == 1,
|
||||||
is_month_end,
|
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,
|
market_cap: factor.market_cap_bn,
|
||||||
free_float_cap: factor.free_float_cap_bn,
|
free_float_cap: factor.free_float_cap_bn,
|
||||||
pe_ttm: factor.pe_ttm,
|
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),
|
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
|
||||||
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
|
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
|
||||||
open: market.day_open,
|
open: market.day_open,
|
||||||
@@ -762,10 +878,16 @@ impl PlatformExprStrategy {
|
|||||||
day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end));
|
day_factors.insert("is_month_end".into(), Dynamic::from(day.is_month_end));
|
||||||
scope.push("day_factors", day_factors);
|
scope.push("day_factors", day_factors);
|
||||||
if let Some(stock) = stock {
|
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("symbol", stock.symbol.clone());
|
||||||
scope.push("market_cap", stock.market_cap);
|
scope.push("market_cap", stock.market_cap);
|
||||||
scope.push("free_float_cap", stock.free_float_cap);
|
scope.push("free_float_cap", stock.free_float_cap);
|
||||||
scope.push("pe_ttm", stock.pe_ttm);
|
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("turnover_ratio", stock.turnover_ratio);
|
||||||
scope.push("effective_turnover_ratio", stock.effective_turnover_ratio);
|
scope.push("effective_turnover_ratio", stock.effective_turnover_ratio);
|
||||||
scope.push("open", stock.open);
|
scope.push("open", stock.open);
|
||||||
@@ -785,6 +907,8 @@ impl PlatformExprStrategy {
|
|||||||
scope.push("allow_buy", stock.allow_buy);
|
scope.push("allow_buy", stock.allow_buy);
|
||||||
scope.push("allow_sell", stock.allow_sell);
|
scope.push("allow_sell", stock.allow_sell);
|
||||||
scope.push("listed_days", stock.listed_days);
|
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_short", stock.stock_ma_short);
|
||||||
scope.push("stock_ma_mid", stock.stock_ma_mid);
|
scope.push("stock_ma_mid", stock.stock_ma_mid);
|
||||||
scope.push("stock_ma_long", stock.stock_ma_long);
|
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("market_cap".into(), Dynamic::from(stock.market_cap));
|
||||||
factors.insert("free_float_cap".into(), Dynamic::from(stock.free_float_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("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("turnover_ratio".into(), Dynamic::from(stock.turnover_ratio));
|
||||||
factors.insert(
|
factors.insert(
|
||||||
"effective_turnover_ratio".into(),
|
"effective_turnover_ratio".into(),
|
||||||
@@ -822,6 +950,8 @@ impl PlatformExprStrategy {
|
|||||||
factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy));
|
factors.insert("allow_buy".into(), Dynamic::from(stock.allow_buy));
|
||||||
factors.insert("allow_sell".into(), Dynamic::from(stock.allow_sell));
|
factors.insert("allow_sell".into(), Dynamic::from(stock.allow_sell));
|
||||||
factors.insert("listed_days".into(), Dynamic::from(stock.listed_days));
|
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_ma5".into(), Dynamic::from(stock.stock_ma5));
|
||||||
factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10));
|
factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10));
|
||||||
factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20));
|
factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20));
|
||||||
@@ -834,6 +964,20 @@ impl PlatformExprStrategy {
|
|||||||
factors.insert(key.clone().into(), Dynamic::from(*value));
|
factors.insert(key.clone().into(), Dynamic::from(*value));
|
||||||
}
|
}
|
||||||
scope.push("factors", factors);
|
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 {
|
if let Some(position) = position {
|
||||||
scope.push("avg_cost", position.avg_cost);
|
scope.push("avg_cost", position.avg_cost);
|
||||||
@@ -855,11 +999,42 @@ impl PlatformExprStrategy {
|
|||||||
) -> Result<Dynamic, BacktestError> {
|
) -> Result<Dynamic, BacktestError> {
|
||||||
let mut scope = self.eval_scope(day, stock, position);
|
let mut scope = self.eval_scope(day, stock, position);
|
||||||
let normalized_expr = Self::normalize_expr(expr);
|
let normalized_expr = Self::normalize_expr(expr);
|
||||||
let script = if self.config.prelude.trim().is_empty() {
|
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude);
|
||||||
normalized_expr
|
if let Some(item) = stock {
|
||||||
} else {
|
let reserved_names = Self::reserved_scope_names();
|
||||||
format!("{}\n{}", self.config.prelude, normalized_expr)
|
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
|
self.engine
|
||||||
.eval_with_scope::<Dynamic>(&mut scope, &script)
|
.eval_with_scope::<Dynamic>(&mut scope, &script)
|
||||||
.map_err(|error| BacktestError::Execution(format!("platform expr eval failed: {}", error)))
|
.map_err(|error| BacktestError::Execution(format!("platform expr eval failed: {}", error)))
|
||||||
@@ -869,6 +1044,69 @@ impl PlatformExprStrategy {
|
|||||||
Self::rewrite_ternary(expr.trim())
|
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 {
|
fn rewrite_ternary(expr: &str) -> String {
|
||||||
let Some((question_idx, colon_idx)) = Self::find_top_level_ternary(expr) else {
|
let Some((question_idx, colon_idx)) = Self::find_top_level_ternary(expr) else {
|
||||||
return expr.trim().to_string();
|
return expr.trim().to_string();
|
||||||
@@ -1025,6 +1263,10 @@ impl PlatformExprStrategy {
|
|||||||
"market_cap" => Some(stock.market_cap),
|
"market_cap" => Some(stock.market_cap),
|
||||||
"free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap),
|
"free_float_cap" | "free_float_market_cap" => Some(stock.free_float_cap),
|
||||||
"pe_ttm" => Some(stock.pe_ttm),
|
"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),
|
"turnover_ratio" => Some(stock.turnover_ratio),
|
||||||
"effective_turnover_ratio" => Some(stock.effective_turnover_ratio),
|
"effective_turnover_ratio" => Some(stock.effective_turnover_ratio),
|
||||||
"open" => Some(stock.open),
|
"open" => Some(stock.open),
|
||||||
|
|||||||
Reference in New Issue
Block a user