diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d1a1029..80c5fa9 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1628,7 +1628,9 @@ impl PlatformExprStrategy { if quantity == 0 { return None; } - if !self.can_sell_position_at_time(ctx, date, symbol, execution_time) { + if !Self::defer_projection_execution_risk(ctx, date) + && !self.can_sell_position_at_time(ctx, date, symbol, execution_time) + { return None; } let market = ctx.data.market(date, symbol)?; @@ -1735,7 +1737,9 @@ impl PlatformExprStrategy { return (filled > 0).then_some(filled); } - if !self.can_sell_position(ctx, date, symbol) { + if !Self::defer_projection_execution_risk(ctx, date) + && !self.can_sell_position(ctx, date, symbol) + { return None; } let sizing_price = self @@ -1944,11 +1948,12 @@ impl PlatformExprStrategy { } Err(_) => return 0, }; - if self - .buy_rejection_reason(ctx, date, symbol, &stock) - .ok() - .flatten() - .is_some() + if !Self::defer_projection_execution_risk(ctx, date) + && self + .buy_rejection_reason(ctx, date, symbol, &stock) + .ok() + .flatten() + .is_some() { return 0; } @@ -2056,6 +2061,10 @@ impl PlatformExprStrategy { fill.quantity } + fn defer_projection_execution_risk(ctx: &StrategyContext<'_>, date: NaiveDate) -> bool { + ctx.is_lagged_execution() && date == ctx.decision_date + } + fn day_state( &self, ctx: &StrategyContext<'_>, @@ -14268,6 +14277,203 @@ mod tests { ); } + #[test] + fn platform_next_open_selection_ignores_decision_day_static_risk_state() { + let factor_date = d(2023, 11, 10); + let decision_date = d(2023, 11, 13); + let execution_date = d(2023, 11, 14); + let signal = "000001.SZ"; + let candidate_symbol = "600462.SH"; + let market = |date: NaiveDate, symbol: &str, paused: bool| DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{} 09:33:00", date)), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + minute_volume: 1_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }; + let candidate = |date: NaiveDate, is_st: bool, is_paused: bool| CandidateEligibility { + date, + symbol: candidate_symbol.to_string(), + is_st, + is_star_st: false, + is_new_listing: false, + is_paused, + allow_buy: !is_paused, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }; + let data = DataSet::from_components_with_actions_and_quotes( + vec![ + Instrument { + symbol: signal.to_string(), + name: signal.to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: candidate_symbol.to_string(), + name: candidate_symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + market(factor_date, signal, false), + market(decision_date, signal, false), + market(execution_date, signal, false), + market(decision_date, candidate_symbol, true), + market(execution_date, candidate_symbol, false), + ], + vec![ + DailyFactorSnapshot { + date: factor_date, + symbol: candidate_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date: decision_date, + symbol: candidate_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.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: candidate_symbol.to_string(), + market_cap_bn: 1.0, + free_float_cap_bn: 1.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + candidate(decision_date, true, true), + candidate(execution_date, false, false), + ], + [factor_date, decision_date, execution_date] + .into_iter() + .map(|date| BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + Vec::new(), + vec![IntradayExecutionQuote { + date: execution_date, + symbol: candidate_symbol.to_string(), + timestamp: execution_date.and_hms_opt(9, 33, 0).unwrap(), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 100, + amount_delta: 1_000.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + let portfolio = PortfolioState::new(500_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date, + decision_date, + decision_index: 2, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: Some(execution_date.and_hms_opt(9, 33, 0).unwrap()), + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.aiquant_transaction_cost = true; + cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap()); + cfg.signal_symbol = signal.to_string(); + cfg.max_positions = 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 > 0".to_string(); + cfg.exposure_expr = "1.0".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision + .diagnostics + .iter() + .any(|item| item == "selected_symbols=600462.SH"), + "{:?}", + decision.diagnostics + ); + assert!( + decision + .diagnostics + .iter() + .all(|item| !item.contains("600462.SH rejected by")), + "{:?}", + decision.diagnostics + ); + assert!( + decision.order_intents.iter().any(|intent| match intent { + OrderIntent::Shares { + symbol, quantity, .. + } => symbol == candidate_symbol && *quantity > 0, + OrderIntent::Value { symbol, value, .. } => { + symbol == candidate_symbol && *value > 0.0 + } + _ => false, + }), + "{:?}", + decision.order_intents + ); + } + #[test] fn platform_buy_rejection_allows_lower_limit_buy_when_ask_is_available() { let date = d(2025, 4, 7);