From 6e8eeb984fb6d183dfeb0aa447576a9dbc360bde Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 17 Jul 2026 16:31:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=BD=93=E5=89=8D=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=9B=A0=E5=AD=90=E6=97=A5=E6=9C=9F=E5=8F=96=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 170 +++++++++++++++++- 1 file changed, 165 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 94cf717..098048f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -777,6 +777,19 @@ fn precomputed_stock_current_rolling_mean( } } +fn is_precomputed_stock_current_rolling_key(key: &str) -> bool { + fn has_numeric_window(key: &str, prefix: &str, suffix: &str) -> bool { + key.strip_prefix(prefix) + .and_then(|value| value.strip_suffix(suffix)) + .is_some_and(|window| { + !window.is_empty() && window.bytes().all(|byte| byte.is_ascii_digit()) + }) + } + + has_numeric_window(key, "ma", "_current_close") + || has_numeric_window(key, "avg_volume", "_current") +} + pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, @@ -3543,6 +3556,25 @@ impl PlatformExprStrategy { feature_market.volume as f64 }; + let extra_factors = if self.stock_extra_factors_required { + let mut values = factor.extra_factors.clone(); + if date != factor_date { + values.retain(|key, _| !is_precomputed_stock_current_rolling_key(key)); + if let Some(current_factor) = ctx.data.factor(date, symbol) { + values.extend( + current_factor + .extra_factors + .iter() + .filter(|(key, _)| is_precomputed_stock_current_rolling_key(key)) + .map(|(key, value)| (key.clone(), *value)), + ); + } + } + values + } else { + BTreeMap::new() + }; + let state = StockExpressionState { symbol: symbol.to_string(), market_cap, @@ -3603,11 +3635,7 @@ impl PlatformExprStrategy { stock_volume_ma20, stock_volume_ma60, stock_volume_ma100, - extra_factors: if self.stock_extra_factors_required { - factor.extra_factors.clone() - } else { - BTreeMap::new() - }, + extra_factors, extra_text_factors: if self.stock_text_factors_required { ctx.data .factor_text_snapshots_on(date) @@ -28481,6 +28509,138 @@ mod tests { assert_eq!(stock.stock_volume_ma5, 1_000.0); } + #[test] + fn platform_current_rolling_uses_decision_date_row_not_prior_factor_row() { + let factor_date = d(2025, 1, 6); + let decision_date = d(2025, 1, 7); + let symbol = "300001.SZ"; + let market_rows = [factor_date, decision_date] + .into_iter() + .map(|date| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 1_000, + minute_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(); + let factor_rows = [(factor_date, 1.0, 100.0), (decision_date, 20.0, 2_000.0)] + .into_iter() + .map( + |(date, current_close, current_volume)| DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 20.0, + pe_ttm: 0.0, + turnover_ratio: None, + effective_turnover_ratio: None, + extra_factors: BTreeMap::from([ + ("adjustment_factor_backward1".to_string(), 1.0), + ("ma5_current_close".to_string(), current_close), + ("avg_volume5_current".to_string(), current_volume), + ]), + }, + ) + .collect(); + let data = 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(), + }], + market_rows, + factor_rows, + vec![CandidateEligibility { + date: decision_date, + symbol: symbol.to_string(), + is_st: false, + is_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], + vec![BenchmarkSnapshot { + date: decision_date, + benchmark: symbol.to_string(), + open: 1_000.0, + close: 1_002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(100_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: decision_date, + decision_date, + decision_index: 1, + 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 = symbol.to_string(); + cfg.prefer_precomputed_rolling_factors = true; + cfg.stock_filter_expr = "rolling_mean_current(\"close\", 5) == 20.0 && rolling_mean_current(\"volume\", 5) == 2000.0".to_string(); + let strategy = PlatformExprStrategy::new(cfg); + let stock = strategy + .stock_state_with_factor_date(&ctx, decision_date, factor_date, symbol) + .expect("stock state"); + let day = strategy.day_state(&ctx, decision_date).expect("day state"); + + assert!( + strategy + .stock_passes_expr(&ctx, &day, &stock) + .expect("current rolling filter") + ); + assert_eq!( + strategy + .resolve_current_rolling_mean(&ctx, &day, Some(&stock), "close", 5) + .expect("current close rolling mean"), + 20.0 + ); + assert_eq!( + strategy + .resolve_current_rolling_mean(&ctx, &day, Some(&stock), "volume", 5) + .expect("current volume rolling mean"), + 2_000.0 + ); + } + #[test] fn platform_stock_state_falls_back_when_precomputed_rolling_is_missing() { let current = d(2025, 5, 30);