diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 326d333..d8ac425 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -4291,6 +4291,15 @@ where execution_cursors, *global_execution_cursor, ); + if requested_qty == 0 { + report.diagnostics.push(format!( + "value_order_skipped symbol={symbol} side=buy reason=below_executable_quantity value={} minimum_order_quantity={} order_step_size={} intent_reason={reason}", + value.abs(), + minimum_order_quantity, + order_step_size, + )); + return Ok(()); + } self.process_buy( date, portfolio, @@ -4312,11 +4321,22 @@ where ) } else { let price = self.value_sell_sizing_price(date, data, symbol, snapshot); + let minimum_order_quantity = self.minimum_order_quantity(data, symbol); + let order_step_size = self.order_step_size(data, symbol); let requested_qty = self.round_buy_quantity( ((value.abs()) / price).floor() as u32, - self.minimum_order_quantity(data, symbol), - self.order_step_size(data, symbol), + minimum_order_quantity, + order_step_size, ); + if requested_qty == 0 { + report.diagnostics.push(format!( + "value_order_skipped symbol={symbol} side=sell reason=below_executable_quantity value={} minimum_order_quantity={} order_step_size={} intent_reason={reason}", + value.abs(), + minimum_order_quantity, + order_step_size, + )); + return Ok(()); + } self.process_sell( date, portfolio, @@ -7495,6 +7515,93 @@ mod tests { assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 2_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"); + 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(10_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", + 9_995.0, + "sub_lot_target_adjust", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("target value execution"); + + assert!(report.order_events.is_empty()); + assert!(report.fill_events.is_empty()); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 1_000); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.contains("value_order_skipped symbol=000001.SZ side=sell") + && diagnostic.contains("reason=below_executable_quantity") + })); + + let mut buy_portfolio = PortfolioState::new(10_000.0); + let mut buy_report = BrokerExecutionReport::default(); + broker + .process_target_value( + date, + &mut buy_portfolio, + &data, + "000001.SZ", + 995.0, + "sub_lot_target_open", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut buy_report, + ) + .expect("target value execution"); + + assert!(buy_report.order_events.is_empty()); + assert!(buy_report.fill_events.is_empty()); + assert!(buy_portfolio.position("000001.SZ").is_none()); + assert!(buy_report.diagnostics.iter().any(|diagnostic| { + diagnostic.contains("value_order_skipped symbol=000001.SZ side=buy") + && diagnostic.contains("reason=below_executable_quantity") + })); + } + #[test] fn target_percent_reduction_from_full_to_twenty_percent_sells_eighty_percent() { let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");