按日期区分生命周期缺价并保留上市前现金区间

This commit is contained in:
boris
2026-09-10 19:09:21 +08:00
parent 2473cc04bb
commit 40481e8825
7 changed files with 174 additions and 45 deletions
+108 -18
View File
@@ -468,9 +468,15 @@ pub struct BacktestEngine<S, C, R> {
preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>,
execution_absence_notes: BTreeMap<NaiveDate, Vec<String>>,
execution_lifecycle_reported: BTreeSet<(String, String)>,
risk_free_rate_contract: Option<RiskFreeRateContract>,
}
fn all_instruments_have_dated_absence(data: &DataSet, date: NaiveDate) -> bool {
!data.instruments().is_empty() && data.instruments().values().all(|instrument| instrument.dated_market_absence_reason(date).is_some())
}
fn backtest_execution_schedule(
data: &DataSet,
start_date: Option<NaiveDate>,
@@ -493,10 +499,15 @@ fn backtest_execution_schedule(
if decision_lag_trading_days == 0 {
if has_decision_inputs(execution_date) {
schedule.push((execution_date, Some((calendar_idx, execution_date))));
} else if all_instruments_have_dated_absence(data, execution_date) {
schedule.push((execution_date, None));
}
continue;
}
if !has_execution_market(execution_date) {
if all_instruments_have_dated_absence(data, execution_date) {
schedule.push((execution_date, None));
}
continue;
}
let decision_slot = calendar_idx
@@ -507,6 +518,7 @@ fn backtest_execution_schedule(
schedule.push((execution_date, decision_slot));
}
None => schedule.push((execution_date, None)),
Some((_, decision_date)) if all_instruments_have_dated_absence(data, decision_date) => schedule.push((execution_date, None)),
_ => {}
}
}
@@ -554,6 +566,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
execution_quote_loader: None,
preplanned_decision_quote_symbols_by_date: None,
execution_quote_request_cache: BTreeSet::new(),
execution_absence_notes: BTreeMap::new(),
execution_lifecycle_reported: BTreeSet::new(),
risk_free_rate_contract: None,
}
}
@@ -768,6 +782,31 @@ where
end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> {
let mut available = BTreeSet::new();
for symbol in symbols.iter() {
let instrument = self.data.instrument(symbol).ok_or_else(|| BacktestError::Execution(format!(
"execution_data_missing reason=instrument_metadata_or_code_mapping_missing symbol={symbol} execution_date={execution_date}"
)))?;
if let Some(reason) = instrument.dated_market_absence_reason(execution_date) {
if self.data.price(execution_date, symbol, PriceField::Close).is_some()
|| !self.data.execution_quotes_on(execution_date, symbol).is_empty()
{
return Err(BacktestError::Execution(format!(
"execution_data_conflict reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?}",
instrument.listed_at, instrument.delisted_at
)));
}
if self.execution_lifecycle_reported.insert((symbol.clone(), reason.to_string())) {
self.execution_absence_notes.entry(execution_date).or_default().push(format!(
"execution_data_absence reason={reason} symbol={symbol} execution_date={execution_date} listed_at={:?} delisted_at={:?} no_price_fill=true",
instrument.listed_at, instrument.delisted_at
));
}
continue;
}
available.insert(symbol.clone());
}
*symbols = available;
symbols.retain(|symbol| {
let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) {
@@ -835,9 +874,6 @@ where
let mut paused_with_quotes = Vec::new();
let mut missing_daily_market = Vec::new();
for symbol in requested_symbols {
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
continue;
};
let Some(market) = self.data.market(execution_date, symbol) else {
missing_daily_market.push(symbol.clone());
continue;
@@ -2191,12 +2227,13 @@ where
date: execution_date,
})?;
let notes = join_text_parts(corporate_action_notes.into_iter());
let absence = all_instruments_have_dated_absence(&self.data, execution_date);
let diagnostics = join_text_parts(
std::iter::once(format!(
"decision_lag_warmup lag_days={} execution_index={}",
self.config.decision_lag_trading_days, execution_idx
))
.chain(broker_diagnostics.into_iter()),
std::iter::once(if absence {
format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
} else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
.chain(broker_diagnostics.into_iter())
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
);
let holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len();
@@ -2213,7 +2250,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint {
signal_baseline: true,
signal_baseline: execution_idx == 0,
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
@@ -3364,7 +3401,8 @@ where
decision
.diagnostics
.into_iter()
.chain(broker_diagnostics.into_iter()),
.chain(broker_diagnostics.into_iter())
.chain(self.execution_absence_notes.remove(&execution_date).unwrap_or_default()),
);
let holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len();
@@ -3964,17 +4002,11 @@ where
let Some(instrument) = self.data.instrument(&symbol) else {
continue;
};
let is_unresolved = instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none()
&& self.data.market(date, &symbol).is_none());
let is_unresolved = instrument.is_delisted_on_or_before(date);
if !is_unresolved {
continue;
}
let effective_delisted_at = instrument
.delisted_at
.or_else(|| self.data.calendar().previous_day(date))
.unwrap_or(date);
let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
let reason = format!(
concat!(
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ",
@@ -5543,6 +5575,32 @@ mod tests {
);
}
#[test]
fn wholly_prelisting_universe_retains_cash_days_without_fabricating_prices() {
let dates = [d(2025, 1, 2), d(2025, 1, 3), d(2025, 1, 6)];
let mut engine = engine_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
engine.config.end_date = Some(dates[2]);
engine.data = DataSet::from_components(
vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }],
vec![market(dates[2], 10.0, 10.0)], vec![factor(dates[2])], vec![candidate(dates[2])],
dates.iter().map(|date| benchmark(*date)).collect(),
).unwrap();
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 0), dates);
assert_eq!(super::backtest_execution_dates(&engine.data, Some(dates[0]), Some(dates[2]), 1), dates);
let result = engine.run().unwrap();
assert_eq!(result.equity_curve.len(), 3);
for point in &result.equity_curve[..2] {
assert_eq!(point.total_equity, 100_000.0);
assert_eq!(point.market_value, 0.0);
assert!(point.diagnostics.contains("cash_period_retained=true"));
}
assert!(result.order_events.is_empty());
assert!(engine.data.market(dates[0], SYMBOL).is_none());
assert!(result.equity_curve[0].signal_baseline);
assert!(!result.equity_curve[1].signal_baseline);
assert!(!super::all_instruments_have_dated_absence(&dataset(), dates[0]));
}
fn engine_with_matching(
matching_type: MatchingType,
execution_price_field: PriceField,
@@ -5996,6 +6054,38 @@ mod tests {
.expect("zero-volume stock may have no minute bars");
}
#[test]
fn lifecycle_quote_filter_skips_only_dated_legal_absence_before_loading() {
let date = d(2025, 9, 10);
for (symbol, listed_at, delisted_at, reason) in [
("920038.BJ", Some(d(2026, 8, 5)), None, "not_yet_listed"),
("563360.SH", Some(d(2026, 8, 5)), None, "not_yet_listed"),
("000001.SZ", Some(d(2010, 1, 1)), Some(d(2025, 9, 9)), "delisted"),
] {
let instrument = Instrument { symbol: symbol.into(), listed_at, delisted_at, ..default_instrument() };
let data = DataSet::from_components(vec![instrument], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
let mut engine = full_day_coverage_engine(data, date);
engine.execution_quote_loader = Some(Box::new(|_| panic!("legal lifecycle absence must not load prices")));
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
let notes = engine.execution_absence_notes.get(&date).unwrap();
assert!(notes[0].contains(reason));
assert!(notes[0].contains(symbol));
engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from([symbol.to_string()])).unwrap();
assert_eq!(engine.execution_absence_notes[&date].len(), 1);
}
}
#[test]
fn unknown_identity_or_missing_candidate_does_not_waive_quote_coverage() {
let date = d(2025, 9, 10);
let data = DataSet::from_components(vec![default_instrument()], vec![], vec![], vec![], vec![benchmark(date)]).unwrap();
let mut engine = full_day_coverage_engine(data, date);
let error = engine.load_missing_execution_quotes(date, None, None, &mut BTreeSet::from(["unmapped".to_string()])).unwrap_err();
assert!(error.to_string().contains("instrument_metadata_or_code_mapping_missing"));
let error = engine.validate_full_day_execution_quote_coverage(date, &[SYMBOL.to_string()]).unwrap_err();
assert!(error.to_string().contains("missing_daily_market"));
}
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
run_scheduled_next_open_with_dataset_and_broker(
dataset,