diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9e73179..72fce20 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3100,8 +3100,7 @@ impl PlatformExprStrategy { '(' => { let next_depth = paren_depth + 1; paren_depth += 1; - if next_depth == ternary_paren_depth && brace_depth == 0 && bracket_depth == 0 - { + if next_depth == ternary_paren_depth && brace_depth == 0 && bracket_depth == 0 { start = idx + ch.len_utf8(); } } @@ -4373,7 +4372,8 @@ impl PlatformExprStrategy { fn stop_take_action( &self, ctx: &StrategyContext<'_>, - date: NaiveDate, + signal_date: NaiveDate, + execution_date: NaiveDate, day: &DayExpressionState, symbol: &str, ) -> Result<(bool, bool), BacktestError> { @@ -4388,7 +4388,7 @@ impl PlatformExprStrategy { if position.quantity == 0 || position.average_cost <= 0.0 { return Ok((false, false)); } - let stock = match self.stock_state(ctx, date, symbol) { + let stock = match self.stock_state(ctx, signal_date, symbol) { Ok(stock) => stock, Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { return Ok((false, false)); @@ -4415,9 +4415,9 @@ impl PlatformExprStrategy { prev_close: stock.prev_close, holding_return, quantity: position.quantity as i64, - sellable_qty: position.sellable_qty(date) as i64, - sellable: position.sellable_qty(date) as i64, - closable: position.sellable_qty(date) as i64, + sellable_qty: position.sellable_qty(execution_date) as i64, + sellable: position.sellable_qty(execution_date) as i64, + closable: position.sellable_qty(execution_date) as i64, old_quantity: position.day_start_quantity() as i64, bought_quantity: position.bought_quantity() as i64, sold_quantity: position.sold_quantity() as i64, @@ -4471,12 +4471,12 @@ impl PlatformExprStrategy { boolean } else if let Some(multiplier) = take_result.clone().try_cast::() { !ctx.data - .require_market(date, symbol)? + .require_market(signal_date, symbol)? .is_at_upper_limit_price(current_price) && current_price / position.average_cost > multiplier } else if let Some(multiplier) = take_result.try_cast::() { !ctx.data - .require_market(date, symbol)? + .require_market(signal_date, symbol)? .is_at_upper_limit_price(current_price) && current_price / position.average_cost > multiplier as f64 } else { @@ -4506,8 +4506,9 @@ impl Strategy for PlatformExprStrategy { } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { - let date = ctx.execution_date; - if self.config.in_skip_window(date) { + let execution_date = ctx.execution_date; + let decision_date = ctx.decision_date; + if self.config.in_skip_window(execution_date) { return Ok(StrategyDecision { rebalance: false, target_weights: BTreeMap::new(), @@ -4523,17 +4524,17 @@ impl Strategy for PlatformExprStrategy { reason: "seasonal_stop_window".to_string(), }) .collect(), - notes: vec![format!("seasonal stop window on {}", date)], + notes: vec![format!("seasonal stop window on {}", execution_date)], diagnostics: vec!["platform expr skip window forced all cash".to_string()], }); } - let day = self.day_state(ctx, date)?; + let day = self.day_state(ctx, decision_date)?; let (explicit_action_intents, explicit_action_diagnostics) = if self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay - && self.explicit_actions_active(ctx.data.calendar(), date) + && self.explicit_actions_active(ctx.data.calendar(), execution_date) { - self.explicit_action_intents(ctx, date, &day)? + self.explicit_action_intents(ctx, decision_date, &day)? } else { (Vec::new(), Vec::new()) }; @@ -4555,8 +4556,14 @@ impl Strategy for PlatformExprStrategy { 0 }; let stock_list = if self.config.rotation_enabled { - let (stock_list, notes) = - self.select_symbols(ctx, date, &day, band_low, band_high, selection_limit)?; + let (stock_list, notes) = self.select_symbols( + ctx, + decision_date, + &day, + band_low, + band_high, + selection_limit, + )?; selection_notes = notes; stock_list } else { @@ -4566,7 +4573,7 @@ impl Strategy for PlatformExprStrategy { if let Some(schedule) = &self.config.rebalance_schedule { schedule.matches( ctx.data.calendar(), - date, + execution_date, ScheduleStage::OnDay, default_stage_time(ScheduleStage::OnDay), ) @@ -4586,8 +4593,8 @@ impl Strategy for PlatformExprStrategy { continue; } let (stop_hit, profit_hit) = - self.stop_take_action(ctx, date, &day, &position.symbol)?; - let can_sell = self.can_sell_position(ctx, date, &position.symbol); + self.stop_take_action(ctx, decision_date, execution_date, &day, &position.symbol)?; + let can_sell = self.can_sell_position(ctx, execution_date, &position.symbol); if stop_hit || profit_hit { let sell_reason = if stop_hit { "stop_loss_exit" @@ -4604,7 +4611,7 @@ impl Strategy for PlatformExprStrategy { self.project_target_zero( ctx, &mut projected, - date, + execution_date, &position.symbol, &mut projected_execution_state, ); @@ -4621,18 +4628,24 @@ impl Strategy for PlatformExprStrategy { { continue; } - let stock = self.stock_state(ctx, date, symbol)?; + let decision_stock = self.stock_state(ctx, decision_date, symbol)?; + let execution_stock = self.stock_state(ctx, execution_date, symbol)?; if self - .buy_rejection_reason(ctx, date, symbol, &stock)? + .buy_rejection_reason( + ctx, + execution_date, + symbol, + &execution_stock, + )? .is_some() { continue; } - if !self.stock_passes_expr(ctx, &day, &stock)? { + if !self.stock_passes_expr(ctx, &day, &decision_stock)? { continue; } let replacement_cash = - replacement_cash * self.buy_scale(ctx, &day, &stock)?; + replacement_cash * self.buy_scale(ctx, &day, &decision_stock)?; if replacement_cash <= 0.0 { continue; } @@ -4644,7 +4657,7 @@ impl Strategy for PlatformExprStrategy { self.project_order_value( ctx, &mut projected, - date, + execution_date, symbol, replacement_cash, &mut projected_execution_state, @@ -4666,7 +4679,7 @@ impl Strategy for PlatformExprStrategy { if stock_list.iter().any(|candidate| candidate == symbol) { continue; } - if !self.can_sell_position(ctx, date, symbol) { + if !self.can_sell_position(ctx, execution_date, symbol) { continue; } order_intents.push(OrderIntent::TargetValue { @@ -4677,7 +4690,7 @@ impl Strategy for PlatformExprStrategy { self.project_target_zero( ctx, &mut projected, - date, + execution_date, symbol, &mut projected_execution_state, ); @@ -4693,17 +4706,18 @@ impl Strategy for PlatformExprStrategy { { continue; } - let stock = self.stock_state(ctx, date, symbol)?; + let decision_stock = self.stock_state(ctx, decision_date, symbol)?; + let execution_stock = self.stock_state(ctx, execution_date, symbol)?; if self - .buy_rejection_reason(ctx, date, symbol, &stock)? + .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? .is_some() { continue; } - if !self.stock_passes_expr(ctx, &day, &stock)? { + if !self.stock_passes_expr(ctx, &day, &decision_stock)? { continue; } - let buy_cash = fixed_buy_cash * self.buy_scale(ctx, &day, &stock)?; + let buy_cash = fixed_buy_cash * self.buy_scale(ctx, &day, &decision_stock)?; if buy_cash <= 0.0 { continue; } @@ -4715,7 +4729,7 @@ impl Strategy for PlatformExprStrategy { self.project_order_value( ctx, &mut projected, - date, + execution_date, symbol, buy_cash, &mut projected_execution_state, @@ -4748,13 +4762,15 @@ impl Strategy for PlatformExprStrategy { ) }, format!( - "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={}", + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} execution_date={}", stock_list.len(), periodic_rebalance, exit_symbols.len(), projected.positions().len(), order_intents.len(), - selection_limit + selection_limit, + decision_date, + execution_date ), "platform strategy script executed through expression runtime + bid1/ask1 snapshot execution".to_string(), ]; @@ -5552,6 +5568,179 @@ mod tests { ); } + #[test] + fn platform_strategy_uses_decision_date_for_next_bar_open_signals() { + let decision_date = d(2025, 2, 3); + let execution_date = d(2025, 2, 4); + let symbol = "000001.SZ"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: "Decision Date Stock".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: decision_date, + symbol: symbol.to_string(), + timestamp: Some("2025-02-03 10:18:00".to_string()), + 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_000, + tick_volume: 10_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, + }, + DailyMarketSnapshot { + date: execution_date, + symbol: symbol.to_string(), + timestamp: Some("2025-02-04 10:18:00".to_string()), + day_open: 12.0, + open: 12.0, + high: 101.0, + low: 11.8, + close: 100.0, + last_price: 100.0, + bid1: 99.99, + ask1: 100.01, + prev_close: 10.0, + 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: 110.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: decision_date, + symbol: symbol.to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: execution_date, + symbol: symbol.to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date: decision_date, + 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, + }, + CandidateEligibility { + date: execution_date, + 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: decision_date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: execution_date, + benchmark: "000852.SH".to_string(), + open: 1002.0, + close: 1004.0, + prev_close: 1002.0, + volume: 1_000_000, + }, + ], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_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.refresh_rate = 1; + cfg.max_positions = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.stock_short_ma_days = 1; + cfg.stock_mid_ma_days = 1; + cfg.stock_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 = "close > 50".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")) + ); + } + #[test] fn platform_helpers_support_generic_rolling_stats_and_normalized_factors() { let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];