增强回测引擎第二版策略与快照层
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use data::{
|
||||
CandidateEligibility,
|
||||
DailyFactorSnapshot,
|
||||
DailyMarketSnapshot,
|
||||
DailySnapshotBundle,
|
||||
DataSet,
|
||||
DataSetError,
|
||||
PriceField,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<_>>();
|
||||
|
||||
@@ -23,6 +23,8 @@ fn candidate() -> CandidateEligibility {
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
crates/fidc-core/tests/strategy_selection.rs
Normal file
34
crates/fidc-core/tests/strategy_selection.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use chrono::NaiveDate;
|
||||
use fidc_core::{CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, Strategy, StrategyContext, PortfolioState};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn strategy_emits_target_weights_and_diagnostics() {
|
||||
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
|
||||
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
|
||||
let decision_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
|
||||
let portfolio = PortfolioState::new(1_000_000.0);
|
||||
let mut cfg = CnSmallCapRotationConfig::demo();
|
||||
cfg.base_index_level = 3000.0;
|
||||
cfg.base_cap_floor = 38.0;
|
||||
cfg.cap_span = 25.0;
|
||||
let mut strategy = CnSmallCapRotationStrategy::new(cfg);
|
||||
|
||||
let decision = strategy
|
||||
.on_day(&StrategyContext {
|
||||
execution_date,
|
||||
decision_date,
|
||||
decision_index: 0,
|
||||
data: &data,
|
||||
portfolio: &portfolio,
|
||||
})
|
||||
.expect("decision");
|
||||
|
||||
assert!(decision.rebalance);
|
||||
assert!(!decision.target_weights.is_empty());
|
||||
assert!(decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("selected=")));
|
||||
}
|
||||
Reference in New Issue
Block a user