diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e678e24..94b4d38 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -195,6 +195,7 @@ pub struct BrokerSimulator { runtime_decision_date: Cell>, runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, + runtime_target_position_limit: Cell>, next_order_id: Cell, open_orders: RefCell>, } @@ -225,6 +226,7 @@ impl BrokerSimulator { runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), + runtime_target_position_limit: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -259,6 +261,7 @@ impl BrokerSimulator { runtime_decision_date: Cell::new(None), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), + runtime_target_position_limit: Cell::new(None), next_order_id: Cell::new(1), open_orders: RefCell::new(Vec::new()), } @@ -538,6 +541,11 @@ where target_value, .. } + | OrderIntent::TimedTargetValue { + symbol, + target_value, + .. + } | OrderIntent::LimitTargetValue { symbol, target_value, @@ -581,6 +589,103 @@ where } } + fn target_position_intent(intent: &OrderIntent) -> Option<(&str, bool)> { + match intent { + OrderIntent::TargetShares { + symbol, + target_quantity, + .. + } + | OrderIntent::LimitTargetShares { + symbol, + target_quantity, + .. + } => Some((symbol, *target_quantity > 0)), + OrderIntent::TargetValue { + symbol, + target_value, + .. + } + | OrderIntent::TimedTargetValue { + symbol, + target_value, + .. + } + | OrderIntent::LimitTargetValue { + symbol, + target_value, + .. + } => Some((symbol, target_value.is_finite() && *target_value > 0.0)), + OrderIntent::TargetPercent { + symbol, + target_percent, + .. + } + | OrderIntent::LimitTargetPercent { + symbol, + target_percent, + .. + } => Some((symbol, target_percent.is_finite() && *target_percent > 0.0)), + _ => None, + } + } + + fn infer_target_position_limit( + &self, + portfolio: &PortfolioState, + intents: &[&OrderIntent], + ) -> Option { + if intents.is_empty() + || intents + .iter() + .any(|intent| Self::target_position_intent(intent).is_none()) + { + return None; + } + + let held_symbols = portfolio + .positions() + .iter() + .filter(|(_, position)| position.quantity > 0) + .map(|(symbol, _)| symbol.clone()) + .collect::>(); + let mut exit_symbols = BTreeSet::new(); + let mut entry_symbols = BTreeSet::new(); + for intent in intents { + let Some((symbol, has_positive_target)) = Self::target_position_intent(intent) else { + continue; + }; + if held_symbols.contains(symbol) && !has_positive_target { + exit_symbols.insert(symbol.to_string()); + } else if !held_symbols.contains(symbol) && has_positive_target { + entry_symbols.insert(symbol.to_string()); + } + } + for symbol in exit_symbols.clone() { + if entry_symbols.contains(&symbol) { + exit_symbols.remove(&symbol); + entry_symbols.remove(&symbol); + } + } + if exit_symbols.is_empty() || entry_symbols.is_empty() { + return None; + } + Some( + held_symbols + .len() + .saturating_sub(exit_symbols.len()) + .saturating_add(entry_symbols.len()), + ) + } + + fn positive_position_count(portfolio: &PortfolioState) -> usize { + portfolio + .positions() + .values() + .filter(|position| position.quantity > 0) + .count() + } + fn value_order_sizing_price( &self, date: NaiveDate, @@ -912,8 +1017,11 @@ where } }); } + let previous_target_position_limit = self + .runtime_target_position_limit + .replace(self.infer_target_position_limit(portfolio, &ordered_intents)); for intent in ordered_intents { - self.process_order_intent( + let result = self.process_order_intent( date, portfolio, data, @@ -923,8 +1031,15 @@ where &mut global_execution_cursor, &mut commission_state, &mut report, - )?; + ); + if let Err(error) = result { + self.runtime_target_position_limit + .set(previous_target_position_limit); + return Err(error); + } } + self.runtime_target_position_limit + .set(previous_target_position_limit); portfolio.prune_flat_positions(); return Ok(report); } @@ -4929,6 +5044,32 @@ where algo_request: Option<&AlgoExecutionRequest>, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + if portfolio + .position(symbol) + .is_none_or(|position| position.quantity == 0) + && self + .runtime_target_position_limit + .get() + .is_some_and(|limit| Self::positive_position_count(portfolio) >= limit) + { + let position_count = Self::positive_position_count(portfolio); + let position_limit = self.runtime_target_position_limit.get().unwrap_or(0); + Self::reject_unavailable_order( + report, + date, + order_id, + symbol, + OrderSide::Buy, + requested_qty, + reason, + "target position slot unavailable after failed exit", + emit_creation_events, + ); + report.diagnostics.push(format!( + "target_position_slot_rejected symbol={symbol} current_positions={position_count} target_position_limit={position_limit}" + )); + return Ok(()); + } let Some(snapshot) = data.market(date, symbol) else { let unavailable_reason = self .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Buy) @@ -6631,6 +6772,52 @@ mod tests { } } + fn target_position_slot_test_data(block_exit: bool) -> DataSet { + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let instruments = symbols + .iter() + .map(|symbol| { + let mut instrument = limit_test_instrument(); + instrument.symbol = (*symbol).to_string(); + instrument.name = (*symbol).to_string(); + instrument + }) + .collect::>(); + let snapshots = symbols + .iter() + .map(|symbol| { + let mut snapshot = limit_test_snapshot(); + snapshot.symbol = (*symbol).to_string(); + if block_exit && *symbol == "000001.SZ" { + snapshot.day_open = snapshot.lower_limit; + snapshot.open = snapshot.lower_limit; + snapshot.last_price = snapshot.lower_limit; + snapshot.bid1 = snapshot.lower_limit; + snapshot.ask1 = snapshot.lower_limit; + } + snapshot + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| { + let mut candidate = limit_test_candidate(true, true); + candidate.symbol = (*symbol).to_string(); + candidate + }) + .collect::>(); + DataSet::from_components_with_actions_and_quotes( + instruments, + snapshots, + Vec::new(), + candidates, + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid target-position slot dataset") + } + fn dated_limit_test_snapshot(date: chrono::NaiveDate) -> DailyMarketSnapshot { let mut snapshot = limit_test_snapshot(); snapshot.date = date; @@ -8504,6 +8691,184 @@ mod tests { } } + #[test] + fn failed_target_exit_blocks_replacement_entry_when_no_position_slot_is_released() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 1_000, 10.0); + let decision = StrategyDecision { + order_intents: vec![ + OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 0.0, + reason: "replace_exit".to_string(), + }, + OrderIntent::TargetValue { + symbol: "000003.SZ".to_string(), + target_value: 9_000.0, + reason: "replace_entry".to_string(), + }, + ], + ..StrategyDecision::default() + }; + + let report = broker + .execute( + date, + &mut portfolio, + &target_position_slot_test_data(true), + &decision, + ) + .expect("failed-exit target batch execution"); + + assert_eq!( + BrokerSimulator::::positive_position_count( + &portfolio + ), + 2 + ); + assert!(portfolio.position("000001.SZ").is_some()); + assert!(portfolio.position("000003.SZ").is_none()); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000001.SZ" + && event.side == OrderSide::Sell + && event.status == OrderStatus::Canceled + })); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000003.SZ" + && event.side == OrderSide::Buy + && event + .reason + .contains("target position slot unavailable after failed exit") + })); + } + + #[test] + fn successful_target_exit_releases_position_slot_for_replacement_entry() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_rebalance_cash_mode(RebalanceCashMode::SellThenBuy) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 1_000, 10.0); + let decision = StrategyDecision { + order_intents: vec![ + OrderIntent::TargetValue { + symbol: "000003.SZ".to_string(), + target_value: 9_000.0, + reason: "replace_entry".to_string(), + }, + OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 0.0, + reason: "replace_exit".to_string(), + }, + ], + ..StrategyDecision::default() + }; + + let report = broker + .execute( + date, + &mut portfolio, + &target_position_slot_test_data(false), + &decision, + ) + .expect("successful-exit target batch execution"); + + assert_eq!( + BrokerSimulator::::positive_position_count( + &portfolio + ), + 2 + ); + assert!(portfolio.position("000001.SZ").is_none()); + assert!( + portfolio + .position("000003.SZ") + .is_some_and(|position| position.quantity > 0) + ); + assert!(report.order_events.iter().all(|event| { + !event + .reason + .contains("target position slot unavailable after failed exit") + })); + } + + #[test] + fn independent_target_entry_is_not_subject_to_replacement_slot_limit() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let prev_date = chrono::NaiveDate::from_ymd_opt(2025, 1, 1).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + let decision = StrategyDecision { + order_intents: vec![OrderIntent::TargetValue { + symbol: "000003.SZ".to_string(), + target_value: 9_000.0, + reason: "independent_entry".to_string(), + }], + ..StrategyDecision::default() + }; + + broker + .execute( + date, + &mut portfolio, + &target_position_slot_test_data(false), + &decision, + ) + .expect("independent target entry"); + + assert_eq!( + BrokerSimulator::::positive_position_count( + &portfolio + ), + 2 + ); + assert!( + portfolio + .position("000003.SZ") + .is_some_and(|position| position.quantity > 0) + ); + } + #[test] fn target_portfolio_smart_open_auction_uses_day_open_for_valuation() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");