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

This commit is contained in:
boris
2026-09-10 19:09:21 +08:00
parent 2473cc04bb
commit 29c649bc94
7 changed files with 174 additions and 45 deletions
+2 -2
View File
@@ -6229,7 +6229,7 @@ mod tests {
} }
#[test] #[test]
fn baseline_selection_uses_structured_instrument_dates_and_status_only() { fn baseline_selection_uses_dated_lifecycle_not_latest_undated_status() {
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap(); let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument { let instrument = |name: &str, status: &str, delisted_at: Option<NaiveDate>| Instrument {
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
@@ -6257,7 +6257,7 @@ mod tests {
Some(&instrument("退市测试", "active", None)), Some(&instrument("退市测试", "active", None)),
date date
)); ));
assert!(!instrument_passes_baseline_selection( assert!(instrument_passes_baseline_selection(
Some(&instrument("正常名称", "delisted", None)), Some(&instrument("正常名称", "delisted", None)),
date date
)); ));
+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>>>>, preplanned_decision_quote_symbols_by_date: Option<Arc<BTreeMap<NaiveDate, BTreeSet<String>>>>,
execution_quote_request_cache: execution_quote_request_cache:
BTreeSet<(NaiveDate, String, Option<NaiveTime>, Option<NaiveTime>)>, 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>, 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( fn backtest_execution_schedule(
data: &DataSet, data: &DataSet,
start_date: Option<NaiveDate>, start_date: Option<NaiveDate>,
@@ -493,10 +499,15 @@ fn backtest_execution_schedule(
if decision_lag_trading_days == 0 { if decision_lag_trading_days == 0 {
if has_decision_inputs(execution_date) { if has_decision_inputs(execution_date) {
schedule.push((execution_date, Some((calendar_idx, 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; continue;
} }
if !has_execution_market(execution_date) { if !has_execution_market(execution_date) {
if all_instruments_have_dated_absence(data, execution_date) {
schedule.push((execution_date, None));
}
continue; continue;
} }
let decision_slot = calendar_idx let decision_slot = calendar_idx
@@ -507,6 +518,7 @@ fn backtest_execution_schedule(
schedule.push((execution_date, decision_slot)); schedule.push((execution_date, decision_slot));
} }
None => schedule.push((execution_date, None)), 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, execution_quote_loader: None,
preplanned_decision_quote_symbols_by_date: None, preplanned_decision_quote_symbols_by_date: None,
execution_quote_request_cache: BTreeSet::new(), execution_quote_request_cache: BTreeSet::new(),
execution_absence_notes: BTreeMap::new(),
execution_lifecycle_reported: BTreeSet::new(),
risk_free_rate_contract: None, risk_free_rate_contract: None,
} }
} }
@@ -768,6 +782,31 @@ where
end_time: Option<NaiveTime>, end_time: Option<NaiveTime>,
symbols: &mut BTreeSet<String>, symbols: &mut BTreeSet<String>,
) -> Result<(), BacktestError> { ) -> 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| { symbols.retain(|symbol| {
let request_key = (execution_date, symbol.clone(), start_time, end_time); let request_key = (execution_date, symbol.clone(), start_time, end_time);
if self.execution_quote_request_cache.contains(&request_key) { if self.execution_quote_request_cache.contains(&request_key) {
@@ -835,9 +874,6 @@ where
let mut paused_with_quotes = Vec::new(); let mut paused_with_quotes = Vec::new();
let mut missing_daily_market = Vec::new(); let mut missing_daily_market = Vec::new();
for symbol in requested_symbols { 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 { let Some(market) = self.data.market(execution_date, symbol) else {
missing_daily_market.push(symbol.clone()); missing_daily_market.push(symbol.clone());
continue; continue;
@@ -2191,12 +2227,13 @@ where
date: execution_date, date: execution_date,
})?; })?;
let notes = join_text_parts(corporate_action_notes.into_iter()); 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( let diagnostics = join_text_parts(
std::iter::once(format!( std::iter::once(if absence {
"decision_lag_warmup lag_days={} execution_index={}", format!("execution_data_absence reason=all_instruments_outside_dated_lifecycle execution_date={execution_date} cash_period_retained=true no_price_fill=true")
self.config.decision_lag_trading_days, execution_idx } else { format!("decision_lag_warmup lag_days={} execution_index={}", self.config.decision_lag_trading_days, execution_idx) })
)) .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 holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len(); let holding_start = result.daily_holdings.len();
@@ -2213,7 +2250,7 @@ where
previous_external_cash_flow_total = portfolio.external_cash_flow_total(); previous_external_cash_flow_total = portfolio.external_cash_flow_total();
result.equity_curve.push(DailyEquityPoint { result.equity_curve.push(DailyEquityPoint {
signal_baseline: true, signal_baseline: execution_idx == 0,
date: execution_date, date: execution_date,
cash: aggregate_cash, cash: aggregate_cash,
market_value: aggregate_market_value, market_value: aggregate_market_value,
@@ -3364,7 +3401,8 @@ where
decision decision
.diagnostics .diagnostics
.into_iter() .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 holdings_for_day = portfolio.holdings_summary(execution_date);
let holding_start = result.daily_holdings.len(); let holding_start = result.daily_holdings.len();
@@ -3964,17 +4002,11 @@ where
let Some(instrument) = self.data.instrument(&symbol) else { let Some(instrument) = self.data.instrument(&symbol) else {
continue; continue;
}; };
let is_unresolved = instrument.is_delisted_on_or_before(date) 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());
if !is_unresolved { if !is_unresolved {
continue; continue;
} }
let effective_delisted_at = instrument let effective_delisted_at = instrument.delisted_at.expect("dated delisting checked");
.delisted_at
.or_else(|| self.data.calendar().previous_day(date))
.unwrap_or(date);
let reason = format!( let reason = format!(
concat!( concat!(
"unresolved_delisted_position symbol={} quantity={} effective_date={} status={} ", "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( fn engine_with_matching(
matching_type: MatchingType, matching_type: MatchingType,
execution_price_field: PriceField, execution_price_field: PriceField,
@@ -5996,6 +6054,38 @@ mod tests {
.expect("zero-volume stock may have no minute bars"); .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 { fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
run_scheduled_next_open_with_dataset_and_broker( run_scheduled_next_open_with_dataset_and_broker(
dataset, dataset,
+30 -2
View File
@@ -70,8 +70,17 @@ impl Instrument {
pub fn is_active_on(&self, date: NaiveDate) -> bool { pub fn is_active_on(&self, date: NaiveDate) -> bool {
self.listed_at.is_none_or(|listed_at| listed_at <= date) self.listed_at.is_none_or(|listed_at| listed_at <= date)
&& !self.is_delisted_before(date) && !self.is_delisted_on_or_before(date)
&& !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none()) }
pub fn dated_market_absence_reason(&self, date: NaiveDate) -> Option<&'static str> {
if self.listed_at.is_some_and(|listed| date < listed) {
Some("not_yet_listed")
} else if self.is_delisted_on_or_before(date) {
Some("delisted")
} else {
None
}
} }
} }
@@ -107,6 +116,25 @@ mod tests {
} }
} }
#[test]
fn lifecycle_is_dated_and_latest_undated_terminal_status_is_not_historical_evidence() {
let mut item = instrument("BJS", 100);
let listing = chrono::NaiveDate::from_ymd_opt(2026, 8, 5).unwrap();
let removal = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
item.listed_at = Some(listing);
item.delisted_at = Some(removal);
assert_eq!(item.dated_market_absence_reason(listing.pred_opt().unwrap()), Some("not_yet_listed"));
assert!(item.is_active_on(listing));
assert!(!item.is_active_on(removal));
assert_eq!(item.dated_market_absence_reason(removal), Some("delisted"));
item.delisted_at = None;
for status in ["delisting", "delisted", "inactive", "terminated"] {
item.status = status.into();
assert!(item.is_active_on(listing));
assert_eq!(item.dated_market_absence_reason(listing), None);
}
}
#[test] #[test]
fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() { fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() {
let kcb = instrument(" kSh ", 100); let kcb = instrument(" kSh ", 100);
@@ -1587,13 +1587,7 @@ impl PlatformExprStrategy {
.filter(|position| position.quantity > 0) .filter(|position| position.quantity > 0)
.filter_map(|position| { .filter_map(|position| {
let instrument = ctx.data.instrument(&position.symbol)?; let instrument = ctx.data.instrument(&position.symbol)?;
let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date) let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date);
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none()
&& ctx
.data
.market(ctx.execution_date, &position.symbol)
.is_none());
unresolved.then(|| position.symbol.clone()) unresolved.then(|| position.symbol.clone())
}) })
.collect() .collect()
+21 -7
View File
@@ -1047,8 +1047,6 @@ impl PortfolioState {
let unresolved_delisting = current_market_missing let unresolved_delisting = current_market_missing
&& data.instrument(&position.symbol).is_some_and(|instrument| { && data.instrument(&position.symbol).is_some_and(|instrument| {
instrument.is_delisted_on_or_before(date) instrument.is_delisted_on_or_before(date)
|| (instrument.status.eq_ignore_ascii_case("delisted")
&& instrument.delisted_at.is_none())
}); });
if unresolved_delisting { if unresolved_delisting {
position.last_price = 0.0; position.last_price = 0.0;
@@ -1068,11 +1066,13 @@ impl PortfolioState {
position.refresh_day_pnl(); position.refresh_day_pnl();
continue; continue;
} }
let confirmed_pause = data.market(date, &position.symbol).is_some_and(|row| row.paused)
|| data.candidate(date, &position.symbol).is_some_and(|row| row.is_paused);
let price = data let price = data
.price(date, &position.symbol, field) .price(date, &position.symbol, field)
.or_else(|| data.price_on_or_before(date, &position.symbol, field)) .or_else(|| confirmed_pause.then(|| data.price_on_or_before(date, &position.symbol, field)).flatten())
.or_else(|| { .or_else(|| {
(position.last_price.is_finite() && position.last_price > 0.0) (confirmed_pause && position.last_price.is_finite() && position.last_price > 0.0)
.then_some(position.last_price) .then_some(position.last_price)
}) })
.ok_or_else(|| DataSetError::MissingSnapshot { .ok_or_else(|| DataSetError::MissingSnapshot {
@@ -1774,7 +1774,7 @@ mod tests {
} }
#[test] #[test]
fn portfolio_carries_last_price_when_position_market_row_is_missing() { fn portfolio_missing_market_requires_formal_suspension_before_carrying_price() {
let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap(); let prev_date = NaiveDate::from_ymd_opt(2025, 5, 26).unwrap();
let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap(); let missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap();
let mut portfolio = PortfolioState::new(10_000.0); let mut portfolio = PortfolioState::new(10_000.0);
@@ -1832,9 +1832,23 @@ mod tests {
.update_prices(prev_date, &dataset, PriceField::Close) .update_prices(prev_date, &dataset, PriceField::Close)
.expect("previous close"); .expect("previous close");
portfolio.begin_trading_day(); portfolio.begin_trading_day();
portfolio let error = portfolio
.update_prices(missing_date, &dataset, PriceField::Close) .update_prices(missing_date, &dataset, PriceField::Close)
.expect("missing current row should carry previous close"); .expect_err("unclassified missing current price must not be filled from history");
assert!(error.to_string().contains("601028.SH"));
let paused_dataset = DataSet::from_components(
vec![dataset.instrument("601028.SH").unwrap().clone()],
vec![dataset.market(prev_date, "601028.SH").unwrap().clone()],
Vec::new(),
vec![crate::data::CandidateEligibility {
date: missing_date, symbol: "601028.SH".into(), is_st: false, is_star_st: false,
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
is_kcb: false, is_one_yuan: false, risk_level_code: None,
}],
vec![dataset.benchmark(prev_date).unwrap().clone()],
).unwrap();
portfolio.update_prices(missing_date, &paused_dataset, PriceField::Close)
.expect("dated suspension permits keeping the last known valuation, not creating a fill");
let position = portfolio.position("601028.SH").expect("position"); let position = portfolio.position("601028.SH").expect("position");
assert!((position.last_price - 10.3).abs() < 1e-6); assert!((position.last_price - 10.3).abs() < 1e-6);
+3 -9
View File
@@ -208,14 +208,8 @@ impl ChinaAShareRiskControl {
{ {
return Some("inactive_or_delisted"); return Some("inactive_or_delisted");
} }
let status = instrument.status.trim().to_ascii_lowercase(); // Latest reference status has no historical as-of date. Execution-day
let terminal_status = matches!( // risk snapshots remain authoritative; missing quotes are not waived.
status.as_str(),
"inactive" | "delisted" | "terminated" | "expired"
);
if terminal_status && instrument.delisted_at.is_none() {
return Some("inactive_or_delisted");
}
None None
} }
@@ -843,7 +837,7 @@ mod tests {
Some(&instrument("delisted", None)), Some(&instrument("delisted", None)),
date, date,
), ),
Some("inactive_or_delisted") None
); );
assert_eq!( assert_eq!(
ChinaAShareRiskControl::instrument_rejection_reason( ChinaAShareRiskControl::instrument_rejection_reason(
@@ -0,0 +1,9 @@
# 生命周期、价格缺失与历史状态
证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。
执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。
整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。
513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。