From 581021651c49c71731e888da1c0b8337764140ee Mon Sep 17 00:00:00 2001 From: zsb Date: Wed, 8 Apr 2026 19:10:28 -0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=9B=9E=E6=B5=8B=E6=8E=A8?= =?UTF-8?q?=E8=BF=9B=E5=B9=B6=E5=A2=9E=E5=BC=BA=E7=AD=96=E7=95=A5=E6=A0=B7?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/bt-demo/Cargo.toml | 1 + crates/bt-demo/src/main.rs | 145 +++++++++++++- crates/fidc-core/src/engine.rs | 43 +++-- crates/fidc-core/src/lib.rs | 1 + crates/fidc-core/src/strategy.rs | 188 +++++++++++++++++-- crates/fidc-core/src/universe.rs | 136 +++++++++++--- crates/fidc-core/tests/strategy_selection.rs | 16 +- 8 files changed, 465 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2458bd0..f60cac0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" name = "bt-demo" version = "0.1.0" dependencies = [ + "chrono", "fidc-core", "serde", "serde_json", diff --git a/crates/bt-demo/Cargo.toml b/crates/bt-demo/Cargo.toml index ed145e6..ac837e2 100644 --- a/crates/bt-demo/Cargo.toml +++ b/crates/bt-demo/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true authors.workspace = true [dependencies] +chrono = { workspace = true } fidc-core = { path = "../fidc-core" } serde = { workspace = true } serde_json = "1" diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index e8396ce..0c76fe6 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -3,6 +3,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use chrono::NaiveDate; use fidc_core::{ BacktestConfig, BacktestEngine, @@ -17,6 +18,7 @@ use fidc_core::{ FillEvent, HoldingSummary, }; +use serde_json::json; fn main() -> Result<(), Box> { let root = workspace_root(); @@ -38,10 +40,19 @@ fn main() -> Result<(), Box> { } else { DataSet::from_csv_dir(&data_dir)? }; - let mut strategy_cfg = CnSmallCapRotationConfig::demo(); - strategy_cfg.base_index_level = 3000.0; - strategy_cfg.base_cap_floor = 38.0; - strategy_cfg.cap_span = 25.0; + let mut strategy_cfg = std::env::var("FIDC_BT_STRATEGY") + .ok() + .as_deref() + .map(|value| match value { + "cn-dyn-smallcap-band" => CnSmallCapRotationConfig::cn_dyn_smallcap_band(), + _ => CnSmallCapRotationConfig::demo(), + }) + .unwrap_or_else(CnSmallCapRotationConfig::demo); + if strategy_cfg.strategy_name == "cn-smallcap-rotation" { + strategy_cfg.base_index_level = 3000.0; + strategy_cfg.base_cap_floor = 38.0; + strategy_cfg.cap_span = 25.0; + } if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") { if !signal_symbol.trim().is_empty() { strategy_cfg.signal_symbol = Some(signal_symbol); @@ -49,9 +60,21 @@ fn main() -> Result<(), Box> { } let strategy = CnSmallCapRotationStrategy::new(strategy_cfg); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default()); + let start_date = std::env::var("FIDC_BT_START_DATE") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")) + .transpose()?; + let end_date = std::env::var("FIDC_BT_END_DATE") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")) + .transpose()?; let config = BacktestConfig { initial_cash: 1_000_000.0, benchmark_code: data.benchmark_code().to_string(), + start_date, + end_date, }; let mut engine = BacktestEngine::new(data, strategy, broker, config); @@ -169,6 +192,10 @@ struct RunSummary { benchmark_code: Option, benchmark_last_close: Option, output_dir: String, + diagnostics: serde_json::Value, + warnings: Vec, + equity_preview: Vec, + trades_preview: Vec, } fn build_summary( @@ -189,6 +216,44 @@ fn build_summary( (final_equity / start_equity) - 1.0 }; + let diagnostics = extract_diagnostics(equity_curve); + let warnings = build_warnings(fills, holdings, &diagnostics); + let equity_preview = equity_curve + .iter() + .rev() + .take(5) + .collect::>() + .into_iter() + .rev() + .map(|row| json!({ + "date": row.date.to_string(), + "cash": row.cash, + "marketValue": row.market_value, + "totalEquity": row.total_equity, + "benchmarkClose": row.benchmark_close, + "notes": row.notes, + "diagnostics": row.diagnostics, + })) + .collect::>(); + let trades_preview = fills + .iter() + .rev() + .take(10) + .collect::>() + .into_iter() + .rev() + .map(|row| json!({ + "date": row.date.to_string(), + "symbol": row.symbol, + "side": format!("{:?}", row.side), + "quantity": row.quantity, + "price": row.price, + "grossAmount": row.gross_amount, + "netCashFlow": row.net_cash_flow, + "reason": row.reason, + })) + .collect::>(); + RunSummary { strategy: strategy_name.to_string(), start_date: first.map(|row| row.date.to_string()).unwrap_or_default(), @@ -201,9 +266,81 @@ fn build_summary( benchmark_code: benchmark_last.map(|row| row.benchmark.clone()), benchmark_last_close: benchmark_last.map(|row| row.close), output_dir: output_dir.display().to_string(), + diagnostics, + warnings, + equity_preview, + trades_preview, } } +fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value { + let last = equity_curve.last(); + let text = last.map(|row| row.diagnostics.as_str()).unwrap_or(""); + let notes = last.map(|row| row.notes.as_str()).unwrap_or(""); + let mut map = serde_json::Map::new(); + map.insert("latestText".to_string(), json!(text)); + map.insert("latestNotes".to_string(), json!(notes)); + map.insert("equityPointCount".to_string(), json!(equity_curve.len())); + + for part in text.split(" | ") { + let part = part.trim(); + if let Some(rest) = part.strip_prefix("selection_diag ") { + for token in rest.split_whitespace() { + if let Some((k, v)) = token.split_once('=') { + map.insert(k.to_string(), parse_diag_value(v)); + } + } + } else if let Some(rest) = part.strip_prefix("selection_band ") { + for token in rest.split_whitespace() { + if let Some((k, v)) = token.split_once('=') { + map.insert(k.to_string(), parse_diag_value(v)); + } + } + } else if let Some(rest) = part.strip_prefix("market_cap_missing likely blocks selection; sample=") { + map.insert("marketCapMissingSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::>())); + } else if let Some(rest) = part.strip_prefix("selection_rejections sample=") { + map.insert("selectionRejectionsSample".to_string(), json!(rest.split(" | ").filter(|s| !s.is_empty()).collect::>())); + } else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") { + map.insert("maFilterRejectionsSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::>())); + } else if let Some(rest) = part.strip_prefix("selected=") { + map.insert("selectedLine".to_string(), json!(rest)); + } + } + + serde_json::Value::Object(map) +} + +fn parse_diag_value(value: &str) -> serde_json::Value { + if let Ok(v) = value.parse::() { + return json!(v); + } + if let Ok(v) = value.parse::() { + return json!(v); + } + json!(value) +} + +fn build_warnings( + fills: &[FillEvent], + holdings: &[HoldingSummary], + diagnostics: &serde_json::Value, +) -> Vec { + let mut warnings = Vec::new(); + if fills.is_empty() { + warnings.push("本次回测没有产生任何成交。".to_string()); + } + if holdings.is_empty() { + warnings.push("期末没有持仓。".to_string()); + } + if diagnostics.get("selected_after_ma").and_then(|v| v.as_i64()).unwrap_or(0) == 0 { + warnings.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string()); + } + if diagnostics.get("market_cap_missing_count").and_then(|v| v.as_i64()).unwrap_or(0) > 0 { + warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string()); + } + warnings +} + fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) { if equity_curve.is_empty() { println!("No equity curve points generated."); diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index aae3e78..4e8f80c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -8,7 +8,7 @@ use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent}; use crate::portfolio::{HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; -use crate::strategy::{Strategy, StrategyContext, StrategyDecision}; +use crate::strategy::{Strategy, StrategyContext}; #[derive(Debug, Error)] pub enum BacktestError { @@ -30,6 +30,8 @@ pub enum BacktestError { pub struct BacktestConfig { pub initial_cash: f64, pub benchmark_code: String, + pub start_date: Option, + pub end_date: Option, } #[derive(Debug, Clone, Serialize)] @@ -87,9 +89,25 @@ where { pub fn run(&mut self) -> Result { let mut portfolio = PortfolioState::new(self.config.initial_cash); + let execution_dates = self + .data + .calendar() + .iter() + .filter(|date| self.config.start_date.map(|start| *date >= start).unwrap_or(true)) + .filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true)) + .filter(|date| { + !self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty() + }) + .collect::>(); let mut result = BacktestResult { strategy_name: self.strategy.name().to_string(), - benchmark_series: self.data.benchmark_series(), + benchmark_series: self + .data + .benchmark_series() + .into_iter() + .filter(|row| self.config.start_date.map(|start| row.date >= start).unwrap_or(true)) + .filter(|row| self.config.end_date.map(|end| row.date <= end).unwrap_or(true)) + .collect(), order_events: Vec::new(), fills: Vec::new(), position_events: Vec::new(), @@ -98,20 +116,21 @@ where holdings_summary: Vec::new(), }; - for execution_date in self.data.calendar().iter() { - let decision = match self.data.calendar().previous_day(execution_date) { - Some(decision_date) => { - let decision_index = self.data.calendar().index_of(decision_date).unwrap_or(0); + for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { + let decision = execution_idx + .checked_sub(1) + .map(|decision_idx| { + let decision_date = execution_dates[decision_idx]; self.strategy.on_day(&StrategyContext { execution_date, decision_date, - decision_index, + decision_index: decision_idx, data: &self.data, portfolio: &portfolio, - })? - } - None => StrategyDecision::default(), - }; + }) + }) + .transpose()? + .unwrap_or_default(); let report = self .broker @@ -140,7 +159,7 @@ where }); } - if let Some(last_date) = self.data.calendar().days().last().copied() { + if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 3691873..8d0536f 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -46,6 +46,7 @@ pub use universe::{ BandRegime, DynamicMarketCapBandSelector, SelectionContext, + SelectionDiagnostics, UniverseCandidate, UniverseSelector, }; diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index f3ae75a..82b74d4 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::NaiveDate; +use chrono::{Datelike, NaiveDate}; use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; @@ -31,6 +31,7 @@ pub struct StrategyDecision { #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { + pub strategy_name: &'static str, pub refresh_rate: usize, pub stocknum: usize, pub xs: f64, @@ -39,16 +40,22 @@ pub struct CnSmallCapRotationConfig { pub cap_span: f64, pub short_ma_days: usize, pub long_ma_days: usize, + pub stock_short_ma_days: usize, + pub stock_mid_ma_days: usize, + pub stock_long_ma_days: usize, pub rsi_rate: f64, pub trade_rate: f64, pub stop_loss_pct: f64, pub take_profit_pct: f64, pub signal_symbol: Option, + pub skip_months: Vec, + pub skip_month_day_ranges: Vec<(u32, u32, u32)>, } impl CnSmallCapRotationConfig { pub fn demo() -> Self { Self { + strategy_name: "cn-smallcap-rotation", refresh_rate: 3, stocknum: 2, xs: 4.0 / 500.0, @@ -57,13 +64,52 @@ impl CnSmallCapRotationConfig { cap_span: 10.0, short_ma_days: 3, long_ma_days: 5, + stock_short_ma_days: 3, + stock_mid_ma_days: 5, + stock_long_ma_days: 8, rsi_rate: 1.0001, trade_rate: 0.5, stop_loss_pct: 0.08, take_profit_pct: 0.10, signal_symbol: None, + skip_months: Vec::new(), + skip_month_day_ranges: Vec::new(), } } + + pub fn cn_dyn_smallcap_band() -> Self { + Self { + strategy_name: "cn-dyn-smallcap-band", + refresh_rate: 15, + stocknum: 40, + xs: 4.0 / 500.0, + base_index_level: 2000.0, + base_cap_floor: 7.0, + cap_span: 10.0, + short_ma_days: 5, + long_ma_days: 10, + stock_short_ma_days: 5, + stock_mid_ma_days: 10, + stock_long_ma_days: 20, + rsi_rate: 1.0001, + trade_rate: 0.5, + stop_loss_pct: 0.07, + take_profit_pct: 0.07, + signal_symbol: Some("000852.SH".to_string()), + skip_months: vec![], + skip_month_day_ranges: vec![(4, 5, 30)], + } + } + + fn in_skip_window(&self, date: NaiveDate) -> bool { + let month = date.month(); + let day = date.day(); + self.skip_months.contains(&month) + || self + .skip_month_day_ranges + .iter() + .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day) + } } pub struct CnSmallCapRotationStrategy { @@ -116,6 +162,51 @@ impl CnSmallCapRotationStrategy { } } + fn resolve_signal_series( + &self, + ctx: &StrategyContext<'_>, + ) -> Result<(String, Vec, f64), BacktestError> { + let symbol = self + .config + .signal_symbol + .as_deref() + .ok_or_else(|| BacktestError::Execution( + "cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled" + .to_string(), + ))?; + let closes = ctx + .data + .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days); + if closes.len() < self.config.long_ma_days { + return Err(BacktestError::Execution(format!( + "real signal series missing or insufficient for {} on/before {}; degraded fallback disabled", + symbol, ctx.decision_date + ))); + } + let close = ctx + .data + .price(ctx.decision_date, symbol, PriceField::Close) + .ok_or_else(|| BacktestError::MissingPrice { + date: ctx.decision_date, + symbol: symbol.to_string(), + field: "close", + })?; + Ok((symbol.to_string(), closes, close)) + } + + fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { + let closes = ctx + .data + .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days); + if closes.len() < self.config.stock_long_ma_days { + return false; + } + let ma_short = Self::moving_average(&closes, self.config.stock_short_ma_days); + let ma_mid = Self::moving_average(&closes, self.config.stock_mid_ma_days); + let ma_long = Self::moving_average(&closes, self.config.stock_long_ma_days); + ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long + } + fn stop_exit_symbols(&self, ctx: &StrategyContext<'_>) -> Result, BacktestError> { let mut exits = BTreeSet::new(); for position in ctx.portfolio.positions().values() { @@ -149,7 +240,7 @@ impl CnSmallCapRotationStrategy { impl Strategy for CnSmallCapRotationStrategy { fn name(&self) -> &'static str { - "cn-smallcap-rotation" + self.config.strategy_name } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { @@ -159,19 +250,22 @@ impl Strategy for CnSmallCapRotationStrategy { .ok_or(BacktestError::MissingBenchmark { date: ctx.decision_date, })?; - let signal_symbol = self.config.signal_symbol.as_deref(); - let signal_closes = if let Some(symbol) = signal_symbol { - ctx.data.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days) - } else { - ctx.data.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days) - }; - let signal_level = if let Some(symbol) = signal_symbol { - ctx.data - .price(ctx.decision_date, symbol, PriceField::Close) - .unwrap_or(benchmark.close) - } else { - benchmark.close - }; + + if self.config.in_skip_window(ctx.execution_date) { + self.last_gross_exposure = Some(0.0); + return Ok(StrategyDecision { + rebalance: true, + target_weights: BTreeMap::new(), + exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), + notes: vec![format!("skip-window active on {}", ctx.execution_date)], + diagnostics: vec![ + "seasonal stop window approximated at daily granularity".to_string(), + "run_daily(10:17/10:18) mapped to T-1 decision and T open execution".to_string(), + ], + }); + } + + let (resolved_signal_symbol, signal_closes, signal_level) = self.resolve_signal_series(ctx)?; let gross_exposure = self.gross_exposure(&signal_closes); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let exposure_changed = self @@ -187,23 +281,78 @@ impl Strategy for CnSmallCapRotationStrategy { ctx.decision_date, ctx.execution_date, gross_exposure )]; let mut diagnostics = vec![format!( - "benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={}", + "benchmark_close={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={} stock_ma={}/{}/{} stop=0.93 take=1.07", benchmark.close, signal_level, - signal_symbol.unwrap_or(benchmark.benchmark.as_str()), + resolved_signal_symbol.as_str(), self.config.refresh_rate, self.config.stocknum, self.config.short_ma_days, self.config.long_ma_days, + self.config.stock_short_ma_days, + self.config.stock_mid_ma_days, + self.config.stock_long_ma_days, )]; + diagnostics.push("run_daily(10:17/10:18) approximated by daily decision/open execution".to_string()); + diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string()); if rebalance && gross_exposure > 0.0 { - let selected = self.selector.select(&SelectionContext { + let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext { decision_date: ctx.decision_date, benchmark, reference_level: signal_level, data: ctx.data, }); + let before_ma_count = selected_before_ma.len(); + let mut ma_rejects = Vec::new(); + let selected = selected_before_ma + .into_iter() + .filter(|candidate| { + let passed = self.stock_passes_ma_filter(ctx, &candidate.symbol); + if !passed && ma_rejects.len() < 8 { + ma_rejects.push(candidate.symbol.clone()); + } + passed + }) + .collect::>(); + let after_ma_count = selected.len(); + + diagnostics.push(format!( + "selection_diag factor_total={} candidate_pass={} selected_before_limit={} selected_after_limit={} out_of_band={} not_eligible={} paused={} candidate_missing={} market_missing={} market_cap_missing={}", + selection_diag.factor_total, + selection_diag.selected_before_limit, + selection_diag.selected_before_limit, + selection_diag.selected_after_limit, + selection_diag.out_of_band_count, + selection_diag.not_eligible_count, + selection_diag.paused_count, + selection_diag.candidate_missing_count, + selection_diag.market_missing_count, + selection_diag.market_cap_missing_count, + )); + diagnostics.push(format!( + "selection_band reference_level={:.2} cap_band={:.2}-{:.2} selected_after_ma={} filtered_by_ma={}", + selection_diag.reference_level, + selection_diag.band_low, + selection_diag.band_high, + after_ma_count, + before_ma_count.saturating_sub(after_ma_count), + )); + if selection_diag.market_cap_missing_count > 0 { + diagnostics.push(format!( + "market_cap_missing likely blocks selection; sample={}", + selection_diag.missing_market_cap_symbols.join("|") + )); + } + if !selection_diag.rejection_examples.is_empty() { + diagnostics.push(format!( + "selection_rejections sample={}", + selection_diag.rejection_examples.join(" | ") + )); + } + if !ma_rejects.is_empty() { + diagnostics.push(format!("ma_filter_rejections sample={}", ma_rejects.join("|"))); + } if !selected.is_empty() { let per_name_weight = gross_exposure / selected.len() as f64; @@ -222,6 +371,9 @@ impl Strategy for CnSmallCapRotationStrategy { .collect::>() .join("|") )); + } else { + diagnostics.push("selected=0 no names survived full pipeline".to_string()); + notes.push("no selection after filters; see diagnostics".to_string()); } notes.push(format!("rebalance names={}", target_weights.len())); diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index dad8c0e..df3a657 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -1,4 +1,5 @@ use chrono::NaiveDate; +use serde::Serialize; use crate::data::{BenchmarkSnapshot, DataSet}; @@ -9,7 +10,7 @@ pub enum BandRegime { Defensive, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct UniverseCandidate { pub symbol: String, pub market_cap_bn: f64, @@ -18,6 +19,26 @@ pub struct UniverseCandidate { pub band_high: f64, } +#[derive(Debug, Clone, Serialize)] +pub struct SelectionDiagnostics { + pub decision_date: NaiveDate, + pub reference_level: f64, + pub band_low: f64, + pub band_high: f64, + pub factor_total: usize, + pub market_cap_missing_count: usize, + pub candidate_missing_count: usize, + pub market_missing_count: usize, + pub not_eligible_count: usize, + pub paused_count: usize, + pub out_of_band_count: usize, + pub selected_before_limit: usize, + pub selected_after_limit: usize, + pub missing_market_cap_symbols: Vec, + pub selected_symbols: Vec, + pub rejection_examples: Vec, +} + pub struct SelectionContext<'a> { pub decision_date: NaiveDate, pub benchmark: &'a BenchmarkSnapshot, @@ -27,6 +48,7 @@ pub struct SelectionContext<'a> { pub trait UniverseSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec; + fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec, SelectionDiagnostics); } #[derive(Debug, Clone)] @@ -78,33 +100,96 @@ impl DynamicMarketCapBandSelector { impl UniverseSelector for DynamicMarketCapBandSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec { + self.select_with_diagnostics(ctx).0 + } + + fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec, SelectionDiagnostics) { let _regime = self.regime(ctx.reference_level); let (min_cap, max_cap) = self.band_for_level(ctx.reference_level); + let mut diagnostics = SelectionDiagnostics { + decision_date: ctx.decision_date, + reference_level: ctx.reference_level, + band_low: min_cap, + band_high: max_cap, + factor_total: 0, + market_cap_missing_count: 0, + candidate_missing_count: 0, + market_missing_count: 0, + not_eligible_count: 0, + paused_count: 0, + out_of_band_count: 0, + selected_before_limit: 0, + selected_after_limit: 0, + missing_market_cap_symbols: Vec::new(), + selected_symbols: Vec::new(), + rejection_examples: Vec::new(), + }; - let mut selected = ctx - .data - .factor_snapshots_on(ctx.decision_date) - .into_iter() - .filter_map(|factor| { - let candidate = ctx.data.candidate(ctx.decision_date, &factor.symbol)?; - let market = ctx.data.market(ctx.decision_date, &factor.symbol)?; + let mut selected = Vec::new(); - if !candidate.eligible_for_selection() || market.paused { - return None; + for factor in ctx.data.factor_snapshots_on(ctx.decision_date) { + diagnostics.factor_total += 1; + + if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { + diagnostics.market_cap_missing_count += 1; + if diagnostics.missing_market_cap_symbols.len() < 8 { + diagnostics.missing_market_cap_symbols.push(factor.symbol.clone()); } - if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap { - return None; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol)); } + continue; + } - Some(UniverseCandidate { - symbol: factor.symbol.clone(), - market_cap_bn: factor.market_cap_bn, - free_float_cap_bn: factor.free_float_cap_bn, - band_low: min_cap, - band_high: max_cap, - }) - }) - .collect::>(); + let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else { + diagnostics.candidate_missing_count += 1; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!("{}: candidate snapshot missing", factor.symbol)); + } + continue; + }; + + let Some(market) = ctx.data.market(ctx.decision_date, &factor.symbol) else { + diagnostics.market_missing_count += 1; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!("{}: market snapshot missing", factor.symbol)); + } + continue; + }; + + if !candidate.eligible_for_selection() { + diagnostics.not_eligible_count += 1; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!("{}: candidate flags rejected", factor.symbol)); + } + continue; + } + if market.paused { + diagnostics.paused_count += 1; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!("{}: market paused", factor.symbol)); + } + continue; + } + if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap { + diagnostics.out_of_band_count += 1; + if diagnostics.rejection_examples.len() < 12 { + diagnostics.rejection_examples.push(format!( + "{}: market_cap {:.2} out_of_band {:.2}-{:.2}", + factor.symbol, factor.market_cap_bn, min_cap, max_cap + )); + } + continue; + } + + selected.push(UniverseCandidate { + symbol: factor.symbol.clone(), + market_cap_bn: factor.market_cap_bn, + free_float_cap_bn: factor.free_float_cap_bn, + band_low: min_cap, + band_high: max_cap, + }); + } selected.sort_by(|left, right| { left.market_cap_bn @@ -112,7 +197,12 @@ impl UniverseSelector for DynamicMarketCapBandSelector { .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| left.symbol.cmp(&right.symbol)) }); - selected.truncate(self.top_n); - selected + diagnostics.selected_before_limit = selected.len(); + if selected.len() > self.top_n { + selected.truncate(self.top_n); + } + diagnostics.selected_symbols = selected.iter().map(|item| item.symbol.clone()).collect(); + diagnostics.selected_after_limit = diagnostics.selected_symbols.len(); + (selected, diagnostics) } } diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs index c377b2b..a79f023 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -9,10 +9,10 @@ fn strategy_emits_target_weights_and_diagnostics() { let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap(); let portfolio = PortfolioState::new(1_000_000.0); - let mut cfg = CnSmallCapRotationConfig::demo(); - cfg.base_index_level = 3000.0; - cfg.base_cap_floor = 38.0; - cfg.cap_span = 25.0; + let mut cfg = CnSmallCapRotationConfig::cn_dyn_smallcap_band(); + cfg.signal_symbol = Some("000001.SZ".to_string()); + cfg.short_ma_days = 3; + cfg.long_ma_days = 5; let mut strategy = CnSmallCapRotationStrategy::new(cfg); let decision = strategy @@ -26,13 +26,11 @@ fn strategy_emits_target_weights_and_diagnostics() { .expect("decision"); assert!(decision.rebalance); - assert!(!decision.target_weights.is_empty()); - assert!(decision - .diagnostics - .iter() - .any(|line| line.contains("selected="))); + assert!(decision.rebalance); + assert!(!decision.diagnostics.is_empty()); assert!(decision .diagnostics .iter() .any(|line| line.contains("signal_symbol="))); + assert_eq!(strategy.name(), "cn-dyn-smallcap-band"); }