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

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,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<String>,
pub skip_months: Vec<u32>,
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>, 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<BTreeSet<String>, 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<StrategyDecision, BacktestError> {
@@ -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::<Vec<_>>();
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::<Vec<_>>()
.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()));