diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 83743c1..c94086d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -205,6 +205,7 @@ struct OpenOrder { decision_date: Option, order_created_date: Option, submission_time: Option, + accepted_date: NaiveDate, symbol: String, side: OrderSide, requested_quantity: u32, @@ -221,6 +222,7 @@ struct OpenOrder { struct RestingOrderOrigin { created_date: Option, submission_time: Option, + accepted_date: NaiveDate, } #[derive(Debug, Default)] @@ -646,11 +648,21 @@ impl BrokerSimulator { .or(self.intraday_execution_start_time) } - fn order_origin(&self) -> RestingOrderOrigin { - self.runtime_resting_order_origin.get().unwrap_or(RestingOrderOrigin { - created_date: self.runtime_order_created_date.get(), - submission_time: self.submission_time(), - }) + fn order_origin(&self) -> (Option, Option) { + self.runtime_resting_order_origin.get().map_or( + (self.runtime_order_created_date.get(), self.submission_time()), + |origin| (origin.created_date, origin.submission_time), + ) + } + + fn accepted_order_date(&self, date: NaiveDate) -> NaiveDate { + self.runtime_resting_order_origin.get().map_or(date, |origin| origin.accepted_date) + } + + fn resting_daily_open_order(&self) -> bool { + self.runtime_resting_order_origin.get().is_some() + && self.runtime_intraday_start_time.get().is_some() + && self.matching_type == MatchingType::NextBarOpen } fn execution_phase_for_submission( @@ -681,8 +693,8 @@ impl BrokerSimulator { let origin = self.order_origin(); self.execution_phase_for_submission( date, - origin.created_date, - origin.submission_time, + origin.0, + origin.1, ) } @@ -693,6 +705,8 @@ impl BrokerSimulator { fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField { if self.is_post_close_fixed_price(date) { PriceField::Close + } else if self.resting_daily_open_order() { + PriceField::Last } else { self.execution_price_field } @@ -705,8 +719,8 @@ impl BrokerSimulator { let origin = self.order_origin(); self.post_close_execution_quote_window_for_submission( date, - origin.created_date, - origin.submission_time, + origin.0, + origin.1, ) .map(|(start, end)| (date.and_time(start), date.and_time(end))) } @@ -1389,6 +1403,7 @@ where match algo_request.map(|request| request.style) { Some(AlgoExecutionStyle::Vwap) => MatchingType::Vwap, Some(AlgoExecutionStyle::Twap) => MatchingType::Twap, + None if self.resting_daily_open_order() => MatchingType::CurrentBarClose, None => self.matching_type, } } @@ -2420,7 +2435,7 @@ where } fn current_order_created_date(&self, date: NaiveDate) -> NaiveDate { - self.order_origin().created_date.unwrap_or(date) + self.order_origin().0.unwrap_or(date) } fn annotate_report_range( @@ -2572,10 +2587,15 @@ where std::mem::take(&mut *open_orders) }; for order in pending_orders { + if self.matching_type == MatchingType::NextBarOpen && self.runtime_intraday_start_time.get().is_none() + && order.accepted_date == date { + self.open_orders.borrow_mut().push(order); + continue; + } let close = self.resting_order_session_close(date, &order); let clock = self.submission_time(); let past_day = order.time_in_force == OrderTimeInForce::Day - && order.order_created_date.is_some_and(|created| created < date); + && order.accepted_date < date; if past_day || clock.is_some_and(|time| time > close) { if order.time_in_force == OrderTimeInForce::Day { Self::emit_resting_day_expiry(report, date, &order, order.filled_quantity); @@ -2611,6 +2631,7 @@ where let previous_origin = self.runtime_resting_order_origin.replace(Some(RestingOrderOrigin { created_date: order.order_created_date, submission_time: order.submission_time, + accepted_date: order.accepted_date, })); let previous_decision_date = self.runtime_decision_date.replace(order.decision_date); let execution_result = self.process_limit_shares_internal( @@ -2651,6 +2672,7 @@ where reopened.decision_date = order.decision_date; reopened.order_created_date = order.order_created_date; reopened.submission_time = order.submission_time; + reopened.accepted_date = order.accepted_date; reopened.requested_quantity = order.requested_quantity; reopened.filled_quantity = cumulative_filled; reopened.remaining_quantity = remaining_quantity; @@ -2725,7 +2747,7 @@ where } fn emit_resting_day_expiry(report: &mut BrokerExecutionReport, date: NaiveDate, order: &OpenOrder, filled: u32) { - let detail = format!("DAY order expired at session end: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled)); + let detail = format!("DAY order expired at market close: {} remaining_quantity={}", order.symbol, order.requested_quantity.saturating_sub(filled)); report.order_events.push(OrderEvent { date, decision_date: order.decision_date, order_created_date: order.order_created_date, execution_date: Some(date), order_id: Some(order.order_id), symbol: order.symbol.clone(), @@ -4056,6 +4078,9 @@ where side: OrderSide, algo_request: Option<&AlgoExecutionRequest>, ) -> f64 { + if self.matching_type == MatchingType::NextBarOpen && !self.resting_daily_open_order() && algo_request.is_none() { + return self.execution_limit_check_price(snapshot, side); + } let matching_type = self.matching_type_for_algo_request(algo_request); let start_cursor = algo_request .and_then(|request| request.start_time) @@ -4653,7 +4678,8 @@ where if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -4743,7 +4769,8 @@ where .unwrap_or("no sellable quantity"); self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -4915,7 +4942,8 @@ where { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -5086,7 +5114,8 @@ where if keep_open { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -6475,7 +6504,8 @@ where if Self::keeps_remainder_open(remainder_policy) { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -6714,7 +6744,8 @@ where { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -6887,7 +6918,8 @@ where if keep_open { self.upsert_open_order(OpenOrder { order_id, - submission_time: self.order_origin().submission_time, + submission_time: if self.matching_type == MatchingType::NextBarOpen { NaiveTime::from_hms_opt(9,30,0) } else { self.order_origin().1 }, + accepted_date: self.accepted_order_date(date), decision_date: Some(self.current_decision_date(date)), order_created_date: Some(self.current_order_created_date(date)), symbol: symbol.to_string(), @@ -7551,7 +7583,7 @@ where .map(|start_time| date.and_time(start_time)) }); let start_cursor = if let Some(origin) = self.runtime_resting_order_origin.get() - && origin.created_date == Some(date) + && origin.accepted_date == date && let Some(submitted) = origin.submission_time { Some(start_cursor.map_or(date.and_time(submitted), |cursor| cursor.max(date.and_time(submitted)))) @@ -8071,6 +8103,7 @@ where } pub(crate) fn matching_type_uses_intraday_quotes(&self) -> bool { + if self.resting_daily_open_order() { return true; } matches!( self.matching_type, MatchingType::MinuteLast @@ -8082,6 +8115,10 @@ where && self.intraday_execution_start_time.is_some()) } + pub(crate) fn drives_resting_quote_clock(&self) -> bool { + self.matching_type_uses_intraday_quotes() || self.matching_type == MatchingType::NextBarOpen + } + fn quote_quantity_limited(&self, matching_type: MatchingType) -> bool { match matching_type { MatchingType::OpenAuction @@ -8214,6 +8251,7 @@ mod tests { decision_date: None, order_created_date: None, submission_time: None, + accepted_date: chrono::NaiveDate::from_ymd_opt(2025,1,2).unwrap(), symbol: "000001.SZ".to_string(), side: OrderSide::Buy, requested_quantity: 200, @@ -8791,6 +8829,30 @@ mod tests { assert!(broker.runtime_resting_order_origin.get().is_none()); } + #[test] + fn next_open_day_limit_remainder_matches_intraday_with_execution_day_ttl() { + let date=chrono::NaiveDate::from_ymd_opt(2026,7,7).unwrap(); + let signal=date.pred_opt().unwrap(); + let mut snapshot=dated_limit_test_snapshot(date); + snapshot.open=10.2;snapshot.close=9.8;snapshot.last_price=9.8;snapshot.upper_limit=20.;snapshot.lower_limit=1.; + let mut quote=limit_test_quote(9.8,9.8,9.8);quote.date=date;quote.timestamp=date.and_hms_opt(10,0,0).unwrap(); + let data=DataSet::from_components_with_actions_and_quotes(vec![limit_test_instrument()],vec![snapshot],Vec::new(), + vec![dated_limit_test_candidate(date,false,false,true,true)],vec![dated_limit_test_benchmark(date)],Vec::new(),vec![quote]).unwrap(); + let broker=BrokerSimulator::new(ChinaAShareCostModel::default(),ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen).with_volume_limit(false).with_liquidity_limit(false); + let mut portfolio=PortfolioState::new(100000.); + let decision=StrategyDecision{order_intents:vec![OrderIntent::LimitTargetShares{symbol:"000001.SZ".into(),target_quantity:100,limit_price:10.,reason:"next-open-entry".into()}],..StrategyDecision::default()}; + let report=broker.execute_with_event_dates(date,signal,signal,&mut portfolio,&data,&decision).unwrap(); + assert!(report.fill_events.is_empty());assert!(broker.has_open_orders()); + let report=broker.execute_between_with_event_dates(date,signal,signal,&mut portfolio,&data,&StrategyDecision::default(), + NaiveTime::from_hms_opt(10,0,0),NaiveTime::from_hms_opt(10,0,0)).unwrap(); + assert_eq!(report.fill_events.len(),1,"{report:?}"); + assert_eq!(report.fill_events[0].price,9.8); + assert_eq!(report.fill_events[0].execution_timestamp,date.and_hms_opt(10,0,0)); + assert_eq!(report.fill_events[0].order_created_date,Some(signal)); + assert!(!broker.has_open_orders()); + } + #[test] fn post_close_order_uses_close_without_slippage_and_waits_until_matching_window() { let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date"); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index a4a31ce..83d5ca2 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2850,11 +2850,11 @@ where )?; if should_run_minute_events(&intraday_schedule_rules, &self.subscriptions) - || (self.broker.has_open_orders() && self.broker.matching_type_uses_intraday_quotes()) + || (self.broker.has_open_orders() && self.broker.drives_resting_quote_clock()) { let unfiltered_minute_stream = self.subscriptions.is_empty(); let mut full_minute_symbols = self.subscriptions.clone(); - if self.broker.matching_type_uses_intraday_quotes() { + if self.broker.drives_resting_quote_clock() { full_minute_symbols.extend(self.broker.open_order_views().into_iter().map(|order| order.symbol)); } if self.execution_quote_loader.is_some() && !full_minute_symbols.is_empty() { @@ -3125,7 +3125,7 @@ where .map(|order| order.symbol) .filter(|symbol| !full_minute_symbols.contains(symbol)) .collect::>(); - if !newly_pending.is_empty() && self.broker.matching_type_uses_intraday_quotes() { + if !newly_pending.is_empty() && self.broker.drives_resting_quote_clock() { full_minute_symbols.extend(newly_pending.iter().cloned()); if self.execution_quote_loader.is_some() { self.load_missing_execution_quotes(execution_date, None, None, &mut newly_pending)?; @@ -5904,10 +5904,11 @@ mod tests { }], ..StrategyDecision::default() }) } } - for scenario in 0..4 { + for scenario in 0..5 { let partial = scenario == 1; - let closing_only = scenario >= 2; - let date = if closing_only { d(2026, 7, 6) } else { d(2026, 6, 1) }; + let closing_only = matches!(scenario,2|3); + let delayed = scenario == 4; + let date = if closing_only { d(2026, 7, 6) } else if delayed { d(2026, 6, 2) } else { d(2026, 6, 1) }; let quote = |hour, minute, price| IntradayExecutionQuote { date, symbol: SYMBOL.into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(), last_price: price, bid1: price, ask1: price, bid1_volume: 10_000, ask1_volume: 10_000, @@ -5920,17 +5921,20 @@ mod tests { let last = if closing_only { quote(15, 0, if scenario == 2 { 9.8 } else { 10.2 }) } else { quote(10, 1, 9.8) }; let mut post_close = quote(15, 5, 9.7); post_close.trading_phase = Some("post_close_fixed_price".into()); - let mut data = dataset_from_market_and_candidates(vec![market(date, 10.2, 9.8)], vec![candidate(date)]); + let prior = date.pred_opt().unwrap(); + let markets = if delayed {vec![market(prior,10.2,10.2),market(date,10.2,9.8)]} else {vec![market(date,10.2,9.8)]}; + let candidates = if delayed {vec![candidate(prior),candidate(date)]} else {vec![candidate(date)]}; + let mut data = dataset_from_market_and_candidates(markets,candidates); data.add_execution_quotes(vec![first.clone()]); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) - .with_matching_type(MatchingType::CurrentBarClose) - .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 30, 0).unwrap()) + .with_matching_type(if delayed {MatchingType::NextBarOpen} else {MatchingType::CurrentBarClose}) .with_volume_limit(partial).with_volume_percent(0.01).with_liquidity_limit(false).with_inactive_limit(false); + let broker = if delayed {broker} else {broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9,30,0).unwrap())}; let requests = Arc::new(Mutex::new(Vec::new())); let captured = Arc::clone(&requests); let mut engine = BacktestEngine::new(data, RestingLimit { quantity: if partial { 300 } else { 100 } }, broker, BacktestConfig { - initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(date), end_date: Some(date), - decision_lag_trading_days: 0, execution_price_field: PriceField::Close, + initial_cash: 100_000.0, benchmark_code: "000852.SH".into(), start_date: Some(if delayed {prior} else {date}), end_date: Some(date), + decision_lag_trading_days: usize::from(delayed), execution_price_field: if delayed {PriceField::Open} else {PriceField::Close}, }).with_execution_quote_loader(move |request| { captured.lock().unwrap().push((request.start_time, request.end_time)); Ok(vec![earlier.clone(), first.clone(), unchanged.clone(), later.clone(), last.clone(), post_close.clone()])