初始化回测核心引擎骨架
This commit is contained in:
192
crates/fidc-core/src/strategy.rs
Normal file
192
crates/fidc-core/src/strategy.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user