按实际委托时间选择盘后撮合阶段
This commit is contained in:
@@ -83,6 +83,8 @@
|
|||||||
|
|
||||||
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
|
Source Lake 日线成交量保留原始可用性合同:源 `volume=null` 与真实 `volume=0` 含义不同。依赖成交量的 rolling 窗口只要包含源空值就返回缺失,不得把空值补成 0;停牌日明确提供的 0 成交量仍是合法观测。该合同随 runner 快照版本冻结,旧快照不能跨版本复用。
|
||||||
|
|
||||||
|
盘后固定价格不是策略类型,也不是 `matchingType`。自 2026-07-06 起,只有实际同日提交时间落在 15:00–15:30 的普通委托才由 broker 进入盘后固定价格执行阶段;15:00–15:04 的委托等待到 15:05,15:05–15:30 按官方收盘价和真实盘后成交量撮合,不叠加滑点,未成交余量不跨日。窗口外委托继续沿用连续竞价、当前收盘或下一交易日开盘合同;`next_bar_open` 策略即使在 15:00 生成信号,也不得被改写为同日盘后委托。缺失盘后行情时必须明确不成交,禁止回退全天成交量或 15:00 前分钟行情。
|
||||||
|
|
||||||
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
`holdUntilExit=true` 与 `stopTakeReferencePriceMode=signal_day_post_adjusted_close` 组合表示持久模型组合语义:股票进入模型目标后即记录信号日和后复权参考价,不以买单是否成交为前提。涨停、停牌或其他执行风控导致买单未成交时,模型成员仍占用目标槽位、每天累计模型持有日并继续生成目标仓位;达到止盈、止损或最大模型持有期后才从模型组合移除。实际订单仍由成交日风控独立决定,不得用实际持仓集合覆盖模型目标集合。
|
||||||
|
|
||||||
## 内置微盘策略
|
## 内置微盘策略
|
||||||
|
|||||||
+243
-18
@@ -255,6 +255,12 @@ pub enum MatchingType {
|
|||||||
Twap,
|
Twap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum EquityExecutionPhase {
|
||||||
|
ContinuousAuction,
|
||||||
|
PostCloseFixedPrice,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RebalanceCashMode {
|
pub enum RebalanceCashMode {
|
||||||
SamePointNet,
|
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() {
|
match self.runtime_time_in_force.get() {
|
||||||
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
|
Some(OrderTimeInForce::Fok) => RemainderPolicy::FillOrKill,
|
||||||
Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled,
|
Some(OrderTimeInForce::Gtc) => RemainderPolicy::KeepUntilCanceled,
|
||||||
@@ -613,15 +693,15 @@ where
|
|||||||
R: EquityRuleHooks,
|
R: EquityRuleHooks,
|
||||||
{
|
{
|
||||||
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
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 {
|
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 {
|
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(
|
fn value_buy_sizing_price(
|
||||||
@@ -651,6 +731,9 @@ where
|
|||||||
symbol: &str,
|
symbol: &str,
|
||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.is_post_close_fixed_price(date) {
|
||||||
|
return snapshot.close;
|
||||||
|
}
|
||||||
if self.matching_type == MatchingType::NextBarOpen {
|
if self.matching_type == MatchingType::NextBarOpen {
|
||||||
let execution_price = snapshot.price(PriceField::Open);
|
let execution_price = snapshot.price(PriceField::Open);
|
||||||
if execution_price.is_finite() && execution_price > 0.0 {
|
if execution_price.is_finite() && execution_price > 0.0 {
|
||||||
@@ -907,6 +990,9 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.is_post_close_fixed_price(date) {
|
||||||
|
return snapshot.close;
|
||||||
|
}
|
||||||
let start_cursor = self
|
let start_cursor = self
|
||||||
.runtime_intraday_start_time
|
.runtime_intraday_start_time
|
||||||
.get()
|
.get()
|
||||||
@@ -940,6 +1026,9 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
|
if self.is_post_close_fixed_price(snapshot.date) {
|
||||||
|
return snapshot.close;
|
||||||
|
}
|
||||||
if self.execution_price_field == PriceField::Last
|
if self.execution_price_field == PriceField::Last
|
||||||
&& self.intraday_execution_start_time.is_some()
|
&& self.intraday_execution_start_time.is_some()
|
||||||
{
|
{
|
||||||
@@ -956,7 +1045,7 @@ where
|
|||||||
snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
) -> f64 {
|
) -> 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 {
|
if price.is_finite() && price > 0.0 {
|
||||||
price
|
price
|
||||||
} else {
|
} else {
|
||||||
@@ -983,6 +1072,10 @@ where
|
|||||||
return self.clamp_execution_price(snapshot, side, raw_price);
|
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 order_value = quantity.and_then(|qty| (qty > 0).then_some(raw_price * qty as f64));
|
||||||
let mut adjusted = match self.slippage_model {
|
let mut adjusted = match self.slippage_model {
|
||||||
SlippageModel::None => raw_price,
|
SlippageModel::None => raw_price,
|
||||||
@@ -1069,11 +1162,15 @@ where
|
|||||||
|
|
||||||
fn select_quote_reference_price(
|
fn select_quote_reference_price(
|
||||||
&self,
|
&self,
|
||||||
_snapshot: &crate::data::DailyMarketSnapshot,
|
snapshot: &crate::data::DailyMarketSnapshot,
|
||||||
quote: &IntradayExecutionQuote,
|
quote: &IntradayExecutionQuote,
|
||||||
side: OrderSide,
|
side: OrderSide,
|
||||||
matching_type: MatchingType,
|
matching_type: MatchingType,
|
||||||
) -> Option<f64> {
|
) -> 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 {
|
let raw_price = match matching_type {
|
||||||
MatchingType::MinuteBestOwn => match side {
|
MatchingType::MinuteBestOwn => match side {
|
||||||
OrderSide::Buy => {
|
OrderSide::Buy => {
|
||||||
@@ -4014,7 +4111,7 @@ where
|
|||||||
algo_request: Option<&AlgoExecutionRequest>,
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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 {
|
let Some(position) = portfolio.position(symbol) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -5725,7 +5822,7 @@ where
|
|||||||
algo_request: Option<&AlgoExecutionRequest>,
|
algo_request: Option<&AlgoExecutionRequest>,
|
||||||
report: &mut BrokerExecutionReport,
|
report: &mut BrokerExecutionReport,
|
||||||
) -> Result<(), BacktestError> {
|
) -> 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
|
if portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
.is_none_or(|position| position.quantity == 0)
|
.is_none_or(|position| position.quantity == 0)
|
||||||
@@ -6925,23 +7022,29 @@ where
|
|||||||
limit_price: Option<f64>,
|
limit_price: Option<f64>,
|
||||||
) -> Option<ExecutionFill> {
|
) -> Option<ExecutionFill> {
|
||||||
let matching_type = self.matching_type_for_algo_request(algo_request);
|
let matching_type = self.matching_type_for_algo_request(algo_request);
|
||||||
let use_intraday_quotes =
|
let post_close_window = self.post_close_execution_window(date);
|
||||||
algo_request.is_some() || self.execution_price_field == PriceField::Last;
|
let use_intraday_quotes = post_close_window.is_some()
|
||||||
|
|| algo_request.is_some()
|
||||||
|
|| self.execution_price_field == PriceField::Last;
|
||||||
if !use_intraday_quotes {
|
if !use_intraday_quotes {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let runtime_start_time = self.runtime_intraday_start_time.get();
|
let runtime_start_time = self.runtime_intraday_start_time.get();
|
||||||
let runtime_end_time = self.runtime_intraday_end_time.get();
|
let runtime_end_time = self.runtime_intraday_end_time.get();
|
||||||
let start_cursor = algo_request
|
let start_cursor = post_close_window.map(|window| window.0).or_else(|| {
|
||||||
|
algo_request
|
||||||
.and_then(|request| request.start_time)
|
.and_then(|request| request.start_time)
|
||||||
.or(runtime_start_time)
|
.or(runtime_start_time)
|
||||||
.or(self.intraday_execution_start_time)
|
.or(self.intraday_execution_start_time)
|
||||||
.map(|start_time| date.and_time(start_time));
|
.map(|start_time| date.and_time(start_time))
|
||||||
let end_cursor = algo_request
|
});
|
||||||
|
let end_cursor = post_close_window.map(|window| window.1).or_else(|| {
|
||||||
|
algo_request
|
||||||
.and_then(|request| request.end_time)
|
.and_then(|request| request.end_time)
|
||||||
.or(runtime_end_time)
|
.or(runtime_end_time)
|
||||||
.map(|end_time| date.and_time(end_time));
|
.map(|end_time| date.and_time(end_time))
|
||||||
|
});
|
||||||
let quotes = data.execution_quotes_on(date, symbol);
|
let quotes = data.execution_quotes_on(date, symbol);
|
||||||
|
|
||||||
if let Some(fill) = self.select_execution_fill_with_ledger(
|
if let Some(fill) = self.select_execution_fill_with_ledger(
|
||||||
@@ -6965,7 +7068,8 @@ where
|
|||||||
return Some(fill);
|
return Some(fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
if algo_request.is_some()
|
if post_close_window.is_some()
|
||||||
|
|| algo_request.is_some()
|
||||||
|| runtime_start_time.is_some()
|
|| runtime_start_time.is_some()
|
||||||
|| runtime_end_time.is_some()
|
|| runtime_end_time.is_some()
|
||||||
|| self.intraday_execution_start_time.is_some()
|
|| self.intraday_execution_start_time.is_some()
|
||||||
@@ -7088,7 +7192,8 @@ where
|
|||||||
&& start_cursor.is_some()
|
&& start_cursor.is_some()
|
||||||
&& end_cursor.is_some()
|
&& end_cursor.is_some()
|
||||||
&& start_cursor == end_cursor;
|
&& 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);
|
&& (matching_type == MatchingType::MinuteLast || exact_time_order_quote);
|
||||||
let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote {
|
let eligible_quotes: Vec<&IntradayExecutionQuote> = if use_decision_time_quote {
|
||||||
self.latest_known_quote_at_or_before(
|
self.latest_known_quote_at_or_before(
|
||||||
@@ -7531,9 +7636,11 @@ fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use chrono::NaiveTime;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BrokerExecutionReport, BrokerSimulator, IntradayExecutionLedger, MatchingType, OpenOrder,
|
BrokerExecutionReport, BrokerSimulator, EquityExecutionPhase, IntradayExecutionLedger,
|
||||||
RebalanceCashMode, SlippageModel,
|
MatchingType, OpenOrder, RebalanceCashMode, SlippageModel,
|
||||||
};
|
};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
use crate::data::{
|
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]
|
#[test]
|
||||||
fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
fn minute_last_without_volume_or_liquidity_limit_does_not_cap_quote_quantity() {
|
||||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||||
|
|||||||
@@ -638,15 +638,23 @@ where
|
|||||||
if self.execution_quote_loader.is_none() {
|
if self.execution_quote_loader.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let post_close_window = self
|
||||||
|
.broker
|
||||||
|
.post_close_execution_quote_window(execution_date);
|
||||||
if self.broker.execution_price_field() != PriceField::Last
|
if self.broker.execution_price_field() != PriceField::Last
|
||||||
&& !decision_has_algo_execution(decision)
|
&& !decision_has_algo_execution(decision)
|
||||||
|
&& post_close_window.is_none()
|
||||||
{
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let caller_start_time = start_time;
|
let caller_start_time = start_time;
|
||||||
let caller_end_time = end_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);
|
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)?;
|
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::cell::RefCell;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::{NaiveDate, NaiveTime};
|
||||||
|
|
||||||
use super::{BacktestConfig, BacktestEngine};
|
use super::{BacktestConfig, BacktestEngine};
|
||||||
use crate::broker::{BrokerSimulator, MatchingType};
|
use crate::broker::{BrokerSimulator, MatchingType, SlippageModel};
|
||||||
use crate::cost::ChinaAShareCostModel;
|
use crate::cost::ChinaAShareCostModel;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
PriceField,
|
IntradayExecutionQuote, PriceField,
|
||||||
};
|
};
|
||||||
use crate::events::{OrderSide, OrderStatus};
|
use crate::events::{OrderSide, OrderStatus};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
@@ -5302,6 +5311,85 @@ mod tests {
|
|||||||
.expect("backtest run")
|
.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]
|
#[test]
|
||||||
fn compact_progress_keeps_counts_without_event_payload_clones() {
|
fn compact_progress_keeps_counts_without_event_payload_clones() {
|
||||||
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user