diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2ed498a..6c455fd 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -967,6 +967,44 @@ impl PlatformExprStrategy { limited } + fn unresolved_delisted_position_symbols(ctx: &StrategyContext<'_>) -> BTreeSet { + ctx.portfolio + .positions() + .values() + .filter(|position| position.quantity > 0) + .filter_map(|position| { + let instrument = ctx.data.instrument(&position.symbol)?; + let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date) + || (instrument.status.eq_ignore_ascii_case("delisted") + && instrument.delisted_at.is_none() + && ctx + .data + .market(ctx.execution_date, &position.symbol) + .is_none()); + unresolved.then(|| position.symbol.clone()) + }) + .collect() + } + + fn reserve_unresolved_delisted_slots( + ranked_selection: Vec, + unresolved_symbols: &BTreeSet, + selection_limit: usize, + ) -> Vec { + if selection_limit == 0 || unresolved_symbols.is_empty() { + return ranked_selection; + } + let reserved_count = unresolved_symbols.len().min(selection_limit); + let active_limit = selection_limit.saturating_sub(reserved_count); + let mut selected = ranked_selection + .into_iter() + .filter(|symbol| !unresolved_symbols.contains(symbol)) + .take(active_limit) + .collect::>(); + selected.extend(unresolved_symbols.iter().take(reserved_count).cloned()); + selected + } + fn effective_rebalance_cash_mode(&self) -> RebalanceCashMode { if self.config.matching_type == MatchingType::MinuteLast { RebalanceCashMode::SellThenBuy @@ -9224,7 +9262,7 @@ impl Strategy for PlatformExprStrategy { let mut risk_decisions = Vec::new(); let mut replacement_reference_source = "disabled"; let mut replacement_reference_count = 0usize; - let stock_list = if self.config.rotation_enabled && !in_skip_window { + let mut stock_list = if self.config.rotation_enabled && !in_skip_window { let selection_buffer_rank = self.selection_candidate_limit(ctx, &day, selection_limit)?; let ranked_selection_limit = if self.config.daily_replacement_limit > 0 { @@ -9279,6 +9317,23 @@ impl Strategy for PlatformExprStrategy { } else { Vec::new() }; + let unresolved_delisted_symbols = Self::unresolved_delisted_position_symbols(ctx); + if !unresolved_delisted_symbols.is_empty() { + stock_list = Self::reserve_unresolved_delisted_slots( + stock_list, + &unresolved_delisted_symbols, + selection_limit, + ); + selection_notes.push(format!( + "unresolved_delisted_slots reserved={} symbols={} settlement_action=missing valuation_policy=zero no_order=true", + unresolved_delisted_symbols.len().min(selection_limit), + unresolved_delisted_symbols + .iter() + .cloned() + .collect::>() + .join(",") + )); + } let empty_rebalance_retry = self.config.retry_empty_rebalance && ctx.portfolio.positions().is_empty(); let effective_refresh_rate = self.effective_refresh_rate(ctx, &day)?; @@ -9336,7 +9391,10 @@ impl Strategy for PlatformExprStrategy { self.pending_highlimit_holdings.clear(); } else { for position in ctx.portfolio.positions().values() { - if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + if position.quantity == 0 + || delayed_sold_symbols.contains(&position.symbol) + || unresolved_delisted_symbols.contains(&position.symbol) + { continue; } let was_pending = self.pending_highlimit_holdings.contains(&position.symbol); @@ -9380,6 +9438,10 @@ impl Strategy for PlatformExprStrategy { if new_highlimit_marks_after_delayed_exit.contains(&symbol) { continue; } + if unresolved_delisted_symbols.contains(&symbol) { + self.pending_highlimit_holdings.remove(&symbol); + continue; + } if !ctx.portfolio.positions().contains_key(&symbol) { self.pending_highlimit_holdings.remove(&symbol); continue; @@ -9464,7 +9526,9 @@ impl Strategy for PlatformExprStrategy { let seasonal_exit_time = self.intraday_execution_start_time(); let mut pending_highlimit_kept = 0usize; for symbol in ctx.portfolio.positions().keys() { - if delayed_sold_symbols.contains(symbol) { + if delayed_sold_symbols.contains(symbol) + || unresolved_delisted_symbols.contains(symbol) + { continue; } if self.config.delayed_limit_open_exit_enabled @@ -9567,7 +9631,10 @@ impl Strategy for PlatformExprStrategy { && let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0) { for position in ctx.portfolio.positions().values() { - if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + if position.quantity == 0 + || delayed_sold_symbols.contains(&position.symbol) + || unresolved_delisted_symbols.contains(&position.symbol) + { continue; } let Some(holding_days) = self.max_holding_days_exceeded(&position.symbol) else { @@ -9622,6 +9689,7 @@ impl Strategy for PlatformExprStrategy { if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) || self.pending_highlimit_holdings.contains(&position.symbol) + || unresolved_delisted_symbols.contains(&position.symbol) { continue; } @@ -9727,7 +9795,10 @@ impl Strategy for PlatformExprStrategy { { if aiquant_total_value.is_finite() && aiquant_total_value > 0.0 { for position in ctx.portfolio.positions().values() { - if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + if position.quantity == 0 + || delayed_sold_symbols.contains(&position.symbol) + || unresolved_delisted_symbols.contains(&position.symbol) + { continue; } if !new_highlimit_marks_after_delayed_exit.contains(&position.symbol) @@ -9891,7 +9962,9 @@ impl Strategy for PlatformExprStrategy { } for position in ctx.portfolio.positions().values() { - if delayed_sold_symbols.contains(&position.symbol) { + if delayed_sold_symbols.contains(&position.symbol) + || unresolved_delisted_symbols.contains(&position.symbol) + { continue; } if interleaved_pending_full_close_symbols.contains(&position.symbol) { @@ -10379,6 +10452,9 @@ impl Strategy for PlatformExprStrategy { .collect::>(); let pre_rebalance_cash = projected.cash(); for symbol in pre_rebalance_symbols.iter() { + if unresolved_delisted_symbols.contains(symbol) { + continue; + } if stock_list.iter().any(|candidate| candidate == symbol) { continue; } @@ -10435,6 +10511,9 @@ impl Strategy for PlatformExprStrategy { let rebalance_value_symbols = rebalance_working_symbols.clone(); let mut rebalance_pending_buy_value = 0.0_f64; for symbol in stock_list.iter().take(selection_limit) { + if unresolved_delisted_symbols.contains(symbol) { + continue; + } if exit_symbols.contains(symbol) { continue; } @@ -11012,6 +11091,233 @@ mod tests { ); } + #[test] + fn unresolved_delisted_position_reserves_target_slot_without_replacement() { + let ranked = vec![ + "000001.SZ".to_string(), + "000002.SZ".to_string(), + "000003.SZ".to_string(), + "000004.SZ".to_string(), + ]; + let unresolved = BTreeSet::from(["000999.SZ".to_string()]); + + let selected = + PlatformExprStrategy::reserve_unresolved_delisted_slots(ranked, &unresolved, 3); + + assert_eq!( + selected, + vec![ + "000001.SZ".to_string(), + "000002.SZ".to_string(), + "000999.SZ".to_string(), + ] + ); + } + + #[test] + fn unresolved_delisted_positions_exhaust_target_slots_before_new_candidates() { + let ranked = vec!["000001.SZ".to_string(), "000002.SZ".to_string()]; + let unresolved = BTreeSet::from(["000998.SZ".to_string(), "000999.SZ".to_string()]); + + let selected = + PlatformExprStrategy::reserve_unresolved_delisted_slots(ranked, &unresolved, 1); + + assert_eq!(selected, vec!["000998.SZ".to_string()]); + } + + #[test] + fn platform_rebalance_keeps_unresolved_delisted_position_without_orders_or_replacement() { + let previous_date = d(2025, 1, 2); + let execution_date = d(2025, 1, 3); + let unresolved_symbol = "000999.SZ"; + let active_symbols = ["000001.SZ", "000002.SZ"]; + let data = DataSet::from_components( + std::iter::once(Instrument { + symbol: unresolved_symbol.to_string(), + name: "Delisted".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: Some(execution_date), + status: "delisted".to_string(), + }) + .chain(active_symbols.iter().map(|symbol| 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(), + })) + .collect(), + active_symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date: execution_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: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + minute_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + active_symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date: execution_date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 9.0 + index as f64, + pe_ttm: 12.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + active_symbols + .iter() + .map(|symbol| CandidateEligibility { + date: execution_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, + }) + .collect(), + vec![BenchmarkSnapshot { + date: execution_date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1001.0, + prev_close: 999.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(100_000.0); + portfolio + .position_mut(unresolved_symbol) + .buy(previous_date, 1_000, 10.0); + let subscriptions = BTreeSet::new(); + let before_delisting_ctx = StrategyContext { + execution_date: previous_date, + decision_date: previous_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: &[], + }; + assert!( + PlatformExprStrategy::unresolved_delisted_position_symbols(&before_delisting_ctx) + .is_empty(), + "future delisting date must not reserve a slot before it becomes effective" + ); + let ctx = StrategyContext { + execution_date, + decision_date: execution_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 = "000001.SZ".to_string(); + cfg.refresh_rate = 1; + cfg.max_positions = 2; + 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 = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr.clear(); + cfg.aiquant_transaction_cost = true; + cfg.daily_replacement_limit = 2; + cfg.selection_buffer_multiple = 2.0; + cfg.rebalance_existing_positions = false; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 1; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.diagnostics.iter().any(|note| { + note.contains("unresolved_delisted_slots reserved=1") + && note.contains(unresolved_symbol) + && note.contains("no_order=true") + })); + assert!(!decision.order_intents.iter().any(|intent| { + matches!( + intent, + OrderIntent::TargetValue { symbol, .. } if symbol == unresolved_symbol + ) + })); + assert!(decision.order_intents.iter().any(|intent| { + matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + reason, + } if symbol == "000001.SZ" + && *target_value > 0.0 + && reason == "periodic_rebalance_buy" + ) + })); + assert!(!decision.order_intents.iter().any(|intent| { + matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + reason, + } if symbol == "000002.SZ" + && *target_value > 0.0 + && reason == "periodic_rebalance_buy" + ) + })); + } + #[test] fn platform_expr_rewrites_nested_ternary() { let expr = "pct_change(\"close\", 10) + (((close / rolling_mean(\"close\", 20)) - 1) > 0 ? 0.04 : -0.04)";