修正回测推进并增强策略样例

This commit is contained in:
zsb
2026-04-08 19:10:28 -07:00
parent a26049ff15
commit 581021651c
8 changed files with 465 additions and 66 deletions

View File

@@ -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<String>,
pub selected_symbols: Vec<String>,
pub rejection_examples: Vec<String>,
}
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<UniverseCandidate>;
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics);
}
#[derive(Debug, Clone)]
@@ -78,33 +100,96 @@ impl DynamicMarketCapBandSelector {
impl UniverseSelector for DynamicMarketCapBandSelector {
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> {
self.select_with_diagnostics(ctx).0
}
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, 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::<Vec<_>>();
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)
}
}