diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 22e54ad..1845845 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2242,6 +2242,23 @@ where let mut symbols = BTreeSet::new(); symbols.extend(portfolio.positions().keys().cloned()); symbols.extend(target_quantities.keys().cloned()); + symbols.extend( + target_weights + .iter() + .filter(|(_, weight)| weight.abs() > f64::EPSILON) + .map(|(symbol, _)| symbol.clone()), + ); + + self.record_denied_target_portfolio_buys( + date, + portfolio, + data, + target_weights, + &target_quantities, + valuation_prices, + reason, + report, + )?; for symbol in &symbols { let current_qty = portfolio @@ -2340,6 +2357,93 @@ where Ok(()) } + fn record_denied_target_portfolio_buys( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + target_weights: &BTreeMap, + target_quantities: &BTreeMap, + valuation_prices: Option<&BTreeMap>, + reason: &str, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let equity = + self.rebalance_total_equity_at_with_overrides(date, portfolio, data, valuation_prices)?; + for (symbol, weight) in target_weights { + if weight.abs() <= f64::EPSILON { + continue; + } + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); + if target_qty > current_qty { + continue; + } + let price = self.rebalance_valuation_price_with_overrides( + date, + symbol, + data, + valuation_prices, + )?; + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); + let desired_qty = self.round_buy_quantity( + ((equity * weight) / price).floor() as u32, + minimum_order_quantity, + order_step_size, + ); + if desired_qty <= current_qty { + continue; + } + let Some(rule_reason) = self.buy_target_denial_reason( + date, + portfolio, + data, + symbol, + current_qty, + minimum_order_quantity, + order_step_size, + ) else { + continue; + }; + let order_id = self.reserve_order_id(); + let requested_quantity = desired_qty - current_qty; + let status = zero_fill_status_for_reason(&rule_reason); + report.order_events.push(OrderEvent { + date, + decision_date: None, + order_created_date: None, + execution_date: None, + order_id: Some(order_id), + symbol: symbol.clone(), + side: OrderSide::Buy, + requested_quantity, + filled_quantity: 0, + status, + reason: format!("{reason}: {rule_reason}"), + }); + Self::emit_order_process_event( + report, + date, + ProcessEventKind::OrderCreationReject, + order_id, + symbol, + OrderSide::Buy, + format!("status={status:?} reason={rule_reason}"), + ); + if report.diagnostics.len() < 32 { + report.diagnostics.push(format!( + "target_portfolio_buy_rejected symbol={} requested={} reason={}", + symbol, requested_quantity, rule_reason + )); + } + } + Ok(()) + } + fn aiquant_limit_check_price( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -5993,6 +6097,7 @@ mod tests { use crate::events::{OrderSide, OrderStatus}; use crate::instrument::Instrument; use crate::portfolio::PortfolioState; + use crate::risk_control::FidcRiskControlConfig; use crate::rules::ChinaEquityRuleHooks; use crate::strategy::{AlgoOrderStyle, OrderIntent, StrategyDecision}; @@ -6939,6 +7044,72 @@ mod tests { ); } + #[test] + fn target_portfolio_smart_records_buy_rejection_when_target_is_blacklisted() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let mut risk_config = FidcRiskControlConfig::default(); + risk_config + .static_rules + .blacklisted_symbols + .insert("000001.SZ".to_string()); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_risk_config(risk_config); + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![limit_test_snapshot()], + Vec::new(), + vec![limit_test_candidate(true, true)], + vec![limit_test_benchmark()], + Vec::new(), + Vec::new(), + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let mut target_weights = BTreeMap::new(); + target_weights.insert("000001.SZ".to_string(), 0.50); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_portfolio_smart( + date, + &mut portfolio, + &data, + &target_weights, + None, + None, + "target_weights_test", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("blacklisted target buy should be rejected, not silently dropped"); + + assert!(portfolio.position("000001.SZ").is_none()); + assert!(report.fill_events.is_empty()); + let rejected = report + .order_events + .iter() + .find(|event| event.symbol == "000001.SZ") + .expect("blacklisted target should produce rejected order event"); + assert_eq!(rejected.side, OrderSide::Buy); + assert_eq!(rejected.status, OrderStatus::Rejected); + assert_eq!(rejected.filled_quantity, 0); + assert!(rejected.requested_quantity > 0); + assert!( + rejected.reason.contains("blacklisted"), + "{}", + rejected.reason + ); + } + #[test] fn target_portfolio_smart_pre_open_cash_does_not_spend_same_batch_sell_proceeds() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");