diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index e5b4b57..6b1afc8 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -4632,6 +4632,7 @@ where quotes, start_cursor, end_cursor, + matching_type == MatchingType::NextTickLast && start_cursor.is_some(), )), }); } @@ -4644,7 +4645,16 @@ where quotes: &[IntradayExecutionQuote], start_cursor: Option, end_cursor: Option, + use_decision_time_quote: bool, ) -> &'static str { + if use_decision_time_quote { + let saw_quote_at_or_before_start = start_cursor + .is_some_and(|cursor| quotes.iter().any(|quote| quote.timestamp <= cursor)); + if saw_quote_at_or_before_start { + return "intraday quote liquidity exhausted"; + } + return "no execution quotes at or before start"; + } let saw_quote_in_window = quotes.iter().any(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor) @@ -4987,7 +4997,9 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { #[cfg(test)] mod tests { - use super::{BrokerSimulator, MatchingType}; + use std::collections::BTreeMap; + + use super::{BrokerExecutionReport, BrokerSimulator, MatchingType, SlippageModel}; use crate::cost::ChinaAShareCostModel; use crate::data::{ BenchmarkSnapshot, CandidateEligibility, DailyMarketSnapshot, DataSet, @@ -4995,6 +5007,7 @@ mod tests { }; use crate::events::OrderSide; use crate::instrument::Instrument; + use crate::portfolio::PortfolioState; use crate::rules::ChinaEquityRuleHooks; fn limit_test_snapshot() -> DailyMarketSnapshot { @@ -5186,6 +5199,73 @@ mod tests { ); } + #[test] + fn value_buy_process_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_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time()) + .with_slippage_model(SlippageModel::PriceRatio(0.002)) + .with_strict_value_budget(true) + .with_volume_limit(false) + .with_liquidity_limit(false) + .with_inactive_limit(false); + let mut snapshot = limit_test_snapshot(); + snapshot.day_open = 8.70; + snapshot.open = 8.70; + snapshot.high = 8.95; + snapshot.low = 8.60; + snapshot.last_price = 8.94; + snapshot.bid1 = 8.93; + snapshot.ask1 = 8.94; + snapshot.close = 8.94; + snapshot.upper_limit = 9.72; + snapshot.lower_limit = 7.96; + let mut quote = limit_test_quote(8.69, 8.69, 8.70); + quote.timestamp = date.and_hms_opt(9, 32, 55).expect("valid timestamp"); + 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![quote], + ) + .expect("valid dataset"); + let mut portfolio = PortfolioState::new(10_000_000.0); + let mut report = BrokerExecutionReport::default(); + + broker + .process_value( + date, + &mut portfolio, + &data, + "000001.SZ", + 125_000.0, + "periodic_rebalance_buy", + &mut BTreeMap::new(), + &mut BTreeMap::new(), + &mut None, + &mut BTreeMap::new(), + &mut report, + ) + .expect("value buy processed"); + + let position = portfolio.position("000001.SZ").unwrap_or_else(|| { + panic!( + "position created from latest known quote; events={:?}", + report.order_events + ) + }); + assert_eq!(position.quantity, 14_300); + assert_eq!(report.order_events.len(), 1); + assert_eq!(report.order_events[0].filled_quantity, 14_300); + } + #[test] fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a19d5b6..937c7be 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -3197,6 +3197,14 @@ fn has_execution_quote_in_window( ) -> bool { let start_cursor = start_time.map(|time| date.and_time(time)); let end_cursor = end_time.map(|time| date.and_time(time)); + if let Some(cursor) = start_cursor + && end_cursor.is_none() + { + return data + .execution_quotes_on(date, symbol) + .iter() + .any(|quote| quote.timestamp <= cursor); + } data.execution_quotes_on(date, symbol).iter().any(|quote| { !start_cursor.is_some_and(|cursor| quote.timestamp < cursor) && !end_cursor.is_some_and(|cursor| quote.timestamp > cursor)