diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 1cbb087..3f89d96 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -402,6 +402,23 @@ impl SymbolPriceSeries { Some(sum / lookback as f64) } + fn decision_close_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = self.decision_end_index(date)?; + if end == 0 { + return None; + } + let start = end.saturating_sub(lookback); + let count = end.saturating_sub(start); + if count == 0 { + return None; + } + let sum = self.prev_close_prefix[end] - self.prev_close_prefix[start]; + Some(sum / count as f64) + } + fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { if lookback == 0 { return None; @@ -415,6 +432,23 @@ impl SymbolPriceSeries { Some(sum / lookback as f64) } + fn decision_volume_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = self.decision_end_index(date)?; + if end == 0 { + return None; + } + let start = end.saturating_sub(lookback); + let count = end.saturating_sub(start); + if count == 0 { + return None; + } + let sum = self.volume_prefix[end] - self.volume_prefix[start]; + Some(sum / count as f64) + } + fn end_index(&self, date: NaiveDate) -> Option { match self.dates.binary_search(&date) { Ok(idx) => Some(idx + 1), @@ -827,6 +861,71 @@ impl DataSet { .and_then(|series| series.decision_volume_moving_average(date, lookback)) } + pub fn factor_numeric_value( + &self, + date: NaiveDate, + symbol: &str, + field: &str, + ) -> Option { + self.factor(date, symbol) + .and_then(|snapshot| factor_numeric_value(snapshot, field)) + } + + pub fn factor_moving_average( + &self, + date: NaiveDate, + symbol: &str, + field: &str, + lookback: usize, + ) -> Option { + if lookback == 0 { + return None; + } + let dates = self.calendar.trailing_days(date, lookback); + if dates.is_empty() { + return None; + } + let mut sum = 0.0_f64; + let mut count = 0usize; + for trading_day in dates { + let snapshot = self.factor(trading_day, symbol)?; + let value = factor_numeric_value(snapshot, field)?; + sum += value; + count += 1; + } + if count == 0 { + None + } else { + Some(sum / count as f64) + } + } + + pub fn market_decision_numeric_moving_average( + &self, + date: NaiveDate, + symbol: &str, + field: &str, + lookback: usize, + ) -> Option { + match field { + "close" | "prev_close" | "stock_close" | "price" => { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_close_rolling_average(date, lookback)) + } + "volume" | "stock_volume" => { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_volume_rolling_average(date, lookback)) + } + "open" => self.market_moving_average(date, symbol, lookback, PriceField::Open), + "last" | "last_price" => { + self.market_moving_average(date, symbol, lookback, PriceField::Last) + } + other => self.factor_moving_average(date, symbol, other, lookback), + } + } + pub fn market_moving_average( &self, date: NaiveDate, @@ -981,6 +1080,19 @@ fn read_factors(path: &Path) -> Result, DataSetError> { Ok(snapshots) } +fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option { + match field { + "market_cap" | "market_cap_bn" => Some(snapshot.market_cap_bn), + "free_float_cap" | "free_float_market_cap" | "free_float_cap_bn" => { + Some(snapshot.free_float_cap_bn) + } + "pe_ttm" => Some(snapshot.pe_ttm), + "turnover_ratio" => snapshot.turnover_ratio, + "effective_turnover_ratio" => snapshot.effective_turnover_ratio, + other => snapshot.extra_factors.get(other).copied(), + } +} + fn read_candidates(path: &Path) -> Result, DataSetError> { let rows = read_rows(path)?; let mut snapshots = Vec::new(); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 4f6d23a..13b910a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -107,6 +107,7 @@ struct ProjectedExecutionFill { #[derive(Debug, Clone)] struct DayExpressionState { + date: NaiveDate, signal_close: f64, benchmark_close: f64, benchmark_ma_short: f64, @@ -701,6 +702,7 @@ impl PlatformExprStrategy { let is_month_end = next_day.month() != date.month(); Ok(DayExpressionState { + date, signal_close, benchmark_close, benchmark_ma_short, @@ -1032,6 +1034,7 @@ impl PlatformExprStrategy { fn eval_dynamic( &self, + ctx: &StrategyContext<'_>, expr: &str, day: &DayExpressionState, stock: Option<&StockExpressionState>, @@ -1039,10 +1042,11 @@ impl PlatformExprStrategy { ) -> Result { let mut scope = self.eval_scope(day, stock, position); let normalized_expr = Self::normalize_expr(expr); + let expanded_expr = self.expand_runtime_helpers(ctx, day, stock, &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) { + for identifier in Self::extract_identifier_candidates(&expanded_expr) { if reserved_names.contains(identifier.as_str()) || prelude_declared_identifiers.contains(&identifier) || !day.available_factor_names.contains(&identifier) @@ -1073,7 +1077,7 @@ impl PlatformExprStrategy { if let Some(alias_prelude) = factor_alias_prelude { script_parts.push(alias_prelude); } - script_parts.push(normalized_expr); + script_parts.push(expanded_expr); let script = script_parts.join("\n"); self.engine .eval_with_scope::(&mut scope, &script) @@ -1084,6 +1088,273 @@ impl PlatformExprStrategy { Self::rewrite_ternary(expr.trim()) } + fn expand_runtime_helpers( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + expr: &str, + ) -> Result { + let mut output = String::with_capacity(expr.len()); + let mut cursor = 0usize; + while cursor < expr.len() { + let Some(ch) = expr[cursor..].chars().next() else { + break; + }; + if !(ch == '_' || ch.is_ascii_alphabetic()) { + output.push(ch); + cursor += ch.len_utf8(); + continue; + } + let ident_start = cursor; + cursor += ch.len_utf8(); + while cursor < expr.len() { + let Some(next) = expr[cursor..].chars().next() else { + break; + }; + if next == '_' || next.is_ascii_alphanumeric() { + cursor += next.len_utf8(); + } else { + break; + } + } + let ident = &expr[ident_start..cursor]; + let whitespace_start = cursor; + while cursor < expr.len() { + let Some(next) = expr[cursor..].chars().next() else { + break; + }; + if next.is_whitespace() { + cursor += next.len_utf8(); + } else { + break; + } + } + let Some(next) = expr[cursor..].chars().next() else { + output.push_str(&expr[ident_start..cursor]); + break; + }; + if next != '(' || !matches!(ident, "factor" | "day_factor" | "rolling_mean" | "sma") { + output.push_str(&expr[ident_start..cursor]); + continue; + } + let Some(close_idx) = Self::find_matching_paren(expr, cursor) else { + return Err(BacktestError::Execution(format!( + "platform helper call not closed: {}", + &expr[ident_start..] + ))); + }; + let inner = &expr[cursor + 1..close_idx]; + let replacement = self.resolve_runtime_helper(ctx, day, stock, ident, inner)?; + output.push_str(&replacement); + cursor = close_idx + 1; + let _ = whitespace_start; + } + Ok(output) + } + + fn resolve_runtime_helper( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + helper: &str, + args_src: &str, + ) -> Result { + let args = Self::split_top_level_args(args_src); + match helper { + "factor" => { + let key = Self::parse_string_or_identifier( + args.first().map(String::as_str).unwrap_or_default(), + )?; + Ok(format!("factors[{}]", Self::quote_rhai_string(&key))) + } + "day_factor" => { + let key = Self::parse_string_or_identifier( + args.first().map(String::as_str).unwrap_or_default(), + )?; + Ok(format!("day_factors[{}]", Self::quote_rhai_string(&key))) + } + "rolling_mean" | "sma" => { + if args.len() != 2 { + return Err(BacktestError::Execution(format!( + "{helper} expects 2 arguments" + ))); + } + let field = Self::parse_string_or_identifier(&args[0])?; + let lookback = Self::parse_positive_usize(&args[1])?; + let value = self.resolve_rolling_mean(ctx, day, stock, &field, lookback)?; + Ok(format!("{value:.12}")) + } + other => Err(BacktestError::Execution(format!( + "unsupported platform helper: {other}" + ))), + } + } + + fn resolve_rolling_mean( + &self, + ctx: &StrategyContext<'_>, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + field: &str, + lookback: usize, + ) -> Result { + if lookback == 0 { + return Err(BacktestError::Execution( + "rolling_mean lookback must be positive".to_string(), + )); + } + let value = match field { + "benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback), + "signal_close" => ctx + .data + .market_decision_close_moving_average(day.date, &self.config.signal_symbol, lookback), + "signal_volume" => ctx + .data + .market_decision_volume_moving_average(day.date, &self.config.signal_symbol, lookback), + other => { + let stock = stock.ok_or_else(|| { + BacktestError::Execution(format!( + "rolling_mean(\"{other}\", {lookback}) requires stock context" + )) + })?; + ctx.data + .market_decision_numeric_moving_average(day.date, &stock.symbol, other, lookback) + } + }; + value.ok_or_else(|| { + BacktestError::Execution(format!( + "missing rolling mean for field {field} with lookback {lookback}" + )) + }) + } + + fn split_top_level_args(args: &str) -> Vec { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut paren_depth = 0i32; + let mut brace_depth = 0i32; + let mut bracket_depth = 0i32; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + for (idx, ch) in args.char_indices() { + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_single_quote || in_double_quote => { + escaped = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + _ if in_single_quote || in_double_quote => {} + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + '{' => brace_depth += 1, + '}' => brace_depth -= 1, + '[' => bracket_depth += 1, + ']' => bracket_depth -= 1, + ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => { + let part = args[start..idx].trim(); + if !part.is_empty() { + parts.push(part.to_string()); + } + start = idx + ch.len_utf8(); + } + _ => {} + } + } + let tail = args[start..].trim(); + if !tail.is_empty() { + parts.push(tail.to_string()); + } + parts + } + + fn parse_string_or_identifier(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(BacktestError::Execution( + "platform helper argument cannot be empty".to_string(), + )); + } + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + return Ok(trimmed[1..trimmed.len() - 1].to_string()); + } + if Self::is_expression_identifier(trimmed) { + return Ok(trimmed.to_string()); + } + Err(BacktestError::Execution(format!( + "platform helper expects a factor name or string literal, got {trimmed}" + ))) + } + + fn parse_positive_usize(raw: &str) -> Result { + let trimmed = raw.trim(); + let value = trimmed.parse::().map_err(|_| { + BacktestError::Execution(format!( + "platform helper expects a positive integer lookback, got {trimmed}" + )) + })?; + if value == 0 { + return Err(BacktestError::Execution( + "platform helper lookback must be positive".to_string(), + )); + } + Ok(value) + } + + fn quote_rhai_string(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\""); + format!("\"{escaped}\"") + } + + fn find_matching_paren(expr: &str, open_idx: usize) -> Option { + let mut paren_depth = 0i32; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escaped = false; + for (idx, ch) in expr[open_idx..].char_indices() { + let absolute_idx = open_idx + idx; + if escaped { + escaped = false; + continue; + } + match ch { + '\\' if in_single_quote || in_double_quote => { + escaped = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + _ if in_single_quote || in_double_quote => {} + '(' => paren_depth += 1, + ')' => { + paren_depth -= 1; + if paren_depth == 0 { + return Some(absolute_idx); + } + } + _ => {} + } + } + None + } + fn declared_prelude_identifiers(prelude: &str) -> BTreeSet { prelude .lines() @@ -1206,12 +1477,13 @@ impl PlatformExprStrategy { fn eval_float( &self, + ctx: &StrategyContext<'_>, expr: &str, day: &DayExpressionState, stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, ) -> Result { - let value = self.eval_dynamic(expr, day, stock, position)?; + let value = self.eval_dynamic(ctx, expr, day, stock, position)?; if let Some(number) = value.clone().try_cast::() { return Ok(number); } @@ -1229,12 +1501,13 @@ impl PlatformExprStrategy { fn eval_bool( &self, + ctx: &StrategyContext<'_>, expr: &str, day: &DayExpressionState, stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, ) -> Result { - let value = self.eval_dynamic(expr, day, stock, position)?; + let value = self.eval_dynamic(ctx, expr, day, stock, position)?; if let Some(boolean) = value.clone().try_cast::() { return Ok(boolean); } @@ -1252,38 +1525,42 @@ impl PlatformExprStrategy { fn trading_ratio( &self, + ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result { - self.eval_float(&self.config.exposure_expr, day, None, None) + self.eval_float(ctx, &self.config.exposure_expr, day, None, None) .map(|value| value.clamp(0.0, 1.0)) } fn market_cap_band( &self, + ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result<(f64, f64), BacktestError> { - let low = self.eval_float(&self.config.market_cap_lower_expr, day, None, None)?; - let high = self.eval_float(&self.config.market_cap_upper_expr, day, None, None)?; + let low = self.eval_float(ctx, &self.config.market_cap_lower_expr, day, None, None)?; + let high = self.eval_float(ctx, &self.config.market_cap_upper_expr, day, None, None)?; Ok((low.min(high), low.max(high))) } fn selection_limit( &self, + ctx: &StrategyContext<'_>, day: &DayExpressionState, ) -> Result { - let value = self.eval_float(&self.config.selection_limit_expr, day, None, None)?; + let value = self.eval_float(ctx, &self.config.selection_limit_expr, day, None, None)?; Ok(value.round().max(1.0) as usize) } fn stock_passes_expr( &self, + ctx: &StrategyContext<'_>, day: &DayExpressionState, stock: &StockExpressionState, ) -> Result { if self.config.stock_filter_expr.trim().is_empty() { return Ok(true); } - self.eval_bool(&self.config.stock_filter_expr, day, Some(stock), None) + self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) } fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { @@ -1356,12 +1633,13 @@ impl PlatformExprStrategy { fn rank_value( &self, + ctx: &StrategyContext<'_>, day: &DayExpressionState, candidate: &EligibleUniverseSnapshot, stock: &StockExpressionState, ) -> Result { if !self.config.rank_expr.trim().is_empty() { - return self.eval_float(&self.config.rank_expr, day, Some(stock), None); + return self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None); } Ok(self .stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) @@ -1448,7 +1726,7 @@ impl PlatformExprStrategy { if field_value < band_low || field_value > band_high { continue; } - let rank_value = self.rank_value(day, &candidate, &stock)?; + let rank_value = self.rank_value(ctx, day, &candidate, &stock)?; candidates.push((candidate, stock, rank_value)); } candidates.sort_by(|lhs, rhs| { @@ -1478,7 +1756,7 @@ impl PlatformExprStrategy { } continue; } - if !self.stock_passes_expr(day, &stock)? { + if !self.stock_passes_expr(ctx, day, &stock)? { if diagnostics.len() < 12 { diagnostics.push(format!("{} rejected by stock_expr", candidate.symbol)); } @@ -1520,7 +1798,7 @@ impl PlatformExprStrategy { quantity: position.quantity as i64, sellable_qty: position.sellable_qty(date) as i64, }; - let stop_result = self.eval_dynamic(&self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; + let stop_result = self.eval_dynamic(ctx, &self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; let stop_hit = if let Some(boolean) = stop_result.clone().try_cast::() { boolean } else if let Some(multiplier) = stop_result.clone().try_cast::() { @@ -1530,7 +1808,7 @@ impl PlatformExprStrategy { } else { false }; - let take_result = self.eval_dynamic(&self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; + let take_result = self.eval_dynamic(ctx, &self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; let profit_hit = if let Some(boolean) = take_result.clone().try_cast::() { boolean } else if let Some(multiplier) = take_result.clone().try_cast::() { @@ -1575,9 +1853,11 @@ impl Strategy for PlatformExprStrategy { } let day = self.day_state(ctx, date)?; - let trading_ratio = self.trading_ratio(&day)?; - let (band_low, band_high) = self.market_cap_band(&day)?; - let selection_limit = self.selection_limit(&day)?.min(self.config.max_positions.max(1)); + let trading_ratio = self.trading_ratio(ctx, &day)?; + let (band_low, band_high) = self.market_cap_band(ctx, &day)?; + let selection_limit = self + .selection_limit(ctx, &day)? + .min(self.config.max_positions.max(1)); let (stock_list, selection_notes) = self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; @@ -1630,7 +1910,7 @@ impl Strategy for PlatformExprStrategy { if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { continue; } - if !self.stock_passes_expr(&day, &stock)? { + if !self.stock_passes_expr(ctx, &day, &stock)? { continue; } order_intents.push(OrderIntent::Value { @@ -1694,7 +1974,7 @@ impl Strategy for PlatformExprStrategy { if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { continue; } - if !self.stock_passes_expr(&day, &stock)? { + if !self.stock_passes_expr(ctx, &day, &stock)? { continue; } order_intents.push(OrderIntent::Value {