增强回测demo输出与分区加载

This commit is contained in:
zsb
2026-04-07 21:25:41 -07:00
parent ec425999b0
commit a26049ff15
9 changed files with 211 additions and 63 deletions

33
Cargo.lock generated
View File

@@ -22,6 +22,8 @@ name = "bt-demo"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"fidc-core", "fidc-core",
"serde",
"serde_json",
] ]
[[package]] [[package]]
@@ -105,6 +107,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.94" version = "0.3.94"
@@ -127,6 +135,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -196,6 +210,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@@ -342,3 +369,9 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -134,6 +134,7 @@ cargo run --bin bt-demo
```bash ```bash
FIDC_BT_DATA_LAYOUT=partitioned \ FIDC_BT_DATA_LAYOUT=partitioned \
FIDC_BT_DATA_DIR=/path/to/snapshots \ FIDC_BT_DATA_DIR=/path/to/snapshots \
FIDC_BT_SIGNAL_SYMBOL=000001.SH \
cargo run --bin bt-demo cargo run --bin bt-demo
``` ```
@@ -143,18 +144,25 @@ cargo run --bin bt-demo
snapshots/ snapshots/
├── instruments.csv ├── instruments.csv
├── benchmark/ ├── benchmark/
── 2024-01-02.csv ── YYYY/MM/*.csv
│ └── ...
├── market/ ├── market/
│ └── YYYY/MM/*.csv
├── factors/ ├── factors/
│ └── YYYY/MM/*.csv
└── candidates/ └── candidates/
└── YYYY/MM/*.csv
``` ```
其中: 其中:
- `market/`:日级行情快照 - `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit`
- `factors/`:估值/因子快照 - `factors/`:估值/因子快照,可扩展 `turnover_ratio/effective_turnover_ratio`
- `candidates/`:候选资格/过滤标记快照 - `candidates/`:候选资格/过滤标记快照
- `benchmark/`:指数快照 - `benchmark/`业绩基准指数快照
补充说明:
- 策略的“调仓信号指数”可以通过 `FIDC_BT_SIGNAL_SYMBOL` 单独指定,例如 `000001.SH`
- `benchmark/` 仍用于业绩基准和默认风险参考,两者现在不必强制相同
- 分区目录支持递归读取,因此可直接消费 `YYYY/MM/*.csv` 这类真实导出布局
这层接口是为后续对接 `FiDataCenter / FiDataScraper` 的预计算 snapshot 数据准备的。 这层接口是为后续对接 `FiDataCenter / FiDataScraper` 的预计算 snapshot 数据准备的。

View File

@@ -7,3 +7,5 @@ authors.workspace = true
[dependencies] [dependencies]
fidc-core = { path = "../fidc-core" } fidc-core = { path = "../fidc-core" }
serde = { workspace = true }
serde_json = "1"

View File

@@ -24,7 +24,12 @@ fn main() -> Result<(), Box<dyn Error>> {
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|_| root.join("data/demo")); .unwrap_or_else(|_| root.join("data/demo"));
let data_layout = std::env::var("FIDC_BT_DATA_LAYOUT").unwrap_or_else(|_| "flat".to_string()); 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)?; fs::create_dir_all(&output_dir)?;
@@ -37,6 +42,11 @@ fn main() -> Result<(), Box<dyn Error>> {
strategy_cfg.base_index_level = 3000.0; strategy_cfg.base_index_level = 3000.0;
strategy_cfg.base_cap_floor = 38.0; strategy_cfg.base_cap_floor = 38.0;
strategy_cfg.cap_span = 25.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 strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default()); let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
let config = BacktestConfig { let config = BacktestConfig {
@@ -51,14 +61,20 @@ fn main() -> Result<(), Box<dyn Error>> {
write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?; write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?;
write_holdings_csv(&output_dir.join("holdings_summary.csv"), &result.holdings_summary)?; 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.equity_curve,
&result.fills, &result.fills,
&result.holdings_summary, &result.holdings_summary,
result.benchmark_series.last(), result.benchmark_series.last(),
&output_dir,
); );
print_summary(&summary, &result.equity_curve, &result.holdings_summary);
println!("Artifacts written under {}", output_dir.display()); println!("Artifacts written under {}", output_dir.display());
if json_output {
println!("{}", serde_json::to_string(&summary)?);
}
Ok(()) Ok(())
} }
@@ -140,34 +156,69 @@ fn sanitize_csv_field(text: &str) -> String {
text.replace(',', ";") 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<String>,
benchmark_last_close: Option<f64>,
output_dir: String,
}
fn build_summary(
strategy_name: &str,
equity_curve: &[DailyEquityPoint], equity_curve: &[DailyEquityPoint],
fills: &[FillEvent], fills: &[FillEvent],
holdings: &[HoldingSummary], holdings: &[HoldingSummary],
benchmark_last: Option<&BenchmarkSnapshot>, benchmark_last: Option<&BenchmarkSnapshot>,
) { output_dir: &Path,
let Some(first) = equity_curve.first() else { ) -> RunSummary {
println!("No equity curve points generated."); let first = equity_curve.first();
return; let last = equity_curve.last();
}; let start_equity = first.map(|row| row.total_equity).unwrap_or_default();
let Some(last) = equity_curve.last() else { let final_equity = last.map(|row| row.total_equity).unwrap_or_default();
println!("No equity curve points generated."); let total_return = if start_equity.abs() < f64::EPSILON {
return; 0.0
} else {
(final_equity / start_equity) - 1.0
}; };
let total_return = (last.total_equity / first.total_equity) - 1.0; RunSummary {
println!("Strategy: cn-smallcap-rotation"); strategy: strategy_name.to_string(),
println!("Start equity: {:.2}", first.total_equity); start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
println!("Final equity: {:.2}", last.total_equity); end_date: last.map(|row| row.date.to_string()).unwrap_or_default(),
println!("Total return: {:.2}%", total_return * 100.0); start_equity,
println!("Trades: {}", fills.len()); final_equity,
println!("Final holdings: {}", holdings.len()); 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 { fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) {
println!( if equity_curve.is_empty() {
"Benchmark last close: {} {:.2}", println!("No equity curve points generated.");
benchmark.benchmark, benchmark.close 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:"); println!("Recent equity points:");

View File

@@ -87,6 +87,8 @@ pub struct DailyFactorSnapshot {
pub market_cap_bn: f64, pub market_cap_bn: f64,
pub free_float_cap_bn: f64, pub free_float_cap_bn: f64,
pub pe_ttm: f64, pub pe_ttm: f64,
pub turnover_ratio: Option<f64>,
pub effective_turnover_ratio: Option<f64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -303,6 +305,14 @@ impl DataSet {
.collect() .collect()
} }
pub fn market_closes_up_to(&self, date: NaiveDate, symbol: &str, lookback: usize) -> Vec<f64> {
self.calendar
.trailing_days(date, lookback)
.into_iter()
.filter_map(|day| self.market(day, symbol).map(|row| row.close))
.collect()
}
pub fn require_market( pub fn require_market(
&self, &self,
date: NaiveDate, date: NaiveDate,
@@ -347,6 +357,8 @@ fn read_market(path: &Path) -> Result<Vec<DailyMarketSnapshot>, DataSetError> {
let mut snapshots = Vec::new(); let mut snapshots = Vec::new();
for row in rows { for row in rows {
let prev_close = row.parse_f64(6)?; 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 { snapshots.push(DailyMarketSnapshot {
date: row.parse_date(0)?, date: row.parse_date(0)?,
symbol: row.get(1)?.to_string(), symbol: row.get(1)?.to_string(),
@@ -357,8 +369,8 @@ fn read_market(path: &Path) -> Result<Vec<DailyMarketSnapshot>, DataSetError> {
prev_close, prev_close,
volume: row.parse_u64(7)?, volume: row.parse_u64(7)?,
paused: row.parse_bool(8)?, paused: row.parse_bool(8)?,
upper_limit: round2(prev_close * 1.10), upper_limit: row.parse_optional_f64(9).unwrap_or(derived_upper_limit),
lower_limit: round2(prev_close * 0.90), lower_limit: row.parse_optional_f64(10).unwrap_or(derived_lower_limit),
}); });
} }
Ok(snapshots) Ok(snapshots)
@@ -374,6 +386,8 @@ fn read_factors(path: &Path) -> Result<Vec<DailyFactorSnapshot>, DataSetError> {
market_cap_bn: row.parse_f64(2)?, market_cap_bn: row.parse_f64(2)?,
free_float_cap_bn: row.parse_f64(3)?, free_float_cap_bn: row.parse_f64(3)?,
pe_ttm: row.parse_f64(4)?, pe_ttm: row.parse_f64(4)?,
turnover_ratio: row.parse_optional_f64(5),
effective_turnover_ratio: row.parse_optional_f64(6),
}); });
} }
Ok(snapshots) Ok(snapshots)
@@ -457,6 +471,17 @@ impl CsvRow {
}) })
} }
fn parse_optional_f64(&self, index: usize) -> Option<f64> {
self.fields.get(index).and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
trimmed.parse::<f64>().ok()
}
})
}
fn parse_bool(&self, index: usize) -> Result<bool, DataSetError> { fn parse_bool(&self, index: usize) -> Result<bool, DataSetError> {
self.get(index)? self.get(index)?
.parse::<bool>() .parse::<bool>()
@@ -478,26 +503,35 @@ fn read_partitioned_dir<T, F>(dir: &Path, mut loader: F) -> Result<Vec<T>, DataS
where where
F: FnMut(&Path) -> Result<Vec<T>, DataSetError>, F: FnMut(&Path) -> Result<Vec<T>, DataSetError>,
{ {
let mut files = fs::read_dir(dir)
.map_err(|source| DataSetError::Io {
path: dir.display().to_string(),
source,
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|source| DataSetError::Io {
path: dir.display().to_string(),
source,
})?;
files.sort_by_key(|entry| entry.path());
let mut rows = Vec::new(); let mut rows = Vec::new();
for entry in files { let mut stack = vec![dir.to_path_buf()];
let path = entry.path();
if path.extension().and_then(|x| x.to_str()) != Some("csv") { while let Some(current_dir) = stack.pop() {
continue; let mut entries = fs::read_dir(&current_dir)
.map_err(|source| DataSetError::Io {
path: current_dir.display().to_string(),
source,
})?
.collect::<Result<Vec<_>, _>>()
.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) Ok(rows)
} }

View File

@@ -43,6 +43,7 @@ pub struct CnSmallCapRotationConfig {
pub trade_rate: f64, pub trade_rate: f64,
pub stop_loss_pct: f64, pub stop_loss_pct: f64,
pub take_profit_pct: f64, pub take_profit_pct: f64,
pub signal_symbol: Option<String>,
} }
impl CnSmallCapRotationConfig { impl CnSmallCapRotationConfig {
@@ -60,6 +61,7 @@ impl CnSmallCapRotationConfig {
trade_rate: 0.5, trade_rate: 0.5,
stop_loss_pct: 0.08, stop_loss_pct: 0.08,
take_profit_pct: 0.10, take_profit_pct: 0.10,
signal_symbol: None,
} }
} }
} }
@@ -157,10 +159,20 @@ impl Strategy for CnSmallCapRotationStrategy {
.ok_or(BacktestError::MissingBenchmark { .ok_or(BacktestError::MissingBenchmark {
date: ctx.decision_date, date: ctx.decision_date,
})?; })?;
let benchmark_closes = ctx let signal_symbol = self.config.signal_symbol.as_deref();
.data let signal_closes = if let Some(symbol) = signal_symbol {
.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days); ctx.data.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days)
let gross_exposure = self.gross_exposure(&benchmark_closes); } 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 periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
let exposure_changed = self let exposure_changed = self
.last_gross_exposure .last_gross_exposure
@@ -175,8 +187,10 @@ impl Strategy for CnSmallCapRotationStrategy {
ctx.decision_date, ctx.execution_date, gross_exposure ctx.decision_date, ctx.execution_date, gross_exposure
)]; )];
let mut diagnostics = vec![format!( 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, benchmark.close,
signal_level,
signal_symbol.unwrap_or(benchmark.benchmark.as_str()),
self.config.refresh_rate, self.config.refresh_rate,
self.config.stocknum, self.config.stocknum,
self.config.short_ma_days, self.config.short_ma_days,
@@ -187,6 +201,7 @@ impl Strategy for CnSmallCapRotationStrategy {
let selected = self.selector.select(&SelectionContext { let selected = self.selector.select(&SelectionContext {
decision_date: ctx.decision_date, decision_date: ctx.decision_date,
benchmark, benchmark,
reference_level: signal_level,
data: ctx.data, data: ctx.data,
}); });

View File

@@ -21,6 +21,7 @@ pub struct UniverseCandidate {
pub struct SelectionContext<'a> { pub struct SelectionContext<'a> {
pub decision_date: NaiveDate, pub decision_date: NaiveDate,
pub benchmark: &'a BenchmarkSnapshot, pub benchmark: &'a BenchmarkSnapshot,
pub reference_level: f64,
pub data: &'a DataSet, pub data: &'a DataSet,
} }
@@ -77,8 +78,8 @@ impl DynamicMarketCapBandSelector {
impl UniverseSelector for DynamicMarketCapBandSelector { impl UniverseSelector for DynamicMarketCapBandSelector {
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> { fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate> {
let _regime = self.regime(ctx.benchmark.close); let _regime = self.regime(ctx.reference_level);
let (min_cap, max_cap) = self.band_for_level(ctx.benchmark.close); let (min_cap, max_cap) = self.band_for_level(ctx.reference_level);
let mut selected = ctx let mut selected = ctx
.data .data

View File

@@ -16,10 +16,10 @@ fn temp_dir() -> PathBuf {
#[test] #[test]
fn can_load_partitioned_snapshot_dir() { fn can_load_partitioned_snapshot_dir() {
let dir = temp_dir(); let dir = temp_dir();
fs::create_dir_all(dir.join("benchmark")).unwrap(); fs::create_dir_all(dir.join("benchmark/2024/01")).unwrap();
fs::create_dir_all(dir.join("market")).unwrap(); fs::create_dir_all(dir.join("market/2024/01")).unwrap();
fs::create_dir_all(dir.join("factors")).unwrap(); fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
fs::create_dir_all(dir.join("candidates")).unwrap(); fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
fs::write( fs::write(
dir.join("instruments.csv"), dir.join("instruments.csv"),
@@ -27,22 +27,22 @@ fn can_load_partitioned_snapshot_dir() {
) )
.unwrap(); .unwrap();
fs::write( 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", "date,benchmark,open,close,prev_close,volume\n2024-01-02,CSI300.DEMO,2990,3000,2980,100000000\n",
) )
.unwrap(); .unwrap();
fs::write( 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", "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(); .unwrap();
fs::write( fs::write(
dir.join("factors/2024-01-02.csv"), dir.join("factors/2024/01/2024-01-02.csv"),
"date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm\n2024-01-02,000001.SZ,40,35,12\n", "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(); .unwrap();
fs::write( 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", "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(); .unwrap();

View File

@@ -31,4 +31,8 @@ fn strategy_emits_target_weights_and_diagnostics() {
.diagnostics .diagnostics
.iter() .iter()
.any(|line| line.contains("selected="))); .any(|line| line.contains("selected=")));
assert!(decision
.diagnostics
.iter()
.any(|line| line.contains("signal_symbol=")));
} }