diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index f912b54..7f11057 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -20,10 +20,16 @@ pub struct BrokerExecutionReport { } #[derive(Debug, Clone, Copy)] -struct ExecutionFill { +struct ExecutionLeg { price: f64, quantity: u32, +} + +#[derive(Debug, Clone)] +struct ExecutionFill { + quantity: u32, next_cursor: NaiveDateTime, + legs: Vec, unfilled_reason: Option<&'static str>, } @@ -968,7 +974,6 @@ where return Ok(()); } - let cash_before = portfolio.cash(); let fill = self.resolve_execution_fill( date, symbol, @@ -985,32 +990,87 @@ where None, None, ); - let (filled_qty, execution_price) = if let Some(fill) = fill { + let (filled_qty, execution_legs) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.price) + (fill.quantity, fill.legs) } else { - (filled_qty, self.sell_price(snapshot)) + ( + filled_qty, + vec![ExecutionLeg { + price: self.sell_price(snapshot), + quantity: filled_qty, + }], + ) }; - let gross_amount = execution_price * filled_qty as f64; - let cost = self.cost_model.calculate_with_order_state( - date, - OrderSide::Sell, - gross_amount, - Some(order_id), - commission_state, - ); - let net_cash = gross_amount - cost.total(); + if execution_legs.len() > 1 { + report.diagnostics.push(format!( + "order_split_fill symbol={symbol} side=sell order_id={order_id} fills={}", + execution_legs.len() + )); + } + for leg in &execution_legs { + let leg_cash_before = portfolio.cash(); + let gross_amount = leg.price * leg.quantity as f64; + let cost = self.cost_model.calculate_with_order_state( + date, + OrderSide::Sell, + gross_amount, + Some(order_id), + commission_state, + ); + let net_cash = gross_amount - cost.total(); + let realized_pnl = portfolio + .position_mut(symbol) + .sell(leg.quantity, leg.price) + .map_err(BacktestError::Execution)?; + portfolio.apply_cash_delta(net_cash); - let realized_pnl = portfolio - .position_mut(symbol) - .sell(filled_qty, execution_price) - .map_err(BacktestError::Execution)?; - portfolio.apply_cash_delta(net_cash); + report.fill_events.push(FillEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Sell, + quantity: leg.quantity, + price: leg.price, + gross_amount, + commission: cost.commission, + stamp_tax: cost.stamp_tax, + net_cash_flow: net_cash, + reason: reason.to_string(), + }); + report.position_events.push(PositionEvent { + date, + symbol: symbol.to_string(), + delta_quantity: -(leg.quantity as i32), + quantity_after: portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0), + average_cost: portfolio + .position(symbol) + .map(|pos| pos.average_cost) + .unwrap_or(0.0), + realized_pnl_delta: realized_pnl, + reason: reason.to_string(), + }); + report.account_events.push(AccountEvent { + date, + cash_before: leg_cash_before, + cash_after: portfolio.cash(), + total_equity: self.total_equity_at( + date, + portfolio, + data, + self.account_mark_price_field(), + )?, + note: format!("sell {symbol} {reason}"), + }); + } portfolio.prune_flat_positions(); *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; @@ -1041,46 +1101,6 @@ where status, reason: order_reason, }); - report.fill_events.push(FillEvent { - date, - order_id: Some(order_id), - symbol: symbol.to_string(), - side: OrderSide::Sell, - quantity: filled_qty, - price: execution_price, - gross_amount, - commission: cost.commission, - stamp_tax: cost.stamp_tax, - net_cash_flow: net_cash, - reason: reason.to_string(), - }); - report.position_events.push(PositionEvent { - date, - symbol: symbol.to_string(), - delta_quantity: -(filled_qty as i32), - quantity_after: portfolio - .position(symbol) - .map(|pos| pos.quantity) - .unwrap_or(0), - average_cost: portfolio - .position(symbol) - .map(|pos| pos.average_cost) - .unwrap_or(0.0), - realized_pnl_delta: realized_pnl, - reason: reason.to_string(), - }); - report.account_events.push(AccountEvent { - date, - cash_before, - cash_after: portfolio.cash(), - total_equity: self.total_equity_at( - date, - portfolio, - data, - self.account_mark_price_field(), - )?, - note: format!("sell {symbol} {reason}"), - }); Ok(()) } @@ -1355,14 +1375,14 @@ where Some(portfolio.cash()), value_budget.map(|budget| budget + 400.0), ); - let (filled_qty, execution_price) = if let Some(fill) = fill { + let (filled_qty, execution_legs) = if let Some(fill) = fill { execution_cursors.insert(symbol.to_string(), fill.next_cursor); if self.uses_serial_execution_cursor(reason) { *global_execution_cursor = Some(fill.next_cursor); } partial_fill_reason = merge_partial_fill_reason(partial_fill_reason, fill.unfilled_reason); - (fill.quantity, fill.price) + (fill.quantity, fill.legs) } else { let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy); let filled_qty = self.affordable_buy_quantity( @@ -1386,7 +1406,13 @@ where ), ); } - (filled_qty, execution_price) + ( + filled_qty, + vec![ExecutionLeg { + price: execution_price, + quantity: filled_qty, + }], + ) }; if filled_qty == 0 { report.order_events.push(OrderEvent { @@ -1407,21 +1433,70 @@ where return Ok(()); } - let cash_before = portfolio.cash(); - let gross_amount = execution_price * filled_qty as f64; - let cost = self.cost_model.calculate_with_order_state( - date, - OrderSide::Buy, - gross_amount, - Some(order_id), - commission_state, - ); - let cash_out = gross_amount + cost.total(); + if execution_legs.len() > 1 { + report.diagnostics.push(format!( + "order_split_fill symbol={symbol} side=buy order_id={order_id} fills={}", + execution_legs.len() + )); + } + for leg in &execution_legs { + let leg_cash_before = portfolio.cash(); + let gross_amount = leg.price * leg.quantity as f64; + let cost = self.cost_model.calculate_with_order_state( + date, + OrderSide::Buy, + gross_amount, + Some(order_id), + commission_state, + ); + let cash_out = gross_amount + cost.total(); - portfolio.apply_cash_delta(-cash_out); - portfolio - .position_mut(symbol) - .buy(date, filled_qty, execution_price); + portfolio.apply_cash_delta(-cash_out); + portfolio + .position_mut(symbol) + .buy(date, leg.quantity, leg.price); + + report.fill_events.push(FillEvent { + date, + order_id: Some(order_id), + symbol: symbol.to_string(), + side: OrderSide::Buy, + quantity: leg.quantity, + price: leg.price, + gross_amount, + commission: cost.commission, + stamp_tax: cost.stamp_tax, + net_cash_flow: -cash_out, + reason: reason.to_string(), + }); + report.position_events.push(PositionEvent { + date, + symbol: symbol.to_string(), + delta_quantity: leg.quantity as i32, + quantity_after: portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0), + average_cost: portfolio + .position(symbol) + .map(|pos| pos.average_cost) + .unwrap_or(0.0), + realized_pnl_delta: 0.0, + reason: reason.to_string(), + }); + report.account_events.push(AccountEvent { + date, + cash_before: leg_cash_before, + cash_after: portfolio.cash(), + total_equity: self.total_equity_at( + date, + portfolio, + data, + self.account_mark_price_field(), + )?, + note: format!("buy {symbol} {reason}"), + }); + } *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { @@ -1451,46 +1526,6 @@ where status, reason: order_reason, }); - report.fill_events.push(FillEvent { - date, - order_id: Some(order_id), - symbol: symbol.to_string(), - side: OrderSide::Buy, - quantity: filled_qty, - price: execution_price, - gross_amount, - commission: cost.commission, - stamp_tax: cost.stamp_tax, - net_cash_flow: -cash_out, - reason: reason.to_string(), - }); - report.position_events.push(PositionEvent { - date, - symbol: symbol.to_string(), - delta_quantity: filled_qty as i32, - quantity_after: portfolio - .position(symbol) - .map(|pos| pos.quantity) - .unwrap_or(0), - average_cost: portfolio - .position(symbol) - .map(|pos| pos.average_cost) - .unwrap_or(0.0), - realized_pnl_delta: 0.0, - reason: reason.to_string(), - }); - report.account_events.push(AccountEvent { - date, - cash_before, - cash_after: portfolio.cash(), - total_equity: self.total_equity_at( - date, - portfolio, - data, - self.account_mark_price_field(), - )?, - note: format!("buy {symbol} {reason}"), - }); Ok(()) } @@ -1734,9 +1769,12 @@ where .map(|start_time| date.and_time(start_time) + Duration::seconds(1)) .unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight")); return Some(ExecutionFill { - price: execution_price, quantity, next_cursor, + legs: vec![ExecutionLeg { + price: execution_price, + quantity, + }], unfilled_reason: self.buy_reduction_reason( cash_limit.unwrap_or(f64::INFINITY), gross_limit, @@ -1793,6 +1831,7 @@ where let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut last_timestamp = None; + let mut legs = Vec::new(); let mut budget_block_reason = None; let mut saw_quote_after_cursor = false; @@ -1865,6 +1904,10 @@ where gross_amount += quote_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); + legs.push(ExecutionLeg { + price: quote_price, + quantity: take_qty, + }); if filled_qty >= requested_qty { break; @@ -1876,9 +1919,9 @@ where } Some(ExecutionFill { - price: gross_amount / filled_qty as f64, quantity: filled_qty, next_cursor: last_timestamp.unwrap() + Duration::seconds(1), + legs, unfilled_reason: if filled_qty < requested_qty { budget_block_reason.or(if saw_quote_after_cursor { Some("intraday quote liquidity exhausted") diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index fbaac40..cbecc6e 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -638,6 +638,150 @@ fn broker_emits_partial_fill_reason_when_intraday_quote_liquidity_exhausted() { ); } +#[test] +fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + Vec::new(), + vec![ + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 3).unwrap(), + last_price: 10.01, + bid1: 10.0, + ask1: 10.02, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + IntradayExecutionQuote { + date, + symbol: "000002.SZ".to_string(), + timestamp: date.and_hms_opt(10, 18, 6).unwrap(), + last_price: 10.03, + bid1: 10.02, + ask1: 10.04, + bid1_volume: 1, + ask1_volume: 1, + volume_delta: 1, + amount_delta: 0.0, + trading_phase: Some("continuous".to_string()), + }, + ], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 2_500.0, + reason: "intraday_split_fill".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.order_events.len(), 1); + assert_eq!( + report.order_events[0].status, + fidc_core::OrderStatus::Filled + ); + assert_eq!(report.fill_events.len(), 2); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(report.fill_events[1].quantity, 100); + assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); + assert!((report.fill_events[1].price - 10.04).abs() < 1e-9); + assert!((report.fill_events[0].commission - 5.0).abs() < 1e-9); + assert_eq!(report.fill_events[1].commission, 0.0); + assert_eq!(report.account_events.len(), 2); + assert!( + report + .diagnostics + .iter() + .any(|item| item.contains("order_split_fill symbol=000002.SZ side=buy")) + ); +} + #[test] fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();