diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index c48ab58..215454a 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1583,12 +1583,7 @@ where .borrow() .get(&date) .is_some_and(|symbols| symbols.contains(symbol)); - let failed_full_close_today = self - .same_day_failed_full_close_sell_symbols - .borrow() - .get(&date) - .is_some_and(|symbols| symbols.contains(symbol)); - if sold_today || failed_full_close_today { + if sold_today { return Some("same_day_rebuy_forbidden"); } None @@ -2374,33 +2369,42 @@ where } Self::extend_report(report, local_report); } - for (symbol, weight) in target_weights { - if weight.abs() <= f64::EPSILON { - continue; - } - let mut local_report = BrokerExecutionReport::default(); - if let Some(limit_price) = limit_prices.and_then(|prices| prices.get(symbol)) { - self.process_limit_target_percent( - date, - portfolio, - data, - symbol, - *weight, - *limit_price, - reason, - intraday_turnover, - execution_cursors, - global_execution_cursor, - commission_state, - &mut local_report, - )?; - } else { - self.process_target_percent( + let target_entries = target_weights + .iter() + .filter(|(_, weight)| weight.abs() > f64::EPSILON) + .map(|(symbol, weight)| { + let total_equity = self.rebalance_total_equity_at_with_overrides( + date, + portfolio, + data, + valuation_prices, + )?; + let side = self.target_percent_order_side_with_total_equity( date, portfolio, data, symbol, *weight, + limit_prices.and_then(|prices| prices.get(symbol)).copied(), + total_equity, + )?; + Ok((symbol.clone(), *weight, side)) + }) + .collect::, BacktestError>>()?; + for pass_side in [OrderSide::Sell, OrderSide::Buy] { + for (symbol, weight, side) in target_entries.iter() { + if *side != Some(pass_side) { + continue; + } + let mut local_report = BrokerExecutionReport::default(); + self.process_target_percent_with_rebalance_valuation( + date, + portfolio, + data, + symbol, + *weight, + limit_prices.and_then(|prices| prices.get(symbol)).copied(), + valuation_prices, reason, intraday_turnover, execution_cursors, @@ -2408,8 +2412,8 @@ where commission_state, &mut local_report, )?; + Self::extend_report(report, local_report); } - Self::extend_report(report, local_report); } return Ok(()); } @@ -2559,6 +2563,109 @@ where Ok(()) } + #[allow(clippy::too_many_arguments)] + fn process_target_percent_with_rebalance_valuation( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_percent: f64, + limit_price: Option, + valuation_prices: Option<&BTreeMap>, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + commission_state: &mut BTreeMap, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let total_equity = + self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; + let target_value = total_equity * target_percent.max(0.0); + if let Some(limit_price) = limit_price.filter(|price| price.is_finite() && *price > 0.0) { + self.process_limit_target_value( + date, + portfolio, + data, + symbol, + target_value, + limit_price, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } else { + self.process_target_value( + date, + portfolio, + data, + symbol, + target_value, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + commission_state, + report, + ) + } + } + + fn target_percent_order_side_with_total_equity( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + symbol: &str, + target_percent: f64, + limit_price: Option, + total_equity: f64, + ) -> Result, BacktestError> { + let current_qty = portfolio + .position(symbol) + .map(|position| position.quantity) + .unwrap_or(0); + let target_value = total_equity * target_percent.max(0.0); + if target_value <= f64::EPSILON { + return Ok((current_qty > 0).then_some(OrderSide::Sell)); + } + if let Some(price) = limit_price.filter(|price| price.is_finite() && *price > 0.0) { + let target_qty = self.round_buy_quantity( + (target_value / price).floor() as u32, + self.minimum_order_quantity(data, symbol), + self.order_step_size(data, symbol), + ); + return Ok(if current_qty > target_qty { + Some(OrderSide::Sell) + } else if target_qty > current_qty { + Some(OrderSide::Buy) + } else { + None + }); + } + let Some(snapshot) = data.market(date, symbol) else { + return Ok(Some(if current_qty > 0 { + OrderSide::Sell + } else { + OrderSide::Buy + })); + }; + let current_value = + self.target_value_valuation_price(date, data, symbol, snapshot) * current_qty as f64; + let cash_delta = target_value - current_value; + Ok(if cash_delta < -f64::EPSILON { + Some(OrderSide::Sell) + } else if cash_delta > f64::EPSILON { + Some(OrderSide::Buy) + } else { + None + }) + } + fn record_denied_target_portfolio_buys( &self, date: NaiveDate, @@ -4029,14 +4136,18 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let price = data - .market(date, symbol) - .map(|snapshot| self.sizing_price(snapshot)) - .ok_or_else(|| BacktestError::MissingPrice { - date, - symbol: symbol.to_string(), - field: price_field_name(self.execution_price_field), - })?; + let price = if self.aiquant_execution_rules && limit_price.is_finite() && limit_price > 0.0 + { + limit_price + } else { + data.market(date, symbol) + .map(|snapshot| self.sizing_price(snapshot)) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })? + }; let current_qty = portfolio .position(symbol) .map(|pos| pos.quantity) @@ -6338,7 +6449,9 @@ mod tests { use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; - use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision}; + use crate::strategy::{ + AlgoOrderStyle, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, + }; fn limit_test_snapshot() -> DailyMarketSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); @@ -7628,11 +7741,10 @@ mod tests { ) .expect("failed full close should trigger per-symbol fallback"); - assert_eq!( + assert!( portfolio .position("000001.SZ") - .map(|position| position.quantity), - Some(1_000), + .is_some_and(|position| position.quantity > 1_000), "{:?}", report.fill_events ); @@ -7643,18 +7755,134 @@ mod tests { "{:?}", report.fill_events ); - assert!(report.order_events.iter().any(|event| { - event.symbol == "000001.SZ" - && event.side == OrderSide::Buy - && event.status == OrderStatus::Rejected - && event.reason.contains("same_day_rebuy_forbidden") - })); + assert!( + report.order_events.iter().all(|event| { + !(event.symbol == "000001.SZ" + && event.side == OrderSide::Buy + && event.reason.contains("same_day_rebuy_forbidden")) + }), + "{:?}", + report.order_events + ); assert!(report.diagnostics.iter().any(|line| { line.contains("target_portfolio_smart_fallback_after_failed_full_close") && line.contains("000001.SZ") })); } + #[test] + fn aiquant_target_portfolio_fallback_sells_before_buys() { + 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(); + let price = if *symbol == "000001.SZ" { 5.0 } else { 20.0 }; + snapshot.day_open = price; + snapshot.open = price; + snapshot.close = price; + snapshot.last_price = price; + snapshot.bid1 = price; + snapshot.ask1 = price; + snapshot.lower_limit = price * 0.9; + snapshot.upper_limit = price * 1.1; + 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 broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_aiquant_execution_rules(true) + .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, 5.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 1_000, 20.0); + broker.mark_failed_full_close_sell(date, "000001.SZ"); + + let mut target_weights = BTreeMap::new(); + target_weights.insert("000001.SZ".to_string(), 0.50); + target_weights.insert("000002.SZ".to_string(), 0.50); + let mut limit_prices = BTreeMap::new(); + limit_prices.insert("000001.SZ".to_string(), 5.0); + limit_prices.insert("000002.SZ".to_string(), 20.0); + let order_prices = TargetPortfolioOrderPricing::LimitPrices(limit_prices.clone()); + let mut report = BrokerExecutionReport::default(); + broker + .process_target_portfolio_smart( + date, + &mut portfolio, + &data, + &target_weights, + Some(&order_prices), + Some(&limit_prices), + "signal_target_weights", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("fallback should rebalance with sells before buys"); + + let filled_target_orders = report + .order_events + .iter() + .filter(|event| event.reason.contains("signal_target_weights")) + .filter(|event| event.status == OrderStatus::Filled && event.filled_quantity > 0) + .map(|event| (event.symbol.as_str(), event.side)) + .collect::>(); + assert_eq!( + filled_target_orders, + vec![ + ("000002.SZ", OrderSide::Sell), + ("000001.SZ", OrderSide::Buy) + ], + "{:?}", + report.order_events + ); + } + #[test] fn target_portfolio_smart_scales_buys_when_full_targets_exceed_cash_by_fees() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");