修复开盘与跨日ETF执行时钟及资金阻断原因

This commit is contained in:
boris
2026-09-14 18:42:25 +08:00
parent 232e9ae154
commit 13c89e8d59
5 changed files with 1684 additions and 368 deletions
+75 -19
View File
@@ -944,9 +944,17 @@ impl<C, R> BrokerSimulator<C, R> {
}
fn new_open_order_submission_time(&self) -> Option<NaiveTime> {
if self.matching_type == MatchingType::NextBarOpen && !self.runtime_stock_pool_followup.get() {
NaiveTime::from_hms_opt(9, 30, 0)
} else { self.order_origin().1 }
if self.runtime_resting_order_origin.get().is_some() {
return self.order_origin().1;
}
if self.matching_type == MatchingType::NextBarOpen
&& !self.runtime_stock_pool_followup.get()
{
let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
Some(self.execution_clock().map_or(open, |clock| clock.max(open)))
} else {
self.execution_clock().or(self.order_origin().1)
}
}
fn resting_order_session_close(&self, date: NaiveDate, order: &OpenOrder) -> NaiveTime {
@@ -8025,22 +8033,31 @@ where
&& 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))))
} else { start_cursor };
let start_cursor = if algo_request.is_some() {
match (start_cursor, self.execution_clock().map(|time| date.and_time(time))) {
(Some(declared), Some(clock)) => Some(declared.max(clock)),
(start, _) => start,
}
} else { start_cursor };
let end_cursor = post_close_window.map(|window| {
runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end)))
}).or_else(|| {
algo_request
.and_then(|request| request.end_time)
.or(runtime_end_time)
.map(|end_time| date.and_time(end_time))
});
Some(start_cursor.map_or(date.and_time(submitted), |cursor| {
cursor.max(date.and_time(submitted))
}))
} else {
start_cursor
};
// A configured session start is not the current submission clock.
// Coarse callbacks and resting-order retries cannot execute backwards
// into an earlier quote, even when they are not algorithm orders.
let start_cursor = match (
start_cursor,
self.execution_clock().map(|time| date.and_time(time)),
) {
(Some(declared), Some(clock)) => Some(declared.max(clock)),
(None, Some(clock)) => Some(clock),
(start, None) => start,
};
let end_cursor = post_close_window
.map(|window| runtime_end_time.map_or(window.1, |end| window.1.min(date.and_time(end))))
.or_else(|| {
algo_request
.and_then(|request| request.end_time)
.or(runtime_end_time)
.map(|end_time| date.and_time(end_time))
});
let end_cursor = if end_cursor.is_none()
&& matching_type == MatchingType::CurrentBarClose
&& self.matching_type_uses_intraday_quotes()
@@ -8253,6 +8270,7 @@ where
let mut last_timestamp = None;
let mut legs = Vec::new();
let mut budget_block_reason = None;
let mut budget_block_timestamp = None;
let mut execution_block_reason = None;
let mut execution_block_timestamp = None;
let mut saw_non_blocked_execution_price = false;
@@ -8393,6 +8411,7 @@ where
self.quote_execution_price(snapshot, side, raw_quote_price, Some(take_qty), calibration)?;
if !quote_price.is_finite() || quote_price <= 0.0 {
budget_block_reason = Some("invalid execution price");
budget_block_timestamp = Some(execution_at);
take_qty = 0;
break;
}
@@ -8411,6 +8430,7 @@ where
.is_some_and(|limit| !Self::fixed_cash_fits(candidate_gross, limit))
{
budget_block_reason = Some("value budget limit");
budget_block_timestamp = Some(execution_at);
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -8436,6 +8456,7 @@ where
break;
}
budget_block_reason = Some("insufficient cash after fees");
budget_block_timestamp = Some(execution_at);
take_qty = self.decrement_order_quantity(
take_qty,
minimum_order_quantity,
@@ -8516,6 +8537,16 @@ where
unfilled_reason: Some(reason),
}));
}
if let Some(reason) = budget_block_reason {
return Ok(Some(ExecutionFill {
quantity: 0,
next_cursor: budget_block_timestamp.expect("budget-blocked quote timestamp")
+ Duration::seconds(1),
legs: Vec::new(),
liquidity_consumption: Vec::new(),
unfilled_reason: Some(reason),
}));
}
return Ok(None);
}
@@ -8717,6 +8748,31 @@ mod tests {
include!("broker_stock_pool_batch_tests.rs");
#[test]
fn queued_order_retains_the_real_creation_clock_when_retried() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let open = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let created = NaiveTime::from_hms_opt(9, 31, 0).unwrap();
let later = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
for matching in [MatchingType::NextBarOpen, MatchingType::MinuteLast] {
let broker =
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(matching)
.with_intraday_execution_start_time(open);
broker.runtime_execution_clock.set(Some(created));
assert_eq!(broker.new_open_order_submission_time(), Some(created));
broker
.runtime_resting_order_origin
.set(Some(super::RestingOrderOrigin {
created_date: Some(date),
submission_time: Some(created),
accepted_date: date,
}));
broker.runtime_execution_clock.set(Some(later));
assert_eq!(broker.new_open_order_submission_time(), Some(created));
}
}
fn test_open_order(order_id: u64) -> OpenOrder {
OpenOrder {
order_id,
File diff suppressed because it is too large Load Diff
@@ -1178,6 +1178,375 @@ fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
}
#[test]
fn late_opening_rule_sees_the_etf_open_fill_after_earlier_quote_callbacks() {
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
use fidc_core::strategy::{Strategy, StrategyContext};
use std::{cell::RefCell, rc::Rc};
struct ObservedPool {
inner: EtfPoolSignal,
observations: Rc<RefCell<Vec<(String, chrono::NaiveDateTime, u32, usize)>>>,
}
impl ObservedPool {
fn record(&self, label: &str, ctx: &StrategyContext<'_>) {
if ctx.execution_date == day(5) {
self.observations.borrow_mut().push((
label.into(),
ctx.current_datetime().unwrap(),
ctx.portfolio
.position(&code(2))
.map_or(0, |position| position.quantity),
ctx.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.count(),
));
}
}
}
impl Strategy for ObservedPool {
fn name(&self) -> &str {
"late opening with ETF fill"
}
fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::from([code(1)])
}
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
self.inner.decision_quote_times()
}
fn decision_quote_symbols(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
self.inner.decision_quote_symbols(ctx)
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("open", ScheduleStage::OpenAuction)
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.record("opening", ctx);
Ok(Default::default())
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.inner.on_day(ctx)
}
fn on_minute(
&mut self,
ctx: &StrategyContext<'_>,
_: &IntradayExecutionQuote,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.record("quote", ctx);
Ok(Default::default())
}
}
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let mut data = etf_fallback_fixture(time);
let quote = data.execution_quotes_on(day(5), &code(1))[0].clone();
data.add_execution_quotes(
[(9, 15), (9, 30), (9, 32)]
.into_iter()
.map(|(hour, minute)| {
let mut row = quote.clone();
row.timestamp = day(5).and_hms_opt(hour, minute, 0).unwrap();
row
})
.collect(),
);
let observations = Rc::new(RefCell::new(Vec::new()));
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data,
ObservedPool {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
observations: observations.clone(),
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.run()
.unwrap();
let seen = observations.borrow();
assert_eq!(
seen[..4],
[
("quote".into(), day(5).and_hms_opt(9, 15, 0).unwrap(), 0, 0),
(
"quote".into(),
day(5).and_hms_opt(9, 30, 0).unwrap(),
3700,
1
),
(
"opening".into(),
day(5).and_hms_opt(9, 31, 0).unwrap(),
3700,
1
),
(
"quote".into(),
day(5).and_hms_opt(9, 32, 0).unwrap(),
3700,
1
),
]
);
let etf = result
.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.collect::<Vec<_>>();
assert_eq!(etf.len(), 1);
assert_eq!(
(etf[0].quantity, etf[0].price, etf[0].execution_timestamp),
(3700, 4., Some(day(5).and_hms_opt(9, 30, 0).unwrap()))
);
}
#[test]
fn no_signal_day_executes_the_etf_open_before_later_deferred_stock_orders() {
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
use fidc_core::strategy::{Strategy, StrategyContext};
struct DeferredStockAndEtf {
inner: EtfPoolSignal,
quantity: i32,
}
impl Strategy for DeferredStockAndEtf {
fn name(&self) -> &str {
"no signal ETF and deferred stock"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
self.inner.decision_quote_times()
}
fn decision_quote_symbols(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
self.inner.decision_quote_symbols(ctx)
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("deferred-stock", ScheduleStage::AfterTrading)
.with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
]
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.inner.on_day(ctx)
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(if ctx.execution_date == day(2) && self.quantity != 0 {
StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: code(1),
quantity: self.quantity,
reason: "after-close stock order".into(),
}],
..Default::default()
}
} else {
StrategyDecision::default()
})
}
}
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let mut parts = etf_fallback_fixture(time).snapshot_components();
parts.factors.retain(|row| row.date != day(5));
let data = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
parts.execution_quotes,
)
.unwrap();
for quantity in [100, -100, 0] {
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data.clone(),
DeferredStockAndEtf {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
quantity,
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.run()
.unwrap();
let fills = result
.fills
.iter()
.filter(|fill| fill.date == day(5))
.collect::<Vec<_>>();
assert_eq!(fills.len(), if quantity < 0 { 2 } else { 1 }, "{fills:?}");
assert_eq!(
(
fills[0].symbol.clone(),
fills[0].quantity,
fills[0].price,
fills[0].execution_timestamp
),
(code(2), 3700, 4., day(5).and_hms_opt(9, 30, 0))
);
if quantity < 0 {
assert_eq!(
(
fills[1].symbol.clone(),
fills[1].side,
fills[1].quantity,
fills[1].execution_timestamp
),
(
code(1),
fidc_core::OrderSide::Sell,
100,
Some(day(5).and_time(time))
)
);
} else if quantity > 0 {
// The later stock buy cannot spend money that the 09:30 ETF fill
// already consumed. It is rejected, not allowed to shrink that fill.
assert!(
result.order_events.iter().any(|order| order.date == day(5)
&& order.symbol == code(1)
&& order.status == fidc_core::OrderStatus::Rejected
&& order.reason.contains("cash")),
"{:?}",
result.order_events
);
}
if quantity != 0 {
assert!(
result.equity_curve.iter().any(
|point| point.date == day(5) && point.diagnostics.contains("no_new_signal")
)
);
}
assert!(result.terminal_audit.is_clean());
}
for minute in [15, 31] {
let observed = format!("2026-01-05T01:{minute}:00Z");
let created = format!("2026-01-05T01:{}:00Z", minute - 1);
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05",
"executedAt":observed,"observedAt":observed,"feeObservationEventId":"receipt","feeObservationSequence":1,
"feeObservedAt":observed,"timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
let order = serde_json::json!({"orderId":"manual-order","sourceAdapter":"paper","symbol":code(1),"side":"Sell","quantity":100,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[fill]});
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
"observationCutoff":"2026-01-05T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[order]}]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data.clone(),
DeferredStockAndEtf {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
quantity: 0,
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.with_observed_manual_executions(replay)
.unwrap()
.run();
if minute < 30 {
assert!(
result
.unwrap_err()
.to_string()
.contains("manual observation conflicts with pending shadow orders")
);
} else {
let result = result.unwrap();
assert_eq!(result.manual_executions.len(), 1);
assert_eq!(
result
.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.count(),
1
);
assert_eq!(
result
.daily_holdings
.iter()
.find(|position| position.date == day(5) && position.symbol == code(1))
.unwrap()
.quantity,
1400
);
}
}
}
#[test]
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();