Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -3,20 +3,12 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveTime};
use fidc_core::{
BacktestConfig,
BacktestEngine,
BenchmarkSnapshot,
BrokerSimulator,
ChinaAShareCostModel,
ChinaEquityRuleHooks,
CnSmallCapRotationConfig,
CnSmallCapRotationStrategy,
DataSet,
DailyEquityPoint,
FillEvent,
HoldingSummary,
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, ChinaAShareCostModel,
ChinaEquityRuleHooks, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DailyEquityPoint,
DataSet, FillEvent, HoldingSummary, JqMicroCapConfig, JqMicroCapStrategy, PortfolioState,
PriceField, Strategy, StrategyContext,
};
use serde_json::json;
@@ -40,26 +32,27 @@ fn main() -> Result<(), Box<dyn Error>> {
} else {
DataSet::from_csv_dir(&data_dir)?
};
let mut strategy_cfg = std::env::var("FIDC_BT_STRATEGY")
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()
.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);
}
}
let strategy = CnSmallCapRotationStrategy::new(strategy_cfg);
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default());
.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())
@@ -70,19 +63,97 @@ fn main() -> Result<(), Box<dyn Error>> {
.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,
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()?
}
};
let mut engine = BacktestEngine::new(data, strategy, broker, config);
let result = 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)?;
write_holdings_csv(
&output_dir.join("holdings_summary.csv"),
&result.holdings_summary,
)?;
let summary = build_summary(
&result.strategy_name,
@@ -110,7 +181,10 @@ fn workspace_root() -> PathBuf {
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")?;
writeln!(
file,
"date,cash,market_value,total_equity,benchmark_close,notes,diagnostics"
)?;
for row in rows {
writeln!(
file,
@@ -225,15 +299,17 @@ fn build_summary(
.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,
}))
.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()
@@ -242,16 +318,18 @@ fn build_summary(
.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,
}))
.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 {
@@ -296,12 +374,35 @@ fn extract_diagnostics(equity_curve: &[DailyEquityPoint]) -> serde_json::Value {
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("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<_>>()));
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<_>>()));
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));
}
@@ -332,16 +433,31 @@ fn build_warnings(
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("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 {
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]) {
fn print_summary(
summary: &RunSummary,
equity_curve: &[DailyEquityPoint],
holdings: &[HoldingSummary],
) {
if equity_curve.is_empty() {
println!("No equity curve points generated.");
return;
@@ -359,7 +475,14 @@ fn print_summary(summary: &RunSummary, equity_curve: &[DailyEquityPoint], holdin
}
println!("Recent equity points:");
for point in equity_curve.iter().rev().take(3).collect::<Vec<_>>().into_iter().rev() {
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