diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 215454a..7f9439f 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -188,7 +188,6 @@ pub struct BrokerSimulator { same_day_buy_close_mark_at_fill: bool, risk_config: FidcRiskControlConfig, same_day_sold_symbols: RefCell>>, - same_day_failed_full_close_sell_symbols: RefCell>>, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, @@ -218,7 +217,6 @@ impl BrokerSimulator { same_day_buy_close_mark_at_fill: false, risk_config: FidcRiskControlConfig::default(), same_day_sold_symbols: RefCell::new(BTreeMap::new()), - same_day_failed_full_close_sell_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -252,7 +250,6 @@ impl BrokerSimulator { same_day_buy_close_mark_at_fill: false, risk_config: FidcRiskControlConfig::default(), same_day_sold_symbols: RefCell::new(BTreeMap::new()), - same_day_failed_full_close_sell_symbols: RefCell::new(BTreeMap::new()), intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -1550,22 +1547,6 @@ where .insert(symbol.to_string()); } - fn mark_failed_full_close_sell(&self, date: NaiveDate, symbol: &str) { - self.same_day_failed_full_close_sell_symbols - .borrow_mut() - .entry(date) - .or_default() - .insert(symbol.to_string()); - } - - fn failed_full_close_sell_symbols_on(&self, date: NaiveDate) -> BTreeSet { - self.same_day_failed_full_close_sell_symbols - .borrow() - .get(&date) - .cloned() - .unwrap_or_default() - } - fn same_day_rebuy_rejection_reason( &self, date: NaiveDate, @@ -2301,122 +2282,6 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { - let failed_full_close_symbols = if self.aiquant_execution_rules { - self.failed_full_close_sell_symbols_on(date) - } else { - BTreeSet::new() - }; - if !failed_full_close_symbols.is_empty() - && failed_full_close_symbols.iter().any(|symbol| { - target_weights.contains_key(symbol) || portfolio.position(symbol).is_some() - }) - { - if report.diagnostics.len() < 32 { - report.diagnostics.push(format!( - "target_portfolio_smart_fallback_after_failed_full_close symbols={}", - failed_full_close_symbols - .iter() - .take(8) - .cloned() - .collect::>() - .join(",") - )); - } - let limit_prices = match order_prices { - Some(TargetPortfolioOrderPricing::LimitPrices(prices)) => Some(prices), - _ => None, - }; - let held_symbols = portfolio - .positions() - .keys() - .cloned() - .collect::>(); - for symbol in held_symbols.iter() { - if target_weights.contains_key(symbol) || failed_full_close_symbols.contains(symbol) - { - 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, - 0.0, - *limit_price, - reason, - intraday_turnover, - execution_cursors, - global_execution_cursor, - commission_state, - &mut local_report, - )?; - } else { - self.process_target_percent( - date, - portfolio, - data, - symbol, - 0.0, - reason, - intraday_turnover, - execution_cursors, - global_execution_cursor, - commission_state, - &mut local_report, - )?; - } - Self::extend_report(report, local_report); - } - 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, - global_execution_cursor, - commission_state, - &mut local_report, - )?; - Self::extend_report(report, local_report); - } - } - return Ok(()); - } let (target_quantities, diagnostics) = self.target_quantities_with_valuation_prices( date, portfolio, @@ -2563,109 +2428,6 @@ 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, @@ -3187,7 +2949,6 @@ where let Some(position) = portfolio.position(symbol) else { return Ok(()); }; - let full_close_requested = requested_qty >= position.quantity; let Some(snapshot) = data.market(date, symbol) else { let unavailable_reason = self .missing_market_execution_risk_rejection_reason(date, data, symbol, OrderSide::Sell) @@ -3258,9 +3019,6 @@ where | Some("open at or below lower limit") => OrderStatus::Canceled, _ => OrderStatus::Rejected, }; - if full_close_requested { - self.mark_failed_full_close_sell(date, symbol); - } report.order_events.push(OrderEvent { date, decision_date: None, @@ -3378,9 +3136,6 @@ where status: zero_fill_status_for_reason(&limit_reason), reason: format!("{reason}: {limit_reason}"), }); - if full_close_requested { - self.mark_failed_full_close_sell(date, symbol); - } Self::emit_order_process_event( report, date, @@ -3450,9 +3205,6 @@ where status: OrderStatus::Rejected, reason: format!("{reason}: no sellable quantity"), }); - if full_close_requested { - self.mark_failed_full_close_sell(date, symbol); - } Self::emit_order_process_event( report, date, @@ -3587,9 +3339,6 @@ where status: zero_fill_status_for_reason(detail), reason: format!("{reason}: {detail}"), }); - if full_close_requested { - self.mark_failed_full_close_sell(date, symbol); - } Self::emit_order_process_event( report, date, @@ -6449,9 +6198,7 @@ mod tests { use crate::portfolio::PortfolioState; use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; - use crate::strategy::{ - AlgoOrderStyle, OrderIntent, StrategyDecision, TargetPortfolioOrderPricing, - }; + use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision}; fn limit_test_snapshot() -> DailyMarketSnapshot { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); @@ -7619,7 +7366,7 @@ mod tests { } #[test] - fn aiquant_target_portfolio_smart_falls_back_after_failed_full_close_sell() { + fn aiquant_target_portfolio_smart_keeps_batch_semantics_after_failed_full_close_sell() { 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"]; @@ -7739,7 +7486,7 @@ mod tests { &mut commission_state, &mut report, ) - .expect("failed full close should trigger per-symbol fallback"); + .expect("failed full close must not change batch target-portfolio semantics"); assert!( portfolio @@ -7764,122 +7511,11 @@ mod tests { "{:?}", 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 + assert!( + report + .diagnostics + .iter() + .all(|line| { !line.contains("fallback_after_failed_full_close") }) ); }