Expose signal open to platform strategies

This commit is contained in:
boris
2026-04-22 08:43:46 -07:00
parent 67b099d6e7
commit 03062b849b
3 changed files with 89 additions and 29 deletions

View File

@@ -485,7 +485,9 @@ impl SymbolPriceSeries {
#[derive(Debug, Clone)]
struct BenchmarkPriceSeries {
dates: Vec<NaiveDate>,
opens: Vec<f64>,
closes: Vec<f64>,
open_prefix: Vec<f64>,
close_prefix: Vec<f64>,
}
@@ -494,16 +496,24 @@ impl BenchmarkPriceSeries {
let mut sorted = rows.to_vec();
sorted.sort_by_key(|row| row.date);
let dates = sorted.iter().map(|row| row.date).collect::<Vec<_>>();
let opens = sorted.iter().map(|row| row.open).collect::<Vec<_>>();
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
let open_prefix = prefix_sums(&opens);
let close_prefix = prefix_sums(&closes);
Self {
dates,
opens,
closes,
open_prefix,
close_prefix,
}
}
fn moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
self.moving_average_for(date, lookback, PriceField::Close)
}
fn moving_average_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option<f64> {
if lookback == 0 {
return None;
}
@@ -516,7 +526,11 @@ impl BenchmarkPriceSeries {
return None;
}
let start = end - lookback;
let sum = self.close_prefix[end] - self.close_prefix[start];
let prefix = match field {
PriceField::Open => &self.open_prefix,
PriceField::Close | PriceField::Last => &self.close_prefix,
};
let sum = prefix[end] - prefix[start];
Some(sum / lookback as f64)
}
@@ -942,6 +956,20 @@ impl DataSet {
self.benchmark_series_cache.moving_average(date, lookback)
}
pub fn benchmark_open_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
self.benchmark_series_cache
.moving_average_for(date, lookback, PriceField::Open)
}
pub fn market_open_moving_average(
&self,
date: NaiveDate,
symbol: &str,
lookback: usize,
) -> Option<f64> {
self.market_moving_average(date, symbol, lookback, PriceField::Open)
}
pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] {
self.eligible_universe_by_date
.get(&date)

View File

@@ -69,10 +69,9 @@ fn band_low(index_close) {
"stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long"
.to_string(),
buy_scale_expr: "1.0".to_string(),
exposure_expr:
"benchmark_ma_short < benchmark_ma_long * ma_ratio ? 0.5 : 1.0".to_string(),
stop_loss_expr: "0.93".to_string(),
take_profit_expr: "1.07".to_string(),
exposure_expr: "1.0".to_string(),
stop_loss_expr: String::new(),
take_profit_expr: String::new(),
rank_by: "market_cap".to_string(),
rank_expr: String::new(),
rank_desc: false,
@@ -110,7 +109,9 @@ struct ProjectedExecutionFill {
#[derive(Debug, Clone)]
struct DayExpressionState {
date: NaiveDate,
signal_open: f64,
signal_close: f64,
benchmark_open: f64,
benchmark_close: f64,
benchmark_ma_short: f64,
benchmark_ma_long: f64,
@@ -647,6 +648,15 @@ impl PlatformExprStrategy {
ctx: &StrategyContext<'_>,
date: NaiveDate,
) -> Result<DayExpressionState, BacktestError> {
let signal_open = ctx
.data
.market(date, &self.config.signal_symbol)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: self.config.signal_symbol.clone(),
field: "open",
})?
.open;
let signal_close = ctx
.data
.market_decision_close(date, &self.config.signal_symbol)
@@ -655,11 +665,12 @@ impl PlatformExprStrategy {
symbol: self.config.signal_symbol.clone(),
field: "decision_close",
})?;
let benchmark_close = ctx
let benchmark = ctx
.data
.benchmark(date)
.ok_or(BacktestError::MissingBenchmark { date })?
.close;
.ok_or(BacktestError::MissingBenchmark { date })?;
let benchmark_open = benchmark.open;
let benchmark_close = benchmark.close;
let benchmark_ma_short = ctx
.data
.market_decision_close_moving_average(
@@ -711,7 +722,9 @@ impl PlatformExprStrategy {
Ok(DayExpressionState {
date,
signal_open,
signal_close,
benchmark_open,
benchmark_close,
benchmark_ma_short,
benchmark_ma_long,
@@ -868,7 +881,9 @@ impl PlatformExprStrategy {
position: Option<&PositionExpressionState>,
) -> Scope<'static> {
let mut scope = Scope::new();
scope.push("signal_open", day.signal_open);
scope.push("signal_close", day.signal_close);
scope.push("benchmark_open", day.benchmark_open);
scope.push("benchmark_close", day.benchmark_close);
scope.push("signal_ma5", day.signal_ma5);
scope.push("signal_ma10", day.signal_ma10);
@@ -898,7 +913,9 @@ impl PlatformExprStrategy {
scope.push("is_month_end", day.is_month_end);
scope.push("signal_ma30", day.signal_ma30);
let mut day_factors = Map::new();
day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open));
day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close));
day_factors.insert("benchmark_open".into(), Dynamic::from(day.benchmark_open));
day_factors.insert("benchmark_close".into(), Dynamic::from(day.benchmark_close));
day_factors.insert("signal_ma5".into(), Dynamic::from(day.signal_ma5));
day_factors.insert("signal_ma10".into(), Dynamic::from(day.signal_ma10));
@@ -1244,7 +1261,11 @@ impl PlatformExprStrategy {
));
}
let value = match field {
"benchmark_open" => ctx.data.benchmark_open_moving_average(day.date, lookback),
"benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback),
"signal_open" => ctx
.data
.market_open_moving_average(day.date, &self.config.signal_symbol, lookback),
"signal_close" => ctx
.data
.market_decision_close_moving_average(day.date, &self.config.signal_symbol, lookback),
@@ -1838,6 +1859,9 @@ impl PlatformExprStrategy {
let Some(position) = ctx.portfolio.position(symbol) else {
return Ok((false, false));
};
if self.config.stop_loss_expr.trim().is_empty() && self.config.take_profit_expr.trim().is_empty() {
return Ok((false, false));
}
if position.quantity == 0 || position.average_cost <= 0.0 {
return Ok((false, false));
}
@@ -1855,27 +1879,35 @@ impl PlatformExprStrategy {
quantity: position.quantity as i64,
sellable_qty: position.sellable_qty(date) as i64,
};
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>() {
boolean
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
current_price <= position.average_cost * multiplier
} else if let Some(multiplier) = stop_result.try_cast::<i64>() {
current_price <= position.average_cost * multiplier as f64
} else {
let stop_hit = if self.config.stop_loss_expr.trim().is_empty() {
false
} else {
let stop_result = self.eval_dynamic(ctx, &self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?;
if let Some(boolean) = stop_result.clone().try_cast::<bool>() {
boolean
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
current_price <= position.average_cost * multiplier
} else if let Some(multiplier) = stop_result.try_cast::<i64>() {
current_price <= position.average_cost * multiplier as f64
} else {
false
}
};
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>() {
boolean
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
!ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier
} else if let Some(multiplier) = take_result.try_cast::<i64>() {
!ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier as f64
} else {
let profit_hit = if self.config.take_profit_expr.trim().is_empty() {
false
} else {
let take_result = self.eval_dynamic(ctx, &self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?;
if let Some(boolean) = take_result.clone().try_cast::<bool>() {
boolean
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
!ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier
} else if let Some(multiplier) = take_result.try_cast::<i64>() {
!ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier as f64
} else {
false
}
};
Ok((stop_hit, profit_hit))
}

View File

@@ -116,8 +116,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "日级字段".to_string(),
detail: "可直接在表达式中使用的日级与账户字段。".to_string(),
fields: vec![
ManualField { name: "signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数前一日收盘价。".to_string() },
ManualField { name: "benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准前一日收盘价。".to_string() },
ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
ManualField { name: "cash/available_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户资金与总资产。".to_string() },
@@ -158,7 +158,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
functions: vec![
ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票的数据库因子列。".to_string() },
ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() },
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio 等。".to_string() },
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。".to_string() },
ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。".to_string() },
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },
ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },