diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 7f11057..7c99b2b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -447,18 +447,12 @@ where data: &DataSet, target_weights: &BTreeMap, ) -> Result<(BTreeMap, Vec), BacktestError> { - let equity = self.total_equity_at(date, portfolio, data, self.execution_price_field)?; + let equity = self.rebalance_total_equity_at(date, portfolio, data)?; let target_weight_sum = target_weights.values().copied().sum::(); let mut desired_targets = BTreeMap::new(); let mut diagnostics = Vec::new(); for (symbol, weight) in target_weights { - 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 price = self.rebalance_valuation_price(date, symbol, data)?; let raw_qty = ((equity * weight) / price).floor() as u32; desired_targets.insert( symbol.clone(), @@ -482,13 +476,7 @@ where .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 price = self.rebalance_valuation_price(date, &symbol, data)?; let minimum_order_quantity = self.minimum_order_quantity(data, &symbol); let order_step_size = self.order_step_size(data, &symbol); let min_target_qty = self.minimum_target_quantity( @@ -1585,6 +1573,78 @@ where } } + fn rebalance_valuation_price_field_name(&self) -> &'static str { + if self.is_open_auction_matching() { + "prev_close" + } else { + price_field_name(self.execution_price_field) + } + } + + fn rebalance_valuation_price_for_snapshot( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + ) -> Option { + let price = if self.is_open_auction_matching() { + snapshot.prev_close + } else { + snapshot.price(self.execution_price_field) + }; + if price.is_finite() && price > 0.0 { + Some(price) + } else { + None + } + } + + fn rebalance_valuation_price( + &self, + date: NaiveDate, + symbol: &str, + data: &DataSet, + ) -> Result { + let snapshot = data + .market(date, symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: self.rebalance_valuation_price_field_name(), + })?; + self.rebalance_valuation_price_for_snapshot(snapshot) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: self.rebalance_valuation_price_field_name(), + }) + } + + fn rebalance_total_equity_at( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + ) -> Result { + let mut market_value = 0.0; + for position in portfolio.positions().values() { + let snapshot = + data.market(date, &position.symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: position.symbol.clone(), + field: self.rebalance_valuation_price_field_name(), + })?; + let price = self + .rebalance_valuation_price_for_snapshot(snapshot) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: position.symbol.clone(), + field: self.rebalance_valuation_price_field_name(), + })?; + market_value += price * position.quantity as f64; + } + Ok(portfolio.cash() + market_value) + } + fn round_buy_quantity( &self, quantity: u32, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index cbecc6e..e014d6e 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -969,6 +969,188 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { ); } +#[test] +fn rebalance_uses_prev_close_for_open_auction_valuation() { + 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 09:25:00".to_string()), + day_open: 20.0, + open: 20.0, + high: 20.0, + low: 20.0, + close: 20.0, + last_price: 20.0, + bid1: 20.0, + ask1: 20.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("open_auction".to_string()), + paused: false, + upper_limit: 22.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 09:25:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("open_auction".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: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }, + DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + 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: true, + 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, 1_000, 10.0); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::DayOpen, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: true, + target_weights: BTreeMap::from([ + ("000001.SZ".to_string(), 0.5), + ("000002.SZ".to_string(), 0.5), + ]), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + let held = portfolio.position("000001.SZ").expect("held position"); + let target = portfolio.position("000002.SZ").expect("target position"); + assert_eq!(held.quantity, 500); + assert_eq!(target.quantity, 400); + assert_eq!(report.fill_events.len(), 2); + assert!( + report + .fill_events + .iter() + .any(|fill| fill.symbol == "000001.SZ" + && fill.side == fidc_core::OrderSide::Sell + && fill.quantity == 500) + ); + assert!( + report + .fill_events + .iter() + .any(|fill| fill.symbol == "000002.SZ" + && fill.side == fidc_core::OrderSide::Buy + && fill.quantity == 400) + ); +} + #[test] fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();