修复平台策略选股表达式口径

This commit is contained in:
boris
2026-06-18 11:12:12 +08:00
parent 8d24badcf2
commit 4f39ac7dfe
3 changed files with 112 additions and 24 deletions
+43 -19
View File
@@ -428,6 +428,7 @@ struct StockExpressionState {
stock_volume_ma10: f64, stock_volume_ma10: f64,
stock_volume_ma20: f64, stock_volume_ma20: f64,
stock_volume_ma60: f64, stock_volume_ma60: f64,
stock_volume_ma100: f64,
extra_factors: BTreeMap<String, f64>, extra_factors: BTreeMap<String, f64>,
extra_text_factors: BTreeMap<String, String>, extra_text_factors: BTreeMap<String, String>,
} }
@@ -1443,16 +1444,6 @@ impl PlatformExprStrategy {
}) })
} }
fn has_execution_quote_at_or_before(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
execution_state: &ProjectedExecutionState,
) -> bool {
self.has_execution_quote_at_or_before_at_time(ctx, date, symbol, execution_state, None)
}
fn has_execution_quote_at_or_before_at_time( fn has_execution_quote_at_or_before_at_time(
&self, &self,
ctx: &StrategyContext<'_>, ctx: &StrategyContext<'_>,
@@ -1886,8 +1877,17 @@ impl PlatformExprStrategy {
execution_state, execution_state,
) )
.or_else(|| { .or_else(|| {
if !self.has_execution_quote_at_or_before(ctx, date, symbol, execution_state) if ctx.data.has_execution_quotes_on_date(date)
&& ctx.data.execution_quotes_on(date, symbol).is_empty() && ctx.data.execution_quotes_on(date, symbol).is_empty()
{
None
} else if !self.has_execution_quote_at_or_before_at_time(
ctx,
date,
symbol,
execution_state,
None,
) && ctx.data.execution_quotes_on(date, symbol).is_empty()
{ {
Some(ProjectedExecutionFill { Some(ProjectedExecutionFill {
price: sizing_price, price: sizing_price,
@@ -2209,6 +2209,9 @@ impl PlatformExprStrategy {
let stock_volume_ma60 = self let stock_volume_ma60 = self
.stock_decision_rolling_mean(ctx, date, symbol, &factor.extra_factors, "volume", 60) .stock_decision_rolling_mean(ctx, date, symbol, &factor.extra_factors, "volume", 60)
.unwrap_or(f64::NAN); .unwrap_or(f64::NAN);
let stock_volume_ma100 = self
.stock_decision_rolling_mean(ctx, date, symbol, &factor.extra_factors, "volume", 100)
.unwrap_or(f64::NAN);
let touched_upper_limit = !market.paused let touched_upper_limit = !market.paused
&& (market.is_at_upper_limit_price(market.close) && (market.is_at_upper_limit_price(market.close)
|| market.is_at_upper_limit_price(market.open) || market.is_at_upper_limit_price(market.open)
@@ -2307,6 +2310,7 @@ impl PlatformExprStrategy {
stock_volume_ma10, stock_volume_ma10,
stock_volume_ma20, stock_volume_ma20,
stock_volume_ma60, stock_volume_ma60,
stock_volume_ma100,
extra_factors: factor.extra_factors.clone(), extra_factors: factor.extra_factors.clone(),
extra_text_factors: ctx extra_text_factors: ctx
.data .data
@@ -2733,10 +2737,12 @@ impl PlatformExprStrategy {
scope.push("stock_volume_ma10", stock.stock_volume_ma10); scope.push("stock_volume_ma10", stock.stock_volume_ma10);
scope.push("stock_volume_ma20", stock.stock_volume_ma20); scope.push("stock_volume_ma20", stock.stock_volume_ma20);
scope.push("stock_volume_ma60", stock.stock_volume_ma60); scope.push("stock_volume_ma60", stock.stock_volume_ma60);
scope.push("stock_volume_ma100", stock.stock_volume_ma100);
scope.push("volume_ma5", stock.stock_volume_ma5); scope.push("volume_ma5", stock.stock_volume_ma5);
scope.push("volume_ma10", stock.stock_volume_ma10); scope.push("volume_ma10", stock.stock_volume_ma10);
scope.push("volume_ma20", stock.stock_volume_ma20); scope.push("volume_ma20", stock.stock_volume_ma20);
scope.push("volume_ma60", stock.stock_volume_ma60); scope.push("volume_ma60", stock.stock_volume_ma60);
scope.push("volume_ma100", stock.stock_volume_ma100);
if include_factors_map { if include_factors_map {
let mut factors = Map::new(); let mut factors = Map::new();
factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone()));
@@ -2868,10 +2874,18 @@ impl PlatformExprStrategy {
"stock_volume_ma60".into(), "stock_volume_ma60".into(),
Dynamic::from(stock.stock_volume_ma60), Dynamic::from(stock.stock_volume_ma60),
); );
factors.insert(
"stock_volume_ma100".into(),
Dynamic::from(stock.stock_volume_ma100),
);
factors.insert("volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5)); factors.insert("volume_ma5".into(), Dynamic::from(stock.stock_volume_ma5));
factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); factors.insert("volume_ma10".into(), Dynamic::from(stock.stock_volume_ma10));
factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20)); factors.insert("volume_ma20".into(), Dynamic::from(stock.stock_volume_ma20));
factors.insert("volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60)); factors.insert("volume_ma60".into(), Dynamic::from(stock.stock_volume_ma60));
factors.insert(
"volume_ma100".into(),
Dynamic::from(stock.stock_volume_ma100),
);
for (key, value) in &stock.extra_factors { for (key, value) in &stock.extra_factors {
factors.insert(key.clone().into(), Dynamic::from(*value)); factors.insert(key.clone().into(), Dynamic::from(*value));
} }
@@ -5749,6 +5763,7 @@ impl PlatformExprStrategy {
"stock_volume_ma10" | "volume_ma10" => Some(stock.stock_volume_ma10), "stock_volume_ma10" | "volume_ma10" => Some(stock.stock_volume_ma10),
"stock_volume_ma20" | "volume_ma20" => Some(stock.stock_volume_ma20), "stock_volume_ma20" | "volume_ma20" => Some(stock.stock_volume_ma20),
"stock_volume_ma60" | "volume_ma60" => Some(stock.stock_volume_ma60), "stock_volume_ma60" | "volume_ma60" => Some(stock.stock_volume_ma60),
"stock_volume_ma100" | "volume_ma100" => Some(stock.stock_volume_ma100),
"allow_buy" => Some(if stock.allow_buy { 1.0 } else { 0.0 }), "allow_buy" => Some(if stock.allow_buy { 1.0 } else { 0.0 }),
"allow_sell" => Some(if stock.allow_sell { 1.0 } else { 0.0 }), "allow_sell" => Some(if stock.allow_sell { 1.0 } else { 0.0 }),
"touched_upper_limit" | "hit_upper_limit" => { "touched_upper_limit" | "hit_upper_limit" => {
@@ -10655,10 +10670,19 @@ mod tests {
"{:?}", "{:?}",
decision.diagnostics decision.diagnostics
); );
assert!(matches!( assert!(
decision.order_intents.first(), decision.order_intents.iter().any(|intent| match intent {
Some(OrderIntent::Shares { symbol, quantity, .. }) if symbol == fallback_symbol && *quantity > 0 OrderIntent::Shares {
)); symbol, quantity, ..
} => symbol == fallback_symbol && *quantity > 0,
OrderIntent::Value { symbol, value, .. } => {
symbol == fallback_symbol && *value > 0.0
}
_ => false,
}),
"{:?}",
decision.order_intents
);
} }
#[test] #[test]
@@ -15105,7 +15129,7 @@ mod tests {
} }
#[test] #[test]
fn platform_aiquant_projection_uses_snapshot_before_execution_quotes_are_loaded() { fn platform_aiquant_projection_rejects_missing_symbol_execution_quote() {
let date = d(2025, 5, 14); let date = d(2025, 5, 14);
let symbol = "000003.SZ"; let symbol = "000003.SZ";
let other_symbol = "000004.SZ"; let other_symbol = "000004.SZ";
@@ -15235,12 +15259,12 @@ mod tests {
&mut execution_state, &mut execution_state,
); );
assert!(filled > 0); assert_eq!(filled, 0);
assert_eq!( assert_eq!(
projected.position(symbol).map(|position| position.quantity), projected.position(symbol).map(|position| position.quantity),
Some(filled) None
); );
assert!(projected.cash() < portfolio.cash()); assert_eq!(projected.cash(), portfolio.cash());
} }
#[test] #[test]
+68 -4
View File
@@ -566,6 +566,20 @@ fn sorted_unique_positive(mut values: Vec<usize>) -> Vec<usize> {
values values
} }
fn stock_close_ma_expr(days: usize) -> String {
match days {
5 | 10 | 20 | 30 => format!("stock_ma{days}"),
_ => format!("rolling_mean(\"close\", {days})"),
}
}
fn stock_volume_ma_expr(days: usize) -> String {
match days {
5 | 10 | 20 | 60 | 100 => format!("stock_volume_ma{days}"),
_ => format!("rolling_mean(\"volume\", {days})"),
}
}
fn infer_expression_windows( fn infer_expression_windows(
cfg: &mut PlatformExprStrategyConfig, cfg: &mut PlatformExprStrategyConfig,
benchmark_short_explicit: bool, benchmark_short_explicit: bool,
@@ -1001,10 +1015,36 @@ pub fn platform_expr_config_from_spec(
} }
if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() { if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() {
let ratio = stock_ma_filter.rsi_rate.unwrap_or(1.0001); let ratio = stock_ma_filter.rsi_rate.unwrap_or(1.0001);
cfg.stock_filter_expr = format!( let short_days = stock_ma_filter
"stock_ma_short > stock_ma_mid * {} && stock_ma_mid * {} > stock_ma_long", .short_days
ratio, ratio .unwrap_or(cfg.stock_short_ma_days);
); let mid_days = stock_ma_filter.mid_days.unwrap_or(cfg.stock_mid_ma_days);
let long_days = stock_ma_filter.long_days.unwrap_or(cfg.stock_long_ma_days);
let mut conditions = vec![
format!(
"{} > {} * {}",
stock_close_ma_expr(short_days),
stock_close_ma_expr(mid_days),
ratio
),
format!(
"{} > {} * {}",
stock_close_ma_expr(mid_days),
stock_close_ma_expr(long_days),
ratio
),
];
if let (Some(volume_short_days), Some(volume_long_days)) = (
stock_ma_filter.volume_short_days.filter(|value| *value > 0),
stock_ma_filter.volume_long_days.filter(|value| *value > 0),
) {
conditions.push(format!(
"{} < {}",
stock_volume_ma_expr(volume_short_days),
stock_volume_ma_expr(volume_long_days)
));
}
cfg.stock_filter_expr = conditions.join(" && ");
} }
if let Some(index_throttle) = engine.index_throttle.as_ref() { if let Some(index_throttle) = engine.index_throttle.as_ref() {
let ratio = index_throttle.rsi_rate.unwrap_or(1.0001); let ratio = index_throttle.rsi_rate.unwrap_or(1.0001);
@@ -1629,6 +1669,30 @@ mod tests {
); );
} }
#[test]
fn engine_stock_ma_filter_generates_price_and_volume_expr() {
let spec = serde_json::json!({
"engineConfig": {
"rankLimit": 40,
"stockMaFilter": {
"shortDays": 5,
"midDays": 10,
"longDays": 30,
"volumeShortDays": 5,
"volumeLongDays": 100,
"rsiRate": 1.00001
}
}
});
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
assert_eq!(
cfg.stock_filter_expr,
"stock_ma5 > stock_ma10 * 1.00001 && stock_ma10 > stock_ma30 * 1.00001 && stock_volume_ma5 < stock_volume_ma100"
);
}
#[test] #[test]
fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() { fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() {
let spec = serde_json::json!({ let spec = serde_json::json!({
+1 -1
View File
@@ -277,7 +277,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() }, ManualField { name: "latest_symbol_open_order_status/latest_symbol_open_order_unfilled_qty".to_string(), field_type: "string/int".to_string(), detail: "当前证券最近一笔挂单的状态和未成交数量。".to_string() },
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() }, ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN,比较条件会自然不通过;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() }, ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名,按当前交易日前 N 个已完成交易日的收盘价计算;历史窗口不足时为 NaN,比较条件会自然不通过;15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() }, ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60/stock_volume_ma100".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名,按当前交易日前 N 个已完成交易日的成交量计算,不包含回测当天未来成交量;历史窗口不足时为 NaN,比较条件会自然不通过;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日可用因子。默认可用字段以手册的“可用指标、参数和字段”清单为准;自定义因子需要预先写入策略数据或 extra_factors。数值字段返回数字,字符串字段返回字符串。".to_string() }, ManualField { name: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日可用因子。默认可用字段以手册的“可用指标、参数和字段”清单为准;自定义因子需要预先写入策略数据或 extra_factors。数值字段返回数字,字符串字段返回字符串。".to_string() },
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() }, ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
], ],