修正回测推进并增强策略样例

This commit is contained in:
zsb
2026-04-08 19:10:28 -07:00
parent a26049ff15
commit 581021651c
8 changed files with 465 additions and 66 deletions

View File

@@ -3,6 +3,7 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use fidc_core::{
BacktestConfig,
BacktestEngine,
@@ -17,6 +18,7 @@ use fidc_core::{
FillEvent,
HoldingSummary,
};
use serde_json::json;
fn main() -> Result<(), Box<dyn Error>> {
let root = workspace_root();
@@ -38,10 +40,19 @@ fn main() -> Result<(), Box<dyn Error>> {
} else {
DataSet::from_csv_dir(&data_dir)?
};
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 mut strategy_cfg = std::env::var("FIDC_BT_STRATEGY")
.ok()
.as_deref()
.map(|value| match value {
"cn-dyn-smallcap-band" => CnSmallCapRotationConfig::cn_dyn_smallcap_band(),
_ => CnSmallCapRotationConfig::demo(),
})
.unwrap_or_else(CnSmallCapRotationConfig::demo);
if strategy_cfg.strategy_name == "cn-smallcap-rotation" {
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);
@@ -49,9 +60,21 @@ fn main() -> Result<(), Box<dyn Error>> {
}
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
let start_date = std::env::var("FIDC_BT_START_DATE")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?;
let end_date = std::env::var("FIDC_BT_END_DATE")
.ok()
.filter(|value| !value.trim().is_empty())
.map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d"))
.transpose()?;
let config = BacktestConfig {
initial_cash: 1_000_000.0,
benchmark_code: data.benchmark_code().to_string(),
start_date,
end_date,
};
let mut engine = BacktestEngine::new(data, strategy, broker, config);
@@ -169,6 +192,10 @@ struct RunSummary {
benchmark_code: Option<String>,
benchmark_last_close: Option<f64>,
output_dir: String,
diagnostics: serde_json::Value,
warnings: Vec<String>,
equity_preview: Vec<serde_json::Value>,
trades_preview: Vec<serde_json::Value>,
}
fn build_summary(
@@ -189,6 +216,44 @@ fn build_summary(
(final_equity / start_equity) - 1.0
};
let diagnostics = extract_diagnostics(equity_curve);
let warnings = build_warnings(fills, holdings, &diagnostics);
let equity_preview = equity_curve
.iter()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|row| json!({
"date": row.date.to_string(),
"cash": row.cash,
"marketValue": row.market_value,
"totalEquity": row.total_equity,
"benchmarkClose": row.benchmark_close,
"notes": row.notes,
"diagnostics": row.diagnostics,
}))
.collect::<Vec<_>>();
let trades_preview = fills
.iter()
.rev()
.take(10)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|row| json!({
"date": row.date.to_string(),
"symbol": row.symbol,
"side": format!("{:?}", row.side),
"quantity": row.quantity,
"price": row.price,
"grossAmount": row.gross_amount,
"netCashFlow": row.net_cash_flow,
"reason": row.reason,
}))
.collect::<Vec<_>>();
RunSummary {
strategy: strategy_name.to_string(),
start_date: first.map(|row| row.date.to_string()).unwrap_or_default(),
@@ -201,9 +266,81 @@ fn build_summary(
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(),
diagnostics,
warnings,
equity_preview,
trades_preview,
}
}
fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
let last = equity_curve.last();
let text = last.map(|row| row.diagnostics.as_str()).unwrap_or("");
let notes = last.map(|row| row.notes.as_str()).unwrap_or("");
let mut map = serde_json::Map::new();
map.insert("latestText".to_string(), json!(text));
map.insert("latestNotes".to_string(), json!(notes));
map.insert("equityPointCount".to_string(), json!(equity_curve.len()));
for part in text.split(" | ") {
let part = part.trim();
if let Some(rest) = part.strip_prefix("selection_diag ") {
for token in rest.split_whitespace() {
if let Some((k, v)) = token.split_once('=') {
map.insert(k.to_string(), parse_diag_value(v));
}
}
} else if let Some(rest) = part.strip_prefix("selection_band ") {
for token in rest.split_whitespace() {
if let Some((k, v)) = token.split_once('=') {
map.insert(k.to_string(), parse_diag_value(v));
}
}
} else if let Some(rest) = part.strip_prefix("market_cap_missing likely blocks selection; sample=") {
map.insert("marketCapMissingSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::<Vec<_>>()));
} else if let Some(rest) = part.strip_prefix("selection_rejections sample=") {
map.insert("selectionRejectionsSample".to_string(), json!(rest.split(" | ").filter(|s| !s.is_empty()).collect::<Vec<_>>()));
} else if let Some(rest) = part.strip_prefix("ma_filter_rejections sample=") {
map.insert("maFilterRejectionsSample".to_string(), json!(rest.split('|').filter(|s| !s.is_empty()).collect::<Vec<_>>()));
} else if let Some(rest) = part.strip_prefix("selected=") {
map.insert("selectedLine".to_string(), json!(rest));
}
}
serde_json::Value::Object(map)
}
fn parse_diag_value(value: &str) -> serde_json::Value {
if let Ok(v) = value.parse::<i64>() {
return json!(v);
}
if let Ok(v) = value.parse::<f64>() {
return json!(v);
}
json!(value)
}
fn build_warnings(
fills: &[FillEvent],
holdings: &[HoldingSummary],
diagnostics: &serde_json::Value,
) -> Vec<String> {
let mut warnings = Vec::new();
if fills.is_empty() {
warnings.push("本次回测没有产生任何成交。".to_string());
}
if holdings.is_empty() {
warnings.push("期末没有持仓。".to_string());
}
if diagnostics.get("selected_after_ma").and_then(|v| v.as_i64()).unwrap_or(0) == 0 {
warnings.push("最终没有股票通过完整选股链路,结果为空时请优先查看 diagnostics。".to_string());
}
if diagnostics.get("market_cap_missing_count").and_then(|v| v.as_i64()).unwrap_or(0) > 0 {
warnings.push("存在 market_cap 缺失或非正值,当前会直接阻断该股票进入候选池。".to_string());
}
warnings
}
fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdings: &[HoldingSummary]) {
if equity_curve.is_empty() {
println!("No equity curve points generated.");