504 lines
17 KiB
Rust
504 lines
17 KiB
Rust
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<dyn Error>> {
|
|
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::<usize>().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::<f64>().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<dyn Error>> {
|
|
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<dyn Error>> {
|
|
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<dyn Error>> {
|
|
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<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(
|
|
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::<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(),
|
|
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::<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.");
|
|
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::<Vec<_>>()
|
|
.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
|
|
);
|
|
}
|
|
}
|
|
}
|