按日期区分生命周期缺价并保留上市前现金区间
This commit is contained in:
+108
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user