diff --git a/Cargo.lock b/Cargo.lock index b37bdf3..2458bd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,8 @@ name = "bt-demo" version = "0.1.0" dependencies = [ "fidc-core", + "serde", + "serde_json", ] [[package]] @@ -105,6 +107,12 @@ dependencies = [ "cc", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.94" @@ -127,6 +135,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "num-traits" version = "0.2.19" @@ -196,6 +210,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "1.3.0" @@ -342,3 +369,9 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/README.md b/README.md index a2d4f81..99808db 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ cargo run --bin bt-demo ```bash FIDC_BT_DATA_LAYOUT=partitioned \ FIDC_BT_DATA_DIR=/path/to/snapshots \ +FIDC_BT_SIGNAL_SYMBOL=000001.SH \ cargo run --bin bt-demo ``` @@ -143,18 +144,25 @@ cargo run --bin bt-demo snapshots/ ├── instruments.csv ├── benchmark/ -│ ├── 2024-01-02.csv -│ └── ... +│ └── YYYY/MM/*.csv ├── market/ +│ └── YYYY/MM/*.csv ├── factors/ +│ └── YYYY/MM/*.csv └── candidates/ + └── YYYY/MM/*.csv ``` 其中: -- `market/`:日级行情快照 -- `factors/`:估值/因子快照 +- `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit` +- `factors/`:估值/因子快照,可扩展 `turnover_ratio/effective_turnover_ratio` - `candidates/`:候选资格/过滤标记快照 -- `benchmark/`:指数快照 +- `benchmark/`:业绩基准指数快照 + +补充说明: +- 策略的“调仓信号指数”可以通过 `FIDC_BT_SIGNAL_SYMBOL` 单独指定,例如 `000001.SH` +- `benchmark/` 仍用于业绩基准和默认风险参考,两者现在不必强制相同 +- 分区目录支持递归读取,因此可直接消费 `YYYY/MM/*.csv` 这类真实导出布局 这层接口是为后续对接 `FiDataCenter / FiDataScraper` 的预计算 snapshot 数据准备的。 diff --git a/crates/bt-demo/Cargo.toml b/crates/bt-demo/Cargo.toml index c384fa3..ed145e6 100644 --- a/crates/bt-demo/Cargo.toml +++ b/crates/bt-demo/Cargo.toml @@ -7,3 +7,5 @@ authors.workspace = true [dependencies] fidc-core = { path = "../fidc-core" } +serde = { workspace = true } +serde_json = "1" diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index aa1cfac..e8396ce 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -24,7 +24,12 @@ fn main() -> Result<(), Box> { .map(PathBuf::from) .unwrap_or_else(|_| root.join("data/demo")); let data_layout = std::env::var("FIDC_BT_DATA_LAYOUT").unwrap_or_else(|_| "flat".to_string()); - let output_dir = root.join("output/demo"); + let output_dir = std::env::var("FIDC_BT_OUTPUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| root.join("output/demo")); + let json_output = std::env::var("FIDC_BT_JSON") + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false); fs::create_dir_all(&output_dir)?; @@ -37,6 +42,11 @@ fn main() -> Result<(), Box> { strategy_cfg.base_index_level = 3000.0; strategy_cfg.base_cap_floor = 38.0; strategy_cfg.cap_span = 25.0; + if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") { + if !signal_symbol.trim().is_empty() { + strategy_cfg.signal_symbol = Some(signal_symbol); + } + } let strategy = CnSmallCapRotationStrategy::new(strategy_cfg); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default()); let config = BacktestConfig { @@ -51,14 +61,20 @@ fn main() -> Result<(), Box> { write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?; write_holdings_csv(&output_dir.join("holdings_summary.csv"), &result.holdings_summary)?; - print_summary( + let summary = build_summary( + &result.strategy_name, &result.equity_curve, &result.fills, &result.holdings_summary, result.benchmark_series.last(), + &output_dir, ); + print_summary(&summary, &result.equity_curve, &result.holdings_summary); println!("Artifacts written under {}", output_dir.display()); + if json_output { + println!("{}", serde_json::to_string(&summary)?); + } Ok(()) } @@ -140,34 +156,69 @@ fn sanitize_csv_field(text: &str) -> String { text.replace(',', ";") } -fn print_summary( +#[derive(Debug, serde::Serialize)] +struct RunSummary { + strategy: String, + start_date: String, + end_date: String, + start_equity: f64, + final_equity: f64, + total_return: f64, + trade_count: usize, + holding_count: usize, + benchmark_code: Option, + benchmark_last_close: Option, + output_dir: String, +} + +fn build_summary( + strategy_name: &str, equity_curve: &[DailyEquityPoint], fills: &[FillEvent], holdings: &[HoldingSummary], benchmark_last: Option<&BenchmarkSnapshot>, -) { - let Some(first) = equity_curve.first() else { - println!("No equity curve points generated."); - return; - }; - let Some(last) = equity_curve.last() else { - println!("No equity curve points generated."); - return; + output_dir: &Path, +) -> RunSummary { + let first = equity_curve.first(); + let last = equity_curve.last(); + let start_equity = first.map(|row| row.total_equity).unwrap_or_default(); + let final_equity = last.map(|row| row.total_equity).unwrap_or_default(); + let total_return = if start_equity.abs() < f64::EPSILON { + 0.0 + } else { + (final_equity / start_equity) - 1.0 }; - let total_return = (last.total_equity / first.total_equity) - 1.0; - println!("Strategy: cn-smallcap-rotation"); - println!("Start equity: {:.2}", first.total_equity); - println!("Final equity: {:.2}", last.total_equity); - println!("Total return: {:.2}%", total_return * 100.0); - println!("Trades: {}", fills.len()); - println!("Final holdings: {}", holdings.len()); + RunSummary { + strategy: strategy_name.to_string(), + start_date: first.map(|row| row.date.to_string()).unwrap_or_default(), + end_date: last.map(|row| row.date.to_string()).unwrap_or_default(), + start_equity, + final_equity, + total_return, + trade_count: fills.len(), + holding_count: holdings.len(), + benchmark_code: benchmark_last.map(|row| row.benchmark.clone()), + benchmark_last_close: benchmark_last.map(|row| row.close), + output_dir: output_dir.display().to_string(), + } +} - if let Some(benchmark) = benchmark_last { - println!( - "Benchmark last close: {} {:.2}", - benchmark.benchmark, benchmark.close - ); +fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) { + if equity_curve.is_empty() { + println!("No equity curve points generated."); + return; + } + + println!("Strategy: {}", summary.strategy); + println!("Start equity: {:.2}", summary.start_equity); + println!("Final equity: {:.2}", summary.final_equity); + println!("Total return: {:.2}%", summary.total_return * 100.0); + println!("Trades: {}", summary.trade_count); + println!("Final holdings: {}", summary.holding_count); + + if let (Some(code), Some(close)) = (&summary.benchmark_code, summary.benchmark_last_close) { + println!("Benchmark last close: {} {:.2}", code, close); } println!("Recent equity points:"); diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index d14f4bc..08c5a19 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -87,6 +87,8 @@ pub struct DailyFactorSnapshot { pub market_cap_bn: f64, pub free_float_cap_bn: f64, pub pe_ttm: f64, + pub turnover_ratio: Option, + pub effective_turnover_ratio: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -303,6 +305,14 @@ impl DataSet { .collect() } + pub fn market_closes_up_to(&self, date: NaiveDate, symbol: &str, lookback: usize) -> Vec { + self.calendar + .trailing_days(date, lookback) + .into_iter() + .filter_map(|day| self.market(day, symbol).map(|row| row.close)) + .collect() + } + pub fn require_market( &self, date: NaiveDate, @@ -347,6 +357,8 @@ fn read_market(path: &Path) -> Result, DataSetError> { let mut snapshots = Vec::new(); for row in rows { let prev_close = row.parse_f64(6)?; + let derived_upper_limit = round2(prev_close * 1.10); + let derived_lower_limit = round2(prev_close * 0.90); snapshots.push(DailyMarketSnapshot { date: row.parse_date(0)?, symbol: row.get(1)?.to_string(), @@ -357,8 +369,8 @@ fn read_market(path: &Path) -> Result, DataSetError> { prev_close, volume: row.parse_u64(7)?, paused: row.parse_bool(8)?, - upper_limit: round2(prev_close * 1.10), - lower_limit: round2(prev_close * 0.90), + upper_limit: row.parse_optional_f64(9).unwrap_or(derived_upper_limit), + lower_limit: row.parse_optional_f64(10).unwrap_or(derived_lower_limit), }); } Ok(snapshots) @@ -374,6 +386,8 @@ fn read_factors(path: &Path) -> Result, DataSetError> { market_cap_bn: row.parse_f64(2)?, free_float_cap_bn: row.parse_f64(3)?, pe_ttm: row.parse_f64(4)?, + turnover_ratio: row.parse_optional_f64(5), + effective_turnover_ratio: row.parse_optional_f64(6), }); } Ok(snapshots) @@ -457,6 +471,17 @@ impl CsvRow { }) } + fn parse_optional_f64(&self, index: usize) -> Option { + self.fields.get(index).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) + } + fn parse_bool(&self, index: usize) -> Result { self.get(index)? .parse::() @@ -478,26 +503,35 @@ fn read_partitioned_dir(dir: &Path, mut loader: F) -> Result, DataS where F: FnMut(&Path) -> Result, DataSetError>, { - let mut files = fs::read_dir(dir) - .map_err(|source| DataSetError::Io { - path: dir.display().to_string(), - source, - })? - .collect::, _>>() - .map_err(|source| DataSetError::Io { - path: dir.display().to_string(), - source, - })?; - files.sort_by_key(|entry| entry.path()); - let mut rows = Vec::new(); - for entry in files { - let path = entry.path(); - if path.extension().and_then(|x| x.to_str()) != Some("csv") { - continue; + let mut stack = vec![dir.to_path_buf()]; + + while let Some(current_dir) = stack.pop() { + let mut entries = fs::read_dir(¤t_dir) + .map_err(|source| DataSetError::Io { + path: current_dir.display().to_string(), + source, + })? + .collect::, _>>() + .map_err(|source| DataSetError::Io { + path: current_dir.display().to_string(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + + for entry in entries.into_iter().rev() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|x| x.to_str()) != Some("csv") { + continue; + } + rows.extend(loader(&path)?); } - rows.extend(loader(&path)?); } + Ok(rows) } diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index a8171cd..f3ae75a 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -43,6 +43,7 @@ pub struct CnSmallCapRotationConfig { pub trade_rate: f64, pub stop_loss_pct: f64, pub take_profit_pct: f64, + pub signal_symbol: Option, } impl CnSmallCapRotationConfig { @@ -60,6 +61,7 @@ impl CnSmallCapRotationConfig { trade_rate: 0.5, stop_loss_pct: 0.08, take_profit_pct: 0.10, + signal_symbol: None, } } } @@ -157,10 +159,20 @@ impl Strategy for CnSmallCapRotationStrategy { .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 signal_symbol = self.config.signal_symbol.as_deref(); + let signal_closes = if let Some(symbol) = signal_symbol { + ctx.data.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days) + } else { + ctx.data.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days) + }; + let signal_level = if let Some(symbol) = signal_symbol { + ctx.data + .price(ctx.decision_date, symbol, PriceField::Close) + .unwrap_or(benchmark.close) + } else { + benchmark.close + }; + let gross_exposure = self.gross_exposure(&signal_closes); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let exposure_changed = self .last_gross_exposure @@ -175,8 +187,10 @@ impl Strategy for CnSmallCapRotationStrategy { 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={:.2} signal_level={:.2} signal_symbol={} refresh_rate={} stocknum={} short_ma_days={} long_ma_days={}", benchmark.close, + signal_level, + signal_symbol.unwrap_or(benchmark.benchmark.as_str()), self.config.refresh_rate, self.config.stocknum, self.config.short_ma_days, @@ -187,6 +201,7 @@ impl Strategy for CnSmallCapRotationStrategy { let selected = self.selector.select(&SelectionContext { decision_date: ctx.decision_date, benchmark, + reference_level: signal_level, data: ctx.data, }); diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index c3f6458..dad8c0e 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -21,6 +21,7 @@ pub struct UniverseCandidate { pub struct SelectionContext<'a> { pub decision_date: NaiveDate, pub benchmark: &'a BenchmarkSnapshot, + pub reference_level: f64, pub data: &'a DataSet, } @@ -77,8 +78,8 @@ impl DynamicMarketCapBandSelector { impl UniverseSelector for DynamicMarketCapBandSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec { - let _regime = self.regime(ctx.benchmark.close); - let (min_cap, max_cap) = self.band_for_level(ctx.benchmark.close); + let _regime = self.regime(ctx.reference_level); + let (min_cap, max_cap) = self.band_for_level(ctx.reference_level); let mut selected = ctx .data diff --git a/crates/fidc-core/tests/partitioned_loader.rs b/crates/fidc-core/tests/partitioned_loader.rs index 77b5945..4d32f4b 100644 --- a/crates/fidc-core/tests/partitioned_loader.rs +++ b/crates/fidc-core/tests/partitioned_loader.rs @@ -16,10 +16,10 @@ fn temp_dir() -> PathBuf { #[test] fn can_load_partitioned_snapshot_dir() { let dir = temp_dir(); - fs::create_dir_all(dir.join("benchmark")).unwrap(); - fs::create_dir_all(dir.join("market")).unwrap(); - fs::create_dir_all(dir.join("factors")).unwrap(); - fs::create_dir_all(dir.join("candidates")).unwrap(); + fs::create_dir_all(dir.join("benchmark/2024/01")).unwrap(); + fs::create_dir_all(dir.join("market/2024/01")).unwrap(); + fs::create_dir_all(dir.join("factors/2024/01")).unwrap(); + fs::create_dir_all(dir.join("candidates/2024/01")).unwrap(); fs::write( dir.join("instruments.csv"), @@ -27,22 +27,22 @@ fn can_load_partitioned_snapshot_dir() { ) .unwrap(); fs::write( - dir.join("benchmark/2024-01-02.csv"), + dir.join("benchmark/2024/01/2024-01-02.csv"), "date,benchmark,open,close,prev_close,volume\n2024-01-02,CSI300.DEMO,2990,3000,2980,100000000\n", ) .unwrap(); fs::write( - dir.join("market/2024-01-02.csv"), + dir.join("market/2024/01/2024-01-02.csv"), "date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9\n", ) .unwrap(); fs::write( - dir.join("factors/2024-01-02.csv"), - "date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm\n2024-01-02,000001.SZ,40,35,12\n", + dir.join("factors/2024/01/2024-01-02.csv"), + "date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm,turnover_ratio,effective_turnover_ratio\n2024-01-02,000001.SZ,40,35,12,3.2,2.1\n", ) .unwrap(); fs::write( - dir.join("candidates/2024-01-02.csv"), + dir.join("candidates/2024/01/2024-01-02.csv"), "date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n", ) .unwrap(); diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs index 23062c6..c377b2b 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -31,4 +31,8 @@ fn strategy_emits_target_weights_and_diagnostics() { .diagnostics .iter() .any(|line| line.contains("selected="))); + assert!(decision + .diagnostics + .iter() + .any(|line| line.contains("signal_symbol="))); }