增强回测demo输出与分区加载
This commit is contained in:
@@ -7,3 +7,5 @@ authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fidc-core = { path = "../fidc-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -24,7 +24,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
.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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<String>,
|
||||
benchmark_last_close: Option<f64>,
|
||||
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:");
|
||||
|
||||
@@ -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<f64>,
|
||||
pub effective_turnover_ratio: Option<f64>,
|
||||
}
|
||||
|
||||
#[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<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(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -347,6 +357,8 @@ fn read_market(path: &Path) -> Result<Vec<DailyMarketSnapshot>, 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<Vec<DailyMarketSnapshot>, 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<Vec<DailyFactorSnapshot>, 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<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> {
|
||||
self.get(index)?
|
||||
.parse::<bool>()
|
||||
@@ -478,26 +503,35 @@ fn read_partitioned_dir<T, F>(dir: &Path, mut loader: F) -> Result<Vec<T>, DataS
|
||||
where
|
||||
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();
|
||||
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::<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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<UniverseCandidate> {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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=")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user