diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 9accded..4568c77 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -31,6 +31,7 @@ pub struct BrokerExecutionReport { #[derive(Debug, Clone, Copy)] struct ExecutionLeg { price: f64, + mark_price: f64, quantity: u32, } @@ -401,19 +402,37 @@ where side: OrderSide, quantity: Option, ) -> f64 { - let raw_price = if self.execution_price_field == PriceField::Last + let raw_price = self.snapshot_raw_execution_price(snapshot, side); + self.apply_slippage(snapshot, side, raw_price, quantity) + } + + fn snapshot_raw_execution_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + ) -> f64 { + if self.execution_price_field == PriceField::Last && self.intraday_execution_start_time.is_some() { - let _ = side; - snapshot.price(PriceField::Last) - } else { - match side { - OrderSide::Buy => self.buy_price(snapshot), - OrderSide::Sell => self.sell_price(snapshot), - } - }; + return snapshot.price(PriceField::Last); + } + match side { + OrderSide::Buy => self.buy_price(snapshot), + OrderSide::Sell => self.sell_price(snapshot), + } + } - self.apply_slippage(snapshot, side, raw_price, quantity) + fn snapshot_mark_price( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + ) -> f64 { + let price = snapshot.price(self.execution_price_field); + if price.is_finite() && price > 0.0 { + price + } else { + self.snapshot_raw_execution_price(snapshot, side) + } } fn is_open_auction_matching(&self) -> bool { @@ -573,6 +592,14 @@ where } } + fn quote_mark_price(&self, quote: &IntradayExecutionQuote, fallback: f64) -> f64 { + if quote.last_price.is_finite() && quote.last_price > 0.0 { + quote.last_price + } else { + fallback + } + } + pub fn execute( &self, date: NaiveDate, @@ -2503,6 +2530,7 @@ where fillable_qty, vec![ExecutionLeg { price: execution_price, + mark_price: self.snapshot_mark_price(snapshot, OrderSide::Sell), quantity: fillable_qty, }], ) @@ -2588,7 +2616,7 @@ where let net_cash = gross_amount - cost.total(); let realized_pnl = portfolio .position_mut(symbol) - .sell(leg.quantity, leg.price) + .sell_with_mark_price(leg.quantity, leg.price, leg.mark_price) .map_err(BacktestError::Execution)?; if let Some(position) = portfolio.position_mut_if_exists(symbol) { position.record_trade_cost(cost.total()); @@ -3909,6 +3937,7 @@ where filled_qty, vec![ExecutionLeg { price: execution_price, + mark_price: self.snapshot_mark_price(snapshot, OrderSide::Buy), quantity: filled_qty, }], ) @@ -3995,9 +4024,12 @@ where let cash_out = gross_amount + cost.total(); portfolio.apply_cash_delta(-cash_out); - portfolio - .position_mut(symbol) - .buy(date, leg.quantity, leg.price); + portfolio.position_mut(symbol).buy_with_mark_price( + date, + leg.quantity, + leg.price, + leg.mark_price, + ); if let Some(position) = portfolio.position_mut_if_exists(symbol) { position.record_buy_trade_cost(leg.quantity, cost.total()); } @@ -4700,6 +4732,7 @@ where }; let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; + let mut mark_amount = 0.0_f64; let mut last_timestamp = None; let mut legs = Vec::new(); let mut budget_block_reason = None; @@ -4717,6 +4750,7 @@ where else { continue; }; + let mark_price = self.quote_mark_price(quote, raw_quote_price); let remaining_qty = requested_qty.saturating_sub(filled_qty); if remaining_qty == 0 { break; @@ -4815,10 +4849,12 @@ where quote_price = self.execution_price_with_limit_slippage(quote_price, limit_price); gross_amount += quote_price * take_qty as f64; + mark_amount += mark_price * take_qty as f64; filled_qty += take_qty; last_timestamp = Some(quote.timestamp); legs.push(ExecutionLeg { price: quote_price, + mark_price, quantity: take_qty, }); @@ -4849,6 +4885,7 @@ where legs: if matching_type == MatchingType::Vwap { vec![ExecutionLeg { price: gross_amount / filled_qty as f64, + mark_price: mark_amount / filled_qty as f64, quantity: filled_qty, }] } else { diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index b7f86a3..385c8ba 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -66,6 +66,16 @@ impl Position { } pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) { + self.buy_with_mark_price(date, quantity, price, price); + } + + pub fn buy_with_mark_price( + &mut self, + date: NaiveDate, + quantity: u32, + execution_price: f64, + mark_price: f64, + ) { if quantity == 0 { return; } @@ -73,19 +83,28 @@ impl Position { self.lots.push(PositionLot { acquired_date: date, quantity, - entry_price: price, - price, + entry_price: execution_price, + price: execution_price, }); self.quantity += quantity; - self.last_price = price; + self.last_price = normalized_mark_price(mark_price, execution_price); self.day_trade_quantity_delta += quantity as i32; self.day_buy_quantity += quantity; - self.day_buy_value += price * quantity as f64; + self.day_buy_value += execution_price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); } pub fn sell(&mut self, quantity: u32, price: f64) -> Result { + self.sell_with_mark_price(quantity, price, price) + } + + pub fn sell_with_mark_price( + &mut self, + quantity: u32, + execution_price: f64, + mark_price: f64, + ) -> Result { if quantity > self.quantity { return Err(format!( "sell quantity {} exceeds current quantity {} for {}", @@ -102,7 +121,7 @@ impl Position { }; let lot_sell = remaining.min(first_lot.quantity); - realized += (price - first_lot.price) * lot_sell as f64; + realized += (execution_price - first_lot.price) * lot_sell as f64; first_lot.quantity -= lot_sell; remaining -= lot_sell; @@ -112,11 +131,11 @@ impl Position { } self.quantity -= quantity; - self.last_price = price; + self.last_price = normalized_mark_price(mark_price, execution_price); self.realized_pnl += realized; self.day_trade_quantity_delta -= quantity as i32; self.day_sell_quantity += quantity; - self.day_sell_value += price * quantity as f64; + self.day_sell_value += execution_price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); Ok(realized) @@ -356,6 +375,14 @@ impl Position { } } +fn normalized_mark_price(mark_price: f64, fallback: f64) -> f64 { + if mark_price.is_finite() && mark_price > 0.0 { + mark_price + } else { + fallback + } +} + #[derive(Debug, Clone)] pub struct PortfolioState { initial_cash: f64, diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index b72aea0..d1ed7d6 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -1701,6 +1701,9 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { assert_eq!(report.fill_events.len(), 1); assert!((report.fill_events[0].price - 10.02).abs() < 1e-9); + let position = portfolio.position("000002.SZ").expect("position"); + assert!((position.last_price - 10.0).abs() < 1e-9); + assert!((position.market_value() - position.quantity as f64 * 10.0).abs() < 1e-6); } #[test]