diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 53029e7..386a258 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -2830,6 +2830,13 @@ impl PlatformExprStrategy { working_symbols.union(slot_blocking_symbols).count() } + fn should_block_partial_exit_residual_slot( + trading_ratio: f64, + defer_execution_risk: bool, + ) -> bool { + trading_ratio < 1.0 && !defer_execution_risk + } + fn remaining_buy_cash_active_value( &self, ctx: &StrategyContext<'_>, @@ -9276,7 +9283,11 @@ impl Strategy for PlatformExprStrategy { let mut deferred_daily_target_values = BTreeMap::::new(); let mut pending_full_close_symbols = BTreeSet::::new(); let mut slot_blocking_symbols = BTreeSet::::new(); - let should_block_partial_exit_residual_slot = trading_ratio < 1.0; + // Lagged execution only freezes the T-day intent. Whether an exit leaves a + // residual position is an execution-day fact and must not consume a T+1 + // replacement slot before the sell leg is actually matched. + let should_block_partial_exit_residual_slot = + Self::should_block_partial_exit_residual_slot(trading_ratio, defer_execution_risk); let interleaved_pending_full_close_symbols = BTreeSet::::new(); let risk_level_forced_exit_time = self.risk_level_forced_exit_time(); self.pending_full_close_symbols.retain(|symbol| { @@ -9552,7 +9563,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "pending_full_close_exit".to_string(), }); - if daily_top_up_pending_buy_value > 0.0 { + if daily_top_up_pending_buy_value > 0.0 && should_block_partial_exit_residual_slot { slot_blocking_symbols.insert(position.symbol.clone()); } self.forget_position_entry_date(&position.symbol); @@ -9576,7 +9587,9 @@ impl Strategy for PlatformExprStrategy { self.pending_full_close_symbols.remove(&position.symbol); } else { pending_full_close_symbols.insert(position.symbol.clone()); - slot_blocking_symbols.insert(position.symbol.clone()); + if should_block_partial_exit_residual_slot { + slot_blocking_symbols.insert(position.symbol.clone()); + } self.pending_full_close_symbols .insert(position.symbol.clone()); } @@ -9643,7 +9656,7 @@ impl Strategy for PlatformExprStrategy { self.pending_full_close_symbols.remove(&position.symbol); } else { exit_symbols.insert(position.symbol.clone()); - if trading_ratio < 1.0 { + if should_block_partial_exit_residual_slot { slot_blocking_symbols.insert(position.symbol.clone()); } order_intents.push(OrderIntent::TargetValue { @@ -9726,7 +9739,7 @@ impl Strategy for PlatformExprStrategy { target_value: 0.0, reason: "stop_loss_exit".to_string(), }); - if daily_top_up_pending_buy_value > 0.0 && trading_ratio < 1.0 { + if daily_top_up_pending_buy_value > 0.0 && should_block_partial_exit_residual_slot { slot_blocking_symbols.insert(position.symbol.clone()); } self.forget_position_entry_date(&position.symbol); @@ -21900,6 +21913,13 @@ mod tests { ); } + #[test] + fn platform_lagged_exit_does_not_reserve_replacement_slot_before_execution() { + assert!(PlatformExprStrategy::should_block_partial_exit_residual_slot(0.5, false)); + assert!(!PlatformExprStrategy::should_block_partial_exit_residual_slot(0.5, true)); + assert!(!PlatformExprStrategy::should_block_partial_exit_residual_slot(1.0, false)); + } + #[test] fn platform_aiquant_daily_top_up_uses_remaining_slot_budget() { let prev_date = d(2025, 2, 2);