From 516691692677cafd3054ef04eaea292274cd8352 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 10 Jul 2026 12:40:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=9B=AE=E6=A0=87=E7=BB=84?= =?UTF-8?q?=E5=90=88=E5=85=A8=E4=BB=93=E5=8D=96=E5=87=BA=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=9B=9E=E8=A1=A5=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/broker.rs | 309 ++++++++++++++++++++++++++++++++- 1 file changed, 303 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index a901359..c48ab58 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -188,6 +188,7 @@ 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>, @@ -217,6 +218,7 @@ 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), @@ -250,6 +252,7 @@ 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), @@ -1547,21 +1550,45 @@ 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, symbol: &str, ) -> Option<&'static str> { - if self + if !self .risk_config .static_rules .forbid_same_day_rebuy_after_sell - && self - .same_day_sold_symbols - .borrow() - .get(&date) - .is_some_and(|symbols| symbols.contains(symbol)) { + return None; + } + let sold_today = self + .same_day_sold_symbols + .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 { return Some("same_day_rebuy_forbidden"); } None @@ -2279,6 +2306,113 @@ 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); + } + 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( + date, + portfolio, + data, + symbol, + *weight, + 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, @@ -2946,6 +3080,7 @@ 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) @@ -3016,6 +3151,9 @@ 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, @@ -3133,6 +3271,9 @@ 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, @@ -3202,6 +3343,9 @@ 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, @@ -3336,6 +3480,9 @@ 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, @@ -7358,6 +7505,156 @@ mod tests { assert!(rejected.reason.contains("buy_disabled"), "{rejected:?}"); } + #[test] + fn aiquant_target_portfolio_smart_falls_back_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"]; + 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(); + if *symbol == "000001.SZ" { + snapshot.day_open = 9.0; + snapshot.open = 9.0; + snapshot.low = 9.0; + snapshot.close = 9.0; + snapshot.last_price = 9.0; + snapshot.bid1 = 9.0; + snapshot.ask1 = 9.0; + snapshot.lower_limit = 9.0; + } + 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(100_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 1_000, 10.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 1_000, 10.0); + let mut report = BrokerExecutionReport::default(); + let mut intraday_turnover = BTreeMap::new(); + let mut execution_cursors = BTreeMap::new(); + let mut global_execution_cursor = None; + let mut commission_state = BTreeMap::new(); + + broker + .process_target_value( + date, + &mut portfolio, + &data, + "000001.SZ", + 0.0, + "stop_loss_exit", + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, + &mut commission_state, + &mut report, + ) + .expect("failed lower-limit full close should be recorded"); + assert_eq!( + portfolio + .position("000001.SZ") + .map(|position| position.quantity), + Some(1_000) + ); + assert!(report.order_events.iter().any(|event| { + event.symbol == "000001.SZ" + && event.side == OrderSide::Sell + && event.status == OrderStatus::Canceled + && event.filled_quantity == 0 + })); + + let mut target_weights = BTreeMap::new(); + target_weights.insert("000001.SZ".to_string(), 0.50); + target_weights.insert("000002.SZ".to_string(), 0.50); + broker + .process_target_portfolio_smart( + date, + &mut portfolio, + &data, + &target_weights, + None, + None, + "signal_target_weights", + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, + &mut commission_state, + &mut report, + ) + .expect("failed full close should trigger per-symbol fallback"); + + assert_eq!( + portfolio + .position("000001.SZ") + .map(|position| position.quantity), + Some(1_000), + "{:?}", + report.fill_events + ); + assert!( + portfolio + .position("000002.SZ") + .is_some_and(|position| position.quantity > 1_000), + "{:?}", + 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.diagnostics.iter().any(|line| { + line.contains("target_portfolio_smart_fallback_after_failed_full_close") + && line.contains("000001.SZ") + })); + } + #[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");