From 4f39ac7dfe8d828ac6855d5d3b8cbbe4bc1d12ec Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 18 Jun 2026 11:12:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B3=E5=8F=B0=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E9=80=89=E8=82=A1=E8=A1=A8=E8=BE=BE=E5=BC=8F=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 62 +++++++++++----- .../fidc-core/src/platform_strategy_spec.rs | 72 +++++++++++++++++-- crates/fidc-core/src/strategy_ai.rs | 2 +- 3 files changed, 112 insertions(+), 24 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3b2a843..480fbfa 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -428,6 +428,7 @@ struct StockExpressionState { stock_volume_ma10: f64, stock_volume_ma20: f64, stock_volume_ma60: f64, + stock_volume_ma100: f64, extra_factors: BTreeMap, extra_text_factors: BTreeMap, } @@ -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( &self, ctx: &StrategyContext<'_>, @@ -1886,8 +1877,17 @@ impl PlatformExprStrategy { execution_state, ) .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() + { + 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 { price: sizing_price, @@ -2209,6 +2209,9 @@ impl PlatformExprStrategy { let stock_volume_ma60 = self .stock_decision_rolling_mean(ctx, date, symbol, &factor.extra_factors, "volume", 60) .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 && (market.is_at_upper_limit_price(market.close) || market.is_at_upper_limit_price(market.open) @@ -2307,6 +2310,7 @@ impl PlatformExprStrategy { stock_volume_ma10, stock_volume_ma20, stock_volume_ma60, + stock_volume_ma100, extra_factors: factor.extra_factors.clone(), extra_text_factors: ctx .data @@ -2733,10 +2737,12 @@ impl PlatformExprStrategy { scope.push("stock_volume_ma10", stock.stock_volume_ma10); scope.push("stock_volume_ma20", stock.stock_volume_ma20); 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_ma10", stock.stock_volume_ma10); scope.push("volume_ma20", stock.stock_volume_ma20); scope.push("volume_ma60", stock.stock_volume_ma60); + scope.push("volume_ma100", stock.stock_volume_ma100); if include_factors_map { let mut factors = Map::new(); factors.insert("symbol".into(), Dynamic::from(stock.symbol.clone())); @@ -2868,10 +2874,18 @@ impl PlatformExprStrategy { "stock_volume_ma60".into(), 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_ma10".into(), Dynamic::from(stock.stock_volume_ma10)); 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_ma100".into(), + Dynamic::from(stock.stock_volume_ma100), + ); for (key, value) in &stock.extra_factors { 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_ma20" | "volume_ma20" => Some(stock.stock_volume_ma20), "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_sell" => Some(if stock.allow_sell { 1.0 } else { 0.0 }), "touched_upper_limit" | "hit_upper_limit" => { @@ -10655,10 +10670,19 @@ mod tests { "{:?}", decision.diagnostics ); - assert!(matches!( - decision.order_intents.first(), - Some(OrderIntent::Shares { symbol, quantity, .. }) if symbol == fallback_symbol && *quantity > 0 - )); + assert!( + decision.order_intents.iter().any(|intent| match intent { + OrderIntent::Shares { + symbol, quantity, .. + } => symbol == fallback_symbol && *quantity > 0, + OrderIntent::Value { symbol, value, .. } => { + symbol == fallback_symbol && *value > 0.0 + } + _ => false, + }), + "{:?}", + decision.order_intents + ); } #[test] @@ -15105,7 +15129,7 @@ mod tests { } #[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 symbol = "000003.SZ"; let other_symbol = "000004.SZ"; @@ -15235,12 +15259,12 @@ mod tests { &mut execution_state, ); - assert!(filled > 0); + assert_eq!(filled, 0); assert_eq!( projected.position(symbol).map(|position| position.quantity), - Some(filled) + None ); - assert!(projected.cash() < portfolio.cash()); + assert_eq!(projected.cash(), portfolio.cash()); } #[test] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index d0026ad..1a2c4ee 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -566,6 +566,20 @@ fn sorted_unique_positive(mut values: Vec) -> Vec { 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( cfg: &mut PlatformExprStrategyConfig, 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() { let ratio = stock_ma_filter.rsi_rate.unwrap_or(1.0001); - cfg.stock_filter_expr = format!( - "stock_ma_short > stock_ma_mid * {} && stock_ma_mid * {} > stock_ma_long", - ratio, ratio - ); + let short_days = stock_ma_filter + .short_days + .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() { 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] fn aiquant_profile_defaults_to_daily_top_up_and_empty_retry() { let spec = serde_json::json!({ diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index ff8847a..271c9a7 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -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: "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_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: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() }, ],