diff --git a/README.md b/README.md index 679cd85..d9d8511 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ ## 当前能力 +- Phase 2:增加 snapshot bundle 视图与更贴近 jqdata 策略语义的动态市值带策略 - 日频交易日历与确定性逐日回放 - A 股日频市场快照、估值/因子快照、基准快照、候选资格标记 - 策略接口与引擎驱动,不直接模拟 `jqdata` API @@ -53,19 +54,24 @@ ## 策略实现 -示例策略 `CnSmallCapRotationStrategy` 对应一类典型的 A 股小市值轮动逻辑: +示例策略 `CnSmallCapRotationStrategy` 对应一类典型的 A 股小市值轮动逻辑,并在 phase 2 里更贴近原始 jqdata 语义: -1. 用指数点位相对基准水平切换市值带: - - 强势区间:更偏小市值 - - 中性区间:中小市值 - - 弱势区间:偏大一些的防御市值带 +1. 用指数点位动态计算市值带: + - `mystart = round((index_close - base_index_level) * xs + base_cap_floor)` + - `myend = mystart + cap_span` 2. 在当前市值带内,按总市值升序取 Top-N。 3. 用指数短均线/长均线关系控制总仓位: - - `1.0`: 风险偏好正常 - - `0.5`: 降半仓 - - `0.0`: 全部转现金 -4. 固定交易日频率再平衡。 + - 当 `MA(short) < MA(long) * rsi_rate` 时降到 `trade_rate` + - 否则恢复到 `1.0` +4. 按 `refresh_rate` 固定频率再平衡。 5. 非再平衡日也会检查止损/止盈钩子并触发退出。 +6. 候选过滤纳入资格快照: + - 停牌 + - ST + - 新股 + - 科创板 + - 1 元股 + - allow_buy / allow_sell 这个接口不是 `jqdata` 风格的 `before_trading_start` / `handle_data` 直接脚本 API,而是: @@ -79,7 +85,7 @@ 如果原始逻辑大致是: -- 依据指数强弱切换可接受市值带 +- 依据指数点位动态切换可接受市值带 - 从候选股票里选最小市值若干只 - 按均线决定是否降仓 - 周期性调仓 @@ -94,7 +100,14 @@ - `order_target_value` -> `StrategyDecision.target_weights` 由 `BrokerSimulator` 解释执行 - 风险控制逻辑 -> `CnSmallCapRotationStrategy::gross_exposure` -## V1 明确简化点 +## Phase 2 新增内容 + +- `DataSet::bundle_on(date)`:引入按日 snapshot bundle 视图,方便未来直接对接 FiDataCenter / FiDataScraper 预计算快照 +- 策略诊断输出:equity curve 里新增 `diagnostics` 字段,记录市值带、候选样本、退出原因等信息 +- 候选资格快照扩展:补入 `is_kcb`、`is_one_yuan` +- 增加策略选择行为测试 + +## V1 / V2 当前仍保留的简化点 下面这些是刻意保留为 v1 简化,而不是遗漏: @@ -146,6 +159,16 @@ cargo build - 组合调仓只关心“目标持仓”和“当前持仓”的差量 - 事件流是 append-only,适合批量写出和后处理分析 +## 距离真实 6 年 / 5 分钟平台还差什么 + +当前仓库已经有“核心引擎 + 规则钩子 + 策略接口 + demo 回放”,但距离生产级目标还差: + +- 真实 snapshot loader:接入 FiDataCenter / FiDataScraper 的 ClickHouse / Parquet / PostgreSQL 预计算快照,而不是 demo CSV +- 分钟级执行层:把当前 `T-1 决策 / T 开盘执行` 扩展到更接近 `10:17 / 10:18` 的分钟级执行语义 +- 更完整的 A 股规则:复权、分红、涨跌停细分、创业板/北交所规则、成交量约束、滑点模型 +- 更高效的数据访问:按日期块和列式布局一次性加载 6 年快照,避免回测时回源拼表 +- 批量参数回测:多个参数集共享预计算快照与候选池缓存 + ## Roadmap - 引入更明确的事件总线和 portfolio/account ledger 分层 diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index c865042..4e121e4 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -26,7 +26,11 @@ fn main() -> Result<(), Box> { fs::create_dir_all(&output_dir)?; let data = DataSet::from_csv_dir(&data_dir)?; - let strategy = CnSmallCapRotationStrategy::new(CnSmallCapRotationConfig::demo()); + let mut strategy_cfg = CnSmallCapRotationConfig::demo(); + strategy_cfg.base_index_level = 3000.0; + strategy_cfg.base_cap_floor = 38.0; + strategy_cfg.cap_span = 25.0; + let strategy = CnSmallCapRotationStrategy::new(strategy_cfg); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default()); let config = BacktestConfig { initial_cash: 1_000_000.0, @@ -60,17 +64,18 @@ fn workspace_root() -> PathBuf { fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box> { let mut file = fs::File::create(path)?; - writeln!(file, "date,cash,market_value,total_equity,benchmark_close,notes")?; + writeln!(file, "date,cash,market_value,total_equity,benchmark_close,notes,diagnostics")?; for row in rows { writeln!( file, - "{},{:.2},{:.2},{:.2},{:.2},{}", + "{},{:.2},{:.2},{:.2},{:.2},{},{}", row.date, row.cash, row.market_value, row.total_equity, row.benchmark_close, sanitize_csv_field(&row.notes), + sanitize_csv_field(&row.diagnostics), )?; } Ok(()) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 9799bb9..b098115 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -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, + pub factors: Vec, + pub candidates: Vec, +} + #[derive(Debug, Clone)] pub struct DataSet { instruments: HashMap, @@ -246,6 +263,20 @@ impl DataSet { .unwrap_or_default() } + pub fn bundle_on(&self, date: NaiveDate) -> Result { + 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 { self.calendar .trailing_days(date, lookback) @@ -342,6 +373,8 @@ fn read_candidates(path: &Path) -> Result, 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 { + self.fields + .get(index) + .and_then(|value| value.parse::().ok()) + } } fn read_rows(path: &Path) -> Result, DataSetError> { diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 164a19d..aae3e78 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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, }); } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 558ba78..3691873 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -18,6 +18,7 @@ pub use data::{ CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, + DailySnapshotBundle, DataSet, DataSetError, PriceField, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 4479e0e..a8171cd 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -26,14 +26,21 @@ pub struct StrategyDecision { pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, pub notes: Vec, + pub diagnostics: Vec, } #[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::>() + .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::>().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, }) } } diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index ccac5e6..c3f6458 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -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 { - 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::>(); diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index a5d9b16..852930a 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -23,6 +23,8 @@ fn candidate() -> CandidateEligibility { is_paused: false, allow_buy: true, allow_sell: true, + is_kcb: false, + is_one_yuan: false, } } diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs new file mode 100644 index 0000000..23062c6 --- /dev/null +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -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="))); +} diff --git a/data/demo/candidate_flags.csv b/data/demo/candidate_flags.csv index 96e6098..f062102 100644 --- a/data/demo/candidate_flags.csv +++ b/data/demo/candidate_flags.csv @@ -1,37 +1,37 @@ -date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell -2024-01-02,000001.SZ,false,true,false,false,true -2024-01-02,000002.SZ,false,false,false,true,true -2024-01-02,000003.SZ,false,false,false,true,true -2024-01-02,600001.SH,false,false,false,true,true -2024-01-03,000001.SZ,false,true,false,false,true -2024-01-03,000002.SZ,false,false,false,true,true -2024-01-03,000003.SZ,false,false,false,true,true -2024-01-03,600001.SH,false,false,false,true,true -2024-01-04,000001.SZ,false,false,false,true,true -2024-01-04,000002.SZ,false,false,false,true,true -2024-01-04,000003.SZ,false,false,false,true,true -2024-01-04,600001.SH,false,false,false,true,true -2024-01-05,000001.SZ,false,false,false,true,true -2024-01-05,000002.SZ,false,false,false,true,true -2024-01-05,000003.SZ,false,false,false,true,true -2024-01-05,600001.SH,false,false,false,true,true -2024-01-08,000001.SZ,false,false,false,true,true -2024-01-08,000002.SZ,false,false,false,true,true -2024-01-08,000003.SZ,false,false,false,true,true -2024-01-08,600001.SH,false,false,false,true,true -2024-01-09,000001.SZ,false,false,false,true,true -2024-01-09,000002.SZ,false,false,false,true,true -2024-01-09,000003.SZ,false,false,false,true,true -2024-01-09,600001.SH,false,false,false,true,true -2024-01-10,000001.SZ,false,false,false,true,true -2024-01-10,000002.SZ,false,false,false,true,true -2024-01-10,000003.SZ,false,false,false,true,true -2024-01-10,600001.SH,false,false,false,true,true -2024-01-11,000001.SZ,false,false,false,true,true -2024-01-11,000002.SZ,false,false,false,true,true -2024-01-11,000003.SZ,false,false,false,true,true -2024-01-11,600001.SH,false,false,true,false,false -2024-01-12,000001.SZ,false,false,false,true,true -2024-01-12,000002.SZ,false,false,false,true,true -2024-01-12,000003.SZ,false,false,false,true,true -2024-01-12,600001.SH,false,false,false,true,true +date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan +2024-01-02,000001.SZ,false,true,false,false,true,false,false +2024-01-02,000002.SZ,false,false,false,true,true,false,false +2024-01-02,000003.SZ,false,false,false,true,true,false,false +2024-01-02,600001.SH,false,false,false,true,true,false,false +2024-01-03,000001.SZ,false,true,false,false,true,false,false +2024-01-03,000002.SZ,false,false,false,true,true,false,false +2024-01-03,000003.SZ,false,false,false,true,true,false,false +2024-01-03,600001.SH,false,false,false,true,true,false,false +2024-01-04,000001.SZ,false,false,false,true,true,false,false +2024-01-04,000002.SZ,false,false,false,true,true,false,false +2024-01-04,000003.SZ,false,false,false,true,true,false,false +2024-01-04,600001.SH,false,false,false,true,true,false,false +2024-01-05,000001.SZ,false,false,false,true,true,false,false +2024-01-05,000002.SZ,false,false,false,true,true,false,false +2024-01-05,000003.SZ,false,false,false,true,true,false,false +2024-01-05,600001.SH,false,false,false,true,true,false,false +2024-01-08,000001.SZ,false,false,false,true,true,false,false +2024-01-08,000002.SZ,false,false,false,true,true,false,false +2024-01-08,000003.SZ,false,false,false,true,true,false,false +2024-01-08,600001.SH,false,false,false,true,true,false,false +2024-01-09,000001.SZ,false,false,false,true,true,false,false +2024-01-09,000002.SZ,false,false,false,true,true,false,false +2024-01-09,000003.SZ,false,false,false,true,true,false,false +2024-01-09,600001.SH,false,false,false,true,true,false,false +2024-01-10,000001.SZ,false,false,false,true,true,false,false +2024-01-10,000002.SZ,false,false,false,true,true,false,false +2024-01-10,000003.SZ,false,false,false,true,true,false,false +2024-01-10,600001.SH,false,false,false,true,true,false,false +2024-01-11,000001.SZ,false,false,false,true,true,false,false +2024-01-11,000002.SZ,false,false,false,true,true,false,false +2024-01-11,000003.SZ,false,false,false,true,true,false,false +2024-01-11,600001.SH,false,false,true,false,false,false,false +2024-01-12,000001.SZ,false,false,false,true,true,false,false +2024-01-12,000002.SZ,false,false,false,true,true,false,false +2024-01-12,000003.SZ,false,false,false,true,true,false,false +2024-01-12,600001.SH,false,false,false,true,true,false,false