按实际委托时间选择盘后撮合阶段

This commit is contained in:
boris
2026-08-28 00:12:19 +08:00
parent a9511f9a4a
commit 6c47c33cab
3 changed files with 342 additions and 27 deletions
+248 -23
View File
@@ -255,6 +255,12 @@ pub enum MatchingType {
Twap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EquityExecutionPhase {
ContinuousAuction,
PostCloseFixedPrice,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebalanceCashMode {
SamePointNet,
@@ -544,7 +550,81 @@ impl<C, R> BrokerSimulator<C, R> {
}
}
fn effective_remainder_policy(&self, allow_pending_limit: bool) -> RemainderPolicy {
fn submission_time(&self) -> Option<NaiveTime> {
self.runtime_intraday_start_time
.get()
.or(self.intraday_execution_start_time)
}
fn execution_phase(&self, date: NaiveDate) -> EquityExecutionPhase {
let effective_date = NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid effective date");
let window_start = NaiveTime::from_hms_opt(15, 0, 0).expect("valid window start");
let window_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid window end");
let submitted_same_day = self
.runtime_order_created_date
.get()
.is_none_or(|created_date| created_date == date);
if date >= effective_date
&& submitted_same_day
&& !matches!(
self.matching_type,
MatchingType::OpenAuction | MatchingType::NextBarOpen
)
&& self
.submission_time()
.is_some_and(|time| time >= window_start && time <= window_end)
{
EquityExecutionPhase::PostCloseFixedPrice
} else {
EquityExecutionPhase::ContinuousAuction
}
}
fn is_post_close_fixed_price(&self, date: NaiveDate) -> bool {
self.execution_phase(date) == EquityExecutionPhase::PostCloseFixedPrice
}
fn effective_execution_price_field(&self, date: NaiveDate) -> PriceField {
if self.is_post_close_fixed_price(date) {
PriceField::Close
} else {
self.execution_price_field
}
}
fn post_close_execution_window(
&self,
date: NaiveDate,
) -> Option<(NaiveDateTime, NaiveDateTime)> {
self.post_close_execution_quote_window(date)
.map(|(start, end)| (date.and_time(start), date.and_time(end)))
}
pub(crate) fn post_close_execution_quote_window(
&self,
date: NaiveDate,
) -> Option<(NaiveTime, NaiveTime)> {
if !self.is_post_close_fixed_price(date) {
return None;
}
let matching_start = NaiveTime::from_hms_opt(15, 5, 0).expect("valid matching start");
let matching_end = NaiveTime::from_hms_opt(15, 30, 0).expect("valid matching end");
let submitted_at = self.submission_time()?;
Some((submitted_at.max(matching_start), matching_end))
}
fn effective_remainder_policy(
&self,
date: NaiveDate,
allow_pending_limit: bool,
) -> RemainderPolicy {
if self.is_post_close_fixed_price(date) {
return match self.runtime_time_in_force.get() {
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
_ => RemainderPolicy::Cancel,
};
}
match self.runtime_time_in_force.get() {
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled,
@@ -613,15 +693,15 @@ where
R: EquityRuleHooks,
{
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.buy_price(self.execution_price_field)
snapshot.buy_price(self.effective_execution_price_field(snapshot.date))
}
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.sell_price(self.execution_price_field)
snapshot.sell_price(self.effective_execution_price_field(snapshot.date))
}
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
snapshot.price(self.execution_price_field)
snapshot.price(self.effective_execution_price_field(snapshot.date))
}
fn value_buy_sizing_price(
@@ -651,6 +731,9 @@ where
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 {
if self.is_post_close_fixed_price(date) {
return snapshot.close;
}
if self.matching_type == MatchingType::NextBarOpen {
let execution_price = snapshot.price(PriceField::Open);
if execution_price.is_finite() && execution_price > 0.0 {
@@ -907,6 +990,9 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
if self.is_post_close_fixed_price(date) {
return snapshot.close;
}
let start_cursor = self
.runtime_intraday_start_time
.get()
@@ -940,6 +1026,9 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
if self.is_post_close_fixed_price(snapshot.date) {
return snapshot.close;
}
if self.execution_price_field == PriceField::Last
&& self.intraday_execution_start_time.is_some()
{
@@ -956,7 +1045,7 @@ where
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
let price = snapshot.price(self.execution_price_field);
let price = snapshot.price(self.effective_execution_price_field(snapshot.date));
if price.is_finite() && price > 0.0 {
price
} else {
@@ -983,6 +1072,10 @@ where
return self.clamp_execution_price(snapshot, side, raw_price);
}
if self.is_post_close_fixed_price(snapshot.date) {
return self.clamp_execution_price(snapshot, side, raw_price);
}
let order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
let mut adjusted = match self.slippage_model {
SlippageModel::None => raw_price,
@@ -1069,11 +1162,15 @@ where
fn select_quote_reference_price(
&self,
_snapshot: &crate::data::DailyMarketSnapshot,
snapshot: &crate::data::DailyMarketSnapshot,
quote: &IntradayExecutionQuote,
side: OrderSide,
matching_type: MatchingType,
) -> Option<f64> {
if self.is_post_close_fixed_price(snapshot.date) {
return (snapshot.close.is_finite() && snapshot.close > 0.0)
.then_some(snapshot.close);
}
let raw_price = match matching_type {
MatchingType::MinuteBestOwn => match side {
OrderSide::Buy => {
@@ -4014,7 +4111,7 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let remainder_policy = self.effective_remainder_policy(allow_pending_limit);
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
let Some(position) = portfolio.position(symbol) else {
return Ok(());
};
@@ -5725,7 +5822,7 @@ where
algo_request: Option<&AlgoExecutionRequest>,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let remainder_policy = self.effective_remainder_policy(allow_pending_limit);
let remainder_policy = self.effective_remainder_policy(date, allow_pending_limit);
if portfolio
.position(symbol)
.is_none_or(|position| position.quantity == 0)
@@ -6925,23 +7022,29 @@ where
limit_price: Option<f64>,
) -> Option<ExecutionFill> {
let matching_type = self.matching_type_for_algo_request(algo_request);
let use_intraday_quotes =
algo_request.is_some() || self.execution_price_field == PriceField::Last;
let post_close_window = self.post_close_execution_window(date);
let use_intraday_quotes = post_close_window.is_some()
|| algo_request.is_some()
|| self.execution_price_field == PriceField::Last;
if !use_intraday_quotes {
return None;
}
let runtime_start_time = self.runtime_intraday_start_time.get();
let runtime_end_time = self.runtime_intraday_end_time.get();
let start_cursor = algo_request
.and_then(|request| request.start_time)
.or(runtime_start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time));
let end_cursor = algo_request
.and_then(|request| request.end_time)
.or(runtime_end_time)
.map(|end_time| date.and_time(end_time));
let start_cursor = post_close_window.map(|window| window.0).or_else(|| {
algo_request
.and_then(|request| request.start_time)
.or(runtime_start_time)
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time))
});
let end_cursor = post_close_window.map(|window| window.1).or_else(|| {
algo_request
.and_then(|request| request.end_time)
.or(runtime_end_time)
.map(|end_time| date.and_time(end_time))
});
let quotes = data.execution_quotes_on(date, symbol);
if let Some(fill) = self.select_execution_fill_with_ledger(
@@ -6965,7 +7068,8 @@ where
return Some(fill);
}
if algo_request.is_some()
if post_close_window.is_some()
|| algo_request.is_some()
|| runtime_start_time.is_some()
|| runtime_end_time.is_some()
|| self.intraday_execution_start_time.is_some()
@@ -7088,7 +7192,8 @@ where
&& start_cursor.is_some()
&& end_cursor.is_some()
&& start_cursor == end_cursor;
let use_decision_time_quote = start_cursor.is_some()
let use_decision_time_quote = !self.is_post_close_fixed_price(snapshot.date)
&& start_cursor.is_some()
&& (matching_type == MatchingType::MinuteLast || exact_time_order_quote);
let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote {
self.latest_known_quote_at_or_before(
@@ -7531,9 +7636,11 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
mod tests {
use std::collections::BTreeMap;
use chrono::NaiveTime;
use super::{
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder,
RebalanceCashMode, SlippageModel,
BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger,
MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
};
use crate::cost::ChinaAShareCostModel;
use crate::data::{
@@ -7769,6 +7876,124 @@ mod tests {
}
}
#[test]
fn post_close_phase_is_derived_from_actual_same_day_submission_time() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 7, 6).expect("valid date");
let before_effective = chrono::NaiveDate::from_ymd_opt(2026, 7, 3).expect("valid date");
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_slippage_model(SlippageModel::PriceRatio(0.25));
broker.runtime_order_created_date.set(Some(date));
let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0;
snapshot.upper_limit = 20.0;
for (hour, minute) in [(14, 59), (15, 31)] {
broker
.runtime_intraday_start_time
.set(NaiveTime::from_hms_opt(hour, minute, 0));
assert_eq!(
broker.execution_phase(date),
EquityExecutionPhase::ContinuousAuction
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
12.5
);
}
for (hour, minute) in [(15, 0), (15, 5), (15, 30)] {
broker
.runtime_intraday_start_time
.set(NaiveTime::from_hms_opt(hour, minute, 0));
assert_eq!(
broker.execution_phase(date),
EquityExecutionPhase::PostCloseFixedPrice
);
assert_eq!(
broker.snapshot_execution_price(&snapshot, OrderSide::Buy, Some(100)),
10.0
);
}
broker
.runtime_order_created_date
.set(Some(before_effective));
assert_eq!(
broker.execution_phase(before_effective),
EquityExecutionPhase::ContinuousAuction
);
let next_open = BrokerSimulator::new(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_intraday_execution_start_time(
NaiveTime::from_hms_opt(15, 0, 0).expect("valid signal time"),
);
next_open
.runtime_order_created_date
.set(Some(date.pred_opt().expect("previous date")));
assert_eq!(
next_open.execution_phase(date),
EquityExecutionPhase::ContinuousAuction,
"a 15:00 signal for next-open execution is not a same-day post-close order"
);
}
#[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");
let mut snapshot = dated_limit_test_snapshot(date);
snapshot.close = 10.0;
snapshot.last_price = 12.0;
snapshot.bid1 = 11.99;
snapshot.ask1 = 12.01;
snapshot.upper_limit = 20.0;
snapshot.lower_limit = 1.0;
let mut quote_before_matching = limit_test_quote(12.0, 11.99, 12.01);
quote_before_matching.date = date;
quote_before_matching.timestamp = date.and_hms_opt(15, 4, 0).expect("valid timestamp");
quote_before_matching.volume_delta = 100_000;
let mut quote_at_matching = quote_before_matching.clone();
quote_at_matching.timestamp = date.and_hms_opt(15, 5, 0).expect("valid timestamp");
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_before_matching, quote_at_matching],
)
.expect("valid post-close dataset");
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_slippage_model(SlippageModel::PriceRatio(0.25))
.with_volume_limit(false)
.with_liquidity_limit(false);
let mut portfolio = PortfolioState::new(100_000.0);
let report = broker
.execute_between(
date,
&mut portfolio,
&data,
&next_open_buy_decision(),
NaiveTime::from_hms_opt(15, 0, 0),
NaiveTime::from_hms_opt(15, 0, 0),
)
.expect("post-close order executes");
assert_eq!(report.fill_events.len(), 1, "{report:?}");
let fill = &report.fill_events[0];
assert_eq!(fill.price, 10.0, "fixed-price trading uses official close");
assert_eq!(
fill.execution_timestamp,
date.and_hms_opt(15, 5, 0),
"15:00 submission must not consume the 15:04 quote"
);
assert!(!broker.has_open_orders());
}
#[test]
fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
+92 -4
View File
@@ -638,15 +638,23 @@ where
if self.execution_quote_loader.is_none() {
return Ok(());
}
let post_close_window = self
.broker
.post_close_execution_quote_window(execution_date);
if self.broker.execution_price_field() != PriceField::Last
&& !decision_has_algo_execution(decision)
&& post_close_window.is_none()
{
return Ok(());
}
let caller_start_time = start_time;
let caller_end_time = end_time;
let start_time = caller_start_time.or_else(|| self.broker.intraday_execution_start_time());
let start_time = post_close_window
.map(|window| window.0)
.or(caller_start_time)
.or_else(|| self.broker.intraday_execution_start_time());
let end_time = post_close_window.map(|window| window.1).or(caller_end_time);
let mut symbols = execution_quote_symbols_for_decision(decision, portfolio, open_orders);
self.load_missing_execution_quotes(execution_date, start_time, end_time, &mut symbols)?;
@@ -4614,15 +4622,16 @@ mod tests {
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveTime};
use super::{BacktestConfig, BacktestEngine};
use crate::broker::{BrokerSimulator, MatchingType};
use crate::broker::{BrokerSimulator, MatchingType, SlippageModel};
use crate::cost::ChinaAShareCostModel;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
IntradayExecutionQuote, PriceField,
};
use crate::events::{OrderSide, OrderStatus};
use crate::instrument::Instrument;
@@ -5302,6 +5311,85 @@ mod tests {
.expect("backtest run")
}
#[test]
fn current_close_order_at_1500_loads_and_uses_post_close_matching_window() {
let date = d(2026, 7, 6);
let data = dataset_from_market_and_candidates(
vec![market(date, 9.5, 10.0)],
vec![candidate(date)],
);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Close,
)
.with_matching_type(MatchingType::CurrentBarClose)
.with_intraday_execution_start_time(
NaiveTime::from_hms_opt(15, 0, 0).expect("valid submission time"),
)
.with_slippage_model(SlippageModel::PriceRatio(0.25))
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
};
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let mut engine = BacktestEngine::new(
data,
BuyWhenDecisionDateStrategy {
decision_date: date,
},
broker,
config,
)
.with_execution_quote_loader(move |request| {
captured
.lock()
.expect("request capture lock")
.push((request.start_time, request.end_time));
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
date: request.date,
symbol,
timestamp: request.date.and_hms_opt(15, 5, 0).expect("valid timestamp"),
last_price: 12.0,
bid1: 11.99,
ask1: 12.01,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta: 10_000,
amount_delta: 120_000.0,
trading_phase: Some("post_close_fixed_price".to_string()),
})
.collect())
});
let result = engine.run().expect("post-close backtest run");
assert_eq!(
requests.lock().expect("request capture lock").as_slice(),
&[(
NaiveTime::from_hms_opt(15, 5, 0),
NaiveTime::from_hms_opt(15, 30, 0),
)]
);
assert_eq!(result.fills.len(), 1, "{result:?}");
assert_eq!(result.fills[0].price, 10.0);
assert_eq!(
result.fills[0].execution_timestamp,
date.and_hms_opt(15, 5, 0)
);
}
#[test]
fn compact_progress_keeps_counts_without_event_payload_clones() {
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);