初始化回测核心引擎骨架

This commit is contained in:
zsb
2026-04-06 23:56:37 -07:00
commit 334864cbc5
25 changed files with 2878 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
use std::collections::{BTreeMap, BTreeSet};
use chrono::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>,
}
#[derive(Debug, Clone)]
pub struct CnSmallCapRotationConfig {
pub rebalance_every_n_days: usize,
pub max_positions: usize,
pub short_ma_days: usize,
pub long_ma_days: usize,
pub stop_loss_pct: f64,
pub take_profit_pct: f64,
}
impl CnSmallCapRotationConfig {
pub fn demo() -> Self {
Self {
rebalance_every_n_days: 3,
max_positions: 2,
short_ma_days: 3,
long_ma_days: 5,
stop_loss_pct: 0.08,
take_profit_pct: 0.10,
}
}
}
pub struct CnSmallCapRotationStrategy {
config: CnSmallCapRotationConfig,
selector: DynamicMarketCapBandSelector,
last_gross_exposure: Option<f64>,
}
impl CnSmallCapRotationStrategy {
pub fn new(config: CnSmallCapRotationConfig) -> Self {
Self {
selector: DynamicMarketCapBandSelector::demo(config.max_positions),
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 current >= long_ma && short_ma >= long_ma {
1.0
} else if current >= long_ma || short_ma >= long_ma {
0.5
} else {
0.0
}
}
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 {
"cn-smallcap-rotation"
}
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,
})?;
let benchmark_closes = ctx
.data
.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days);
let gross_exposure = self.gross_exposure(&benchmark_closes);
let periodic_rebalance = ctx.decision_index % self.config.rebalance_every_n_days == 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
)];
if rebalance && gross_exposure > 0.0 {
let selected = self.selector.select(&SelectionContext {
decision_date: ctx.decision_date,
benchmark,
data: ctx.data,
});
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);
}
}
notes.push(format!("rebalance names={}", target_weights.len()));
}
if !exit_symbols.is_empty() {
notes.push(format!("exit hooks={}", exit_symbols.len()));
}
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,
})
}
}