diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index d8ac425..e678e24 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -472,6 +472,115 @@ where } } + fn cash_rebalance_intent_side( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + intent: &OrderIntent, + ) -> Option { + let target_value_side = |symbol: &str, target_value: f64| { + let current_qty = portfolio + .position(symbol) + .map(|position| position.quantity) + .unwrap_or(0); + let current_value = data + .market(date, symbol) + .map(|snapshot| { + self.target_value_valuation_price(date, data, symbol, snapshot) + * current_qty as f64 + }) + .unwrap_or(0.0); + if target_value.max(0.0) + f64::EPSILON < current_value { + OrderSide::Sell + } else { + OrderSide::Buy + } + }; + + match intent { + OrderIntent::Shares { quantity, .. } | OrderIntent::LimitShares { quantity, .. } => { + Some(if *quantity < 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }) + } + OrderIntent::Lots { lots, .. } | OrderIntent::LimitLots { lots, .. } => { + Some(if *lots < 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }) + } + OrderIntent::TargetShares { + symbol, + target_quantity, + .. + } + | OrderIntent::LimitTargetShares { + symbol, + target_quantity, + .. + } => { + let current_qty = portfolio + .position(symbol) + .map(|position| position.quantity as i64) + .unwrap_or(0); + Some(if i64::from(*target_quantity).max(0) < current_qty { + OrderSide::Sell + } else { + OrderSide::Buy + }) + } + OrderIntent::TargetValue { + symbol, + target_value, + .. + } + | OrderIntent::LimitTargetValue { + symbol, + target_value, + .. + } => Some(target_value_side(symbol, *target_value)), + OrderIntent::Value { value, .. } | OrderIntent::LimitValue { value, .. } => { + Some(if *value < 0.0 { + OrderSide::Sell + } else { + OrderSide::Buy + }) + } + OrderIntent::Percent { percent, .. } | OrderIntent::LimitPercent { percent, .. } => { + Some(if *percent < 0.0 { + OrderSide::Sell + } else { + OrderSide::Buy + }) + } + OrderIntent::TargetPercent { + symbol, + target_percent, + .. + } + | OrderIntent::LimitTargetPercent { + symbol, + target_percent, + .. + } => { + let total_equity = self + .runtime_decision_total_equity + .get() + .filter(|value| value.is_finite() && *value >= 0.0) + .unwrap_or_else(|| portfolio.total_equity()); + Some(target_value_side( + symbol, + total_equity * target_percent.max(0.0), + )) + } + _ => None, + } + } + fn value_order_sizing_price( &self, date: NaiveDate, @@ -786,7 +895,24 @@ where &mut report, )?; if !decision.order_intents.is_empty() { - for intent in &decision.order_intents { + let mut ordered_intents = decision.order_intents.iter().collect::>(); + if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash + && ordered_intents.iter().all(|intent| { + self.cash_rebalance_intent_side(date, portfolio, data, intent) + .is_some() + }) + { + ordered_intents.sort_by_key(|intent| { + if self.cash_rebalance_intent_side(date, portfolio, data, intent) + == Some(OrderSide::Sell) + { + 0 + } else { + 1 + } + }); + } + for intent in ordered_intents { self.process_order_intent( date, portfolio, @@ -8279,6 +8405,105 @@ mod tests { ); } + #[test] + fn target_value_batch_executes_reductions_before_increases_when_sell_cash_is_reusable() { + 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 symbols = ["000001.SZ", "000002.SZ"]; + let instruments = symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }) + .collect::>(); + let snapshots = symbols + .iter() + .map(|symbol| { + let mut snapshot = limit_test_snapshot(); + snapshot.symbol = (*symbol).to_string(); + snapshot + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| { + let mut candidate = limit_test_candidate(true, true); + candidate.symbol = (*symbol).to_string(); + candidate + }) + .collect::>(); + let data = DataSet::from_components_with_actions_and_quotes( + instruments, + snapshots, + Vec::new(), + candidates, + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let decision = StrategyDecision { + order_intents: vec![ + OrderIntent::TargetValue { + symbol: "000002.SZ".to_string(), + target_value: 9_000.0, + reason: "buy_submitted_first".to_string(), + }, + OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), + target_value: 0.0, + reason: "sell_submitted_second".to_string(), + }, + ], + ..StrategyDecision::default() + }; + + for mode in [ + RebalanceCashMode::SamePointNet, + RebalanceCashMode::SellThenBuy, + ] { + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_aiquant_execution_rules(true) + .with_rebalance_cash_mode(mode) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut portfolio = PortfolioState::new(0.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + + let report = broker + .execute(date, &mut portfolio, &data, &decision) + .expect("target-value batch execution"); + + assert!(portfolio.position("000001.SZ").is_none(), "{mode:?}"); + assert!( + portfolio + .position("000002.SZ") + .is_some_and(|position| position.quantity > 0), + "mode={mode:?} fills={:?}", + report.fill_events + ); + assert_eq!( + report.fill_events.first().map(|fill| fill.side), + Some(OrderSide::Sell), + "mode={mode:?} fills={:?}", + report.fill_events + ); + } + } + #[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");