From 4664f1a2d340c1676af55e186a467c3ce129a7a7 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 10 Sep 2026 20:21:44 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=8E=92=E9=99=A4=E4=BB=85=E4=BD=9C?= =?UTF-8?q?=E5=8F=82=E8=80=83=E7=9A=84=E5=9F=BA=E5=87=86=E5=B9=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=91=A8=E6=9C=9F=E4=B9=B0=E5=85=A5=E9=A3=8E=E6=8E=A7?= =?UTF-8?q?=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 6 ++ crates/fidc-core/src/engine.rs | 10 +++- .../fidc-core/src/platform_expr_strategy.rs | 58 ++++++++++++------- crates/fidc-core/src/risk_control.rs | 10 ++++ docs/lifecycle-price-evidence-20260910.md | 6 ++ 5 files changed, 67 insertions(+), 23 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index af8f270..5192a44 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -3375,6 +3375,12 @@ impl DataSet { .unwrap_or(&[]) } + pub fn is_reference_only_benchmark(&self, symbol: &str) -> bool { + if symbol != self.benchmark_code() { return false; } + let Some(symbol_id) = self.symbol_id(symbol) else { return true; }; + !self.candidate_symbol_ids_by_date.values().any(|ids| ids.contains(&symbol_id)) + } + pub fn bundle_on(&self, date: NaiveDate) -> Result { let benchmark = self .benchmark(date) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index cd7a0b0..957364d 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -474,7 +474,9 @@ pub struct BacktestEngine { } 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()) + let mut instruments = data.instruments().values() + .filter(|instrument| !data.is_reference_only_benchmark(&instrument.symbol)).peekable(); + instruments.peek().is_some() && instruments.all(|instrument| instrument.dated_market_absence_reason(date).is_some()) } fn backtest_execution_schedule( @@ -5580,9 +5582,11 @@ mod tests { 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]); + let mut markets = vec![market(dates[2], 10.0, 10.0)]; + markets.extend(dates.iter().map(|date| DailyMarketSnapshot { symbol: "000852.SH".into(), ..market(*date, 1000.0, 1000.0) })); 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])], + vec![Instrument { listed_at: Some(dates[2]), ..default_instrument() }, Instrument { symbol: "000852.SH".into(), listed_at: None, ..default_instrument() }], + markets, 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); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index dbc71d1..9eeb958 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -3853,16 +3853,9 @@ impl PlatformExprStrategy { { continue; } - if !defer_execution_risk - && self - .buy_rejection_reason( - ctx, - execution_date, - symbol, - self.stock_state(ctx, execution_date, symbol)?.as_ref(), - )? - .is_some() - { + if !defer_execution_risk && self.buy_rejection_reason( + ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(), + )?.is_some() { continue; } let decision_stock = self.stock_state_with_factor_date( @@ -14013,16 +14006,10 @@ impl PlatformExprStrategy { if target_value <= 0.0 { continue; } - if !defer_execution_risk - && self - .buy_rejection_reason( - ctx, - execution_date, - symbol, - self.stock_state(ctx, execution_date, symbol)?.as_ref(), - )? - .is_some() - { + if !defer_execution_risk && let Some(reason) = self.buy_rejection_reason( + ctx, execution_date, symbol, self.stock_state(ctx, execution_date, symbol)?.as_ref(), + )? { + risk_decisions.push(FidcRiskDecisionAudit::rejected_buy_plan(execution_date, symbol, &reason)); continue; } if !self.stock_passes_expr(ctx, &day, &decision_stock)? { @@ -14299,6 +14286,37 @@ mod tests { assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); } + #[test] + fn periodic_selected_bjse_buy_rejection_is_audited_without_creating_an_order() { + let dates = [d(2026, 8, 5), d(2026, 8, 6)]; + let symbol = "920038.BJ"; + let data = single_symbol_platform_data(&dates, symbol); + let portfolio = PortfolioState::new(100_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: dates[1], decision_date: dates[1], decision_index: 1, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.signal_symbol = symbol.into(); + cfg.stock_filter_expr = "close > 0".into(); + cfg.hold_until_exit_enabled = true; + cfg.target_portfolio_daily_enabled = true; + cfg.daily_top_up_enabled = true; + cfg.daily_position_target_adjust_enabled = true; + cfg.rebalance_existing_positions = true; + cfg.risk_config.static_rules.reject_bjse_selection = false; + cfg.risk_config.static_rules.reject_bjse_buy = true; + let decision = PlatformExprStrategy::new(cfg.clone()).on_day(&ctx).unwrap(); + assert!(decision.order_intents.is_empty()); + assert!(decision.risk_decisions.iter().any(|audit| audit.symbol == symbol && audit.stage == "buy_planning" && audit.rule_code == "bjse" && !audit.accepted)); + cfg.risk_config.static_rules.reject_bjse_buy = false; + let allowed = PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap(); + assert!(!allowed.order_intents.is_empty()); + } + #[test] fn daily_pattern_runtime_uses_the_shared_kernel_and_rejects_early_visibility() { let dates=(0..21).map(|n|d(2025,1,1)+chrono::Duration::days(n)).collect::>(); diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index 2351ff6..ece85c9 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -138,6 +138,16 @@ pub struct FidcRiskDecisionAudit { } impl FidcRiskDecisionAudit { + pub fn rejected_buy_plan(date: NaiveDate, symbol: &str, reason: &str) -> Self { + Self { + date, symbol: symbol.into(), scope: RiskCheckScope::Buy, + stage: "buy_planning".into(), accepted: false, + rule_code: reason.into(), reason: reason.into(), + config_version: Some("inline_risk_policy".into()), data_epoch: date.to_string(), + selection_batch_id: None, order_id: None, + } + } + pub fn rejected_selection( date: NaiveDate, symbol: impl Into, diff --git a/docs/lifecycle-price-evidence-20260910.md b/docs/lifecycle-price-evidence-20260910.md index 336676b..b2585c8 100644 --- a/docs/lifecycle-price-evidence-20260910.md +++ b/docs/lifecycle-price-evidence-20260910.md @@ -7,3 +7,9 @@ 整个明确证券范围尚未上市时保留官方日历内现金净值点,不缩短回测范围,不伪造成交或 OHLCV。基准只在首个基线点归一,后续无交易日不反复重置。 513 项核心测试通过,6 项原有测试忽略。新增验证包含沪深北股票和 ETF 上市前、实际摘牌日、未知证券身份、候选缺失、正式停牌和普通价格缺口、全池上市前现金期间。对单个正式分区的数据缺口仍需数据源修复,不从这些测试外推全市场完整性。 + +## 真实边界回放补充 + +177 回测 `btr_1789041425783_797911_1`:920038.BJ,2026-08-04 至 08-07。真实上市日08-05,原结果只保留08-05至08-07三个净值点。原因是准备面同时加载基准000300.SH,基准不是交易候选但参与了“全部证券生命周期外”的判定。现在只排除已声明且没有交易候选记录的基准,不按代码或名称猜测指数,也不把真实候选排除;补充真实准备结构的回归后,4日现金区间完整保留。 + +该草稿沿用源池 `rejectBjseSelection=false`、`rejectBjseBuy=true`,所以选中北交所但不下单符合其买入政策;原规划阶段没有记录拒绝原因则是审计缺项。新增 `scope=buy, stage=buy_planning` 审计,不伪造订单ID,不把买入否决改写成选股排除。测试验证禁止时无订单且有bjse原因,放开买入政策时正常生成意图。最新核心514项通过、6项原有忽略。 From 7d05f8f7c75627f949b035610a5564cdab041b5c Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 10 Sep 2026 22:18:50 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=B8=BA=E6=89=A7=E8=A1=8C=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E9=A2=84=E5=8F=96=E6=B5=8B=E8=AF=95=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=98=8E=E7=A1=AE=E8=AF=81=E5=88=B8=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/tests/decision_quote_preload.rs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index fc06c99..e08db24 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -2,7 +2,7 @@ use chrono::{Duration, NaiveDate, NaiveTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, - IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, + Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, }; use std::collections::{BTreeMap, BTreeSet}; @@ -16,6 +16,18 @@ fn t(hour: u32, minute: u32, second: u32) -> NaiveTime { NaiveTime::from_hms_opt(hour, minute, second).expect("valid time") } +fn fixture_instruments() -> Vec { + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "quote-plan-fixture".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }] +} + #[derive(Default)] struct DecisionQuoteReader { day_count: usize, @@ -90,7 +102,7 @@ impl Strategy for NoLoaderDecisionQuoteStrategy { fn single_day_quote_plan_data(date: NaiveDate) -> DataSet { DataSet::from_components( - Vec::new(), + fixture_instruments(), vec![DailyMarketSnapshot { date, symbol: "000001.SZ".to_string(), @@ -253,7 +265,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() { let first = d(2026, 1, 5); let second = d(2026, 1, 6); let data = DataSet::from_components( - Vec::new(), + fixture_instruments(), vec![ DailyMarketSnapshot { date: first, @@ -423,7 +435,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() { let first = d(2026, 1, 5); let second = d(2026, 1, 6); let data = DataSet::from_components_with_actions_and_quotes( - Vec::new(), + fixture_instruments(), vec![ DailyMarketSnapshot { date: first, @@ -658,7 +670,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() { let first = d(2026, 1, 5); let second = d(2026, 1, 6); let data = DataSet::from_components( - Vec::new(), + fixture_instruments(), vec![ DailyMarketSnapshot { date: first, From 75e5e322814130e65d6098dd14268540dc22dc21 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 10 Sep 2026 22:22:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E7=BB=91=E5=AE=9A=E4=BF=A1=E5=8F=B7?= =?UTF-8?q?=E7=B0=BF=E5=86=85=E5=AE=B9=E6=91=98=E8=A6=81=E5=B9=B6=E6=8C=89?= =?UTF-8?q?=E5=86=B3=E7=AD=96=E6=97=A5=E6=A0=A1=E9=AA=8C=E6=97=A5=E7=BA=BF?= =?UTF-8?q?=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/signal_contract.rs | 49 ++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/crates/fidc-core/src/signal_contract.rs b/crates/fidc-core/src/signal_contract.rs index 5784018..07f4247 100644 --- a/crates/fidc-core/src/signal_contract.rs +++ b/crates/fidc-core/src/signal_contract.rs @@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::strategy::{OrderIntent, StrategyContext}; use crate::portfolio::PortfolioState; @@ -86,6 +87,13 @@ fn shanghai(value: DateTime) -> NaiveDateTime { } impl SignalBook { + pub fn content_sha256(&self) -> Result { + let mut value=serde_json::to_value(self).map_err(|error|error.to_string())?; + value.as_object_mut().ok_or("signal_book_object_required")?.remove("versionSha256"); + let raw=serde_json::to_vec(&value).map_err(|error|error.to_string())?; + Ok(format!("{:x}",Sha256::digest(raw))) + } + pub fn validate(self) -> Result { if self.schema != SIGNAL_BOOK_SCHEMA || !valid_sha(&self.version_sha256) || !valid_sha(&self.generator_sha256) @@ -154,6 +162,9 @@ impl SignalBook { } index.insert(shanghai(*expected), number); } + if self.content_sha256()? != self.version_sha256 { + return Err("signal_book_content_hash_mismatch".into()); + } Ok(ValidatedSignalBook { book: self, index }) } } @@ -179,7 +190,7 @@ impl ValidatedSignalBook { pub fn snapshot_for(&self, ctx: &StrategyContext<'_>) -> Result<&SignalSnapshot, String> { let snapshot = self.snapshot_at(ctx.execution_date, ctx.current_time(), ctx.is_lagged_execution())?; - if ctx.is_lagged_execution() && shanghai(snapshot.input_as_of) > ctx.decision_date.and_hms_opt(15,0,0).expect("completed decision session") { + if ctx.is_lagged_execution() && shanghai(snapshot.input_as_of).date() > ctx.decision_date { return Err("next_open_signal_contains_execution_session_inputs".into()); } Ok(snapshot) @@ -260,7 +271,7 @@ mod tests { fn book() -> SignalBook { let decision: DateTime = "2025-01-07T09:30:00+08:00".parse().unwrap(); let source: DateTime = "2025-01-06T15:00:00+08:00".parse().unwrap(); - SignalBook { + seal(SignalBook { schema: SIGNAL_BOOK_SCHEMA.into(), version_sha256: "a".repeat(64), generator_sha256: "b".repeat(64), knowledge_cutoff: "2024-12-31T15:00:00+08:00".parse().unwrap(), provenance: SignalProvenance::Reconstructed, frequency: SignalFrequency::Daily, @@ -270,7 +281,12 @@ mod tests { input_sha256: "c".repeat(64), complete_targets: true, actions: vec![SignalAction::TargetWeight { symbol: "000001.SZ".into(), weight: 0.5 }], }], - } + }) + } + + fn seal(mut book:SignalBook)->SignalBook { + book.version_sha256=book.content_sha256().unwrap(); + book } #[test] @@ -282,7 +298,7 @@ mod tests { assert!(observed.clone().validate().unwrap_err().contains("not_available")); observed.snapshots[0].generated_at = observed.snapshots[0].decision_at; observed.snapshots[0].published_at = observed.snapshots[0].decision_at; - observed.validate().unwrap().require_observed().unwrap(); + seal(observed).validate().unwrap().require_observed().unwrap(); } #[test] @@ -352,7 +368,7 @@ mod tests { let mut raw = book(); raw.snapshots[0].complete_targets = false; raw.snapshots[0].actions = vec![SignalAction::Reduce {symbol:"000001.SZ".into(),remaining_ratio:0.5}]; - let value = raw.validate().unwrap(); + let value = seal(raw).validate().unwrap(); let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap(); for (held, expected) in [(1000,500),(3000,1500)] { let mut portfolio = PortfolioState::new(100_000.0); @@ -368,7 +384,7 @@ mod tests { fn empty_complete_snapshot_clears_only_that_accounts_holdings() { let mut raw = book(); raw.snapshots[0].actions.clear(); - let value = raw.validate().unwrap(); + let value = seal(raw).validate().unwrap(); let day = NaiveDate::from_ymd_opt(2025,1,3).unwrap(); let mut portfolio = PortfolioState::new(100_000.0); portfolio.position_mut("000002.SZ").buy(day,200,10.0); @@ -383,4 +399,25 @@ mod tests { assert!(!config.rotation_enabled && config.signal_book.is_some()); assert!(matches!(config.explicit_actions.as_slice(),[crate::PlatformTradeAction::ConsumeSignal])); } + + #[test] + fn changed_valid_contents_must_not_reuse_a_version_hash() { + let mut raw=book(); + raw.snapshots[0].actions=vec![SignalAction::TargetWeight{symbol:"000001.SZ".into(),weight:0.4}]; + assert_eq!(raw.clone().validate().unwrap_err(),"signal_book_content_hash_mismatch"); + seal(raw).validate().unwrap(); + } + + #[test] + fn completed_daily_inputs_may_be_published_after_market_close() { + let mut raw=book(); + raw.expected_decisions=vec!["2026-07-07T09:30:00+08:00".parse().unwrap()]; + raw.snapshots[0].decision_at=raw.expected_decisions[0]; + raw.snapshots[0].input_as_of="2026-07-06T15:30:00+08:00".parse().unwrap(); + raw.snapshots[0].input_available_at="2026-07-06T16:00:00+08:00".parse().unwrap(); + raw.snapshots[0].generated_at=raw.snapshots[0].input_available_at; + raw.snapshots[0].published_at=raw.snapshots[0].generated_at; + raw.provenance=SignalProvenance::Observed; + seal(raw).validate().unwrap().require_observed().unwrap(); + } }