Improve jq microcap execution semantics
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user