From 40481e8825ce39147028db4899511babece47c68 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 10 Sep 2026 19:09:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E6=97=A5=E6=9C=9F=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E7=BC=BA=E4=BB=B7=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=B8=8A=E5=B8=82=E5=89=8D=E7=8E=B0=E9=87=91?= =?UTF-8?q?=E5=8C=BA=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 4 +- crates/fidc-core/src/engine.rs | 126 +++++++++++++++--- crates/fidc-core/src/instrument.rs | 32 ++++- .../fidc-core/src/platform_expr_strategy.rs | 8 +- crates/fidc-core/src/portfolio.rs | 28 +++- crates/fidc-core/src/risk_control.rs | 12 +- docs/lifecycle-price-evidence-20260910.md | 9 ++ 7 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 docs/lifecycle-price-evidence-20260910.md diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 8dc0d07..af8f270 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -6229,7 +6229,7 @@ mod tests { } #[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 instrument = |name: &str, status: &str, delisted_at: Option| Instrument { symbol: "000001.SZ".to_string(), @@ -6257,7 +6257,7 @@ mod tests { Some(&instrument("退市测试", "active", None)), date )); - assert!(!instrument_passes_baseline_selection( + assert!(instrument_passes_baseline_selection( Some(&instrument("正常名称", "delisted", None)), date )); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index cd683f0..cd7a0b0 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -468,9 +468,15 @@ pub struct BacktestEngine { preplanned_decision_quote_symbols_by_date: Option>>>, execution_quote_request_cache: BTreeSet<(NaiveDate, String, Option, Option)>, + execution_absence_notes: BTreeMap>, + execution_lifecycle_reported: BTreeSet<(String, String)>, risk_free_rate_contract: Option, } +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, @@ -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 BacktestEngine { 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, symbols: &mut BTreeSet, ) -> 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, diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index 381639b..feede4f 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -70,8 +70,17 @@ impl Instrument { pub fn is_active_on(&self, date: NaiveDate) -> bool { self.listed_at.is_none_or(|listed_at| listed_at <= date) - && !self.is_delisted_before(date) - && !(self.status.eq_ignore_ascii_case("inactive") && self.delisted_at.is_none()) + && !self.is_delisted_on_or_before(date) + } + + 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] fn order_quantity_rules_are_case_insensitive_without_allocating_normalized_boards() { let kcb = instrument(" kSh ", 100); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index d2d169c..dbc71d1 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1587,13 +1587,7 @@ impl PlatformExprStrategy { .filter(|position| position.quantity > 0) .filter_map(|position| { let instrument = ctx.data.instrument(&position.symbol)?; - 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()); + let unresolved = instrument.is_delisted_on_or_before(ctx.execution_date); unresolved.then(|| position.symbol.clone()) }) .collect() diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 8072324..ef3c329 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -1047,8 +1047,6 @@ impl PortfolioState { let unresolved_delisting = current_market_missing && data.instrument(&position.symbol).is_some_and(|instrument| { instrument.is_delisted_on_or_before(date) - || (instrument.status.eq_ignore_ascii_case("delisted") - && instrument.delisted_at.is_none()) }); if unresolved_delisting { position.last_price = 0.0; @@ -1068,11 +1066,13 @@ impl PortfolioState { position.refresh_day_pnl(); 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 .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(|| { - (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) }) .ok_or_else(|| DataSetError::MissingSnapshot { @@ -1774,7 +1774,7 @@ mod tests { } #[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 missing_date = NaiveDate::from_ymd_opt(2025, 5, 27).unwrap(); let mut portfolio = PortfolioState::new(10_000.0); @@ -1832,9 +1832,23 @@ mod tests { .update_prices(prev_date, &dataset, PriceField::Close) .expect("previous close"); portfolio.begin_trading_day(); - portfolio + let error = portfolio .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"); assert!((position.last_price - 10.3).abs() < 1e-6); diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index cec4d24..2351ff6 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -208,14 +208,8 @@ impl ChinaAShareRiskControl { { return Some("inactive_or_delisted"); } - let status = instrument.status.trim().to_ascii_lowercase(); - let terminal_status = matches!( - status.as_str(), - "inactive" | "delisted" | "terminated" | "expired" - ); - if terminal_status && instrument.delisted_at.is_none() { - return Some("inactive_or_delisted"); - } + // Latest reference status has no historical as-of date. Execution-day + // risk snapshots remain authoritative; missing quotes are not waived. None } @@ -843,7 +837,7 @@ mod tests { Some(&instrument("delisted", None)), date, ), - Some("inactive_or_delisted") + None ); assert_eq!( ChinaAShareRiskControl::instrument_rejection_reason( diff --git a/docs/lifecycle-price-evidence-20260910.md b/docs/lifecycle-price-evidence-20260910.md new file mode 100644 index 0000000..336676b --- /dev/null +++ b/docs/lifecycle-price-evidence-20260910.md @@ -0,0 +1,9 @@ +# 生命周期、价格缺失与历史状态 + +证券有效区间为 `[listed_at, delisted_at)`。无明确摘牌日期的最新 terminal 标签不能反向污染历史;已知未来摘牌日不阻断此前的正常交易。退市整理期不是已摘牌。 + +执行价加载前分别核验证券身份、正式上市/摘牌边界。合法上市前、摘牌后不查询和补价,记录结构化原因;同一日已有正执行价与生命周期边界冲突时报错。未知身份/代码映射、上市后的分钟缺口、候选事实缺失继续失败,不因 missing candidate 而跳过校验。持仓仅在当日正式暂停交易事实成立时允许按既定估值合同沿用历史价格;普通行情缺口不再无条件沿用旧价。 + +整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。 + +513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。