diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 1fb171b..27573f5 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -367,15 +367,16 @@ where .get() .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); - if let Some(price) = data - .execution_quotes_on(date, symbol) - .iter() - .filter(|quote| { - start_cursor - .map(|cursor| quote.timestamp >= cursor) - .unwrap_or(true) - }) - .find_map(|quote| { + if let Some(price) = self + .latest_known_quote_at_or_before( + data.execution_quotes_on(date, symbol), + start_cursor, + snapshot, + OrderSide::Buy, + MatchingType::NextTickLast, + false, + ) + .and_then(|quote| { (quote.last_price.is_finite() && quote.last_price > 0.0) .then_some(quote.last_price) }) @@ -399,22 +400,17 @@ where .get() .or(self.intraday_execution_start_time) .map(|start_time| date.and_time(start_time)); - data.execution_quotes_on(date, symbol) - .iter() - .filter(|quote| { - start_cursor - .map(|cursor| quote.timestamp >= cursor) - .unwrap_or(true) - }) - .find_map(|quote| { - self.select_quote_reference_price( - snapshot, - quote, - side, - self.matching_type_for_algo_request(None), - ) - }) - .unwrap_or_else(|| self.sizing_price(snapshot)) + let matching_type = self.matching_type_for_algo_request(None); + self.latest_known_quote_at_or_before( + data.execution_quotes_on(date, symbol), + start_cursor, + snapshot, + side, + matching_type, + false, + ) + .and_then(|quote| self.select_quote_reference_price(snapshot, quote, side, matching_type)) + .unwrap_or_else(|| self.sizing_price(snapshot)) } fn snapshot_execution_price( @@ -1143,6 +1139,36 @@ where } } + fn latest_known_quote_at_or_before<'a>( + &self, + quotes: &'a [IntradayExecutionQuote], + cursor: Option, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + matching_type: MatchingType, + require_executable_liquidity: bool, + ) -> Option<&'a IntradayExecutionQuote> { + let Some(cursor) = cursor else { + return quotes.iter().find(|quote| { + self.select_quote_reference_price(snapshot, quote, side, matching_type) + .is_some() + && (!require_executable_liquidity + || self.quote_has_executable_liquidity(quote, side, matching_type)) + }); + }; + quotes + .iter() + .filter(|quote| { + quote.timestamp <= cursor + && self + .select_quote_reference_price(snapshot, quote, side, matching_type) + .is_some() + && (!require_executable_liquidity + || self.quote_has_executable_liquidity(quote, side, matching_type)) + }) + .max_by_key(|quote| quote.timestamp) + } + fn process_limit_shares( &self, date: NaiveDate, @@ -4654,15 +4680,31 @@ where let quote_quantity_limited = self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); let lot = round_lot.max(1); - let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes - .iter() - .filter(|quote| { - !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) - && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) - && (!quote_quantity_limited - || self.quote_has_executable_liquidity(quote, side, matching_type)) - }) - .collect(); + let use_decision_time_quote = matching_type == MatchingType::NextTickLast + && start_cursor.is_some() + && end_cursor.is_none_or(|end| start_cursor.is_some_and(|start| end <= start)); + let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote { + self.latest_known_quote_at_or_before( + quotes, + start_cursor, + snapshot, + side, + matching_type, + quote_quantity_limited, + ) + .into_iter() + .collect() + } else { + quotes + .iter() + .filter(|quote| { + !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) + && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) + && (!quote_quantity_limited + || self.quote_has_executable_liquidity(quote, side, matching_type)) + }) + .collect() + }; let mut filled_qty = 0_u32; let mut gross_amount = 0.0_f64; let mut last_timestamp = None; @@ -5077,7 +5119,7 @@ mod tests { snapshot.last_price = 10.0; snapshot.close = 10.0; let mut quote = limit_test_quote(11.0, 10.99, 11.01); - quote.timestamp = date.and_hms_opt(9, 34, 0).expect("valid timestamp"); + quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp"); let data = DataSet::from_components_with_actions_and_quotes( vec![limit_test_instrument()], vec![snapshot.clone()], @@ -5100,6 +5142,51 @@ mod tests { ); } + #[test] + fn next_tick_last_execution_uses_latest_quote_before_decision_time() { + let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date"); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks, + PriceField::Last, + ) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let snapshot = limit_test_snapshot(); + let mut quote = limit_test_quote(10.8, 10.79, 10.81); + quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp"); + let quote_timestamp = quote.timestamp; + let decision_time = date.and_hms_opt(9, 33, 0).expect("valid timestamp"); + + let fill = broker + .select_execution_fill( + &snapshot, + &[quote], + OrderSide::Sell, + MatchingType::NextTickLast, + Some(decision_time), + Some(decision_time), + 200, + 100, + 100, + 100, + false, + None, + None, + None, + ) + .expect("fill from latest known quote before decision time"); + + assert_eq!(fill.quantity, 200); + assert_eq!(fill.legs.len(), 1); + assert_eq!(fill.legs[0].price, 10.8); + assert_eq!( + fill.next_cursor, + quote_timestamp + chrono::Duration::seconds(1) + ); + } + #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)