diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 769c574..1d2a718 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -5567,8 +5567,8 @@ impl PlatformExprStrategy { fn fast_stock_passes_expr( &self, - ctx: &StrategyContext<'_>, - day: &DayExpressionState, + _ctx: &StrategyContext<'_>, + _day: &DayExpressionState, stock: &StockExpressionState, ) -> Option { let compact = Self::compact_expr(&Self::normalize_expr(&self.config.stock_filter_expr)); @@ -5583,31 +5583,55 @@ impl PlatformExprStrategy { ); } - if compact - != "listed_days>=min_listed_days&&rolling_mean(\"close\",5)>rolling_mean(\"close\",10)*ma_ratio&&rolling_mean(\"close\",10)>rolling_mean(\"close\",30)*ma_ratio&&rolling_mean(\"volume\",5)=min_listed_days&&") { + filter_body = rest; + true + } else { + false + }; + let base_microcap_filter = "rolling_mean(\"close\",5)>rolling_mean(\"close\",10)*ma_ratio&&rolling_mean(\"close\",10)>rolling_mean(\"close\",30)*ma_ratio&&rolling_mean(\"volume\",5)0&&rolling_mean(\"volume\",100)>0" + | "&&rolling_mean(\"volume\",5)>0.0&&rolling_mean(\"volume\",100)>0.0" + ) + }) { + true + } else { return None; - } + }; - let min_listed_days = self - .prelude_numeric_constant("min_listed_days") - .unwrap_or(0.0); + let listed_days_pass = if requires_min_listed_days { + let min_listed_days = self + .prelude_numeric_constant("min_listed_days") + .unwrap_or(0.0); + (stock.listed_days as f64) >= min_listed_days + } else { + true + }; let max_volume_ratio = self .prelude_numeric_constant("max_volume_ratio") .unwrap_or(1.0); - let volume_ma100 = ctx - .data - .market_decision_volume_moving_average(day.date, &stock.symbol, 100) - .or_else(|| precomputed_stock_rolling_mean(&stock.extra_factors, "volume", 100)) - .unwrap_or(f64::NAN); + let volume_ma100 = stock.stock_volume_ma100; + let positive_volume_pass = + !requires_positive_volume || (stock.stock_volume_ma5 > 0.0 && volume_ma100 > 0.0); Some( - (stock.listed_days as f64) >= min_listed_days + listed_days_pass && stock.stock_ma5.is_finite() && stock.stock_ma10.is_finite() && stock.stock_ma30.is_finite() && stock.stock_volume_ma5.is_finite() && volume_ma100.is_finite() + && positive_volume_pass && stock.stock_ma5 > stock.stock_ma10 * ma_ratio && stock.stock_ma10 > stock.stock_ma30 * ma_ratio && stock.stock_volume_ma5 < volume_ma100 * max_volume_ratio, @@ -9529,6 +9553,124 @@ mod tests { ); } + #[test] + fn platform_stock_expr_fast_path_handles_positive_volume_guard() { + let current = d(2023, 5, 4); + let symbol = "000153.SZ"; + let build_data = |volume_ma5: f64, volume_ma100: f64| { + DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date: current, + symbol: symbol.to_string(), + timestamp: Some(format!("{current} 10:18:00")), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 1_000, + tick_volume: 1_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date: current, + symbol: symbol.to_string(), + market_cap_bn: 33.64, + free_float_cap_bn: 33.64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::from([ + ("ma5".to_string(), 11.0), + ("ma10".to_string(), 10.0), + ("ma30".to_string(), 9.0), + ("avg_volume5".to_string(), volume_ma5), + ("avg_volume100".to_string(), volume_ma100), + ]), + }], + vec![CandidateEligibility { + date: current, + symbol: symbol.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: current, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset") + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = symbol.to_string(); + cfg.prefer_precomputed_rolling_factors = true; + cfg.prelude = "let ma_ratio = 1.00001; let max_volume_ratio = 1;".to_string(); + cfg.stock_filter_expr = "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) * ma_ratio && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30) * ma_ratio && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 100) * max_volume_ratio && rolling_mean(\"volume\", 5) > 0 && rolling_mean(\"volume\", 100) > 0".to_string(); + + for (volume_ma5, volume_ma100, expected) in [(50.0, 100.0, true), (0.0, 100.0, false)] { + let data = build_data(volume_ma5, volume_ma100); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: current, + decision_date: current, + 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 strategy = PlatformExprStrategy::new(cfg.clone()); + let day = strategy.day_state(&ctx, current).expect("day state"); + let stock = strategy + .stock_state_with_factor_date(&ctx, current, current, symbol) + .expect("stock state"); + + assert_eq!( + strategy + .stock_passes_expr(&ctx, &day, &stock) + .expect("stock expr"), + expected + ); + assert_eq!(strategy.ast_cache_misses(), 0); + } + } + #[test] fn platform_aiquant_weak_market_emits_daily_position_target_adjustment() { let date = d(2023, 5, 5);