diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index a44171a..0c92a90 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -135,6 +135,7 @@ struct DayExpressionState { weekday: i64, is_month_start: bool, is_month_end: bool, + available_factor_names: BTreeSet, } #[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 { 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::>() + .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::(&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 { + 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 { + 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),