use std::error::Error; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use chrono::{NaiveDate, NaiveTime}; use fidc_core::{ BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint, DataSet, FillEvent, HoldingSummary, JqMicroCapConfig, JqMicroCapStrategy, PortfolioState, PriceField, Strategy, StrategyContext, }; use serde_json::json; fn main() -> Result<(), Box> { let root = workspace_root(); let data_dir = std::env::var("FIDC_BT_DATA_DIR") .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 = 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)?; let data = if data_layout == "partitioned" { DataSet::from_partitioned_dir(&data_dir)? } else { DataSet::from_csv_dir(&data_dir)? }; let strategy_name = std::env::var("FIDC_BT_STRATEGY").unwrap_or_else(|_| "cn-smallcap-rotation".to_string()); let debug_date = std::env::var("FIDC_BT_DEBUG_DATE") .ok() .filter(|value| !value.trim().is_empty()) .map(|value| NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d")) .transpose()?; let decision_lag = std::env::var("FIDC_BT_DECISION_LAG") .ok() .and_then(|value| value.parse::().ok()); let execution_price = std::env::var("FIDC_BT_EXECUTION_PRICE") .ok() .map(|value| match value.as_str() { "close" => PriceField::Close, "last" => PriceField::Last, _ => PriceField::Open, }); let initial_cash = std::env::var("FIDC_BT_INITIAL_CASH") .ok() .and_then(|value| value.parse::().ok()); 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 mut config = BacktestConfig { initial_cash: initial_cash.unwrap_or(1_000_000.0), benchmark_code: data.benchmark_code().to_string(), start_date, end_date, decision_lag_trading_days: 1, execution_price_field: PriceField::Open, }; let result = match strategy_name.as_str() { "cn-smallcap-rotation" | "cn-dyn-smallcap-band" => { let mut strategy_cfg = if strategy_name == "cn-dyn-smallcap-band" { CnSmallCapRotationConfig::cn_dyn_smallcap_band() } 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); } } config.decision_lag_trading_days = decision_lag.unwrap_or(1); config.execution_price_field = execution_price.unwrap_or(PriceField::Open); let strategy = CnSmallCapRotationStrategy::new(strategy_cfg); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), config.execution_price_field, ); let mut engine = BacktestEngine::new(data, strategy, broker, config); engine.run()? } _ => { let mut strategy_cfg = JqMicroCapConfig::jq_microcap(); if let Ok(signal_symbol) = std::env::var("FIDC_BT_SIGNAL_SYMBOL") { if !signal_symbol.trim().is_empty() { strategy_cfg.benchmark_signal_symbol = signal_symbol; } } if let Some(date) = debug_date { let eligible = data.eligible_universe_on(date); eprintln!( "DEBUG eligible_universe_on {} count={}", date, eligible.len() ); for row in eligible.iter().take(20) { eprintln!(" {} {:.6}", row.symbol, row.market_cap_bn); } let mut debug_strategy = JqMicroCapStrategy::new(strategy_cfg.clone()); let decision = debug_strategy.on_day(&StrategyContext { execution_date: date, decision_date: date, decision_index: 1, data: &data, portfolio: &PortfolioState::new(10_000_000.0), })?; eprintln!("DEBUG notes={:?}", decision.notes); eprintln!("DEBUG diagnostics={:?}", decision.diagnostics); return Ok(()); } config.decision_lag_trading_days = decision_lag.unwrap_or(0); config.execution_price_field = execution_price.unwrap_or(PriceField::Last); config.initial_cash = initial_cash.unwrap_or(10_000_000.0); let strategy = JqMicroCapStrategy::new(strategy_cfg); let broker = BrokerSimulator::new_with_execution_price( ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default(), config.execution_price_field, ) .with_intraday_execution_start_time( NaiveTime::parse_from_str("10:18:00", "%H:%M:%S").expect("valid 10:18:00"), ) .with_volume_limit(false) .with_inactive_limit(false) .with_liquidity_limit(false); let mut engine = BacktestEngine::new(data, strategy, broker, config); engine.run()? } }; write_equity_curve_csv(&output_dir.join("equity_curve.csv"), &result.equity_curve)?; write_trades_csv(&output_dir.join("trades.csv"), &result.fills)?; write_holdings_csv( &output_dir.join("holdings_summary.csv"), &result.holdings_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(()) } fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("../..") .canonicalize() .expect("workspace root") } fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box> { let mut file = fs::File::create(path)?; writeln!( file, "date,cash,market_value,total_equity,benchmark_close,notes,diagnostics" )?; for row in rows { writeln!( file, "{},{:.2},{:.2},{:.2},{:.2},{},{}", row.date, row.cash, row.market_value, row.total_equity, row.benchmark_close, sanitize_csv_field(&row.notes), sanitize_csv_field(&row.diagnostics), )?; } Ok(()) } fn write_trades_csv(path: &Path, rows: &[FillEvent]) -> Result<(), Box> { let mut file = fs::File::create(path)?; writeln!( file, "date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason" )?; for row in rows { writeln!( file, "{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}", row.date, row.symbol, row.side, row.quantity, row.price, row.gross_amount, row.commission, row.stamp_tax, row.net_cash_flow, sanitize_csv_field(&row.reason), )?; } Ok(()) } fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box> { let mut file = fs::File::create(path)?; writeln!( file, "date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl" )?; for row in rows { writeln!( file, "{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}", row.date, row.symbol, row.quantity, row.average_cost, row.last_price, row.market_value, row.unrealized_pnl, row.realized_pnl, )?; } Ok(()) } fn sanitize_csv_field(text: &str) -> String { text.replace(',', ";") } #[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, diagnostics: serde_json::Value, warnings: Vec, equity_preview: Vec, trades_preview: Vec, } fn build_summary( strategy_name: &str, equity_curve: &[DailyEquityPoint], fills: &[FillEvent], holdings: &[HoldingSummary], benchmark_last: Option<&BenchmarkSnapshot>, 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 diagnostics = extract_diagnostics(equity_curve); let warnings = build_warnings(fills, holdings, &diagnostics); let equity_preview = equity_curve .iter() .rev() .take(5) .collect::>() .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::>(); let trades_preview = fills .iter() .rev() .take(10) .collect::>() .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::>(); 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(), 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::>() ), ); } 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::>() ), ); } 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::>() ), ); } 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::() { return json!(v); } if let Ok(v) = value.parse::() { return json!(v); } json!(value) } fn build_warnings( fills: &[FillEvent], holdings: &[HoldingSummary], diagnostics: &serde_json::Value, ) -> Vec { 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."); 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:"); for point in equity_curve .iter() .rev() .take(3) .collect::>() .into_iter() .rev() { println!( " {} equity {:.2} cash {:.2} mv {:.2}", point.date, point.total_equity, point.cash, point.market_value ); } if holdings.is_empty() { println!("No holdings at the end of the demo run."); } else { println!("Ending holdings:"); for holding in holdings { println!( " {} qty {} mv {:.2} pnl {:.2}", holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl ); } } }