Add generic rolling factor helpers

This commit is contained in:
boris
2026-04-22 00:59:44 -07:00
parent f809399f8e
commit 29ba97f471
2 changed files with 411 additions and 19 deletions

View File

@@ -402,6 +402,23 @@ impl SymbolPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_close_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
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<f64> { fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 { if lookback == 0 {
return None; return None;
@@ -415,6 +432,23 @@ impl SymbolPriceSeries {
Some(sum / lookback as f64) Some(sum / lookback as f64)
} }
fn decision_volume_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
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<usize> { fn end_index(&self, date: NaiveDate) -> Option<usize> {
match self.dates.binary_search(&date) { match self.dates.binary_search(&date) {
Ok(idx) => Some(idx + 1), Ok(idx) => Some(idx + 1),
@@ -827,6 +861,71 @@ impl DataSet {
.and_then(|series| series.decision_volume_moving_average(date, lookback)) .and_then(|series| series.decision_volume_moving_average(date, lookback))
} }
pub fn factor_numeric_value(
&self,
date: NaiveDate,
symbol: &str,
field: &str,
) -> Option<f64> {
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<f64> {
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<f64> {
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( pub fn market_moving_average(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -981,6 +1080,19 @@ fn read_factors(path: &Path) -> Result<Vec<DailyFactorSnapshot>, DataSetError> {
Ok(snapshots) Ok(snapshots)
} }
fn factor_numeric_value(snapshot: &DailyFactorSnapshot, field: &str) -> Option<f64> {
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<Vec<CandidateEligibility>, DataSetError> { fn read_candidates(path: &Path) -> Result<Vec<CandidateEligibility>, DataSetError> {
let rows = read_rows(path)?; let rows = read_rows(path)?;
let mut snapshots = Vec::new(); let mut snapshots = Vec::new();

View File

@@ -107,6 +107,7 @@ struct ProjectedExecutionFill {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DayExpressionState { struct DayExpressionState {
date: NaiveDate,
signal_close: f64, signal_close: f64,
benchmark_close: f64, benchmark_close: f64,
benchmark_ma_short: f64, benchmark_ma_short: f64,
@@ -701,6 +702,7 @@ impl PlatformExprStrategy {
let is_month_end = next_day.month() != date.month(); let is_month_end = next_day.month() != date.month();
Ok(DayExpressionState { Ok(DayExpressionState {
date,
signal_close, signal_close,
benchmark_close, benchmark_close,
benchmark_ma_short, benchmark_ma_short,
@@ -1032,6 +1034,7 @@ impl PlatformExprStrategy {
fn eval_dynamic( fn eval_dynamic(
&self, &self,
ctx: &StrategyContext<'_>,
expr: &str, expr: &str,
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
@@ -1039,10 +1042,11 @@ 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 expanded_expr = self.expand_runtime_helpers(ctx, day, stock, &normalized_expr)?;
let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude); let prelude_declared_identifiers = Self::declared_prelude_identifiers(&self.config.prelude);
if let Some(item) = stock { if let Some(item) = stock {
let reserved_names = Self::reserved_scope_names(); 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()) if reserved_names.contains(identifier.as_str())
|| prelude_declared_identifiers.contains(&identifier) || prelude_declared_identifiers.contains(&identifier)
|| !day.available_factor_names.contains(&identifier) || !day.available_factor_names.contains(&identifier)
@@ -1073,7 +1077,7 @@ impl PlatformExprStrategy {
if let Some(alias_prelude) = factor_alias_prelude { if let Some(alias_prelude) = factor_alias_prelude {
script_parts.push(alias_prelude); script_parts.push(alias_prelude);
} }
script_parts.push(normalized_expr); script_parts.push(expanded_expr);
let script = script_parts.join("\n"); let script = script_parts.join("\n");
self.engine self.engine
.eval_with_scope::<Dynamic>(&mut scope, &script) .eval_with_scope::<Dynamic>(&mut scope, &script)
@@ -1084,6 +1088,273 @@ impl PlatformExprStrategy {
Self::rewrite_ternary(expr.trim()) Self::rewrite_ternary(expr.trim())
} }
fn expand_runtime_helpers(
&self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
expr: &str,
) -> Result<String, BacktestError> {
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<String, BacktestError> {
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<f64, BacktestError> {
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<String> {
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<String, BacktestError> {
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<usize, BacktestError> {
let trimmed = raw.trim();
let value = trimmed.parse::<usize>().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<usize> {
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<String> { fn declared_prelude_identifiers(prelude: &str) -> BTreeSet<String> {
prelude prelude
.lines() .lines()
@@ -1206,12 +1477,13 @@ impl PlatformExprStrategy {
fn eval_float( fn eval_float(
&self, &self,
ctx: &StrategyContext<'_>,
expr: &str, expr: &str,
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>, position: Option<&PositionExpressionState>,
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
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::<f64>() { if let Some(number) = value.clone().try_cast::<f64>() {
return Ok(number); return Ok(number);
} }
@@ -1229,12 +1501,13 @@ impl PlatformExprStrategy {
fn eval_bool( fn eval_bool(
&self, &self,
ctx: &StrategyContext<'_>,
expr: &str, expr: &str,
day: &DayExpressionState, day: &DayExpressionState,
stock: Option<&StockExpressionState>, stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>, position: Option<&PositionExpressionState>,
) -> Result<bool, BacktestError> { ) -> Result<bool, BacktestError> {
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::<bool>() { if let Some(boolean) = value.clone().try_cast::<bool>() {
return Ok(boolean); return Ok(boolean);
} }
@@ -1252,38 +1525,42 @@ impl PlatformExprStrategy {
fn trading_ratio( fn trading_ratio(
&self, &self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
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)) .map(|value| value.clamp(0.0, 1.0))
} }
fn market_cap_band( fn market_cap_band(
&self, &self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
) -> Result<(f64, f64), BacktestError> { ) -> Result<(f64, f64), BacktestError> {
let low = self.eval_float(&self.config.market_cap_lower_expr, day, None, None)?; let low = self.eval_float(ctx, &self.config.market_cap_lower_expr, day, None, None)?;
let high = self.eval_float(&self.config.market_cap_upper_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))) Ok((low.min(high), low.max(high)))
} }
fn selection_limit( fn selection_limit(
&self, &self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
) -> Result<usize, BacktestError> { ) -> Result<usize, BacktestError> {
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) Ok(value.round().max(1.0) as usize)
} }
fn stock_passes_expr( fn stock_passes_expr(
&self, &self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
stock: &StockExpressionState, stock: &StockExpressionState,
) -> Result<bool, BacktestError> { ) -> Result<bool, BacktestError> {
if self.config.stock_filter_expr.trim().is_empty() { if self.config.stock_filter_expr.trim().is_empty() {
return Ok(true); 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 { fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 {
@@ -1356,12 +1633,13 @@ impl PlatformExprStrategy {
fn rank_value( fn rank_value(
&self, &self,
ctx: &StrategyContext<'_>,
day: &DayExpressionState, day: &DayExpressionState,
candidate: &EligibleUniverseSnapshot, candidate: &EligibleUniverseSnapshot,
stock: &StockExpressionState, stock: &StockExpressionState,
) -> Result<f64, BacktestError> { ) -> Result<f64, BacktestError> {
if !self.config.rank_expr.trim().is_empty() { 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 Ok(self
.stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) .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 { if field_value < band_low || field_value > band_high {
continue; 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.push((candidate, stock, rank_value));
} }
candidates.sort_by(|lhs, rhs| { candidates.sort_by(|lhs, rhs| {
@@ -1478,7 +1756,7 @@ impl PlatformExprStrategy {
} }
continue; continue;
} }
if !self.stock_passes_expr(day, &stock)? { if !self.stock_passes_expr(ctx, day, &stock)? {
if diagnostics.len() < 12 { if diagnostics.len() < 12 {
diagnostics.push(format!("{} rejected by stock_expr", candidate.symbol)); diagnostics.push(format!("{} rejected by stock_expr", candidate.symbol));
} }
@@ -1520,7 +1798,7 @@ impl PlatformExprStrategy {
quantity: position.quantity as i64, quantity: position.quantity as i64,
sellable_qty: position.sellable_qty(date) 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::<bool>() { let stop_hit = if let Some(boolean) = stop_result.clone().try_cast::<bool>() {
boolean boolean
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() { } else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
@@ -1530,7 +1808,7 @@ impl PlatformExprStrategy {
} else { } else {
false 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::<bool>() { let profit_hit = if let Some(boolean) = take_result.clone().try_cast::<bool>() {
boolean boolean
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() { } else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
@@ -1575,9 +1853,11 @@ impl Strategy for PlatformExprStrategy {
} }
let day = self.day_state(ctx, date)?; let day = self.day_state(ctx, date)?;
let trading_ratio = self.trading_ratio(&day)?; let trading_ratio = self.trading_ratio(ctx, &day)?;
let (band_low, band_high) = self.market_cap_band(&day)?; let (band_low, band_high) = self.market_cap_band(ctx, &day)?;
let selection_limit = self.selection_limit(&day)?.min(self.config.max_positions.max(1)); let selection_limit = self
.selection_limit(ctx, &day)?
.min(self.config.max_positions.max(1));
let (stock_list, selection_notes) = let (stock_list, selection_notes) =
self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?;
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; 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() { if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() {
continue; continue;
} }
if !self.stock_passes_expr(&day, &stock)? { if !self.stock_passes_expr(ctx, &day, &stock)? {
continue; continue;
} }
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {
@@ -1694,7 +1974,7 @@ impl Strategy for PlatformExprStrategy {
if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() { if self.buy_rejection_reason(ctx, date, symbol, &stock)?.is_some() {
continue; continue;
} }
if !self.stock_passes_expr(&day, &stock)? { if !self.stock_passes_expr(ctx, &day, &stock)? {
continue; continue;
} }
order_intents.push(OrderIntent::Value { order_intents.push(OrderIntent::Value {