Add generic rolling factor helpers
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user