修正回测推进并增强策略样例
This commit is contained in:
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user