From 650e2e8319a81c8f78c8f2c8b9c2efe5ba28f20b Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 22 Apr 2026 21:48:49 -0700 Subject: [PATCH] Clip portfolio targets before rebalance orders --- crates/fidc-core/src/broker.rs | 195 +++++++++++++++++- crates/fidc-core/tests/explicit_order_flow.rs | 171 +++++++++++++++ 2 files changed, 361 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 2f14594..d564858 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -25,6 +25,17 @@ struct ExecutionFill { next_cursor: NaiveDateTime, } +#[derive(Debug, Clone)] +struct TargetConstraint { + symbol: String, + current_qty: u32, + min_target_qty: u32, + max_target_qty: u32, + provisional_target_qty: u32, + price: f64, + round_lot: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MatchingType { CurrentBarClose, @@ -396,9 +407,8 @@ where data: &DataSet, target_weights: &BTreeMap, ) -> Result, BacktestError> { - let equity = self.total_equity_at(date, portfolio, data, PriceField::Open)?; - let mut targets = BTreeMap::new(); - + let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?; + let mut desired_targets = BTreeMap::new(); for (symbol, weight) in target_weights { let price = data .price(date, symbol, self.execution_price_field) @@ -408,13 +418,188 @@ where field: price_field_name(self.execution_price_field), })?; let raw_qty = ((equity * weight) / price).floor() as u32; - let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol)); - targets.insert(symbol.clone(), rounded_qty); + desired_targets.insert( + symbol.clone(), + self.round_buy_quantity(raw_qty, self.round_lot(data, symbol)), + ); + } + + let mut symbols = BTreeSet::new(); + symbols.extend(portfolio.positions().keys().cloned()); + symbols.extend(desired_targets.keys().cloned()); + + let mut constraints = Vec::new(); + let mut projected_cash = portfolio.cash(); + for symbol in symbols { + let current_qty = portfolio + .position(&symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let desired_qty = *desired_targets.get(&symbol).unwrap_or(&0); + let price = data + .price(date, &symbol, self.execution_price_field) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.clone(), + field: price_field_name(self.execution_price_field), + })?; + let round_lot = self.round_lot(data, &symbol); + let min_target_qty = self.minimum_target_quantity( + date, + portfolio, + data, + &symbol, + current_qty, + round_lot, + ); + let max_target_qty = self.maximum_target_quantity( + date, + portfolio, + data, + &symbol, + current_qty, + round_lot, + ); + let provisional_target_qty = desired_qty.clamp(min_target_qty, max_target_qty); + if current_qty > provisional_target_qty { + projected_cash += self.estimated_sell_net_cash( + price, + current_qty.saturating_sub(provisional_target_qty), + ); + } + constraints.push(TargetConstraint { + symbol, + current_qty, + min_target_qty, + max_target_qty, + provisional_target_qty, + price, + round_lot, + }); + } + + let mut targets = BTreeMap::new(); + for constraint in &constraints { + let mut target_qty = constraint.provisional_target_qty; + if target_qty > constraint.current_qty { + let desired_additional = target_qty - constraint.current_qty; + let affordable_additional = self.affordable_buy_quantity( + projected_cash, + None, + constraint.price, + desired_additional, + constraint.round_lot, + ); + target_qty = (constraint.current_qty + affordable_additional) + .clamp(constraint.min_target_qty, constraint.max_target_qty); + if target_qty > constraint.current_qty { + projected_cash -= self.estimated_buy_cash_out( + constraint.price, + target_qty - constraint.current_qty, + ); + } + } + + if target_qty > 0 { + targets.insert(constraint.symbol.clone(), target_qty); + } } Ok(targets) } + fn minimum_target_quantity( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + symbol: &str, + current_qty: u32, + round_lot: u32, + ) -> u32 { + if current_qty == 0 { + return 0; + } + let Some(position) = portfolio.position(symbol) else { + return 0; + }; + let Ok(snapshot) = data.require_market(date, symbol) else { + return current_qty; + }; + let Ok(candidate) = data.require_candidate(date, symbol) else { + return current_qty; + }; + let rule = self.rules.can_sell( + date, + snapshot, + candidate, + position, + self.execution_price_field, + ); + if !rule.allowed { + return current_qty; + } + let sellable = position.sellable_qty(date); + let sell_limit = match self.market_fillable_quantity( + snapshot, + OrderSide::Sell, + sellable.min(current_qty), + round_lot, + 0, + ) { + Ok(quantity) => quantity.min(sellable).min(current_qty), + Err(_) => 0, + }; + current_qty.saturating_sub(sell_limit) + } + + fn maximum_target_quantity( + &self, + date: NaiveDate, + _portfolio: &PortfolioState, + data: &DataSet, + symbol: &str, + current_qty: u32, + round_lot: u32, + ) -> u32 { + let Ok(snapshot) = data.require_market(date, symbol) else { + return current_qty; + }; + let Ok(candidate) = data.require_candidate(date, symbol) else { + return current_qty; + }; + let rule = self + .rules + .can_buy(date, snapshot, candidate, self.execution_price_field); + if !rule.allowed { + return current_qty; + } + let additional_limit = + match self.market_fillable_quantity(snapshot, OrderSide::Buy, u32::MAX, round_lot, 0) { + Ok(quantity) => quantity, + Err(_) => 0, + }; + current_qty.saturating_add(additional_limit) + } + + fn estimated_sell_net_cash(&self, price: f64, quantity: u32) -> f64 { + if quantity == 0 { + return 0.0; + } + let gross = price * quantity as f64; + let cost = self.cost_model.calculate(OrderSide::Sell, gross); + gross - cost.total() + } + + fn estimated_buy_cash_out(&self, price: f64, quantity: u32) -> f64 { + if quantity == 0 { + return 0.0; + } + let gross = price * quantity as f64; + let cost = self.cost_model.calculate(OrderSide::Buy, gross); + gross + cost.total() + } + fn process_sell( &self, date: NaiveDate, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 0d1ddad..fec92af 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -306,6 +306,177 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); } +#[test] +fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "Held".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + Instrument { + symbol: "000002.SZ".to_string(), + name: "Target".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 9.5, + close: 9.8, + last_price: 9.8, + bid1: 9.79, + ask1: 9.81, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.1, + last_price: 10.1, + bid1: 10.09, + ask1: 10.11, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 60.0, + free_float_cap_bn: 50.0, + pe_ttm: 18.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }, + ], + vec![ + CandidateEligibility { + date, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: false, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(0.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 10_000, 10.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert!( + report + .order_events + .iter() + .all(|event| !(event.symbol == "000002.SZ" && event.side == fidc_core::OrderSide::Buy)), + "optimizer should skip unfunded rebalance buy when locked holding cannot be sold" + ); + assert_eq!( + portfolio + .position("000001.SZ") + .map(|position| position.quantity) + .unwrap_or(0), + 10_000 + ); +} + #[test] fn broker_uses_instrument_round_lot_for_buy_sizing() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();