404 lines
15 KiB
Rust
404 lines
15 KiB
Rust
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<StrategyDecision, BacktestError>;
|
|
}
|
|
|
|
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<String, f64>,
|
|
pub exit_symbols: BTreeSet<String>,
|
|
pub notes: Vec<String>,
|
|
pub diagnostics: Vec<String>,
|
|
}
|
|
|
|
#[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<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,
|
|
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<f64>,
|
|
}
|
|
|
|
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>, 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() {
|
|
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<StrategyDecision, BacktestError> {
|
|
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::<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;
|
|
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::<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()));
|
|
}
|
|
|
|
if !exit_symbols.is_empty() {
|
|
notes.push(format!("exit hooks={}", exit_symbols.len()));
|
|
diagnostics.push(format!(
|
|
"exit_symbols={}",
|
|
exit_symbols.iter().cloned().collect::<Vec<_>>().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,
|
|
})
|
|
}
|
|
}
|