diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 3c3f5bf..9b7aaa0 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -3897,19 +3897,6 @@ where if target_value <= f64::EPSILON { if current_qty == 0 { - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.to_string(), - side: OrderSide::Sell, - requested_quantity: 0, - filled_quantity: 0, - status: OrderStatus::Filled, - reason: format!("{reason}: already at target value"), - }); return Ok(()); } if data.market(date, symbol).is_none() { @@ -3980,24 +3967,6 @@ where commission_state, report, )?; - } else { - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.to_string(), - side: if current_qty > 0 { - OrderSide::Sell - } else { - OrderSide::Buy - }, - requested_quantity: 0, - filled_quantity: 0, - status: OrderStatus::Filled, - reason: format!("{reason}: already at target value"), - }); } Ok(()) @@ -4021,6 +3990,13 @@ where commission_state: &mut BTreeMap, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + if target_value <= f64::EPSILON && current_qty == 0 { + return Ok(()); + } let snapshot = data .market(date, symbol) .ok_or_else(|| BacktestError::MissingPrice { @@ -4028,10 +4004,6 @@ where symbol: symbol.to_string(), field: price_field_name(self.execution_price_field), })?; - let current_qty = portfolio - .position(symbol) - .map(|pos| pos.quantity) - .unwrap_or(0); let algo_request = AlgoExecutionRequest { style: match style { AlgoOrderStyle::Vwap => AlgoExecutionStyle::Vwap, @@ -4042,22 +4014,6 @@ where }; if target_value <= f64::EPSILON { - if current_qty == 0 { - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.to_string(), - side: OrderSide::Sell, - requested_quantity: 0, - filled_quantity: 0, - status: OrderStatus::Filled, - reason: format!("{reason}: already at target value"), - }); - return Ok(()); - } self.process_sell( date, portfolio, @@ -4099,24 +4055,6 @@ where commission_state, report, )?; - } else { - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.to_string(), - side: if current_qty > 0 { - OrderSide::Sell - } else { - OrderSide::Buy - }, - requested_quantity: 0, - filled_quantity: 0, - status: OrderStatus::Filled, - reason: format!("{reason}: already at target value"), - }); } Ok(()) } @@ -4198,24 +4136,6 @@ where report, )?; } - } else { - report.order_events.push(OrderEvent { - date, - decision_date: None, - order_created_date: None, - execution_date: None, - order_id: None, - symbol: symbol.to_string(), - side: if current_qty > 0 { - OrderSide::Sell - } else { - OrderSide::Buy - }, - requested_quantity: 0, - filled_quantity: 0, - status: OrderStatus::Filled, - reason: format!("{reason}: already at target shares"), - }); } Ok(()) @@ -7807,6 +7727,96 @@ mod tests { assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 2_000); } + #[test] + fn unchanged_targets_do_not_create_zero_quantity_orders() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Open, + ) + .with_matching_type(MatchingType::NextBarOpen) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.date = date; + snapshot.prev_close = 10.0; + snapshot.open = 10.0; + snapshot.close = 10.0; + let data = DataSet::from_components_with_actions_and_quotes( + vec![limit_test_instrument()], + vec![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(20_000.0); + portfolio.position_mut("000001.SZ").buy( + date.pred_opt().expect("previous date"), + 1_000, + 10.0, + ); + portfolio.apply_cash_delta(-10_000.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_target_value( + date, + &mut portfolio, + &data, + "000001.SZ", + 10_000.0, + "unchanged_target_value", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("unchanged target value"); + broker + .process_timed_target_value( + date, + &mut portfolio, + &data, + "000001.SZ", + 10_000.0, + AlgoOrderStyle::Twap, + None, + None, + "unchanged timed target value", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("unchanged timed target value"); + broker + .process_target_shares( + date, + &mut portfolio, + &data, + "000001.SZ", + 1_000, + "unchanged target shares", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("unchanged target shares"); + + assert!(report.order_events.is_empty()); + assert!(report.fill_events.is_empty()); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 1_000); + } + #[test] fn target_value_delta_below_order_step_is_audited_without_creating_zero_quantity_order() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");