增强回测引擎第二版策略与快照层

This commit is contained in:
zsb
2026-04-07 00:34:52 -07:00
parent 334864cbc5
commit d039c4e741
10 changed files with 244 additions and 86 deletions

View File

@@ -110,14 +110,31 @@ pub struct CandidateEligibility {
pub is_paused: bool,
pub allow_buy: bool,
pub allow_sell: bool,
pub is_kcb: bool,
pub is_one_yuan: bool,
}
impl CandidateEligibility {
pub fn eligible_for_selection(&self) -> bool {
!self.is_st && !self.is_new_listing && !self.is_paused && self.allow_buy && self.allow_sell
!self.is_st
&& !self.is_new_listing
&& !self.is_paused
&& !self.is_kcb
&& !self.is_one_yuan
&& self.allow_buy
&& self.allow_sell
}
}
#[derive(Debug, Clone)]
pub struct DailySnapshotBundle {
pub date: NaiveDate,
pub benchmark: BenchmarkSnapshot,
pub market: Vec<DailyMarketSnapshot>,
pub factors: Vec<DailyFactorSnapshot>,
pub candidates: Vec<CandidateEligibility>,
}
#[derive(Debug, Clone)]
pub struct DataSet {
instruments: HashMap<String, Instrument>,
@@ -246,6 +263,20 @@ impl DataSet {
.unwrap_or_default()
}
pub fn bundle_on(&self, date: NaiveDate) -> Result<DailySnapshotBundle, DataSetError> {
let benchmark = self
.benchmark(date)
.cloned()
.ok_or(DataSetError::MissingBenchmark { date })?;
Ok(DailySnapshotBundle {
date,
benchmark,
market: self.market_by_date.get(&date).cloned().unwrap_or_default(),
factors: self.factor_by_date.get(&date).cloned().unwrap_or_default(),
candidates: self.candidate_by_date.get(&date).cloned().unwrap_or_default(),
})
}
pub fn benchmark_closes_up_to(&self, date: NaiveDate, lookback: usize) -> Vec<f64> {
self.calendar
.trailing_days(date, lookback)
@@ -342,6 +373,8 @@ fn read_candidates(path: &Path) -> Result<Vec<CandidateEligibility>, DataSetErro
is_paused: row.parse_bool(4)?,
allow_buy: row.parse_bool(5)?,
allow_sell: row.parse_bool(6)?,
is_kcb: row.parse_optional_bool(7).unwrap_or(false),
is_one_yuan: row.parse_optional_bool(8).unwrap_or(false),
});
}
Ok(snapshots)
@@ -415,6 +448,12 @@ impl CsvRow {
message: format!("invalid bool: {err}"),
})
}
fn parse_optional_bool(&self, index: usize) -> Option<bool> {
self.fields
.get(index)
.and_then(|value| value.parse::<bool>().ok())
}
}
fn read_rows(path: &Path) -> Result<Vec<CsvRow>, DataSetError> {

View File

@@ -41,6 +41,7 @@ pub struct DailyEquityPoint {
pub total_equity: f64,
pub benchmark_close: f64,
pub notes: String,
pub diagnostics: String,
}
#[derive(Debug, Clone)]
@@ -126,6 +127,7 @@ where
date: execution_date,
})?;
let notes = decision.notes.join(" | ");
let diagnostics = decision.diagnostics.join(" | ");
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
@@ -134,6 +136,7 @@ where
total_equity: portfolio.total_equity(),
benchmark_close: benchmark.close,
notes,
diagnostics,
});
}

View File

@@ -18,6 +18,7 @@ pub use data::{
CandidateEligibility,
DailyFactorSnapshot,
DailyMarketSnapshot,
DailySnapshotBundle,
DataSet,
DataSetError,
PriceField,

View File

@@ -26,14 +26,21 @@ pub struct StrategyDecision {
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 rebalance_every_n_days: usize,
pub max_positions: usize,
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 rsi_rate: f64,
pub trade_rate: f64,
pub stop_loss_pct: f64,
pub take_profit_pct: f64,
}
@@ -41,10 +48,16 @@ pub struct CnSmallCapRotationConfig {
impl CnSmallCapRotationConfig {
pub fn demo() -> Self {
Self {
rebalance_every_n_days: 3,
max_positions: 2,
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,
rsi_rate: 1.0001,
trade_rate: 0.5,
stop_loss_pct: 0.08,
take_profit_pct: 0.10,
}
@@ -60,7 +73,13 @@ pub struct CnSmallCapRotationStrategy {
impl CnSmallCapRotationStrategy {
pub fn new(config: CnSmallCapRotationConfig) -> Self {
Self {
selector: DynamicMarketCapBandSelector::demo(config.max_positions),
selector: DynamicMarketCapBandSelector::new(
config.base_index_level,
config.base_cap_floor,
config.cap_span,
config.xs,
config.stocknum,
),
config,
last_gross_exposure: None,
}
@@ -86,12 +105,12 @@ impl CnSmallCapRotationStrategy {
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 {
if short_ma < long_ma * self.config.rsi_rate {
self.config.trade_rate
} else if current >= long_ma {
1.0
} else if current >= long_ma || short_ma >= long_ma {
0.5
} else {
0.0
self.config.trade_rate
}
}
@@ -142,7 +161,7 @@ impl Strategy for CnSmallCapRotationStrategy {
.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 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)
@@ -155,6 +174,14 @@ impl Strategy for CnSmallCapRotationStrategy {
"decision={} exec={} exposure={:.2}",
ctx.decision_date, ctx.execution_date, gross_exposure
)];
let mut diagnostics = vec![format!(
"benchmark_close={:.2} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={}",
benchmark.close,
self.config.refresh_rate,
self.config.stocknum,
self.config.short_ma_days,
self.config.long_ma_days,
)];
if rebalance && gross_exposure > 0.0 {
let selected = self.selector.select(&SelectionContext {
@@ -165,9 +192,21 @@ impl Strategy for CnSmallCapRotationStrategy {
if !selected.is_empty() {
let per_name_weight = gross_exposure / selected.len() as f64;
for candidate in selected {
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("|")
));
}
notes.push(format!("rebalance names={}", target_weights.len()));
@@ -175,6 +214,10 @@ impl Strategy for CnSmallCapRotationStrategy {
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());
@@ -187,6 +230,7 @@ impl Strategy for CnSmallCapRotationStrategy {
target_weights,
exit_symbols,
notes,
diagnostics,
})
}
}

View File

@@ -14,6 +14,8 @@ pub struct UniverseCandidate {
pub symbol: String,
pub market_cap_bn: f64,
pub free_float_cap_bn: f64,
pub band_low: f64,
pub band_high: f64,
}
pub struct SelectionContext<'a> {
@@ -29,51 +31,54 @@ pub trait UniverseSelector {
#[derive(Debug, Clone)]
pub struct DynamicMarketCapBandSelector {
pub base_index_level: f64,
pub bullish_threshold: f64,
pub neutral_threshold: f64,
pub bullish_band: (f64, f64),
pub neutral_band: (f64, f64),
pub defensive_band: (f64, f64),
pub base_cap_floor: f64,
pub cap_span: f64,
pub xs: f64,
pub top_n: usize,
}
impl DynamicMarketCapBandSelector {
pub fn demo(top_n: usize) -> Self {
pub fn new(
base_index_level: f64,
base_cap_floor: f64,
cap_span: f64,
xs: f64,
top_n: usize,
) -> Self {
Self {
base_index_level: 3000.0,
bullish_threshold: 1.02,
neutral_threshold: 1.0,
bullish_band: (30.0, 60.0),
neutral_band: (40.0, 90.0),
defensive_band: (60.0, 120.0),
base_index_level,
base_cap_floor,
cap_span,
xs,
top_n,
}
}
pub fn demo(top_n: usize) -> Self {
Self::new(2000.0, 7.0, 10.0, 4.0 / 500.0, top_n)
}
pub fn regime(&self, benchmark_level: f64) -> BandRegime {
let ratio = benchmark_level / self.base_index_level;
if ratio >= self.bullish_threshold {
if benchmark_level >= self.base_index_level + 400.0 {
BandRegime::Bullish
} else if ratio >= self.neutral_threshold {
} else if benchmark_level >= self.base_index_level {
BandRegime::Neutral
} else {
BandRegime::Defensive
}
}
fn band(&self, regime: BandRegime) -> (f64, f64) {
match regime {
BandRegime::Bullish => self.bullish_band,
BandRegime::Neutral => self.neutral_band,
BandRegime::Defensive => self.defensive_band,
}
pub fn band_for_level(&self, benchmark_level: f64) -> (f64, f64) {
let start = ((benchmark_level - self.base_index_level) * self.xs) + self.base_cap_floor;
let low = start.round();
(low, low + self.cap_span)
}
}
impl UniverseSelector for DynamicMarketCapBandSelector {
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> {
let regime = self.regime(ctx.benchmark.close);
let (min_cap, max_cap) = self.band(regime);
let _regime = self.regime(ctx.benchmark.close);
let (min_cap, max_cap) = self.band_for_level(ctx.benchmark.close);
let mut selected = ctx
.data
@@ -94,6 +99,8 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
symbol: factor.symbol.clone(),
market_cap_bn: factor.market_cap_bn,
free_float_cap_bn: factor.free_float_cap_bn,
band_low: min_cap,
band_high: max_cap,
})
})
.collect::<Vec<_>>();