diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index c3c409b..777f789 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -451,7 +451,6 @@ struct SymbolPriceSeries { closes: Vec, prev_closes: Vec, last_prices: Vec, - volumes: Vec, open_prefix: Vec, close_prefix: Vec, prev_close_prefix: Vec, @@ -485,7 +484,6 @@ impl SymbolPriceSeries { closes, prev_closes, last_prices, - volumes, open_prefix, close_prefix, prev_close_prefix, @@ -532,6 +530,14 @@ impl SymbolPriceSeries { } } + fn previous_completed_end_index(&self, date: NaiveDate) -> Option { + match self.dates.binary_search(&date) { + Ok(idx) => Some(idx), + Err(0) => None, + Err(idx) => Some(idx), + } + } + fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { if lookback == 0 { return None; @@ -545,28 +551,11 @@ impl SymbolPriceSeries { Some(sum / lookback as f64) } - fn decision_close_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option { - 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 { if lookback == 0 { return None; } - let end = self.decision_end_index(date)?; + let end = self.previous_completed_end_index(date)?; if end < lookback { return None; } @@ -575,23 +564,6 @@ impl SymbolPriceSeries { Some(sum / lookback as f64) } - fn decision_volume_rolling_average(&self, date: NaiveDate, lookback: usize) -> Option { - 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 { match self.dates.binary_search(&date) { Ok(idx) => Some(idx + 1), @@ -630,7 +602,6 @@ impl SymbolPriceSeries { #[derive(Debug, Clone)] struct BenchmarkPriceSeries { dates: Vec, - opens: Vec, closes: Vec, open_prefix: Vec, close_prefix: Vec, @@ -647,7 +618,6 @@ impl BenchmarkPriceSeries { let close_prefix = prefix_sums(&closes); Self { dates, - opens, closes, open_prefix, close_prefix, @@ -2014,11 +1984,11 @@ impl DataSet { "close" | "prev_close" | "stock_close" | "price" => self .market_series_by_symbol .get(symbol) - .and_then(|series| series.decision_close_rolling_average(date, lookback)), + .and_then(|series| series.decision_close_moving_average(date, lookback)), "volume" | "stock_volume" => self .market_series_by_symbol .get(symbol) - .and_then(|series| series.decision_volume_rolling_average(date, lookback)), + .and_then(|series| series.decision_volume_moving_average(date, lookback)), "day_open" | "dayopen" => { self.market_moving_average(date, symbol, lookback, PriceField::DayOpen) } @@ -3111,6 +3081,63 @@ mod tests { std::env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos)) } + fn market_row(date: &str, prev_close: f64, volume: u64) -> DailyMarketSnapshot { + DailyMarketSnapshot { + date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: prev_close, + open: prev_close, + high: prev_close, + low: prev_close, + close: prev_close, + last_price: prev_close, + bid1: prev_close, + ask1: prev_close, + prev_close, + volume, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: None, + paused: false, + upper_limit: prev_close * 1.1, + lower_limit: prev_close * 0.9, + price_tick: 0.01, + } + } + + #[test] + fn decision_volume_average_uses_previous_completed_days_only() { + let series = SymbolPriceSeries::new(&[ + market_row("2025-01-02", 10.0, 100), + market_row("2025-01-03", 11.0, 200), + market_row("2025-01-06", 12.0, 10_000), + ]); + + assert_eq!( + series.decision_close_moving_average( + NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), + 2 + ), + Some(11.5) + ); + assert_eq!( + series.decision_volume_moving_average( + NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), + 2 + ), + Some(150.0) + ); + assert_eq!( + series.decision_volume_moving_average( + NaiveDate::parse_from_str("2025-01-06", "%Y-%m-%d").unwrap(), + 3 + ), + None + ); + } + #[test] fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() { let path = temp_csv_path("mixed_factor_maps"); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9fb4eda..adf08a2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1224,47 +1224,47 @@ impl PlatformExprStrategy { let stock_ma_short = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_short_ma_days) - .unwrap_or(0.0); + .unwrap_or(f64::NAN); let stock_ma_mid = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_mid_ma_days) - .unwrap_or(0.0); + .unwrap_or(f64::NAN); let stock_ma_long = ctx .data .market_decision_close_moving_average(date, symbol, self.config.stock_long_ma_days) - .unwrap_or(0.0); + .unwrap_or(f64::NAN); let stock_ma5 = ctx .data .market_decision_close_moving_average(date, symbol, 5) - .unwrap_or(stock_ma_short); + .unwrap_or(f64::NAN); let stock_ma10 = ctx .data .market_decision_close_moving_average(date, symbol, 10) - .unwrap_or(stock_ma_mid); + .unwrap_or(f64::NAN); let stock_ma20 = ctx .data .market_decision_close_moving_average(date, symbol, 20) - .unwrap_or(stock_ma_long); + .unwrap_or(f64::NAN); let stock_ma30 = ctx .data .market_decision_close_moving_average(date, symbol, 30) - .unwrap_or(stock_ma20); + .unwrap_or(f64::NAN); let stock_volume_ma5 = ctx .data .market_decision_volume_moving_average(date, symbol, 5) - .unwrap_or(market.volume as f64); + .unwrap_or(f64::NAN); let stock_volume_ma10 = ctx .data .market_decision_volume_moving_average(date, symbol, 10) - .unwrap_or(stock_volume_ma5); + .unwrap_or(f64::NAN); let stock_volume_ma20 = ctx .data .market_decision_volume_moving_average(date, symbol, 20) - .unwrap_or(stock_volume_ma10); + .unwrap_or(f64::NAN); let stock_volume_ma60 = ctx .data .market_decision_volume_moving_average(date, symbol, 60) - .unwrap_or(stock_volume_ma20); + .unwrap_or(f64::NAN); let touched_upper_limit = factor .extra_factors .get("touched_upper_limit") @@ -2623,6 +2623,14 @@ impl PlatformExprStrategy { }) } + fn is_missing_rolling_mean_error(error: &BacktestError) -> bool { + matches!( + error, + BacktestError::Execution(message) + if message.starts_with("missing rolling mean for field ") + ) + } + fn split_top_level_args(args: &str) -> Vec { let mut parts = Vec::new(); let mut start = 0usize; @@ -2961,8 +2969,11 @@ impl PlatformExprStrategy { if self.config.buy_scale_expr.trim().is_empty() { return Ok(1.0); } - self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) - .map(|value| value.clamp(0.0, 1.0)) + match self.eval_float(ctx, &self.config.buy_scale_expr, day, Some(stock), None) { + Ok(value) => Ok(value.clamp(0.0, 1.0)), + Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(0.0), + Err(error) => Err(error), + } } fn eval_i32( @@ -3832,7 +3843,11 @@ impl PlatformExprStrategy { if self.config.stock_filter_expr.trim().is_empty() { return Ok(true); } - self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) + match self.eval_bool(ctx, &self.config.stock_filter_expr, day, Some(stock), None) { + Ok(value) => Ok(value), + Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(false), + Err(error) => Err(error), + } } fn field_value(&self, row: &EligibleUniverseSnapshot) -> f64 { @@ -3919,7 +3934,11 @@ impl PlatformExprStrategy { stock: &StockExpressionState, ) -> Result { if !self.config.rank_expr.trim().is_empty() { - return self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None); + return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) { + Ok(value) => Ok(value), + Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN), + Err(error) => Err(error), + }; } Ok(self .stock_numeric_field_value(candidate, stock, self.config.rank_by.as_str()) @@ -4003,10 +4022,28 @@ impl PlatformExprStrategy { for candidate in universe { let stock = self.stock_state(ctx, date, &candidate.symbol)?; let field_value = self.selection_field_value(&candidate, &stock); + if !field_value.is_finite() { + if diagnostics.len() < 12 { + diagnostics.push(format!( + "{} rejected by missing selection field", + candidate.symbol + )); + } + continue; + } if field_value < band_low || field_value > band_high { continue; } let rank_value = self.rank_value(ctx, day, &candidate, &stock)?; + if !rank_value.is_finite() { + if diagnostics.len() < 12 { + diagnostics.push(format!( + "{} rejected by missing rank field", + candidate.symbol + )); + } + continue; + } candidates.push((candidate, stock, rank_value)); } candidates.sort_by(|lhs, rhs| { @@ -5070,6 +5107,120 @@ mod tests { } } + #[test] + fn platform_strategy_treats_missing_stock_rolling_window_as_filter_reject() { + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Short History Stock".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2025, 1, 20)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-02-03 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.3, + low: 9.9, + close: 10.1, + last_price: 10.05, + bid1: 10.04, + ask1: 10.05, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.95, + lower_limit: 8.95, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 1; + cfg.max_positions = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "rolling_mean(\"volume\", 60) > 0".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.is_empty()); + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("selected=0")) + ); + assert!( + decision + .diagnostics + .iter() + .any(|item| item.contains("000001.SZ rejected by stock_expr")) + ); + } + #[test] fn platform_strategy_emits_target_shares_explicit_action() { let date = d(2025, 2, 3); diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 2ff8c51..479e128 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -177,8 +177,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".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: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名。只内建这几个窗口;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: "个股成交量均线内建别名。只内建这几个窗口;任意窗口请改用 rolling_mean(\"volume\", 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: "factors[\"field\"] / factor(\"field\")".to_string(), field_type: "float/string".to_string(), detail: "当前证券当日数据库因子。数值字段返回数字,字符串字段返回字符串;字符串字段名如果是合法标识符,也可直接写字段名,例如 concept == \"ai_chip\"。".to_string() }, ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() }, ], @@ -229,7 +229,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason;可用便捷函数读取状态、成交均价和费用,对齐 平台内核 Order 的核心属性。".to_string() }, ManualFunction { name: "account/portfolio_view/accounts".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 account_type、cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等;DSL 中同名字段可直接使用。也可用 ctx.stock_account()、ctx.account_by_type(\"STOCK\")、ctx.accounts() 按账户类型读取;当前股票回测路径不会把 FUTURE 虚假映射成 STOCK。".to_string() }, ManualFunction { name: "deposit_withdraw/finance_repay/management_fee".to_string(), signature: "account.deposit_withdraw(amount, receiving_days=0)".to_string(), detail: "策略账户资金动作。deposit_withdraw 正数入金、负数出金;receiving_days 大于 0 时按交易日延迟到账,并保持净值口径不把外部资金流当成收益。finance_repay 正数融资、负数还款,会同步维护 cash_liabilities。set_management_fee_rate 设置结算管理费率;普通策略可覆盖 management_fee(ctx, rate) 自定义计算器,对齐 平台内核 管理费回调能力。".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 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".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 等。个股 volume 与 close 均按当前交易日前已完成交易日计算;单只股票历史窗口不足时,在选股过滤和买入仓位表达式中按不通过/0 仓处理,不会中断整次回测。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() }, ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".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() },