use std::collections::{BTreeMap, BTreeSet}; use chrono::{Datelike, NaiveDate}; use crate::data::{DataSet, PriceField}; use crate::engine::BacktestError; use crate::portfolio::PortfolioState; use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; pub trait Strategy { fn name(&self) -> &'static str; fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result; } pub struct StrategyContext<'a> { pub execution_date: NaiveDate, pub decision_date: NaiveDate, pub decision_index: usize, pub data: &'a DataSet, pub portfolio: &'a PortfolioState, } #[derive(Debug, Clone, Default)] pub struct StrategyDecision { pub rebalance: bool, pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, pub notes: Vec, pub diagnostics: Vec, } #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { pub strategy_name: &'static str, pub refresh_rate: usize, pub stocknum: usize, pub xs: f64, pub base_index_level: f64, pub base_cap_floor: f64, 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, base_index_level: 2000.0, base_cap_floor: 7.0, 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 { config: CnSmallCapRotationConfig, selector: DynamicMarketCapBandSelector, last_gross_exposure: Option, } impl CnSmallCapRotationStrategy { pub fn new(config: CnSmallCapRotationConfig) -> Self { Self { selector: DynamicMarketCapBandSelector::new( config.base_index_level, config.base_cap_floor, config.cap_span, config.xs, config.stocknum, ), config, last_gross_exposure: None, } } fn moving_average(values: &[f64], lookback: usize) -> f64 { let len = values.len(); let window = values.iter().skip(len.saturating_sub(lookback)); let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| (sum + value, count + 1)); if count == 0 { 0.0 } else { sum / count as f64 } } fn gross_exposure(&self, closes: &[f64]) -> f64 { if closes.is_empty() { return 0.0; } let current = *closes.last().unwrap_or(&0.0); let short_ma = Self::moving_average(closes, self.config.short_ma_days); let long_ma = Self::moving_average(closes, self.config.long_ma_days); if short_ma < long_ma * self.config.rsi_rate { self.config.trade_rate } else if current >= long_ma { 1.0 } else { self.config.trade_rate } } 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() { if position.quantity == 0 { continue; } let close_price = ctx .data .price(ctx.decision_date, &position.symbol, PriceField::Close) .ok_or_else(|| BacktestError::MissingPrice { date: ctx.decision_date, symbol: position.symbol.clone(), field: "close", })?; let Some(holding_return) = position.holding_return(close_price) else { continue; }; if holding_return <= -self.config.stop_loss_pct || holding_return >= self.config.take_profit_pct { exits.insert(position.symbol.clone()); } } Ok(exits) } } impl Strategy for CnSmallCapRotationStrategy { fn name(&self) -> &'static str { self.config.strategy_name } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { let benchmark = ctx .data .benchmark(ctx.decision_date) .ok_or(BacktestError::MissingBenchmark { date: ctx.decision_date, })?; 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 .last_gross_exposure .map(|previous| (previous - gross_exposure).abs() > f64::EPSILON) .unwrap_or(true); let exit_symbols = self.stop_exit_symbols(ctx)?; let rebalance = periodic_rebalance || exposure_changed; let mut target_weights = BTreeMap::new(); let mut notes = vec![format!( "decision={} exec={} exposure={:.2}", 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={} stock_ma={}/{}/{} stop=0.93 take=1.07", benchmark.close, signal_level, 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_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; for candidate in &selected { target_weights.insert(candidate.symbol.clone(), per_name_weight); } diagnostics.push(format!( "selected={} cap_band={:.2}-{:.2} sample={}", selected.len(), selected.first().map(|x| x.band_low).unwrap_or_default(), selected.first().map(|x| x.band_high).unwrap_or_default(), selected .iter() .take(5) .map(|x| format!("{}:{:.2}", x.symbol, x.market_cap_bn)) .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())); } if !exit_symbols.is_empty() { notes.push(format!("exit hooks={}", exit_symbols.len())); diagnostics.push(format!( "exit_symbols={}", exit_symbols.iter().cloned().collect::>().join("|") )); } if rebalance && gross_exposure == 0.0 { notes.push("risk throttle forced all-cash".to_string()); } self.last_gross_exposure = Some(gross_exposure); Ok(StrategyDecision { rebalance, target_weights, exit_symbols, notes, diagnostics, }) } }