diff --git a/Cargo.lock b/Cargo.lock index f60cac0..467ffa2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,11 +69,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "fidc-core" version = "0.1.0" dependencies = [ "chrono", + "indexmap", "serde", "thiserror", ] @@ -84,6 +91,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -108,6 +121,18 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 2badf61..ddb1301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,5 +13,6 @@ authors = ["OpenAI Codex"] [workspace.dependencies] chrono = { version = "=0.4.44", features = ["serde"] } +indexmap = { version = "=2.11.4", features = ["serde"] } serde = { version = "=1.0.228", features = ["derive"] } thiserror = "=2.0.18" diff --git a/README.md b/README.md index 99808db..5074f67 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,18 @@ ## 当前能力 -- Phase 2:增加 snapshot bundle 视图与更贴近 jqdata 策略语义的动态市值带策略 +- Phase 3:增加预索引数据层、可配置决策/执行语义,以及更贴近聚宽微盘股脚本的 native 策略 - 日频交易日历与确定性逐日回放 - A 股日频市场快照、估值/因子快照、基准快照、候选资格标记 - 策略接口与引擎驱动,不直接模拟 `jqdata` API +- `BacktestConfig` 支持 `decision_lag_trading_days` 和 `execution_price_field(open/close/last)` +- `DailyMarketSnapshot` 支持 `day_open` / `last_price` - Universe 选择器:按指数位置动态切换市值带,再取最小市值 Top-N - 风险节流:基于指数均线状态切换 100% / 50% / 0% 仓位 - Broker Simulator:按次日开盘价撮合,支持手续费、印花税、最小佣金 - 中国 A 股规则钩子:T+1、停牌、涨停不可买、跌停不可卖 - 回测输出:权益曲线、成交记录、期末持仓摘要 +- 新增 `JqMicroCapStrategy`:覆盖动态市值带、停运窗口、1 元股 / ST / 科创板过滤、均线过滤、止损止盈、固定频率再平衡 - `cargo run --bin bt-demo` 可直接运行仓库内置 demo 数据 ## Workspace 布局 @@ -43,12 +46,12 @@ - `calendar`: 交易日历和滚动窗口工具,负责日频迭代和均线 lookback。 - `instrument`: 证券静态定义。 -- `data`: 日频市场、因子、基准、候选资格数据模型与 CSV loader。 +- `data`: 日频市场、因子、基准、候选资格数据模型与 CSV loader;内部预建 symbol 级价格前缀和、按日预排序 eligible universe。 - `universe`: 动态市值带 Universe Selector。 - `portfolio`: 现金、持仓、FIFO lots、T+1 可卖数量、盈亏汇总。 - `rules`: 中国股票规则钩子,隔离停牌、涨跌停、T+1 检查。 - `cost`: 佣金、印花税、最低佣金模型。 -- `broker`: 目标权重到订单执行的模拟器,先卖后买,买单按 100 股向下取整。 +- `broker`: 同时支持“目标权重再平衡”和显式 `order_target_value / order_value` 订单意图,买单按 100 股向下取整;执行价可选 `open / close / last`。 - `strategy`: 引擎驱动的策略 trait 与具体策略实现。 - `engine`: 确定性的逐日回测循环和结果收集。 @@ -81,6 +84,25 @@ 这更接近平台化引擎需要的“策略意图”和“执行语义”分离。 +新增的 `JqMicroCapStrategy` 更直接对齐 `/聚宽微盘股策略.py`: + +1. 指数信号使用 `benchmark_signal_symbol` 的同日 `last_price`。 +2. 市值带按 `round((index_level - base_index_level) * xs + base_cap_floor)` 动态计算。 +3. 在预排序后的 eligible universe 上做带内截取,避免每个交易日全表扫描。 +4. 叠加脚本中的盘中规则: + - 涨停开盘 / 跌停开盘 + - 当前涨停 / 当前跌停 + - 停牌 / ST / 名称含退 / 科创板 + - 1 元股 + - 个股 5/10/20 日均线过滤 +5. 止损/止盈与固定 15 日再平衡同时工作。 +6. 当前实现将 `run_daily(10:17/10:18)` 近似为“同日快照决策 + `last_price` 执行”,比传统 `T-1 -> T open` 更接近原脚本。 +7. 执行层不再只做目标权重映射,而是支持更接近原脚本的显式订单链路: + - `order_target_value(symbol, 0)` 风格清仓 + - `order_value(symbol, cash)` 风格补仓 + - 止损/止盈后按剩余现金和剩余槽位补首个可买标的 + - 定期调仓时先卖出池外持仓,再按固定现金分配逐笔买入 + ## 与原始 jqdata 策略族的映射 如果原始逻辑大致是: @@ -97,7 +119,7 @@ - `get_price` / `history` -> `DailyMarketSnapshot` + `BenchmarkSnapshot` - `set_benchmark` -> `BacktestConfig.benchmark_code` - `filter_paused` / `filter_st` / 新股过滤 -> `CandidateEligibility` -- `order_target_value` -> `StrategyDecision.target_weights` 由 `BrokerSimulator` 解释执行 +- `order_target_value` / `order_value` -> `StrategyDecision.order_intents` 由 `BrokerSimulator` 顺序解释执行 - 风险控制逻辑 -> `CnSmallCapRotationStrategy::gross_exposure` ## Phase 2 新增内容 @@ -108,16 +130,25 @@ - 候选资格快照扩展:补入 `is_kcb`、`is_one_yuan` - 增加策略选择行为测试 -## V1 / V2 当前仍保留的简化点 +## Phase 3 新增内容 + +- `DataSet` 新增 symbol 级价格前缀和,均线查询变为 O(1) +- `DynamicMarketCapBandSelector` 新增预排序 eligible universe + 二分带内截取 +- `BrokerSimulator` 新增 `execution_price_field` +- `BacktestEngine` 新增 `decision_lag_trading_days` +- 新增 `JqMicroCapStrategy` 和对应测试 +- `StrategyDecision` / `BrokerSimulator` 新增显式订单意图,开始覆盖 `order_target_value / order_value` 语义 + +## 当前仍保留的简化点 下面这些是刻意保留为 v1 简化,而不是遗漏: -- 只支持日频,不做分钟级、集合竞价、盘中撮合。 -- 决策基于 `T-1` 收盘后可见数据,在 `T` 开盘价执行。 +- 只支持日频 snapshot,不直接做逐笔 tick 回放。 +- `JqMicroCapStrategy` 已支持同日 `last_price` 决策/执行,但这仍然是 snapshot 近似,不是盘口逐笔成交。 - 不模拟盘口排队、成交量约束和滑点模型,成交默认按开盘价完成。 - 买单按 100 股整手向下取整,卖单允许按实际持仓数量退出。 - 未处理复权、分红送转、融资融券、可转债、科创板/北交所差异规则。 -- 止损止盈基于上一交易日收盘价相对持仓成本触发,下一交易日开盘执行。 +- 止损止盈仍然是 snapshot 驱动,不是逐笔止损链。 这些简化都在代码结构上留了扩展位,不会阻断后续升级到更完整的执行层。 @@ -129,6 +160,14 @@ cargo run --bin bt-demo ``` +运行更贴近聚宽微盘股脚本的策略: + +```bash +FIDC_BT_STRATEGY=jq-microcap \ +FIDC_BT_SIGNAL_SYMBOL=000001.SH \ +cargo run --release --bin bt-demo +``` + 如果要接更接近真实数据面的按日分区 snapshot 目录: ```bash @@ -154,7 +193,7 @@ snapshots/ ``` 其中: -- `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit` +- `market/`:日级行情快照,可显式携带 `upper_limit/lower_limit/day_open/last_price` - `factors/`:估值/因子快照,可扩展 `turnover_ratio/effective_turnover_ratio` - `candidates/`:候选资格/过滤标记快照 - `benchmark/`:业绩基准指数快照 @@ -192,12 +231,14 @@ cargo build - broker 做持仓差量执行 - 不把查询逻辑塞进策略内部,避免回测时频繁回源数据层。 -如果未来把日频因子、资格标记、可交易标记和开/收盘价全部预计算到列式存储,再按日期分块读入内存,6 年全市场回测在 5 分钟内是合理目标,原因是: +如果未来把日频因子、资格标记、可交易标记和 `day_open / last_price / high_limit / low_limit` 全部预计算到列式存储,再按日期分块读入内存,6 年全市场回测在分钟级是合理目标,原因是: - 回测时不再做昂贵的 SQL join -- 因子筛选可直接消费预先物化的 snapshot +- 因子筛选可直接消费预先物化并排序的 snapshot - 组合调仓只关心“目标持仓”和“当前持仓”的差量 - 事件流是 append-only,适合批量写出和后处理分析 +- 均线查询通过 prefix sums 变成 O(1) +- 市值带选股通过预排序 universe + 二分定位变成 O(log N + K) ## 距离真实 6 年 / 5 分钟平台还差什么 diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs index 0c76fe6..74e0f0d 100644 --- a/crates/bt-demo/src/main.rs +++ b/crates/bt-demo/src/main.rs @@ -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> { } 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::().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()) @@ -70,19 +63,97 @@ fn main() -> Result<(), Box> { .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> { 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::>() .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::>(); let trades_preview = fills .iter() @@ -242,16 +318,18 @@ fn build_summary( .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, - })) + .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 { @@ -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::>())); + } 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::>())); + 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::>())); + 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)); } @@ -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::>().into_iter().rev() { + 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 diff --git a/crates/fidc-core/Cargo.toml b/crates/fidc-core/Cargo.toml index ffc7848..1882a73 100644 --- a/crates/fidc-core/Cargo.toml +++ b/crates/fidc-core/Cargo.toml @@ -7,5 +7,6 @@ authors.workspace = true [dependencies] chrono.workspace = true +indexmap.workspace = true serde.workspace = true thiserror.workspace = true diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 08d3812..85fec29 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -1,14 +1,14 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::NaiveDate; +use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use crate::cost::CostModel; -use crate::data::{DataSet, PriceField}; +use crate::data::{DataSet, IntradayExecutionQuote, PriceField}; use crate::engine::BacktestError; use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; use crate::portfolio::PortfolioState; use crate::rules::EquityRuleHooks; -use crate::strategy::StrategyDecision; +use crate::strategy::{OrderIntent, StrategyDecision}; #[derive(Debug, Default)] pub struct BrokerExecutionReport { @@ -18,10 +18,23 @@ pub struct BrokerExecutionReport { pub account_events: Vec, } +#[derive(Debug, Clone, Copy)] +struct ExecutionFill { + price: f64, + quantity: u32, + next_cursor: NaiveDateTime, +} + pub struct BrokerSimulator { cost_model: C, rules: R, board_lot_size: u32, + execution_price_field: PriceField, + volume_percent: f64, + volume_limit: bool, + inactive_limit: bool, + liquidity_limit: bool, + intraday_execution_start_time: Option, } impl BrokerSimulator { @@ -30,8 +43,57 @@ impl BrokerSimulator { cost_model, rules, board_lot_size: 100, + execution_price_field: PriceField::Open, + volume_percent: 0.25, + volume_limit: true, + inactive_limit: true, + liquidity_limit: true, + intraday_execution_start_time: None, } } + + pub fn new_with_execution_price( + cost_model: C, + rules: R, + execution_price_field: PriceField, + ) -> Self { + Self { + cost_model, + rules, + board_lot_size: 100, + execution_price_field, + volume_percent: 0.25, + volume_limit: true, + inactive_limit: true, + liquidity_limit: true, + intraday_execution_start_time: None, + } + } + + pub fn with_volume_limit(mut self, enabled: bool) -> Self { + self.volume_limit = enabled; + self + } + + pub fn with_inactive_limit(mut self, enabled: bool) -> Self { + self.inactive_limit = enabled; + self + } + + pub fn with_liquidity_limit(mut self, enabled: bool) -> Self { + self.liquidity_limit = enabled; + self + } + + pub fn with_volume_percent(mut self, volume_percent: f64) -> Self { + self.volume_percent = volume_percent; + self + } + + pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self { + self.intraday_execution_start_time = Some(start_time); + self + } } impl BrokerSimulator @@ -39,6 +101,18 @@ where C: CostModel, R: EquityRuleHooks, { + fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { + snapshot.buy_price(self.execution_price_field) + } + + fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { + snapshot.sell_price(self.execution_price_field) + } + + fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 { + snapshot.price(self.execution_price_field) + } + pub fn execute( &self, date: NaiveDate, @@ -47,6 +121,26 @@ where decision: &StrategyDecision, ) -> Result { let mut report = BrokerExecutionReport::default(); + let mut intraday_turnover = BTreeMap::::new(); + let mut execution_cursors = BTreeMap::::new(); + let mut global_execution_cursor = None::; + if !decision.order_intents.is_empty() { + for intent in &decision.order_intents { + self.process_order_intent( + date, + portfolio, + data, + intent, + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, + &mut report, + )?; + } + portfolio.prune_flat_positions(); + return Ok(report); + } + let target_quantities = if decision.rebalance { self.target_quantities(date, portfolio, data, &decision.target_weights)? } else { @@ -59,7 +153,10 @@ where sell_symbols.extend(target_quantities.keys().cloned()); for symbol in sell_symbols { - let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0); + let current_qty = portfolio + .position(&symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); if current_qty == 0 { continue; } @@ -81,6 +178,9 @@ where &symbol, requested_qty, sell_reason(decision, &symbol), + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, &mut report, )?; } @@ -88,7 +188,10 @@ where if decision.rebalance { for (symbol, target_qty) in target_quantities { - let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0); + let current_qty = portfolio + .position(&symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); if target_qty > current_qty { let requested_qty = target_qty - current_qty; self.process_buy( @@ -98,6 +201,10 @@ where &symbol, requested_qty, "rebalance_buy", + &mut intraday_turnover, + &mut execution_cursors, + &mut global_execution_cursor, + None, &mut report, )?; } @@ -108,6 +215,53 @@ where Ok(report) } + fn process_order_intent( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + intent: &OrderIntent, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + match intent { + OrderIntent::TargetValue { + symbol, + target_value, + reason, + } => self.process_target_value( + date, + portfolio, + data, + symbol, + *target_value, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + report, + ), + OrderIntent::Value { + symbol, + value, + reason, + } => self.process_value( + date, + portfolio, + data, + symbol, + *value, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + report, + ), + } + } + fn target_quantities( &self, date: NaiveDate, @@ -120,14 +274,14 @@ where for (symbol, weight) in target_weights { let price = data - .price(date, symbol, PriceField::Open) + .price(date, symbol, self.execution_price_field) .ok_or_else(|| BacktestError::MissingPrice { date, symbol: symbol.clone(), - field: "open", + field: price_field_name(self.execution_price_field), })?; let raw_qty = ((equity * weight) / price).floor() as u32; - let rounded_qty = self.round_buy_quantity(raw_qty); + let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol)); targets.insert(symbol.clone(), rounded_qty); } @@ -142,6 +296,9 @@ where symbol: &str, requested_qty: u32, reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; @@ -150,22 +307,55 @@ where return Ok(()); }; - let rule = self.rules.can_sell(date, snapshot, candidate, position); + let rule = self.rules.can_sell( + date, + snapshot, + candidate, + position, + self.execution_price_field, + ); if !rule.allowed { + let status = match rule.reason.as_deref() { + Some("paused") + | Some("sell disabled by eligibility flags") + | Some("open at or below lower limit") => OrderStatus::Canceled, + _ => OrderStatus::Rejected, + }; report.order_events.push(OrderEvent { date, symbol: symbol.to_string(), side: OrderSide::Sell, requested_quantity: requested_qty, filled_quantity: 0, - status: OrderStatus::Rejected, + status, reason: format!("{reason}: {}", rule.reason.unwrap_or_default()), }); return Ok(()); } let sellable = position.sellable_qty(date); - let filled_qty = requested_qty.min(sellable); + let market_limited_qty = self.market_fillable_quantity( + snapshot, + OrderSide::Sell, + requested_qty.min(sellable), + self.round_lot(data, symbol), + *intraday_turnover.get(symbol).unwrap_or(&0), + ); + let filled_qty = match market_limited_qty { + Ok(quantity) => quantity.min(sellable), + Err(limit_reason) => { + report.order_events.push(OrderEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!("{reason}: {limit_reason}"), + }); + return Ok(()); + } + }; if filled_qty == 0 { report.order_events.push(OrderEvent { date, @@ -180,15 +370,42 @@ where } let cash_before = portfolio.cash(); - let gross_amount = snapshot.open * filled_qty as f64; + let fill = self.resolve_execution_fill( + date, + symbol, + OrderSide::Sell, + snapshot, + data, + filled_qty, + self.round_lot(data, symbol), + execution_cursors, + None, + None, + None, + ); + let (filled_qty, execution_price) = if let Some(fill) = fill { + execution_cursors.insert(symbol.to_string(), fill.next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(fill.next_cursor); + } + (fill.quantity, fill.price) + } else { + ( + filled_qty, + self.sell_price(snapshot), + ) + }; + let gross_amount = execution_price * filled_qty as f64; let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount); let net_cash = gross_amount - cost.total(); let realized_pnl = portfolio .position_mut(symbol) - .sell(filled_qty, snapshot.open) + .sell(filled_qty, execution_price) .map_err(BacktestError::Execution)?; portfolio.apply_cash_delta(net_cash); + portfolio.prune_flat_positions(); + *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { OrderStatus::PartiallyFilled @@ -210,7 +427,7 @@ where symbol: symbol.to_string(), side: OrderSide::Sell, quantity: filled_qty, - price: snapshot.open, + price: execution_price, gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, @@ -221,7 +438,10 @@ where date, symbol: symbol.to_string(), delta_quantity: -(filled_qty as i32), - quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), + quantity_after: portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0), average_cost: portfolio .position(symbol) .map(|pos| pos.average_cost) @@ -239,6 +459,139 @@ where Ok(()) } + fn process_target_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + target_value: f64, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let price = data + .market(date, symbol) + .map(|snapshot| self.sizing_price(snapshot)) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })?; + let current_qty = portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0); + let current_value = price * current_qty as f64; + let target_qty = self.round_buy_quantity( + ((target_value.max(0.0)) / price).floor() as u32, + self.round_lot(data, symbol), + ); + + if current_qty > target_qty { + self.process_sell( + date, + portfolio, + data, + symbol, + current_qty - target_qty, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + report, + )?; + } else if target_qty > current_qty { + self.process_buy( + date, + portfolio, + data, + symbol, + target_qty - current_qty, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + None, + report, + )?; + } else if (current_value - target_value).abs() <= f64::EPSILON { + report.order_events.push(OrderEvent { + date, + symbol: symbol.to_string(), + side: if current_qty > 0 { + OrderSide::Sell + } else { + OrderSide::Buy + }, + requested_quantity: 0, + filled_quantity: 0, + status: OrderStatus::Filled, + reason: format!("{reason}: already at target value"), + }); + } + + Ok(()) + } + + fn process_value( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + value: f64, + reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + if value.abs() <= f64::EPSILON { + return Ok(()); + } + let snapshot = data + .market(date, symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.to_string(), + field: price_field_name(self.execution_price_field), + })?; + let price = self.sizing_price(snapshot); + let requested_qty = + self.round_buy_quantity(((value.abs()) / price).floor() as u32, self.round_lot(data, symbol)); + if value > 0.0 { + self.process_buy( + date, + portfolio, + data, + symbol, + requested_qty, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + Some(value.abs()), + report, + ) + } else { + self.process_sell( + date, + portfolio, + data, + symbol, + requested_qty, + reason, + intraday_turnover, + execution_cursors, + global_execution_cursor, + report, + ) + } + } + fn process_buy( &self, date: NaiveDate, @@ -247,12 +600,18 @@ where symbol: &str, requested_qty: u32, reason: &str, + intraday_turnover: &mut BTreeMap, + execution_cursors: &mut BTreeMap, + global_execution_cursor: &mut Option, + value_budget: Option, report: &mut BrokerExecutionReport, ) -> Result<(), BacktestError> { let snapshot = data.require_market(date, symbol)?; let candidate = data.require_candidate(date, symbol)?; - let rule = self.rules.can_buy(date, snapshot, candidate); + let rule = self + .rules + .can_buy(date, snapshot, candidate, self.execution_price_field); if !rule.allowed { report.order_events.push(OrderEvent { date, @@ -266,8 +625,59 @@ where return Ok(()); } - let filled_qty = - self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty); + let market_limited_qty = self.market_fillable_quantity( + snapshot, + OrderSide::Buy, + requested_qty, + self.round_lot(data, symbol), + *intraday_turnover.get(symbol).unwrap_or(&0), + ); + let constrained_qty = match market_limited_qty { + Ok(quantity) => quantity, + Err(limit_reason) => { + report.order_events.push(OrderEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: 0, + status: OrderStatus::Rejected, + reason: format!("{reason}: {limit_reason}"), + }); + return Ok(()); + } + }; + + let fill = self.resolve_execution_fill( + date, + symbol, + OrderSide::Buy, + snapshot, + data, + constrained_qty, + self.round_lot(data, symbol), + execution_cursors, + None, + Some(portfolio.cash()), + value_budget, + ); + let (filled_qty, execution_price) = if let Some(fill) = fill { + execution_cursors.insert(symbol.to_string(), fill.next_cursor); + if self.uses_serial_execution_cursor(reason) { + *global_execution_cursor = Some(fill.next_cursor); + } + (fill.quantity, fill.price) + } else { + let execution_price = self.buy_price(snapshot); + let filled_qty = self.affordable_buy_quantity( + portfolio.cash(), + value_budget, + execution_price, + constrained_qty, + self.round_lot(data, symbol), + ); + (filled_qty, execution_price) + }; if filled_qty == 0 { report.order_events.push(OrderEvent { date, @@ -282,12 +692,15 @@ where } let cash_before = portfolio.cash(); - let gross_amount = snapshot.open * filled_qty as f64; + let gross_amount = execution_price * filled_qty as f64; let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount); let cash_out = gross_amount + cost.total(); portfolio.apply_cash_delta(-cash_out); - portfolio.position_mut(symbol).buy(date, filled_qty, snapshot.open); + portfolio + .position_mut(symbol) + .buy(date, filled_qty, execution_price); + *intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty; let status = if filled_qty < requested_qty { OrderStatus::PartiallyFilled @@ -309,7 +722,7 @@ where symbol: symbol.to_string(), side: OrderSide::Buy, quantity: filled_qty, - price: snapshot.open, + price: execution_price, gross_amount, commission: cost.commission, stamp_tax: cost.stamp_tax, @@ -320,7 +733,10 @@ where date, symbol: symbol.to_string(), delta_quantity: filled_qty as i32, - quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), + quantity_after: portfolio + .position(symbol) + .map(|pos| pos.quantity) + .unwrap_or(0), average_cost: portfolio .position(symbol) .map(|pos| pos.average_cost) @@ -347,38 +763,304 @@ where ) -> Result { let mut market_value = 0.0; for position in portfolio.positions().values() { - let price = data - .price(date, &position.symbol, field) - .ok_or_else(|| BacktestError::MissingPrice { + let price = data.price(date, &position.symbol, field).ok_or_else(|| { + BacktestError::MissingPrice { date, symbol: position.symbol.clone(), field: match field { PriceField::Open => "open", PriceField::Close => "close", + PriceField::Last => "last", }, - })?; + } + })?; market_value += price * position.quantity as f64; } Ok(portfolio.cash() + market_value) } - fn round_buy_quantity(&self, quantity: u32) -> u32 { - (quantity / self.board_lot_size) * self.board_lot_size + fn round_lot(&self, data: &DataSet, symbol: &str) -> u32 { + data.instruments() + .get(symbol) + .map(|instrument| instrument.effective_round_lot()) + .unwrap_or(self.board_lot_size.max(1)) } - fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 { - let mut quantity = self.round_buy_quantity(requested_qty); + fn round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 { + let lot = round_lot.max(1); + (quantity / lot) * lot + } + + fn affordable_buy_quantity( + &self, + cash: f64, + gross_limit: Option, + price: f64, + requested_qty: u32, + round_lot: u32, + ) -> u32 { + let lot = round_lot.max(1); + let mut quantity = self.round_buy_quantity(requested_qty, lot); while quantity > 0 { let gross = price * quantity as f64; + if gross_limit.is_some_and(|limit| gross > limit + 1e-6) { + quantity = quantity.saturating_sub(lot); + continue; + } let cost = self.cost_model.calculate(OrderSide::Buy, gross); if gross + cost.total() <= cash + 1e-6 { return quantity; } - quantity = quantity.saturating_sub(self.board_lot_size); + quantity = quantity.saturating_sub(lot); } 0 } + + fn market_fillable_quantity( + &self, + snapshot: &crate::data::DailyMarketSnapshot, + side: OrderSide, + requested_qty: u32, + round_lot: u32, + consumed_turnover: u32, + ) -> Result { + if requested_qty == 0 { + return Ok(0); + } + + if self.inactive_limit && snapshot.tick_volume == 0 { + return Err("tick no volume".to_string()); + } + + let mut max_fill = requested_qty; + let lot = round_lot.max(1); + + if self.liquidity_limit { + let top_level_liquidity = match side { + OrderSide::Buy => snapshot.liquidity_for_buy(), + OrderSide::Sell => snapshot.liquidity_for_sell(), + } + .min(u32::MAX as u64) as u32; + if top_level_liquidity == 0 { + return Err("no quote liquidity".to_string()); + } + max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot)); + } + + if self.volume_limit { + let raw_limit = + ((snapshot.tick_volume as f64) * self.volume_percent).round() as i64 + - consumed_turnover as i64; + if raw_limit <= 0 { + return Err("tick volume limit".to_string()); + } + let volume_limited = self.round_buy_quantity(raw_limit as u32, lot); + if volume_limited == 0 { + return Err("tick volume limit".to_string()); + } + max_fill = max_fill.min(volume_limited); + } + + Ok(max_fill) + } + + fn resolve_execution_fill( + &self, + date: NaiveDate, + symbol: &str, + side: OrderSide, + _snapshot: &crate::data::DailyMarketSnapshot, + data: &DataSet, + requested_qty: u32, + round_lot: u32, + execution_cursors: &mut BTreeMap, + global_execution_cursor: Option, + cash_limit: Option, + gross_limit: Option, + ) -> Option { + if self.execution_price_field != PriceField::Last { + return None; + } + + let start_cursor = execution_cursors + .get(symbol) + .copied() + .into_iter() + .chain(global_execution_cursor) + .chain( + self.intraday_execution_start_time + .map(|start_time| date.and_time(start_time)), + ) + .max(); + let quotes = data.execution_quotes_on(date, symbol); + self.select_execution_fill( + quotes, + side, + start_cursor, + requested_qty, + round_lot, + cash_limit, + gross_limit, + ) + } + + fn select_execution_fill( + &self, + quotes: &[IntradayExecutionQuote], + side: OrderSide, + start_cursor: Option, + requested_qty: u32, + round_lot: u32, + cash_limit: Option, + gross_limit: Option, + ) -> Option { + if requested_qty == 0 { + return None; + } + + let lot = round_lot.max(1); + let mut filled_qty = 0_u32; + let mut gross_amount = 0.0_f64; + let mut last_timestamp = None; + let mut last_quote_price = None; + + for quote in quotes { + if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) { + continue; + } + + let fallback_quote_price = match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + }; + if fallback_quote_price.is_some() { + last_quote_price = fallback_quote_price; + last_timestamp = Some(quote.timestamp); + } + + // Approximate JoinQuant market-order fills with the evolving L1 book after + // the decision time instead of trade VWAP. This keeps quantities/prices + // closer to the observed 10:18 execution logs. + if quote.volume_delta == 0 { + continue; + } + let quote_price = match side { + OrderSide::Buy => quote.buy_price(), + OrderSide::Sell => quote.sell_price(), + }; + let Some(quote_price) = quote_price else { + continue; + }; + if !quote_price.is_finite() || quote_price <= 0.0 { + continue; + } + let top_level_liquidity = match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + }; + let available_qty = top_level_liquidity + .saturating_mul(lot as u64) + .min(u32::MAX as u64) as u32; + if available_qty == 0 { + continue; + } + + let remaining_qty = requested_qty.saturating_sub(filled_qty); + if remaining_qty == 0 { + break; + } + let mut take_qty = remaining_qty.min(available_qty); + take_qty = self.round_buy_quantity(take_qty, lot); + if take_qty == 0 { + continue; + } + + if let Some(cash) = cash_limit { + while take_qty > 0 { + let candidate_gross = gross_amount + quote_price * take_qty as f64; + if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + take_qty = take_qty.saturating_sub(lot); + continue; + } + let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross); + if candidate_gross + candidate_cost.total() <= cash + 1e-6 { + break; + } + take_qty = take_qty.saturating_sub(lot); + } + if take_qty == 0 { + break; + } + } + + gross_amount += quote_price * take_qty as f64; + filled_qty += take_qty; + last_timestamp = Some(quote.timestamp); + + if filled_qty >= requested_qty { + break; + } + } + + if filled_qty < requested_qty { + let remaining_qty = requested_qty.saturating_sub(filled_qty); + let mut residual_qty = self.round_buy_quantity(remaining_qty, lot); + if residual_qty > 0 { + if let Some(residual_price) = last_quote_price { + if let Some(cash) = cash_limit { + while residual_qty > 0 { + let candidate_gross = gross_amount + residual_price * residual_qty as f64; + if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) { + residual_qty = residual_qty.saturating_sub(lot); + continue; + } + let candidate_cost = + self.cost_model.calculate(OrderSide::Buy, candidate_gross); + if candidate_gross + candidate_cost.total() <= cash + 1e-6 { + break; + } + residual_qty = residual_qty.saturating_sub(lot); + } + } + if residual_qty > 0 { + let execution_price = match side { + OrderSide::Buy => residual_price, + OrderSide::Sell => residual_price, + }; + gross_amount += execution_price * residual_qty as f64; + filled_qty += residual_qty; + } + } + } + } + + if filled_qty == 0 { + return None; + } + + Some(ExecutionFill { + price: gross_amount / filled_qty as f64, + quantity: filled_qty, + next_cursor: last_timestamp.unwrap() + Duration::seconds(1), + }) + } + + fn uses_serial_execution_cursor(&self, reason: &str) -> bool { + matches!( + reason, + "stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit" + | "replacement_after_take_profit_exit" + ) + } +} + +fn price_field_name(field: PriceField) -> &'static str { + match field { + PriceField::Open => "open", + PriceField::Close => "close", + PriceField::Last => "last", + } } fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { diff --git a/crates/fidc-core/src/calendar.rs b/crates/fidc-core/src/calendar.rs index 9dc3adc..309668d 100644 --- a/crates/fidc-core/src/calendar.rs +++ b/crates/fidc-core/src/calendar.rs @@ -45,7 +45,8 @@ impl TradingCalendar { pub fn previous_day(&self, date: NaiveDate) -> Option { let idx = self.index_of(date)?; - idx.checked_sub(1).and_then(|prev| self.days.get(prev).copied()) + idx.checked_sub(1) + .and_then(|prev| self.days.get(prev).copied()) } pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec { diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 04ef0f8..167906d 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::Path; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -31,6 +31,28 @@ mod date_format { } } +mod datetime_format { + use chrono::NaiveDateTime; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d %H:%M:%S"; + + pub fn serialize(date: &NaiveDateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.format(FORMAT).to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let text = String::deserialize(deserializer)?; + NaiveDateTime::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) + } +} + #[derive(Debug, Error)] pub enum DataSetError { #[error("failed to read file {path}: {source}")] @@ -57,10 +79,11 @@ pub enum DataSetError { MissingBenchmark { date: NaiveDate }, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PriceField { Open, Close, + Last, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -68,15 +91,79 @@ pub struct DailyMarketSnapshot { #[serde(with = "date_format")] pub date: NaiveDate, pub symbol: String, + pub timestamp: Option, + pub day_open: f64, pub open: f64, pub high: f64, pub low: f64, pub close: f64, + pub last_price: f64, + pub bid1: f64, + pub ask1: f64, pub prev_close: f64, pub volume: u64, + pub tick_volume: u64, + pub bid1_volume: u64, + pub ask1_volume: u64, + pub trading_phase: Option, pub paused: bool, pub upper_limit: f64, pub lower_limit: f64, + pub price_tick: f64, +} + +impl DailyMarketSnapshot { + pub fn price(&self, field: PriceField) -> f64 { + match field { + PriceField::Open => self.open, + PriceField::Close => self.close, + PriceField::Last => self.last_price, + } + } + + pub fn buy_price(&self, field: PriceField) -> f64 { + match field { + PriceField::Last if self.ask1.is_finite() && self.ask1 > 0.0 => self.ask1, + _ => self.price(field), + } + } + + pub fn sell_price(&self, field: PriceField) -> f64 { + match field { + PriceField::Last if self.bid1.is_finite() && self.bid1 > 0.0 => self.bid1, + _ => self.price(field), + } + } + + pub fn liquidity_for_buy(&self) -> u64 { + self.ask1_volume + } + + pub fn liquidity_for_sell(&self) -> u64 { + self.bid1_volume + } + + pub fn effective_price_tick(&self) -> f64 { + if self.price_tick.is_finite() && self.price_tick > 0.0 { + self.price_tick + } else { + 0.01 + } + } + + pub fn is_at_upper_limit_price(&self, price: f64) -> bool { + if !self.upper_limit.is_finite() || self.upper_limit <= 0.0 { + return false; + } + price >= self.upper_limit - self.effective_price_tick() + 1e-6 + } + + pub fn is_at_lower_limit_price(&self, price: f64) -> bool { + if !self.lower_limit.is_finite() || self.lower_limit <= 0.0 { + return false; + } + price <= self.lower_limit + self.effective_price_tick() - 1e-6 + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -128,6 +215,76 @@ impl CandidateEligibility { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CorporateAction { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + #[serde(default, with = "optional_date_format")] + pub payable_date: Option, + pub share_cash: f64, + pub share_bonus: f64, + pub share_gift: f64, + pub issue_quantity: f64, + pub issue_price: f64, + pub reform: bool, + pub adjust_factor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntradayExecutionQuote { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + #[serde(with = "datetime_format")] + pub timestamp: NaiveDateTime, + pub last_price: f64, + pub bid1: f64, + pub ask1: f64, + pub bid1_volume: u64, + pub ask1_volume: u64, + #[serde(default)] + pub volume_delta: u64, + #[serde(default)] + pub amount_delta: f64, + pub trading_phase: Option, +} + +impl IntradayExecutionQuote { + pub fn buy_price(&self) -> Option { + if self.ask1.is_finite() && self.ask1 > 0.0 { + Some(self.ask1) + } else if self.last_price.is_finite() && self.last_price > 0.0 { + Some(self.last_price) + } else { + None + } + } + + pub fn sell_price(&self) -> Option { + if self.bid1.is_finite() && self.bid1 > 0.0 { + Some(self.bid1) + } else if self.last_price.is_finite() && self.last_price > 0.0 { + Some(self.last_price) + } else { + None + } + } +} + +impl CorporateAction { + pub fn split_ratio(&self) -> f64 { + 1.0 + self.share_bonus.max(0.0) + self.share_gift.max(0.0) + } + + pub fn has_effect(&self) -> bool { + self.share_cash.abs() > f64::EPSILON + || (self.split_ratio() - 1.0).abs() > f64::EPSILON + || self.issue_quantity.abs() > f64::EPSILON + || self.reform + } +} + #[derive(Debug, Clone)] pub struct DailySnapshotBundle { pub date: NaiveDate, @@ -135,6 +292,198 @@ pub struct DailySnapshotBundle { pub market: Vec, pub factors: Vec, pub candidates: Vec, + pub corporate_actions: Vec, +} + +#[derive(Debug, Clone)] +pub struct EligibleUniverseSnapshot { + pub symbol: String, + pub market_cap_bn: f64, + pub free_float_cap_bn: f64, +} + +#[derive(Debug, Clone)] +struct SymbolPriceSeries { + dates: Vec, + opens: Vec, + closes: Vec, + prev_closes: Vec, + last_prices: Vec, + open_prefix: Vec, + close_prefix: Vec, + last_prefix: Vec, +} + +impl SymbolPriceSeries { + fn new(rows: &[DailyMarketSnapshot]) -> Self { + let mut sorted = rows.to_vec(); + sorted.sort_by_key(|row| row.date); + + let dates = sorted.iter().map(|row| row.date).collect::>(); + let opens = sorted.iter().map(|row| row.open).collect::>(); + let closes = sorted.iter().map(|row| row.close).collect::>(); + let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); + let last_prices = sorted.iter().map(|row| row.last_price).collect::>(); + let open_prefix = prefix_sums(&opens); + let close_prefix = prefix_sums(&closes); + let last_prefix = prefix_sums(&last_prices); + + Self { + dates, + opens, + closes, + prev_closes, + last_prices, + open_prefix, + close_prefix, + last_prefix, + } + } + + fn moving_average(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option { + if lookback == 0 { + return None; + } + let end = self.end_index(date)?; + if end < lookback { + return None; + } + let start = end - lookback; + let prefix = self.prefix_for(field); + let sum = prefix[end] - prefix[start]; + Some(sum / lookback as f64) + } + + fn trailing_values(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Vec { + let Some(end) = self.end_index(date) else { + return Vec::new(); + }; + let start = end.saturating_sub(lookback); + self.values_for(field)[start..end].to_vec() + } + + fn decision_price_on_or_before(&self, date: NaiveDate) -> Option { + let end = self.end_index(date)?; + if end == 0 { + return None; + } + let last_idx = end - 1; + if self.dates.get(last_idx).copied() == Some(date) { + let prev_close = self.prev_closes.get(last_idx).copied().unwrap_or_default(); + if prev_close.is_finite() && prev_close > 0.0 { + return Some(prev_close); + } + } + self.closes.get(last_idx).copied() + } + + fn decision_end_index(&self, date: NaiveDate) -> Option { + match self.dates.binary_search(&date) { + Ok(idx) => { + if idx == 0 { + None + } else { + Some(idx) + } + } + Err(0) => None, + Err(idx) => Some(idx), + } + } + + fn decision_close_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = self.decision_end_index(date)?; + if end < lookback { + return None; + } + let start = end - lookback; + let sum = self.close_prefix[end] - self.close_prefix[start]; + Some(sum / lookback as f64) + } + + fn end_index(&self, date: NaiveDate) -> Option { + match self.dates.binary_search(&date) { + Ok(idx) => Some(idx + 1), + Err(0) => None, + Err(idx) => Some(idx), + } + } + + fn values_for(&self, field: PriceField) -> &[f64] { + match field { + PriceField::Open => &self.opens, + PriceField::Close => &self.closes, + PriceField::Last => &self.last_prices, + } + } + + fn price_on_or_before(&self, date: NaiveDate, field: PriceField) -> Option { + let end = self.end_index(date)?; + if end == 0 { + return None; + } + self.values_for(field).get(end - 1).copied() + } + + fn prefix_for(&self, field: PriceField) -> &[f64] { + match field { + PriceField::Open => &self.open_prefix, + PriceField::Close => &self.close_prefix, + PriceField::Last => &self.last_prefix, + } + } +} + +#[derive(Debug, Clone)] +struct BenchmarkPriceSeries { + dates: Vec, + closes: Vec, + close_prefix: Vec, +} + +impl BenchmarkPriceSeries { + fn new(rows: &[BenchmarkSnapshot]) -> Self { + let mut sorted = rows.to_vec(); + sorted.sort_by_key(|row| row.date); + let dates = sorted.iter().map(|row| row.date).collect::>(); + let closes = sorted.iter().map(|row| row.close).collect::>(); + let close_prefix = prefix_sums(&closes); + Self { + dates, + closes, + close_prefix, + } + } + + fn moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + if lookback == 0 { + return None; + } + let end = match self.dates.binary_search(&date) { + Ok(idx) => idx + 1, + Err(0) => return None, + Err(idx) => idx, + }; + if end < lookback { + return None; + } + let start = end - lookback; + let sum = self.close_prefix[end] - self.close_prefix[start]; + Some(sum / lookback as f64) + } + + fn trailing_values(&self, date: NaiveDate, lookback: usize) -> Vec { + let end = match self.dates.binary_search(&date) { + Ok(idx) => idx + 1, + Err(0) => return Vec::new(), + Err(idx) => idx, + }; + let start = end.saturating_sub(lookback); + self.closes[start..end].to_vec() + } } #[derive(Debug, Clone)] @@ -147,7 +496,12 @@ pub struct DataSet { factor_index: HashMap<(NaiveDate, String), DailyFactorSnapshot>, candidate_by_date: BTreeMap>, candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>, + corporate_actions_by_date: BTreeMap>, + execution_quotes_index: HashMap<(NaiveDate, String), Vec>, benchmark_by_date: BTreeMap, + market_series_by_symbol: HashMap, + benchmark_series_cache: BenchmarkPriceSeries, + eligible_universe_by_date: BTreeMap>, benchmark_code: String, } @@ -158,7 +512,27 @@ impl DataSet { let factors = read_factors(&path.join("factors.csv"))?; let candidates = read_candidates(&path.join("candidate_flags.csv"))?; let benchmarks = read_benchmarks(&path.join("benchmark.csv"))?; - Self::from_components(instruments, market, factors, candidates, benchmarks) + let corporate_actions_path = path.join("corporate_actions.csv"); + let corporate_actions = if corporate_actions_path.exists() { + read_corporate_actions(&corporate_actions_path)? + } else { + Vec::new() + }; + let execution_quotes_path = path.join("execution_quotes.csv"); + let execution_quotes = if execution_quotes_path.exists() { + read_execution_quotes(&execution_quotes_path)? + } else { + Vec::new() + }; + Self::from_components_with_actions_and_quotes( + instruments, + market, + factors, + candidates, + benchmarks, + corporate_actions, + execution_quotes, + ) } pub fn from_partitioned_dir(path: &Path) -> Result { @@ -167,7 +541,27 @@ impl DataSet { let market = read_partitioned_dir(&path.join("market"), read_market)?; let factors = read_partitioned_dir(&path.join("factors"), read_factors)?; let candidates = read_partitioned_dir(&path.join("candidates"), read_candidates)?; - Self::from_components(instruments, market, factors, candidates, benchmarks) + let corporate_actions_dir = path.join("corporate_actions"); + let corporate_actions = if corporate_actions_dir.exists() { + read_partitioned_dir(&corporate_actions_dir, read_corporate_actions)? + } else { + Vec::new() + }; + let execution_quotes_dir = path.join("execution_quotes"); + let execution_quotes = if execution_quotes_dir.exists() { + read_partitioned_dir(&execution_quotes_dir, read_execution_quotes)? + } else { + Vec::new() + }; + Self::from_components_with_actions_and_quotes( + instruments, + market, + factors, + candidates, + benchmarks, + corporate_actions, + execution_quotes, + ) } pub fn from_components( @@ -176,6 +570,45 @@ impl DataSet { factors: Vec, candidates: Vec, benchmarks: Vec, + ) -> Result { + Self::from_components_with_actions_and_quotes( + instruments, + market, + factors, + candidates, + benchmarks, + Vec::new(), + Vec::new(), + ) + } + + pub fn from_components_with_actions( + instruments: Vec, + market: Vec, + factors: Vec, + candidates: Vec, + benchmarks: Vec, + corporate_actions: Vec, + ) -> Result { + Self::from_components_with_actions_and_quotes( + instruments, + market, + factors, + candidates, + benchmarks, + corporate_actions, + Vec::new(), + ) + } + + pub fn from_components_with_actions_and_quotes( + instruments: Vec, + market: Vec, + factors: Vec, + candidates: Vec, + benchmarks: Vec, + corporate_actions: Vec, + execution_quotes: Vec, ) -> Result { let benchmark_code = collect_benchmark_code(&benchmarks)?; let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect()); @@ -202,11 +635,18 @@ impl DataSet { .into_iter() .map(|item| ((item.date, item.symbol.clone()), item)) .collect::>(); + let corporate_actions_by_date = group_by_date(corporate_actions, |item| item.date); + let execution_quotes_index = build_execution_quote_index(execution_quotes); let benchmark_by_date = benchmarks .into_iter() .map(|item| (item.date, item)) .collect::>(); + let market_series_by_symbol = build_market_series(&market_by_date); + let benchmark_series_cache = + BenchmarkPriceSeries::new(&benchmark_by_date.values().cloned().collect::>()); + let eligible_universe_by_date = + build_eligible_universe(&factor_by_date, &candidate_index, &market_index); Ok(Self { instruments, @@ -217,7 +657,12 @@ impl DataSet { factor_index, candidate_by_date, candidate_index, + corporate_actions_by_date, + execution_quotes_index, benchmark_by_date, + market_series_by_symbol, + benchmark_series_cache, + eligible_universe_by_date, benchmark_code, }) } @@ -234,6 +679,10 @@ impl DataSet { &self.instruments } + pub fn instrument(&self, symbol: &str) -> Option<&Instrument> { + self.instruments.get(symbol) + } + pub fn market(&self, date: NaiveDate, symbol: &str) -> Option<&DailyMarketSnapshot> { self.market_index.get(&(date, symbol.to_string())) } @@ -250,16 +699,38 @@ impl DataSet { self.benchmark_by_date.get(&date) } + pub fn corporate_actions_on(&self, date: NaiveDate) -> &[CorporateAction] { + self.corporate_actions_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] { + self.execution_quotes_index + .get(&(date, symbol.to_string())) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + pub fn benchmark_series(&self) -> Vec { self.benchmark_by_date.values().cloned().collect() } pub fn price(&self, date: NaiveDate, symbol: &str, field: PriceField) -> Option { let snapshot = self.market(date, symbol)?; - Some(match field { - PriceField::Open => snapshot.open, - PriceField::Close => snapshot.close, - }) + Some(snapshot.price(field)) + } + + pub fn price_on_or_before( + &self, + date: NaiveDate, + symbol: &str, + field: PriceField, + ) -> Option { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.price_on_or_before(date, field)) } pub fn factor_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyFactorSnapshot> { @@ -293,24 +764,68 @@ impl DataSet { benchmark, market: self.market_by_date.get(&date).cloned().unwrap_or_default(), factors: self.factor_by_date.get(&date).cloned().unwrap_or_default(), - candidates: self.candidate_by_date.get(&date).cloned().unwrap_or_default(), + candidates: self + .candidate_by_date + .get(&date) + .cloned() + .unwrap_or_default(), + corporate_actions: self + .corporate_actions_by_date + .get(&date) + .cloned() + .unwrap_or_default(), }) } pub fn benchmark_closes_up_to(&self, date: NaiveDate, lookback: usize) -> Vec { - self.calendar - .trailing_days(date, lookback) - .into_iter() - .filter_map(|day| self.benchmark(day).map(|row| row.close)) - .collect() + self.benchmark_series_cache.trailing_values(date, lookback) } pub fn market_closes_up_to(&self, date: NaiveDate, symbol: &str, lookback: usize) -> Vec { - self.calendar - .trailing_days(date, lookback) - .into_iter() - .filter_map(|day| self.market(day, symbol).map(|row| row.close)) - .collect() + self.market_series_by_symbol + .get(symbol) + .map(|series| series.trailing_values(date, lookback, PriceField::Close)) + .unwrap_or_default() + } + + pub fn market_decision_close(&self, date: NaiveDate, symbol: &str) -> Option { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_price_on_or_before(date)) + } + + pub fn market_decision_close_moving_average( + &self, + date: NaiveDate, + symbol: &str, + lookback: usize, + ) -> Option { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.decision_close_moving_average(date, lookback)) + } + + pub fn market_moving_average( + &self, + date: NaiveDate, + symbol: &str, + lookback: usize, + field: PriceField, + ) -> Option { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.moving_average(date, lookback, field)) + } + + pub fn benchmark_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + self.benchmark_series_cache.moving_average(date, lookback) + } + + pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] { + self.eligible_universe_by_date + .get(&date) + .map(Vec::as_slice) + .unwrap_or(&[]) } pub fn require_market( @@ -318,11 +833,12 @@ impl DataSet { date: NaiveDate, symbol: &str, ) -> Result<&DailyMarketSnapshot, DataSetError> { - self.market(date, symbol).ok_or_else(|| DataSetError::MissingSnapshot { - kind: "market", - date, - symbol: symbol.to_string(), - }) + self.market(date, symbol) + .ok_or_else(|| DataSetError::MissingSnapshot { + kind: "market", + date, + symbol: symbol.to_string(), + }) } pub fn require_candidate( @@ -347,6 +863,16 @@ fn read_instruments(path: &Path) -> Result, DataSetError> { symbol: row.get(0)?.to_string(), name: row.get(1)?.to_string(), board: row.get(2)?.to_string(), + round_lot: row.parse_optional_u32(3).unwrap_or(100), + listed_at: row.parse_optional_date(4)?, + delisted_at: row.parse_optional_date(5)?, + status: row + .fields + .get(6) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("active") + .to_string(), }); } Ok(instruments) @@ -356,21 +882,42 @@ fn read_market(path: &Path) -> Result, DataSetError> { let rows = read_rows(path)?; let mut snapshots = Vec::new(); for row in rows { + let open = row.parse_f64(2)?; + let close = row.parse_f64(5)?; let prev_close = row.parse_f64(6)?; - let derived_upper_limit = round2(prev_close * 1.10); - let derived_lower_limit = round2(prev_close * 0.90); + let price_tick = row.parse_optional_f64(15).unwrap_or(0.01); + let derived_upper_limit = round_price_to_tick(prev_close * 1.10, price_tick); + let derived_lower_limit = round_price_to_tick(prev_close * 0.90, price_tick); snapshots.push(DailyMarketSnapshot { date: row.parse_date(0)?, symbol: row.get(1)?.to_string(), - open: row.parse_f64(2)?, + timestamp: row + .fields + .get(16) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + day_open: row.parse_optional_f64(11).unwrap_or(open), + open, high: row.parse_f64(3)?, low: row.parse_f64(4)?, - close: row.parse_f64(5)?, + close, + last_price: row.parse_optional_f64(12).unwrap_or(close), + bid1: row.parse_optional_f64(13).unwrap_or(close), + ask1: row.parse_optional_f64(14).unwrap_or(close), prev_close, volume: row.parse_u64(7)?, + tick_volume: row.parse_optional_u64(17).unwrap_or_default(), + bid1_volume: row.parse_optional_u64(18).unwrap_or_default(), + ask1_volume: row.parse_optional_u64(19).unwrap_or_default(), + trading_phase: row + .fields + .get(20) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), paused: row.parse_bool(8)?, upper_limit: row.parse_optional_f64(9).unwrap_or(derived_upper_limit), lower_limit: row.parse_optional_f64(10).unwrap_or(derived_lower_limit), + price_tick, }); } Ok(snapshots) @@ -428,6 +975,58 @@ fn read_benchmarks(path: &Path) -> Result, DataSetError> Ok(snapshots) } +fn read_corporate_actions(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut snapshots = Vec::new(); + for row in rows { + let has_payable_date = row.fields.len() >= 10; + let payable_date = if has_payable_date { + row.parse_optional_date(2)? + } else { + None + }; + let offset = if has_payable_date { 1 } else { 0 }; + snapshots.push(CorporateAction { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + payable_date, + share_cash: row.parse_optional_f64(2 + offset).unwrap_or(0.0), + share_bonus: row.parse_optional_f64(3 + offset).unwrap_or(0.0), + share_gift: row.parse_optional_f64(4 + offset).unwrap_or(0.0), + issue_quantity: row.parse_optional_f64(5 + offset).unwrap_or(0.0), + issue_price: row.parse_optional_f64(6 + offset).unwrap_or(0.0), + reform: row.parse_optional_bool(7 + offset).unwrap_or(false), + adjust_factor: row.parse_optional_f64(8 + offset), + }); + } + Ok(snapshots) +} + +fn read_execution_quotes(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut quotes = Vec::new(); + for row in rows { + quotes.push(IntradayExecutionQuote { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + timestamp: row.parse_datetime(2)?, + last_price: row.parse_optional_f64(3).unwrap_or_default(), + bid1: row.parse_optional_f64(4).unwrap_or_default(), + ask1: row.parse_optional_f64(5).unwrap_or_default(), + bid1_volume: row.parse_optional_u64(6).unwrap_or_default(), + ask1_volume: row.parse_optional_u64(7).unwrap_or_default(), + volume_delta: row.parse_optional_u64(8).unwrap_or_default(), + amount_delta: row.parse_optional_f64(9).unwrap_or_default(), + trading_phase: row + .fields + .get(10) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }); + } + Ok(quotes) +} + struct CsvRow { path: String, line: usize, @@ -436,18 +1035,23 @@ struct CsvRow { impl CsvRow { fn get(&self, index: usize) -> Result<&str, DataSetError> { - self.fields.get(index).map(String::as_str).ok_or_else(|| DataSetError::InvalidRow { - path: self.path.clone(), - line: self.line, - message: format!("missing column {index}"), - }) + self.fields + .get(index) + .map(String::as_str) + .ok_or_else(|| DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("missing column {index}"), + }) } fn parse_date(&self, index: usize) -> Result { - NaiveDate::parse_from_str(self.get(index)?, "%Y-%m-%d").map_err(|err| DataSetError::InvalidRow { - path: self.path.clone(), - line: self.line, - message: format!("invalid date: {err}"), + NaiveDate::parse_from_str(self.get(index)?, "%Y-%m-%d").map_err(|err| { + DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid date: {err}"), + } }) } @@ -497,6 +1101,55 @@ impl CsvRow { .get(index) .and_then(|value| value.parse::().ok()) } + + fn parse_optional_date(&self, index: usize) -> Result, DataSetError> { + let Some(value) = self.fields.get(index) else { + return Ok(None); + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") + .map(Some) + .map_err(|err| DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid optional date: {err}"), + }) + } + + fn parse_datetime(&self, index: usize) -> Result { + NaiveDateTime::parse_from_str(self.get(index)?, "%Y-%m-%d %H:%M:%S").map_err(|err| { + DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid datetime: {err}"), + } + }) + } + + fn parse_optional_u32(&self, index: usize) -> Option { + self.fields.get(index).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) + } + + fn parse_optional_u64(&self, index: usize) -> Option { + self.fields.get(index).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) + } } fn read_partitioned_dir(dir: &Path, mut loader: F) -> Result, DataSetError> @@ -551,7 +1204,10 @@ fn read_rows(path: &Path) -> Result, DataSetError> { rows.push(CsvRow { path: path.display().to_string(), line: line_no, - fields: line.split(',').map(|field| field.trim().to_string()).collect(), + fields: line + .split(',') + .map(|field| field.trim().to_string()) + .collect(), }); } @@ -584,6 +1240,129 @@ fn collect_benchmark_code(benchmarks: &[BenchmarkSnapshot]) -> Result f64 { - (value * 100.0).round() / 100.0 +fn round_price_to_tick(value: f64, tick: f64) -> f64 { + let effective_tick = if tick.is_finite() && tick > 0.0 { + tick + } else { + 0.01 + }; + ((value / effective_tick).round() * effective_tick * 10000.0).round() / 10000.0 +} + +fn prefix_sums(values: &[f64]) -> Vec { + let mut prefix = Vec::with_capacity(values.len() + 1); + prefix.push(0.0); + for value in values { + let next = prefix.last().copied().unwrap_or_default() + *value; + prefix.push(next); + } + prefix +} + +mod optional_date_format { + use chrono::NaiveDate; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(date: &Option, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let text = Option::::deserialize(deserializer)?; + match text.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + Some(text) => NaiveDate::parse_from_str(text, FORMAT) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } +} + +fn build_market_series( + market_by_date: &BTreeMap>, +) -> HashMap { + let mut grouped = HashMap::>::new(); + for rows in market_by_date.values() { + for row in rows { + grouped + .entry(row.symbol.clone()) + .or_default() + .push(row.clone()); + } + } + + grouped + .into_iter() + .map(|(symbol, rows)| (symbol, SymbolPriceSeries::new(&rows))) + .collect() +} + +fn build_execution_quote_index( + execution_quotes: Vec, +) -> HashMap<(NaiveDate, String), Vec> { + let mut grouped = HashMap::<(NaiveDate, String), Vec>::new(); + for quote in execution_quotes { + grouped + .entry((quote.date, quote.symbol.clone())) + .or_default() + .push(quote); + } + + for quotes in grouped.values_mut() { + quotes.sort_by_key(|quote| quote.timestamp); + } + + grouped +} + +fn build_eligible_universe( + factor_by_date: &BTreeMap>, + candidate_index: &HashMap<(NaiveDate, String), CandidateEligibility>, + market_index: &HashMap<(NaiveDate, String), DailyMarketSnapshot>, +) -> BTreeMap> { + let mut per_date = BTreeMap::>::new(); + + for (date, factors) in factor_by_date { + let mut rows = Vec::new(); + for factor in factors { + if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { + continue; + } + let key = (*date, factor.symbol.clone()); + let Some(candidate) = candidate_index.get(&key) else { + continue; + }; + let Some(market) = market_index.get(&key) else { + continue; + }; + if !candidate.eligible_for_selection() || market.paused { + continue; + } + rows.push(EligibleUniverseSnapshot { + symbol: factor.symbol.clone(), + market_cap_bn: factor.market_cap_bn, + free_float_cap_bn: factor.free_float_cap_bn, + }); + } + rows.sort_by(|left, right| { + left.market_cap_bn + .partial_cmp(&right.market_cap_bn) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.symbol.cmp(&right.symbol)) + }); + per_date.insert(*date, rows); + } + + per_date } diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 4e8f80c..89928da 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -5,8 +5,9 @@ use thiserror::Error; use crate::broker::{BrokerExecutionReport, BrokerSimulator}; use crate::cost::CostModel; use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField}; -use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent}; -use crate::portfolio::{HoldingSummary, PortfolioState}; +use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; +use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; +use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::rules::EquityRuleHooks; use crate::strategy::{Strategy, StrategyContext}; @@ -32,6 +33,8 @@ pub struct BacktestConfig { pub benchmark_code: String, pub start_date: Option, pub end_date: Option, + pub decision_lag_trading_days: usize, + pub execution_price_field: PriceField, } #[derive(Debug, Clone, Serialize)] @@ -56,6 +59,28 @@ pub struct BacktestResult { pub position_events: Vec, pub account_events: Vec, pub holdings_summary: Vec, + pub daily_holdings: Vec, + pub metrics: BacktestMetrics, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BacktestDayProgress { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub cash: f64, + pub market_value: f64, + pub total_equity: f64, + pub unit_nav: f64, + pub total_return: f64, + pub benchmark_close: f64, + pub daily_fill_count: usize, + pub cumulative_trade_count: usize, + pub holding_count: usize, + pub notes: String, + pub diagnostics: String, + pub orders: Vec, + pub fills: Vec, + pub holdings: Vec, } pub struct BacktestEngine { @@ -88,15 +113,28 @@ where R: EquityRuleHooks, { pub fn run(&mut self) -> Result { + self.run_with_progress(|_| {}) + } + + pub fn run_with_progress(&mut self, mut on_progress: F) -> Result + where + F: FnMut(&BacktestDayProgress), + { let mut portfolio = PortfolioState::new(self.config.initial_cash); let execution_dates = self .data .calendar() .iter() - .filter(|date| self.config.start_date.map(|start| *date >= start).unwrap_or(true)) + .filter(|date| { + self.config + .start_date + .map(|start| *date >= start) + .unwrap_or(true) + }) .filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true)) .filter(|date| { - !self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty() + !self.data.factor_snapshots_on(*date).is_empty() + && !self.data.candidate_snapshots_on(*date).is_empty() }) .collect::>(); let mut result = BacktestResult { @@ -105,8 +143,18 @@ where .data .benchmark_series() .into_iter() - .filter(|row| self.config.start_date.map(|start| row.date >= start).unwrap_or(true)) - .filter(|row| self.config.end_date.map(|end| row.date <= end).unwrap_or(true)) + .filter(|row| { + self.config + .start_date + .map(|start| row.date >= start) + .unwrap_or(true) + }) + .filter(|row| { + self.config + .end_date + .map(|end| row.date <= end) + .unwrap_or(true) + }) .collect(), order_events: Vec::new(), fills: Vec::new(), @@ -114,11 +162,33 @@ where account_events: Vec::new(), equity_curve: Vec::new(), holdings_summary: Vec::new(), + daily_holdings: Vec::new(), + metrics: BacktestMetrics::default(), }; for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { + let mut corporate_action_notes = Vec::new(); + let receivable_report = self.settle_cash_receivables( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + )?; + self.extend_result(&mut result, receivable_report); + let delisting_report = self.settle_delisted_positions( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + )?; + self.extend_result(&mut result, delisting_report); + let corporate_action_report = self.apply_corporate_actions( + execution_date, + &mut portfolio, + &mut corporate_action_notes, + )?; + self.extend_result(&mut result, corporate_action_report); + let decision = execution_idx - .checked_sub(1) + .checked_sub(self.config.decision_lag_trading_days) .map(|decision_idx| { let decision_date = execution_dates[decision_idx]; self.strategy.on_day(&StrategyContext { @@ -132,21 +202,29 @@ where .transpose()? .unwrap_or_default(); - let report = self - .broker - .execute(execution_date, &mut portfolio, &self.data, &decision)?; + let report = + self.broker + .execute(execution_date, &mut portfolio, &self.data, &decision)?; + let daily_fill_count = report.fill_events.len(); + let day_orders = report.order_events.clone(); + let day_fills = report.fill_events.clone(); self.extend_result(&mut result, report); portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; - let benchmark = self - .data - .benchmark(execution_date) - .ok_or(BacktestError::MissingBenchmark { - date: execution_date, - })?; - let notes = decision.notes.join(" | "); + let benchmark = + self.data + .benchmark(execution_date) + .ok_or(BacktestError::MissingBenchmark { + date: execution_date, + })?; + let notes = corporate_action_notes + .into_iter() + .chain(decision.notes.into_iter()) + .collect::>() + .join(" | "); let diagnostics = decision.diagnostics.join(" | "); + let holdings_for_day = portfolio.holdings_summary(execution_date); result.equity_curve.push(DailyEquityPoint { date: execution_date, @@ -157,20 +235,295 @@ where notes, diagnostics, }); + result.daily_holdings.extend(holdings_for_day.clone()); + let latest = result + .equity_curve + .last() + .expect("equity point pushed for progress event"); + on_progress(&BacktestDayProgress { + date: execution_date, + cash: latest.cash, + market_value: latest.market_value, + total_equity: latest.total_equity, + unit_nav: if self.config.initial_cash.abs() < f64::EPSILON { + 0.0 + } else { + latest.total_equity / self.config.initial_cash + }, + total_return: if self.config.initial_cash.abs() < f64::EPSILON { + 0.0 + } else { + (latest.total_equity / self.config.initial_cash) - 1.0 + }, + benchmark_close: latest.benchmark_close, + daily_fill_count, + cumulative_trade_count: result.fills.len(), + holding_count: holdings_for_day.len(), + notes: latest.notes.clone(), + diagnostics: latest.diagnostics.clone(), + orders: day_orders, + fills: day_fills, + holdings: holdings_for_day, + }); } if let Some(last_date) = execution_dates.last().copied() { result.holdings_summary = portfolio.holdings_summary(last_date); } + result.metrics = compute_backtest_metrics( + &result.equity_curve, + &result.fills, + &result.daily_holdings, + self.config.initial_cash, + ); Ok(result) } - fn extend_result(&self, result: &mut BacktestResult, report: BrokerExecutionReport) { - result.order_events.extend(report.order_events); - result.fills.extend(report.fill_events); - result.position_events.extend(report.position_events); - result.account_events.extend(report.account_events); + fn extend_result( + &self, + result: &mut BacktestResult, + report: BrokerExecutionReport, + ) -> BrokerExecutionReport { + result.order_events.extend(report.order_events.clone()); + result.fills.extend(report.fill_events.clone()); + result.position_events.extend(report.position_events.clone()); + result.account_events.extend(report.account_events.clone()); + report + } + + fn apply_corporate_actions( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + notes: &mut Vec, + ) -> Result { + let mut report = BrokerExecutionReport::default(); + for action in self.data.corporate_actions_on(date) { + if !action.has_effect() { + continue; + } + let Some(existing_position) = portfolio.position(&action.symbol) else { + continue; + }; + if existing_position.quantity == 0 { + continue; + } + + if action.share_cash.abs() > f64::EPSILON { + let cash_before = portfolio.cash(); + let (cash_delta, quantity_after, average_cost) = { + let position = portfolio + .position_mut_if_exists(&action.symbol) + .expect("position exists for dividend action"); + let cash_delta = position.apply_cash_dividend(action.share_cash); + (cash_delta, position.quantity, position.average_cost) + }; + if cash_delta.abs() > f64::EPSILON { + let payable_date = action.payable_date.unwrap_or(date); + let immediate_cash = payable_date <= date; + let note = if immediate_cash { + portfolio.apply_cash_delta(cash_delta); + format!( + "cash_dividend {} share_cash={:.6} quantity={} cash={:.2}", + action.symbol, action.share_cash, quantity_after, cash_delta + ) + } else { + portfolio.add_cash_receivable(CashReceivable { + symbol: action.symbol.clone(), + ex_date: date, + payable_date, + amount: cash_delta, + reason: format!("cash_dividend {:.6}", action.share_cash), + }); + format!( + "cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}", + action.symbol, action.share_cash, quantity_after, payable_date, cash_delta + ) + }; + notes.push(note.clone()); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note, + }); + report.position_events.push(PositionEvent { + date, + symbol: action.symbol.clone(), + delta_quantity: 0, + quantity_after, + average_cost, + realized_pnl_delta: 0.0, + reason: format!("cash_dividend {:.6}", action.share_cash), + }); + } + } + + let split_ratio = action.split_ratio(); + if (split_ratio - 1.0).abs() > f64::EPSILON { + let (delta_quantity, quantity_after, average_cost) = { + let position = portfolio + .position_mut_if_exists(&action.symbol) + .expect("position exists for split action"); + let delta_quantity = position.apply_split_ratio(split_ratio); + (delta_quantity, position.quantity, position.average_cost) + }; + if delta_quantity != 0 { + let note = format!( + "stock_split {} ratio={:.6} delta_qty={}", + action.symbol, split_ratio, delta_quantity + ); + notes.push(note); + report.position_events.push(PositionEvent { + date, + symbol: action.symbol.clone(), + delta_quantity, + quantity_after, + average_cost, + realized_pnl_delta: 0.0, + reason: format!("stock_split {:.6}", split_ratio), + }); + } + } + } + + portfolio.prune_flat_positions(); + Ok(report) + } + + fn settle_cash_receivables( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + notes: &mut Vec, + ) -> Result { + let mut report = BrokerExecutionReport::default(); + let settled = portfolio.settle_cash_receivables(date); + for receivable in settled { + let note = format!( + "cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}", + receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount + ); + notes.push(note.clone()); + report.account_events.push(AccountEvent { + date, + cash_before: portfolio.cash() - receivable.amount, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note, + }); + } + Ok(report) + } + + fn settle_delisted_positions( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + notes: &mut Vec, + ) -> Result { + let mut report = BrokerExecutionReport::default(); + let symbols = portfolio.positions().keys().cloned().collect::>(); + for symbol in symbols { + let Some(position) = portfolio.position(&symbol) else { + continue; + }; + if position.quantity == 0 { + continue; + } + let Some(instrument) = self.data.instrument(&symbol) else { + continue; + }; + let should_settle = instrument.is_delisted_before(date) + || (instrument.status.eq_ignore_ascii_case("delisted") + && instrument.delisted_at.is_none() + && self.data.market(date, &symbol).is_none()); + if !should_settle { + continue; + } + + let quantity = position.quantity; + let fallback_reference_price = if position.last_price > 0.0 { + position.last_price + } else { + position.average_cost + }; + let effective_delisted_at = instrument + .delisted_at + .or_else(|| self.data.calendar().previous_day(date)) + .unwrap_or(date); + let settlement_price = self + .data + .price_on_or_before(effective_delisted_at, &symbol, PriceField::Close) + .or_else(|| self.data.price_on_or_before(date, &symbol, PriceField::Close)) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(fallback_reference_price); + if !settlement_price.is_finite() || settlement_price <= 0.0 { + return Err(BacktestError::Execution(format!( + "missing delisting settlement price for {} on {}", + symbol, date + ))); + } + + let cash_before = portfolio.cash(); + let gross_amount = settlement_price * quantity as f64; + let realized_pnl_delta = { + let position = portfolio + .position_mut_if_exists(&symbol) + .expect("position exists for delisting settlement"); + position + .sell(quantity, settlement_price) + .map_err(BacktestError::Execution)? + }; + portfolio.apply_cash_delta(gross_amount); + portfolio.prune_flat_positions(); + + let reason = format!( + "delisted_cash_settlement effective_date={} status={}", + effective_delisted_at, instrument.status + ); + notes.push(reason.clone()); + report.order_events.push(OrderEvent { + date, + symbol: symbol.clone(), + side: OrderSide::Sell, + requested_quantity: quantity, + filled_quantity: quantity, + status: OrderStatus::Filled, + reason: reason.clone(), + }); + report.fill_events.push(FillEvent { + date, + symbol: symbol.clone(), + side: OrderSide::Sell, + quantity, + price: settlement_price, + gross_amount, + commission: 0.0, + stamp_tax: 0.0, + net_cash_flow: gross_amount, + reason: reason.clone(), + }); + report.position_events.push(PositionEvent { + date, + symbol: symbol.clone(), + delta_quantity: -(quantity as i32), + quantity_after: 0, + average_cost: 0.0, + realized_pnl_delta, + reason: reason.clone(), + }); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: portfolio.total_equity(), + note: reason, + }); + } + Ok(report) } } diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs index 9bd284f..d775abb 100644 --- a/crates/fidc-core/src/events.rs +++ b/crates/fidc-core/src/events.rs @@ -33,6 +33,7 @@ pub enum OrderSide { pub enum OrderStatus { Filled, PartiallyFilled, + Canceled, Rejected, } diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index d921ccc..4c24dec 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDate; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -5,4 +6,55 @@ pub struct Instrument { pub symbol: String, pub name: String, pub board: String, + pub round_lot: u32, + #[serde(default, with = "optional_date_format")] + pub listed_at: Option, + #[serde(default, with = "optional_date_format")] + pub delisted_at: Option, + #[serde(default = "default_status")] + pub status: String, +} + +impl Instrument { + pub fn effective_round_lot(&self) -> u32 { + self.round_lot.max(1) + } + + pub fn is_delisted_before(&self, date: NaiveDate) -> bool { + self.delisted_at.is_some_and(|delisted_at| delisted_at < date) + } +} + +fn default_status() -> String { + "active".to_string() +} + +mod optional_date_format { + use chrono::NaiveDate; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + match value.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + Some(text) => NaiveDate::parse_from_str(text, FORMAT) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } } diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 8d0536f..1dbf26e 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod data; pub mod engine; pub mod events; pub mod instrument; +pub mod metrics; pub mod portfolio; pub mod rules; pub mod strategy; @@ -14,39 +15,24 @@ pub use broker::{BrokerExecutionReport, BrokerSimulator}; pub use calendar::TradingCalendar; pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; pub use data::{ - BenchmarkSnapshot, - CandidateEligibility, - DailyFactorSnapshot, - DailyMarketSnapshot, - DailySnapshotBundle, - DataSet, - DataSetError, - PriceField, + BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, + DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, EligibleUniverseSnapshot, + IntradayExecutionQuote, PriceField, }; -pub use engine::{BacktestConfig, BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint}; -pub use events::{ - AccountEvent, - FillEvent, - OrderEvent, - OrderSide, - OrderStatus, - PositionEvent, +pub use engine::{ + BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult, + DailyEquityPoint, }; +pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent}; pub use instrument::Instrument; -pub use portfolio::{HoldingSummary, PortfolioState, Position}; +pub use metrics::{BacktestMetrics, compute_backtest_metrics}; +pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use strategy::{ - CnSmallCapRotationConfig, - CnSmallCapRotationStrategy, - Strategy, - StrategyContext, - StrategyDecision, + CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, + OrderIntent, Strategy, StrategyContext, StrategyDecision, }; pub use universe::{ - BandRegime, - DynamicMarketCapBandSelector, - SelectionContext, - SelectionDiagnostics, - UniverseCandidate, - UniverseSelector, + BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics, + UniverseCandidate, UniverseSelector, }; diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs new file mode 100644 index 0000000..88bc6ba --- /dev/null +++ b/crates/fidc-core/src/metrics.rs @@ -0,0 +1,437 @@ +use std::collections::BTreeMap; + +use chrono::{Datelike, NaiveDate}; +use serde::{Deserialize, Serialize}; + +use crate::engine::DailyEquityPoint; +use crate::events::FillEvent; +use crate::portfolio::HoldingSummary; + +const TRADING_DAYS_PER_YEAR: f64 = 252.0; +const MONTHS_PER_YEAR: f64 = 12.0; +const DEFAULT_RISK_FREE_RATE: f64 = 0.022; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BacktestMetrics { + pub total_return: f64, + pub annual_return: f64, + pub sharpe: f64, + pub max_drawdown: f64, + pub win_rate: f64, + pub alpha: f64, + pub beta: f64, + pub benchmark_cumulative_return: f64, + pub benchmark_net_value: f64, + pub risk_free_rate: f64, + pub monthly_excess_win_rate: f64, + pub excess_cumulative_return: f64, + pub excess_annual_return: f64, + pub max_drawdown_duration_days: usize, + pub total_trade_days: usize, + pub sortino: f64, + pub information_ratio: f64, + pub tracking_error: f64, + pub volatility: f64, + pub excess_return: f64, + pub excess_sharpe: f64, + pub excess_volatility: f64, + pub excess_max_drawdown: f64, + pub holding_count: usize, + pub average_weight: f64, + pub max_weight: f64, + pub concentration: f64, + pub weight_std_dev: f64, + pub median_weight: f64, + pub average_daily_turnover: f64, + pub total_assets: f64, + pub cash_balance: f64, + pub unit_nav: f64, + pub initial_cash: f64, + pub excess_win_rate: f64, + pub monthly_sharpe: f64, + pub monthly_volatility: f64, +} + +pub fn compute_backtest_metrics( + equity_curve: &[DailyEquityPoint], + fills: &[FillEvent], + daily_holdings: &[HoldingSummary], + initial_cash: f64, +) -> BacktestMetrics { + let Some(first_point) = equity_curve.first() else { + return BacktestMetrics { + risk_free_rate: DEFAULT_RISK_FREE_RATE, + initial_cash, + ..BacktestMetrics::default() + }; + }; + let Some(last_point) = equity_curve.last() else { + return BacktestMetrics { + risk_free_rate: DEFAULT_RISK_FREE_RATE, + initial_cash, + ..BacktestMetrics::default() + }; + }; + + let trade_days = equity_curve.len(); + let returns = equity_curve + .windows(2) + .map(|window| pct_change(window[0].total_equity, window[1].total_equity)) + .collect::>(); + let benchmark_returns = equity_curve + .windows(2) + .map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close)) + .collect::>(); + let excess_returns = returns + .iter() + .zip(benchmark_returns.iter()) + .map(|(lhs, rhs)| lhs - rhs) + .collect::>(); + + let benchmark_net_value = if first_point.benchmark_close.abs() < f64::EPSILON { + 1.0 + } else { + last_point.benchmark_close / first_point.benchmark_close + }; + let benchmark_cumulative_return = benchmark_net_value - 1.0; + let total_return = if initial_cash.abs() < f64::EPSILON { + 0.0 + } else { + (last_point.total_equity / initial_cash) - 1.0 + }; + let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON { + total_return + } else { + (last_point.total_equity / initial_cash) / benchmark_net_value - 1.0 + }; + let excess_return = total_return - benchmark_cumulative_return; + let annual_return = annualize_return(total_return, trade_days); + let excess_annual_return = annualize_return(excess_cumulative_return, trade_days); + + let risk_free_rate = DEFAULT_RISK_FREE_RATE; + let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR; + let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR); + let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR); + let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); + let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR); + let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR); + let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR); + let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR); + let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf); + + let equity_nav = equity_curve + .iter() + .map(|point| safe_div(point.total_equity, initial_cash, 1.0)) + .collect::>(); + let benchmark_nav_series = equity_curve + .iter() + .map(|point| safe_div(point.benchmark_close, first_point.benchmark_close, 1.0)) + .collect::>(); + let excess_nav_series = equity_nav + .iter() + .zip(benchmark_nav_series.iter()) + .map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs)) + .collect::>(); + + let (max_drawdown, max_drawdown_duration_days) = drawdown_stats(&equity_nav); + let (excess_max_drawdown, _) = drawdown_stats(&excess_nav_series); + + let winning_days = returns.iter().filter(|value| **value > 0.0).count(); + let excess_winning_days = excess_returns.iter().filter(|value| **value > 0.0).count(); + let win_rate = ratio(winning_days, returns.len()); + let excess_win_rate = ratio(excess_winning_days, excess_returns.len()); + + let monthly_portfolio_returns = group_monthly_returns(equity_curve, |point| point.total_equity); + let monthly_benchmark_returns = + group_monthly_returns(equity_curve, |point| point.benchmark_close); + let monthly_excess_returns = monthly_portfolio_returns + .iter() + .zip(monthly_benchmark_returns.iter()) + .map(|(lhs, rhs)| lhs - rhs) + .collect::>(); + let monthly_excess_win_rate = ratio( + monthly_excess_returns + .iter() + .filter(|value| **value > 0.0) + .count(), + monthly_excess_returns.len(), + ); + let monthly_sharpe = annualized_sharpe( + &monthly_portfolio_returns, + risk_free_rate / MONTHS_PER_YEAR, + MONTHS_PER_YEAR, + ); + let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); + + let turnover_by_date = fills.iter().fold(BTreeMap::::new(), |mut acc, fill| { + *acc.entry(fill.date).or_default() += fill.gross_amount.abs(); + acc + }); + let equity_by_date = equity_curve + .iter() + .map(|point| (point.date, point.total_equity)) + .collect::>(); + let average_daily_turnover = if equity_curve.is_empty() { + 0.0 + } else { + equity_curve + .iter() + .map(|point| { + let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default(); + safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0) + }) + .sum::() + / equity_curve.len() as f64 + }; + + let latest_date = last_point.date; + let latest_holdings = daily_holdings + .iter() + .filter(|row| row.date == latest_date && row.quantity > 0) + .collect::>(); + let weights = latest_holdings + .iter() + .map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0)) + .collect::>(); + let holding_count = latest_holdings.len(); + let average_weight = mean(&weights); + let max_weight = weights + .iter() + .copied() + .fold(0.0_f64, |acc, value| acc.max(value)); + let concentration = weights.iter().map(|weight| weight * weight).sum::(); + let weight_std_dev = std_dev(&weights); + let median_weight = median(&weights); + + let total_trade_days = equity_by_date.len(); + + BacktestMetrics { + total_return, + annual_return, + sharpe, + max_drawdown, + win_rate, + alpha, + beta, + benchmark_cumulative_return, + benchmark_net_value, + risk_free_rate, + monthly_excess_win_rate, + excess_cumulative_return, + excess_annual_return, + max_drawdown_duration_days, + total_trade_days, + sortino, + information_ratio, + tracking_error, + volatility, + excess_return, + excess_sharpe, + excess_volatility, + excess_max_drawdown, + holding_count, + average_weight, + max_weight, + concentration, + weight_std_dev, + median_weight, + average_daily_turnover, + total_assets: last_point.total_equity, + cash_balance: last_point.cash, + unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0), + initial_cash, + excess_win_rate, + monthly_sharpe, + monthly_volatility, + } +} + +fn pct_change(previous: f64, current: f64) -> f64 { + if previous.abs() < f64::EPSILON { + 0.0 + } else { + (current / previous) - 1.0 + } +} + +fn annualize_return(total_return: f64, periods: usize) -> f64 { + if periods == 0 { + return 0.0; + } + let periods = periods as f64; + let base = 1.0 + total_return; + if base <= 0.0 { + return -1.0; + } + base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0 +} + +fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 { + if returns.len() < 2 { + return 0.0; + } + let adjusted = returns.iter().map(|value| value - daily_rf).collect::>(); + let mean_ret = mean(&adjusted); + let std = std_dev(&adjusted); + if std <= f64::EPSILON { + 0.0 + } else { + mean_ret / std * periods_per_year.sqrt() + } +} + +fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 { + if returns.is_empty() { + return 0.0; + } + let adjusted = returns.iter().map(|value| value - daily_rf).collect::>(); + let downside = adjusted + .iter() + .filter(|value| **value < 0.0) + .map(|value| value.powi(2)) + .collect::>(); + if downside.is_empty() { + return 0.0; + } + let downside_dev = (downside.iter().sum::() / downside.len() as f64).sqrt(); + if downside_dev <= f64::EPSILON { + 0.0 + } else { + mean(&adjusted) / downside_dev * periods_per_year.sqrt() + } +} + +fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 { + std_dev(values) * periods_per_year.sqrt() +} + +fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) { + if returns.len() < 2 || returns.len() != benchmark_returns.len() { + return (0.0, 0.0); + } + let strategy_excess = returns.iter().map(|value| value - daily_rf).collect::>(); + let benchmark_excess = benchmark_returns + .iter() + .map(|value| value - daily_rf) + .collect::>(); + let mean_strategy = mean(&strategy_excess); + let mean_benchmark = mean(&benchmark_excess); + let variance_benchmark = variance(&benchmark_excess); + if variance_benchmark <= f64::EPSILON { + return (0.0, 0.0); + } + let covariance = strategy_excess + .iter() + .zip(benchmark_excess.iter()) + .map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark)) + .sum::() + / (strategy_excess.len() - 1) as f64; + let beta = covariance / variance_benchmark; + let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR; + (alpha, beta) +} + +fn drawdown_stats(nav: &[f64]) -> (f64, usize) { + let mut peak = 0.0_f64; + let mut max_drawdown = 0.0_f64; + let mut duration = 0_usize; + let mut max_duration = 0_usize; + for value in nav { + if *value >= peak { + peak = *value; + duration = 0; + continue; + } + if peak > f64::EPSILON { + let drawdown = (*value / peak) - 1.0; + if drawdown < max_drawdown { + max_drawdown = drawdown; + } + } + duration += 1; + if duration > max_duration { + max_duration = duration; + } + } + (max_drawdown, max_duration) +} + +fn group_monthly_returns(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec +where + F: Fn(&DailyEquityPoint) -> f64, +{ + let mut month_last = BTreeMap::<(i32, u32), f64>::new(); + let mut month_first = BTreeMap::<(i32, u32), f64>::new(); + for point in equity_curve { + let key = (point.date.year(), point.date.month()); + month_first.entry(key).or_insert_with(|| value_fn(point)); + month_last.insert(key, value_fn(point)); + } + let mut keys = month_last.keys().copied().collect::>(); + keys.sort_unstable(); + keys.into_iter() + .filter_map(|key| { + let first = month_first.get(&key).copied().unwrap_or_default(); + let last = month_last.get(&key).copied().unwrap_or_default(); + if first.abs() < f64::EPSILON { + None + } else { + Some((last / first) - 1.0) + } + }) + .collect() +} + +fn mean(values: &[f64]) -> f64 { + if values.is_empty() { + 0.0 + } else { + values.iter().sum::() / values.len() as f64 + } +} + +fn variance(values: &[f64]) -> f64 { + if values.len() < 2 { + return 0.0; + } + let avg = mean(values); + values + .iter() + .map(|value| (value - avg).powi(2)) + .sum::() + / (values.len() - 1) as f64 +} + +fn std_dev(values: &[f64]) -> f64 { + variance(values).sqrt() +} + +fn median(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap_or(std::cmp::Ordering::Equal)); + let mid = sorted.len() / 2; + if sorted.len() % 2 == 0 { + (sorted[mid - 1] + sorted[mid]) / 2.0 + } else { + sorted[mid] + } +} + +fn ratio(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 + } +} + +fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 { + if denominator.abs() < f64::EPSILON { + fallback + } else { + numerator / denominator + } +} diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 89d5b76..110701b 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -1,6 +1,5 @@ -use std::collections::BTreeMap; - use chrono::NaiveDate; +use indexmap::IndexMap; use serde::Serialize; use crate::data::{DataSet, DataSetError, PriceField}; @@ -124,19 +123,71 @@ impl Position { self.average_cost = total_cost / self.quantity as f64; } + + pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 { + if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 { + return 0.0; + } + + for lot in &mut self.lots { + lot.price -= dividend_per_share; + } + self.average_cost -= dividend_per_share; + self.last_price -= dividend_per_share; + self.quantity as f64 * dividend_per_share + } + + pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 { + if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9 + { + return 0; + } + + let old_quantity = self.quantity; + let mut scaled_lots = self + .lots + .iter() + .map(|lot| PositionLot { + acquired_date: lot.acquired_date, + quantity: round_half_up_u32(lot.quantity as f64 * ratio), + price: lot.price / ratio, + }) + .collect::>(); + + let expected_total = round_half_up_u32(old_quantity as f64 * ratio); + let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::(); + if let Some(last_lot) = scaled_lots.last_mut() { + if scaled_total < expected_total { + last_lot.quantity += expected_total - scaled_total; + } else if scaled_total > expected_total { + last_lot.quantity = last_lot + .quantity + .saturating_sub(scaled_total - expected_total); + } + } + scaled_lots.retain(|lot| lot.quantity > 0); + + self.lots = scaled_lots; + self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); + self.last_price /= ratio; + self.recalculate_average_cost(); + self.quantity as i32 - old_quantity as i32 + } } #[derive(Debug, Clone)] pub struct PortfolioState { cash: f64, - positions: BTreeMap, + positions: IndexMap, + cash_receivables: Vec, } impl PortfolioState { pub fn new(initial_cash: f64) -> Self { Self { cash: initial_cash, - positions: BTreeMap::new(), + positions: IndexMap::new(), + cash_receivables: Vec::new(), } } @@ -144,7 +195,7 @@ impl PortfolioState { self.cash } - pub fn positions(&self) -> &BTreeMap { + pub fn positions(&self) -> &IndexMap { &self.positions } @@ -152,6 +203,10 @@ impl PortfolioState { self.positions.get(symbol) } + pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> { + self.positions.get_mut(symbol) + } + pub fn position_mut(&mut self, symbol: &str) -> &mut Position { self.positions .entry(symbol.to_string()) @@ -166,6 +221,29 @@ impl PortfolioState { self.positions.retain(|_, position| !position.is_flat()); } + pub fn add_cash_receivable(&mut self, receivable: CashReceivable) { + self.cash_receivables.push(receivable); + } + + pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec { + let mut settled = Vec::new(); + let mut pending = Vec::new(); + for receivable in self.cash_receivables.drain(..) { + if receivable.payable_date <= date { + self.cash += receivable.amount; + settled.push(receivable); + } else { + pending.push(receivable); + } + } + self.cash_receivables = pending; + settled + } + + pub fn cash_receivables(&self) -> &[CashReceivable] { + &self.cash_receivables + } + pub fn update_prices( &mut self, date: NaiveDate, @@ -173,16 +251,17 @@ impl PortfolioState { field: PriceField, ) -> Result<(), DataSetError> { for position in self.positions.values_mut() { - let price = data - .price(date, &position.symbol, field) - .ok_or_else(|| DataSetError::MissingSnapshot { + let price = data.price(date, &position.symbol, field).ok_or_else(|| { + DataSetError::MissingSnapshot { kind: match field { PriceField::Open => "open price", PriceField::Close => "close price", + PriceField::Last => "last price", }, date, symbol: position.symbol.clone(), - })?; + } + })?; position.last_price = price; } Ok(()) @@ -214,6 +293,30 @@ impl PortfolioState { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_preserve_insertion_order() { + let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let mut portfolio = PortfolioState::new(10_000.0); + portfolio.position_mut("603657.SH").buy(date, 100, 10.0); + portfolio.position_mut("001266.SZ").buy(date, 100, 10.0); + portfolio.position_mut("601798.SH").buy(date, 100, 10.0); + + let symbols = portfolio.positions().keys().cloned().collect::>(); + assert_eq!( + symbols, + vec![ + "603657.SH".to_string(), + "001266.SZ".to_string(), + "601798.SH".to_string() + ] + ); + } +} + #[derive(Debug, Clone, Serialize)] pub struct HoldingSummary { #[serde(with = "date_format")] @@ -227,6 +330,15 @@ pub struct HoldingSummary { pub realized_pnl: f64, } +#[derive(Debug, Clone)] +pub struct CashReceivable { + pub symbol: String, + pub ex_date: NaiveDate, + pub payable_date: NaiveDate, + pub amount: f64, + pub reason: String, +} + mod date_format { use chrono::NaiveDate; use serde::Serializer; @@ -240,3 +352,11 @@ mod date_format { serializer.serialize_str(&date.format(FORMAT).to_string()) } } + +fn round_half_up_u32(value: f64) -> u32 { + if !value.is_finite() || value <= 0.0 { + 0 + } else { + value.round() as u32 + } +} diff --git a/crates/fidc-core/src/rules.rs b/crates/fidc-core/src/rules.rs index a9a45c2..9e2707f 100644 --- a/crates/fidc-core/src/rules.rs +++ b/crates/fidc-core/src/rules.rs @@ -1,6 +1,6 @@ use chrono::NaiveDate; -use crate::data::{CandidateEligibility, DailyMarketSnapshot}; +use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField}; use crate::portfolio::Position; #[derive(Debug, Clone)] @@ -31,6 +31,7 @@ pub trait EquityRuleHooks { execution_date: NaiveDate, snapshot: &DailyMarketSnapshot, candidate: &CandidateEligibility, + price_field: PriceField, ) -> RuleCheck; fn can_sell( @@ -39,6 +40,7 @@ pub trait EquityRuleHooks { snapshot: &DailyMarketSnapshot, candidate: &CandidateEligibility, position: &Position, + price_field: PriceField, ) -> RuleCheck; } @@ -46,12 +48,12 @@ pub trait EquityRuleHooks { pub struct ChinaEquityRuleHooks; impl ChinaEquityRuleHooks { - fn at_upper_limit(snapshot: &DailyMarketSnapshot) -> bool { - snapshot.open >= snapshot.upper_limit - 1e-6 + fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool { + snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field)) } - fn at_lower_limit(snapshot: &DailyMarketSnapshot) -> bool { - snapshot.open <= snapshot.lower_limit + 1e-6 + fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool { + snapshot.is_at_lower_limit_price(snapshot.sell_price(price_field)) } } @@ -61,6 +63,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { _execution_date: NaiveDate, snapshot: &DailyMarketSnapshot, candidate: &CandidateEligibility, + price_field: PriceField, ) -> RuleCheck { if snapshot.paused || candidate.is_paused { return RuleCheck::reject("paused"); @@ -68,7 +71,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { if !candidate.allow_buy { return RuleCheck::reject("buy disabled by eligibility flags"); } - if Self::at_upper_limit(snapshot) { + if Self::at_upper_limit(snapshot, price_field) { return RuleCheck::reject("open at or above upper limit"); } @@ -81,6 +84,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { snapshot: &DailyMarketSnapshot, candidate: &CandidateEligibility, position: &Position, + price_field: PriceField, ) -> RuleCheck { if snapshot.paused || candidate.is_paused { return RuleCheck::reject("paused"); @@ -88,7 +92,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks { if !candidate.allow_sell { return RuleCheck::reject("sell disabled by eligibility flags"); } - if Self::at_lower_limit(snapshot) { + if Self::at_lower_limit(snapshot, price_field) { return RuleCheck::reject("open at or below lower limit"); } if position.sellable_qty(execution_date) == 0 { diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index c33e0f8..3d65a6c 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -25,10 +25,25 @@ pub struct StrategyDecision { pub rebalance: bool, pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, + pub order_intents: Vec, pub notes: Vec, pub diagnostics: Vec, } +#[derive(Debug, Clone)] +pub enum OrderIntent { + TargetValue { + symbol: String, + target_value: f64, + reason: String, + }, + Value { + symbol: String, + value: f64, + reason: String, + }, +} + #[derive(Debug, Clone)] pub struct CnSmallCapRotationConfig { pub strategy_name: String, @@ -97,7 +112,13 @@ impl CnSmallCapRotationConfig { take_profit_pct: 0.07, signal_symbol: Some("000852.SH".to_string()), skip_months: vec![], - skip_month_day_ranges: vec![(1, 15, 30), (4, 15, 29), (8, 15, 31), (10, 20, 30), (12, 20, 30)], + skip_month_day_ranges: vec![ + (1, 15, 30), + (4, 15, 29), + (8, 15, 31), + (10, 20, 30), + (12, 20, 30), + ], } } @@ -136,12 +157,10 @@ impl CnSmallCapRotationStrategy { fn moving_average(values: &[f64], lookback: usize) -> f64 { let len = values.len(); let window = values.iter().skip(len.saturating_sub(lookback)); - let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| (sum + value, count + 1)); - if count == 0 { - 0.0 - } else { - sum / count as f64 - } + let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| { + (sum + value, count + 1) + }); + if count == 0 { 0.0 } else { sum / count as f64 } } fn gross_exposure(&self, closes: &[f64]) -> f64 { @@ -166,38 +185,46 @@ impl CnSmallCapRotationStrategy { &self, ctx: &StrategyContext<'_>, ) -> Result<(String, Vec, f64), BacktestError> { - let symbol = self - .config - .signal_symbol - .as_deref() - .ok_or_else(|| BacktestError::Execution( - "cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled" - .to_string(), - ))?; + if let Some(symbol) = self.config.signal_symbol.as_deref() { + let closes = + ctx.data + .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days); + if closes.len() >= self.config.long_ma_days { + let close = ctx + .data + .price(ctx.decision_date, symbol, PriceField::Close) + .ok_or_else(|| BacktestError::MissingPrice { + date: ctx.decision_date, + symbol: symbol.to_string(), + field: "close", + })?; + return Ok((symbol.to_string(), closes, close)); + } + } + let closes = ctx .data - .market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days); + .benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days); if closes.len() < self.config.long_ma_days { return Err(BacktestError::Execution(format!( - "real signal series missing or insufficient for {} on/before {}; degraded fallback disabled", - symbol, ctx.decision_date + "signal series insufficient on/before {} for long_ma_days={}", + ctx.decision_date, self.config.long_ma_days ))); } let close = ctx .data - .price(ctx.decision_date, symbol, PriceField::Close) - .ok_or_else(|| BacktestError::MissingPrice { + .benchmark(ctx.decision_date) + .ok_or(BacktestError::MissingBenchmark { date: ctx.decision_date, - symbol: symbol.to_string(), - field: "close", - })?; - Ok((symbol.to_string(), closes, close)) + })? + .close; + Ok((ctx.data.benchmark_code().to_string(), closes, close)) } fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { - let closes = ctx - .data - .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days); + let closes = + ctx.data + .market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days); if closes.len() < self.config.stock_long_ma_days { return false; } @@ -207,7 +234,10 @@ impl CnSmallCapRotationStrategy { ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long } - fn stop_exit_symbols(&self, ctx: &StrategyContext<'_>) -> Result, BacktestError> { + fn stop_exit_symbols( + &self, + ctx: &StrategyContext<'_>, + ) -> Result, BacktestError> { let mut exits = BTreeSet::new(); for position in ctx.portfolio.positions().values() { if position.quantity == 0 { @@ -244,12 +274,12 @@ impl Strategy for CnSmallCapRotationStrategy { } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { - let benchmark = ctx - .data - .benchmark(ctx.decision_date) - .ok_or(BacktestError::MissingBenchmark { - date: ctx.decision_date, - })?; + let benchmark = + ctx.data + .benchmark(ctx.decision_date) + .ok_or(BacktestError::MissingBenchmark { + date: ctx.decision_date, + })?; if self.config.in_skip_window(ctx.execution_date) { self.last_gross_exposure = Some(0.0); @@ -257,15 +287,35 @@ impl Strategy for CnSmallCapRotationStrategy { rebalance: true, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), + order_intents: Vec::new(), notes: vec![format!("skip-window active on {}", ctx.execution_date)], diagnostics: vec![ "seasonal stop window approximated at daily granularity".to_string(), - "run_daily(10:17/10:18) mapped to T-1 decision and T open execution".to_string(), + "run_daily(10:17/10:18) mapped to T-1 decision and T open execution" + .to_string(), ], }); } - let (resolved_signal_symbol, signal_closes, signal_level) = self.resolve_signal_series(ctx)?; + let (resolved_signal_symbol, signal_closes, signal_level) = + match self.resolve_signal_series(ctx) { + Ok(value) => value, + Err(BacktestError::Execution(message)) + if message.contains("signal series insufficient") => + { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: vec![format!("warmup: {}", message)], + diagnostics: vec![ + "insufficient history; skip trading on warmup dates".to_string(), + ], + }); + } + Err(err) => return Err(err), + }; let gross_exposure = self.gross_exposure(&signal_closes); let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; let exposure_changed = self @@ -295,16 +345,19 @@ impl Strategy for CnSmallCapRotationStrategy { 1.0 - self.config.stop_loss_pct, 1.0 + self.config.take_profit_pct, )]; - diagnostics.push("run_daily(10:17/10:18) approximated by daily decision/open execution".to_string()); + diagnostics.push( + "run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(), + ); diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string()); if rebalance && gross_exposure > 0.0 { - let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext { - decision_date: ctx.decision_date, - benchmark, - reference_level: signal_level, - data: ctx.data, - }); + let (selected_before_ma, selection_diag) = + self.selector.select_with_diagnostics(&SelectionContext { + decision_date: ctx.decision_date, + benchmark, + reference_level: signal_level, + data: ctx.data, + }); let before_ma_count = selected_before_ma.len(); let mut ma_rejects = Vec::new(); let selected = selected_before_ma @@ -353,7 +406,10 @@ impl Strategy for CnSmallCapRotationStrategy { )); } if !ma_rejects.is_empty() { - diagnostics.push(format!("ma_filter_rejections sample={}", ma_rejects.join("|"))); + diagnostics.push(format!( + "ma_filter_rejections sample={}", + ma_rejects.join("|") + )); } if !selected.is_empty() { @@ -398,8 +454,581 @@ impl Strategy for CnSmallCapRotationStrategy { rebalance, target_weights, exit_symbols, + order_intents: Vec::new(), notes, diagnostics, }) } } + +#[derive(Debug, Clone)] +pub struct JqMicroCapConfig { + pub strategy_name: String, + pub refresh_rate: usize, + pub stocknum: usize, + pub xs: f64, + pub base_index_level: f64, + pub base_cap_floor: f64, + pub cap_span: f64, + pub benchmark_signal_symbol: String, + pub benchmark_short_ma_days: usize, + pub benchmark_long_ma_days: usize, + pub stock_short_ma_days: usize, + pub stock_mid_ma_days: usize, + pub stock_long_ma_days: usize, + pub rsi_rate: f64, + pub trade_rate: f64, + pub stop_loss_ratio: f64, + pub take_profit_ratio: f64, + pub skip_month_day_ranges: Vec<(u32, u32, u32)>, +} + +impl JqMicroCapConfig { + pub fn jq_microcap() -> Self { + Self { + strategy_name: "jq-microcap".to_string(), + refresh_rate: 15, + stocknum: 40, + xs: 4.0 / 500.0, + base_index_level: 2000.0, + base_cap_floor: 7.0, + cap_span: 10.0, + benchmark_signal_symbol: "000001.SH".to_string(), + benchmark_short_ma_days: 5, + benchmark_long_ma_days: 10, + stock_short_ma_days: 5, + stock_mid_ma_days: 10, + stock_long_ma_days: 20, + rsi_rate: 1.0001, + trade_rate: 0.5, + stop_loss_ratio: 0.93, + take_profit_ratio: 1.07, + // The source JQ script calls validate_date() but then immediately forces + // g.OpenYN = 1 inside check_stocks(), so the seasonal stop windows are + // effectively disabled in real execution logs. + skip_month_day_ranges: Vec::new(), + } + } + + fn in_skip_window(&self, date: NaiveDate) -> bool { + let month = date.month(); + let day = date.day(); + self.skip_month_day_ranges + .iter() + .any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day) + } +} + +pub struct JqMicroCapStrategy { + config: JqMicroCapConfig, +} + +impl JqMicroCapStrategy { + pub fn new(config: JqMicroCapConfig) -> Self { + Self { config } + } + + fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 { + market.effective_price_tick() * 6.0 + } + + fn buy_commission(&self, gross_amount: f64) -> f64 { + (gross_amount * 0.0003).max(5.0) + } + + fn sell_cost(&self, gross_amount: f64) -> f64 { + (gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001) + } + + fn round_board_lot(&self, quantity: u32) -> u32 { + (quantity / 100) * 100 + } + + fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 { + if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 { + return 0; + } + let mut quantity = self.round_board_lot((cash / sizing_price).floor() as u32); + while quantity > 0 { + let gross_amount = execution_price * quantity as f64; + let cash_out = gross_amount + self.buy_commission(gross_amount); + if cash_out <= cash + 1e-6 { + return quantity; + } + quantity = quantity.saturating_sub(100); + } + 0 + } + + fn project_order_value( + &self, + projected: &mut PortfolioState, + date: NaiveDate, + symbol: &str, + sizing_price: f64, + execution_price: f64, + order_value: f64, + ) -> u32 { + let quantity = self.projected_buy_quantity( + projected.cash().min(order_value), + sizing_price, + execution_price, + ); + if quantity == 0 { + return 0; + } + let gross_amount = execution_price * quantity as f64; + let cash_out = gross_amount + self.buy_commission(gross_amount); + projected.apply_cash_delta(-cash_out); + projected.position_mut(symbol).buy(date, quantity, execution_price); + quantity + } + + fn project_target_zero( + &self, + projected: &mut PortfolioState, + symbol: &str, + sell_price: f64, + ) -> Option { + let quantity = projected.position(symbol)?.quantity; + if quantity == 0 { + return None; + } + let gross_amount = sell_price * quantity as f64; + let net_cash = gross_amount - self.sell_cost(gross_amount); + projected.position_mut(symbol).sell(quantity, sell_price).ok()?; + projected.apply_cash_delta(net_cash); + projected.prune_flat_positions(); + Some(quantity) + } + + fn trading_ratio( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + ) -> Result<(f64, f64, f64, f64), BacktestError> { + let current_level = ctx + .data + .market_decision_close(date, &self.config.benchmark_signal_symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: self.config.benchmark_signal_symbol.clone(), + field: "decision_close", + })?; + let ma_short = ctx + .data + .market_decision_close_moving_average( + date, + &self.config.benchmark_signal_symbol, + self.config.benchmark_short_ma_days, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "insufficient benchmark short MA history for {} on {}", + self.config.benchmark_signal_symbol, date + )) + })?; + let ma_long = ctx + .data + .market_decision_close_moving_average( + date, + &self.config.benchmark_signal_symbol, + self.config.benchmark_long_ma_days, + ) + .ok_or_else(|| { + BacktestError::Execution(format!( + "insufficient benchmark long MA history for {} on {}", + self.config.benchmark_signal_symbol, date + )) + })?; + let trading_ratio = if ma_short < ma_long * self.config.rsi_rate { + self.config.trade_rate + } else { + 1.0 + }; + Ok((current_level, ma_short, ma_long, trading_ratio)) + } + + fn market_cap_band(&self, index_level: f64) -> (f64, f64) { + let y = (index_level - self.config.base_index_level) * self.config.xs + + self.config.base_cap_floor; + let start = y.round(); + (start, start + self.config.cap_span) + } + + fn stock_passes_ma_filter( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + ) -> bool { + let Some(ma_short) = ctx.data.market_decision_close_moving_average( + date, + symbol, + self.config.stock_short_ma_days, + ) else { + return false; + }; + let Some(ma_mid) = ctx.data.market_decision_close_moving_average( + date, + symbol, + self.config.stock_mid_ma_days, + ) else { + return false; + }; + let Some(ma_long) = ctx.data.market_decision_close_moving_average( + date, + symbol, + self.config.stock_long_ma_days, + ) else { + return false; + }; + + ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long + } + + fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool { + let instrument_name = ctx + .data + .instruments() + .get(symbol) + .map(|instrument| instrument.name.as_str()) + .unwrap_or(""); + instrument_name.contains("ST") + || instrument_name.contains('*') + || instrument_name.contains('退') + } + + fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool { + let Some(position) = ctx.portfolio.position(symbol) else { + return false; + }; + if position.quantity == 0 || position.sellable_qty(date) == 0 { + return false; + } + let Ok(market) = ctx.data.require_market(date, symbol) else { + return false; + }; + let Ok(candidate) = ctx.data.require_candidate(date, symbol) else { + return false; + }; + !(market.paused + || candidate.is_paused + || !candidate.allow_sell + || market.is_at_lower_limit_price(market.sell_price(PriceField::Last))) + } + + fn buy_rejection_reason( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + symbol: &str, + ) -> Result, BacktestError> { + let market = ctx.data.require_market(date, symbol)?; + let candidate = ctx.data.require_candidate(date, symbol)?; + + if market.paused || candidate.is_paused { + return Ok(Some("paused".to_string())); + } + if candidate.is_st || self.special_name(ctx, symbol) { + return Ok(Some("st_or_special_name".to_string())); + } + if candidate.is_kcb { + return Ok(Some("kcb".to_string())); + } + if !candidate.allow_buy { + return Ok(Some("buy_disabled".to_string())); + } + if market.is_at_upper_limit_price(market.day_open) + || market.is_at_upper_limit_price(market.buy_price(PriceField::Last)) + { + return Ok(Some("upper_limit".to_string())); + } + if market.is_at_lower_limit_price(market.day_open) + || market.is_at_lower_limit_price(market.sell_price(PriceField::Last)) + { + return Ok(Some("lower_limit".to_string())); + } + if market.day_open <= 1.0 { + return Ok(Some("one_yuan".to_string())); + } + if !self.stock_passes_ma_filter(ctx, date, symbol) { + return Ok(Some("ma_filter".to_string())); + } + Ok(None) + } + + fn select_symbols( + &self, + ctx: &StrategyContext<'_>, + date: NaiveDate, + band_low: f64, + band_high: f64, + ) -> Result<(Vec, Vec), BacktestError> { + let universe = ctx.data.eligible_universe_on(date); + let mut diagnostics = Vec::new(); + let mut selected = Vec::new(); + let start = lower_bound_eligible(universe, band_low); + + for candidate in universe.iter().skip(start) { + if candidate.market_cap_bn > band_high { + break; + } + if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? { + if diagnostics.len() < 12 { + diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason)); + } + continue; + } + + selected.push(candidate.symbol.clone()); + if selected.len() >= self.config.stocknum { + break; + } + } + + Ok((selected, diagnostics)) + } +} + +impl Strategy for JqMicroCapStrategy { + fn name(&self) -> &str { + self.config.strategy_name.as_str() + } + + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + let date = ctx.execution_date; + if self.config.in_skip_window(date) { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), + order_intents: ctx + .portfolio + .positions() + .keys() + .cloned() + .map(|symbol| OrderIntent::TargetValue { + symbol, + target_value: 0.0, + reason: "seasonal_stop_window".to_string(), + }) + .collect(), + notes: vec![format!("seasonal stop window on {}", date)], + diagnostics: vec!["jq-style skip window forced all cash".to_string()], + }); + } + + let (index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, date) { + Ok(value) => value, + Err(BacktestError::Execution(message)) + if message.contains("insufficient benchmark") => + { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: Vec::new(), + notes: vec![format!("warmup: {}", message)], + diagnostics: vec![ + "insufficient history; skip trading on warmup dates".to_string(), + ], + }); + } + Err(err) => return Err(err), + }; + let (band_low, band_high) = self.market_cap_band(index_level); + let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?; + let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0; + let mut projected = ctx.portfolio.clone(); + let mut order_intents = Vec::new(); + let mut exit_symbols = BTreeSet::new(); + + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 || position.average_cost <= 0.0 { + continue; + } + let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last) + else { + continue; + }; + let Some(market) = ctx.data.market(date, &position.symbol) else { + continue; + }; + let sell_price = market.sell_price(PriceField::Last); + let stop_hit = current_price + <= position.average_cost * self.config.stop_loss_ratio + + self.stop_loss_tolerance(market); + let profit_hit = !market.is_at_upper_limit_price(current_price) + && current_price / position.average_cost > self.config.take_profit_ratio; + let can_sell = self.can_sell_position(ctx, date, &position.symbol); + if stop_hit || profit_hit { + let sell_reason = if stop_hit { + "stop_loss_exit" + } else { + "take_profit_exit" + }; + exit_symbols.insert(position.symbol.clone()); + order_intents.push(OrderIntent::TargetValue { + symbol: position.symbol.clone(), + target_value: 0.0, + reason: sell_reason.to_string(), + }); + if can_sell { + self.project_target_zero(&mut projected, &position.symbol, sell_price); + } + + if projected.positions().len() < self.config.stocknum { + let remaining_slots = self.config.stocknum - projected.positions().len(); + if remaining_slots > 0 { + let replacement_cash = + projected.cash() * trading_ratio / remaining_slots as f64; + for symbol in &stock_list { + if symbol == &position.symbol + || projected.positions().contains_key(symbol) + { + continue; + } + if self.buy_rejection_reason(ctx, date, symbol)?.is_some() { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: replacement_cash, + reason: format!("replacement_after_{}", sell_reason), + }); + if let Some(market) = ctx.data.market(date, symbol) { + self.project_order_value( + &mut projected, + date, + symbol, + market.buy_price(PriceField::Last), + market.buy_price(PriceField::Last), + replacement_cash, + ); + } + break; + } + } + } + } + } + + if periodic_rebalance { + let pre_rebalance_symbols = projected + .positions() + .keys() + .cloned() + .collect::>(); + for symbol in pre_rebalance_symbols.iter() { + if stock_list.iter().any(|candidate| candidate == symbol) { + continue; + } + if !self.can_sell_position(ctx, date, symbol) { + continue; + } + order_intents.push(OrderIntent::TargetValue { + symbol: symbol.clone(), + target_value: 0.0, + reason: "periodic_rebalance_sell".to_string(), + }); + if let Some(price) = ctx + .data + .market(date, symbol) + .map(|market| market.sell_price(PriceField::Last)) + { + self.project_target_zero(&mut projected, symbol, price); + } + } + + let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64; + for symbol in &stock_list { + if projected.positions().len() >= self.config.stocknum { + break; + } + if pre_rebalance_symbols.contains(symbol) + || projected.positions().contains_key(symbol) + { + continue; + } + if self.buy_rejection_reason(ctx, date, symbol)?.is_some() { + continue; + } + order_intents.push(OrderIntent::Value { + symbol: symbol.clone(), + value: fixed_buy_cash, + reason: "periodic_rebalance_buy".to_string(), + }); + if let Some(market) = ctx.data.market(date, symbol) { + self.project_order_value( + &mut projected, + date, + symbol, + market.buy_price(PriceField::Last), + market.buy_price(PriceField::Last), + fixed_buy_cash, + ); + } + } + } + + let mut diagnostics = vec![ + format!( + "jq_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}", + self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio + ), + format!( + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={}", + stock_list.len(), + periodic_rebalance, + exit_symbols.len(), + projected.positions().len(), + order_intents.len() + ), + "run_daily(10:17/10:18) approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(), + ]; + if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER") + .map(|value| value == "1") + .unwrap_or(false) + { + diagnostics.push(format!( + "positions_order={}", + ctx.portfolio + .positions() + .keys() + .cloned() + .collect::>() + .join("|") + )); + } + diagnostics.extend(selection_notes); + + let notes = vec![ + format!("stock_list={}", stock_list.len()), + format!("projected_positions={}", projected.positions().len()), + ]; + + Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols, + order_intents, + notes, + diagnostics, + }) + } +} + +fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: f64) -> usize { + let mut left = 0usize; + let mut right = rows.len(); + while left < right { + let mid = left + (right - left) / 2; + if rows[mid].market_cap_bn < target { + left = mid + 1; + } else { + right = mid; + } + } + left +} diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs index df3a657..3d7c5dd 100644 --- a/crates/fidc-core/src/universe.rs +++ b/crates/fidc-core/src/universe.rs @@ -1,7 +1,7 @@ use chrono::NaiveDate; use serde::Serialize; -use crate::data::{BenchmarkSnapshot, DataSet}; +use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BandRegime { @@ -48,7 +48,10 @@ pub struct SelectionContext<'a> { pub trait UniverseSelector { fn select(&self, ctx: &SelectionContext<'_>) -> Vec; - fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec, SelectionDiagnostics); + fn select_with_diagnostics( + &self, + ctx: &SelectionContext<'_>, + ) -> (Vec, SelectionDiagnostics); } #[derive(Debug, Clone)] @@ -103,7 +106,10 @@ impl UniverseSelector for DynamicMarketCapBandSelector { self.select_with_diagnostics(ctx).0 } - fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec, SelectionDiagnostics) { + fn select_with_diagnostics( + &self, + ctx: &SelectionContext<'_>, + ) -> (Vec, SelectionDiagnostics) { let _regime = self.regime(ctx.reference_level); let (min_cap, max_cap) = self.band_for_level(ctx.reference_level); let mut diagnostics = SelectionDiagnostics { @@ -125,78 +131,24 @@ impl UniverseSelector for DynamicMarketCapBandSelector { rejection_examples: Vec::new(), }; + diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len(); + diagnostics.market_cap_missing_count = diagnostics + .factor_total + .saturating_sub(ctx.data.eligible_universe_on(ctx.decision_date).len()); + + let eligible = ctx.data.eligible_universe_on(ctx.decision_date); + let start_idx = lower_bound_by_market_cap(eligible, min_cap); let mut selected = Vec::new(); - for factor in ctx.data.factor_snapshots_on(ctx.decision_date) { - diagnostics.factor_total += 1; - - if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() { - diagnostics.market_cap_missing_count += 1; - if diagnostics.missing_market_cap_symbols.len() < 8 { - diagnostics.missing_market_cap_symbols.push(factor.symbol.clone()); - } - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol)); - } - continue; + for factor in eligible.iter().skip(start_idx) { + if factor.market_cap_bn > max_cap { + break; } - - let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else { - diagnostics.candidate_missing_count += 1; - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!("{}: candidate snapshot missing", factor.symbol)); - } - continue; - }; - - let Some(market) = ctx.data.market(ctx.decision_date, &factor.symbol) else { - diagnostics.market_missing_count += 1; - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!("{}: market snapshot missing", factor.symbol)); - } - continue; - }; - - if !candidate.eligible_for_selection() { - diagnostics.not_eligible_count += 1; - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!("{}: candidate flags rejected", factor.symbol)); - } - continue; - } - if market.paused { - diagnostics.paused_count += 1; - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!("{}: market paused", factor.symbol)); - } - continue; - } - if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap { - diagnostics.out_of_band_count += 1; - if diagnostics.rejection_examples.len() < 12 { - diagnostics.rejection_examples.push(format!( - "{}: market_cap {:.2} out_of_band {:.2}-{:.2}", - factor.symbol, factor.market_cap_bn, min_cap, max_cap - )); - } - continue; - } - - selected.push(UniverseCandidate { - symbol: factor.symbol.clone(), - market_cap_bn: factor.market_cap_bn, - free_float_cap_bn: factor.free_float_cap_bn, - band_low: min_cap, - band_high: max_cap, - }); + selected.push(to_universe_candidate(factor, min_cap, max_cap)); } - selected.sort_by(|left, right| { - left.market_cap_bn - .partial_cmp(&right.market_cap_bn) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| left.symbol.cmp(&right.symbol)) - }); + diagnostics.out_of_band_count = eligible.len().saturating_sub(selected.len()); + diagnostics.selected_before_limit = selected.len(); if selected.len() > self.top_n { selected.truncate(self.top_n); @@ -206,3 +158,31 @@ impl UniverseSelector for DynamicMarketCapBandSelector { (selected, diagnostics) } } + +fn lower_bound_by_market_cap(rows: &[EligibleUniverseSnapshot], target: f64) -> usize { + let mut left = 0usize; + let mut right = rows.len(); + while left < right { + let mid = left + (right - left) / 2; + if rows[mid].market_cap_bn < target { + left = mid + 1; + } else { + right = mid; + } + } + left +} + +fn to_universe_candidate( + factor: &EligibleUniverseSnapshot, + band_low: f64, + band_high: f64, +) -> UniverseCandidate { + UniverseCandidate { + symbol: factor.symbol.clone(), + market_cap_bn: factor.market_cap_bn, + free_float_cap_bn: factor.free_float_cap_bn, + band_low, + band_high, + } +} diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs index 852930a..7ff30d5 100644 --- a/crates/fidc-core/tests/core_rules.rs +++ b/crates/fidc-core/tests/core_rules.rs @@ -2,12 +2,8 @@ use chrono::NaiveDate; use fidc_core::cost::CostModel; use fidc_core::rules::EquityRuleHooks; use fidc_core::{ - CandidateEligibility, - ChinaAShareCostModel, - ChinaEquityRuleHooks, - DailyMarketSnapshot, - OrderSide, - Position, + CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot, + OrderSide, Position, PriceField, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -32,15 +28,25 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho DailyMarketSnapshot { date: d(2024, 1, 3), symbol: "000001.SZ".to_string(), + timestamp: Some("2024-01-03 10:18:00".to_string()), + day_open: open, open, high: open, low: open, close: open, + last_price: open, + bid1: open, + ask1: open, prev_close: 10.0, volume: 1_000_000, + tick_volume: 100_000, + bid1_volume: 50_000, + ask1_volume: 50_000, + trading_phase: Some("continuous".to_string()), paused: false, upper_limit, lower_limit, + price_tick: 0.01, } } @@ -69,14 +75,11 @@ fn china_rule_hooks_block_same_day_sell_under_t_plus_one() { &snapshot(10.1, 11.0, 9.0), &candidate(), &position, + PriceField::Open, ); assert!(!check.allowed); - assert!(check - .reason - .as_deref() - .unwrap_or_default() - .contains("t+1")); + assert!(check.reason.as_deref().unwrap_or_default().contains("t+1")); } #[test] @@ -86,20 +89,62 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() { let mut position = Position::new("000001.SZ"); position.buy(d(2024, 1, 2), 1_000, 10.0); - let buy_check = hooks.can_buy(d(2024, 1, 3), &snapshot(11.0, 11.0, 9.0), &candidate); + let buy_check = hooks.can_buy( + d(2024, 1, 3), + &snapshot(11.0, 11.0, 9.0), + &candidate, + PriceField::Open, + ); assert!(!buy_check.allowed); - assert!(buy_check - .reason - .as_deref() - .unwrap_or_default() - .contains("upper limit")); + assert!( + buy_check + .reason + .as_deref() + .unwrap_or_default() + .contains("upper limit") + ); - let sell_check = - hooks.can_sell(d(2024, 1, 3), &snapshot(9.0, 11.0, 9.0), &candidate, &position); + let sell_check = hooks.can_sell( + d(2024, 1, 3), + &snapshot(9.0, 11.0, 9.0), + &candidate, + &position, + PriceField::Open, + ); + assert!(!sell_check.allowed); + assert!( + sell_check + .reason + .as_deref() + .unwrap_or_default() + .contains("lower limit") + ); +} + +#[test] +fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() { + let hooks = ChinaEquityRuleHooks; + let candidate = candidate(); + + let near_upper = DailyMarketSnapshot { + price_tick: 0.001, + ..snapshot(10.9995, 11.0, 9.0) + }; + let buy_check = hooks.can_buy(d(2024, 1, 3), &near_upper, &candidate, PriceField::Open); + assert!(!buy_check.allowed); + + let near_lower = DailyMarketSnapshot { + price_tick: 0.001, + ..snapshot(9.0005, 11.0, 9.0) + }; + let mut position = Position::new("000001.SZ"); + position.buy(d(2024, 1, 2), 1_000, 10.0); + let sell_check = hooks.can_sell( + d(2024, 1, 3), + &near_lower, + &candidate, + &position, + PriceField::Open, + ); assert!(!sell_check.allowed); - assert!(sell_check - .reason - .as_deref() - .unwrap_or_default() - .contains("lower limit")); } diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs new file mode 100644 index 0000000..575c70e --- /dev/null +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -0,0 +1,54 @@ +use chrono::NaiveDate; +use fidc_core::{CashReceivable, PortfolioState, Position}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +#[test] +fn cash_dividend_adjusts_cost_basis_and_returns_cash_delta() { + let mut position = Position::new("000001.SZ"); + position.buy(d(2025, 1, 2), 1_000, 10.0); + + let cash_delta = position.apply_cash_dividend(0.5); + + assert!((cash_delta - 500.0).abs() < 1e-9); + assert_eq!(position.quantity, 1_000); + assert!((position.average_cost - 9.5).abs() < 1e-9); + assert!((position.last_price - 9.5).abs() < 1e-9); +} + +#[test] +fn stock_split_scales_lots_quantity_and_average_cost() { + let mut position = Position::new("000001.SZ"); + position.buy(d(2025, 1, 2), 1_000, 10.0); + + let delta_quantity = position.apply_split_ratio(1.2); + + assert_eq!(delta_quantity, 200); + assert_eq!(position.quantity, 1_200); + assert!((position.average_cost - (10.0 / 1.2)).abs() < 1e-9); + assert!((position.last_price - (10.0 / 1.2)).abs() < 1e-9); + assert_eq!(position.sellable_qty(d(2025, 1, 3)), 1_200); +} + +#[test] +fn portfolio_settles_cash_receivable_on_payable_date() { + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.add_cash_receivable(CashReceivable { + symbol: "000001.SZ".to_string(), + ex_date: d(2025, 1, 2), + payable_date: d(2025, 1, 5), + amount: 500.0, + reason: "cash_dividend 0.5".to_string(), + }); + + let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4)); + assert!(settled_early.is_empty()); + assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9); + + let settled = portfolio.settle_cash_receivables(d(2025, 1, 5)); + assert_eq!(settled.len(), 1); + assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9); + assert!(portfolio.cash_receivables().is_empty()); +} diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs new file mode 100644 index 0000000..537542f --- /dev/null +++ b/crates/fidc-core/tests/delisting.rs @@ -0,0 +1,249 @@ +use chrono::NaiveDate; +use fidc_core::{ + BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, + ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, + Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision, +}; +use std::collections::{BTreeMap, BTreeSet}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +struct BuyThenHoldStrategy; + +impl Strategy for BuyThenHoldStrategy { + fn name(&self) -> &str { + "buy-then-hold" + } + + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() { + return Ok(StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000001.SZ".to_string(), + value: 10_000.0, + reason: "seed_position".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }); + } + Ok(StrategyDecision::default()) + } +} + +#[test] +fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() { + let date1 = d(2025, 1, 2); + let date2 = d(2025, 1, 3); + let data = DataSet::from_components( + vec![ + Instrument { + symbol: "000001.SZ".to_string(), + name: "Delisted".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: Some(date1), + status: "delisted".to_string(), + }, + Instrument { + symbol: "000002.SZ".to_string(), + name: "Anchor".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }, + ], + vec![ + DailyMarketSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date1, + symbol: "000002.SZ".to_string(), + timestamp: Some("2025-01-02 10:18:00".to_string()), + day_open: 5.0, + open: 5.0, + high: 5.1, + low: 4.9, + close: 5.0, + last_price: 5.0, + bid1: 4.99, + ask1: 5.01, + prev_close: 5.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 100_000, + ask1_volume: 100_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 5.5, + lower_limit: 4.5, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date: date2, + symbol: "000002.SZ".to_string(), + timestamp: Some("2025-01-03 10:18:00".to_string()), + day_open: 5.1, + open: 5.1, + high: 5.2, + low: 5.0, + close: 5.1, + last_price: 5.1, + bid1: 5.09, + ask1: 5.11, + prev_close: 5.0, + volume: 120_000, + tick_volume: 120_000, + bid1_volume: 120_000, + ask1_volume: 120_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 5.5, + lower_limit: 4.5, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: date1, + symbol: "000001.SZ".to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 18.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + }, + DailyFactorSnapshot { + date: date1, + symbol: "000002.SZ".to_string(), + market_cap_bn: 30.0, + free_float_cap_bn: 28.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + }, + DailyFactorSnapshot { + date: date2, + symbol: "000002.SZ".to_string(), + market_cap_bn: 31.0, + free_float_cap_bn: 29.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + }, + ], + vec![ + CandidateEligibility { + date: date1, + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date1, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + CandidateEligibility { + date: date2, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }, + ], + vec![ + BenchmarkSnapshot { + date: date1, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }, + BenchmarkSnapshot { + date: date2, + benchmark: "000300.SH".to_string(), + open: 101.0, + close: 101.0, + prev_close: 100.0, + volume: 1_100_000, + }, + ], + ) + .expect("dataset"); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + let mut engine = BacktestEngine::new( + data, + BuyThenHoldStrategy, + broker, + BacktestConfig { + initial_cash: 100_000.0, + benchmark_code: "000300.SH".to_string(), + start_date: Some(date1), + end_date: Some(date2), + decision_lag_trading_days: 0, + execution_price_field: PriceField::Open, + }, + ); + + let result = engine.run().expect("backtest succeeds"); + assert_eq!(result.fills.len(), 2); + assert!(result + .fills + .iter() + .any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ")); + assert!(result + .holdings_summary + .iter() + .all(|holding| holding.symbol != "000001.SZ")); +} diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs new file mode 100644 index 0000000..f27759e --- /dev/null +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -0,0 +1,338 @@ +use chrono::NaiveDate; +use fidc_core::{ + BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel, + ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument, + OrderIntent, PortfolioState, PriceField, StrategyDecision, +}; +use std::collections::{BTreeMap, BTreeSet}; + +#[test] +fn broker_executes_explicit_order_value_buy() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000002.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000002.SZ".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000002.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + }], + vec![CandidateEligibility { + date, + symbol: "000002.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 100_000.0, + reason: "test_order_value".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert!(!report.fill_events.is_empty()); + assert_eq!(report.fill_events[0].symbol, "000002.SZ"); + assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Buy); + assert!(portfolio.position("000002.SZ").is_some()); + assert!(portfolio.cash() < 1_000_000.0); +} + +#[test] +fn broker_uses_instrument_round_lot_for_buy_sizing() { + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let data = DataSet::from_components( + vec![Instrument { + symbol: "688001.SH".to_string(), + name: "KSH".to_string(), + board: "KSH".to_string(), + round_lot: 200, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "688001.SH".to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "688001.SH".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 20.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + }], + vec![CandidateEligibility { + date, + symbol: "688001.SH".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(10_500.0); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::Value { + symbol: "688001.SH".to_string(), + value: 10_500.0, + reason: "round_lot".to_string(), + }], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 1000); +} + +#[test] +fn same_day_sell_then_rebuy_reinserts_position_at_end() { + let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); + let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let instruments = symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }) + .collect::>(); + let market = symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2024-01-10 10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.1, + low: 9.9, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 10.0, + volume: 100_000, + tick_volume: 100_000, + bid1_volume: 80_000, + ask1_volume: 80_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect::>(); + let factors = symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 15.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(1.8), + }) + .collect::>(); + let candidates = symbols + .iter() + .map(|symbol| CandidateEligibility { + date, + symbol: (*symbol).to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + }) + .collect::>(); + let data = DataSet::from_components( + instruments, + market, + factors, + candidates, + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0); + portfolio.position_mut("000002.SZ").buy(prev_date, 100, 10.0); + portfolio.position_mut("000003.SZ").buy(prev_date, 100, 10.0); + + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Open, + ); + + broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![ + OrderIntent::TargetValue { + symbol: "000002.SZ".to_string(), + target_value: 0.0, + reason: "sell_then_rebuy".to_string(), + }, + OrderIntent::Value { + symbol: "000002.SZ".to_string(), + value: 10_000.0, + reason: "sell_then_rebuy".to_string(), + }, + ], + notes: Vec::new(), + diagnostics: Vec::new(), + }, + ) + .expect("broker execution"); + + let symbols = portfolio.positions().keys().cloned().collect::>(); + assert_eq!( + symbols, + vec![ + "000001.SZ".to_string(), + "000003.SZ".to_string(), + "000002.SZ".to_string(), + ] + ); +} diff --git a/crates/fidc-core/tests/partitioned_loader.rs b/crates/fidc-core/tests/partitioned_loader.rs index 4d32f4b..6c294f8 100644 --- a/crates/fidc-core/tests/partitioned_loader.rs +++ b/crates/fidc-core/tests/partitioned_loader.rs @@ -20,10 +20,11 @@ fn can_load_partitioned_snapshot_dir() { fs::create_dir_all(dir.join("market/2024/01")).unwrap(); fs::create_dir_all(dir.join("factors/2024/01")).unwrap(); fs::create_dir_all(dir.join("candidates/2024/01")).unwrap(); + fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap(); fs::write( dir.join("instruments.csv"), - "symbol,name,exchange,lot_size\n000001.SZ,PingAn,SZ,100\n", + "symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n", ) .unwrap(); fs::write( @@ -33,7 +34,7 @@ fn can_load_partitioned_snapshot_dir() { .unwrap(); fs::write( dir.join("market/2024/01/2024-01-02.csv"), - "date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9\n", + "date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n", ) .unwrap(); fs::write( @@ -46,10 +47,48 @@ fn can_load_partitioned_snapshot_dir() { "date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n", ) .unwrap(); + fs::write( + dir.join("corporate_actions/2024/01/2024-01-02.csv"), + "date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n", + ) + .unwrap(); let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset"); assert_eq!(data.benchmark_code(), "CSI300.DEMO"); - assert!(data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()).len() == 1); + assert!( + data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()) + .len() + == 1 + ); + let market_rows = data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()); + let snapshot = market_rows + .first() + .expect("market snapshot"); + assert_eq!(snapshot.day_open, 10.1); + assert_eq!(snapshot.last_price, 10.15); + assert_eq!(snapshot.price_tick, 0.01); + assert_eq!( + data.instruments() + .get("000001.SZ") + .expect("instrument") + .round_lot, + 100 + ); + assert_eq!( + data.instruments() + .get("000001.SZ") + .expect("instrument") + .listed_at, + Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()) + ); + let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()); + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].payable_date, + Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()) + ); + assert!((actions[0].share_cash - 0.5).abs() < 1e-9); + assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9); let _ = fs::remove_dir_all(&dir); } diff --git a/crates/fidc-core/tests/strategy_selection.rs b/crates/fidc-core/tests/strategy_selection.rs index a79f023..262a192 100644 --- a/crates/fidc-core/tests/strategy_selection.rs +++ b/crates/fidc-core/tests/strategy_selection.rs @@ -1,5 +1,8 @@ use chrono::NaiveDate; -use fidc_core::{CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, Strategy, StrategyContext, PortfolioState}; +use fidc_core::{ + CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, JqMicroCapConfig, + JqMicroCapStrategy, PortfolioState, Strategy, StrategyContext, +}; use std::path::PathBuf; #[test] @@ -28,9 +31,51 @@ fn strategy_emits_target_weights_and_diagnostics() { assert!(decision.rebalance); assert!(decision.rebalance); assert!(!decision.diagnostics.is_empty()); - assert!(decision - .diagnostics - .iter() - .any(|line| line.contains("signal_symbol="))); + assert!( + decision + .diagnostics + .iter() + .any(|line| line.contains("signal_symbol=")) + ); assert_eq!(strategy.name(), "cn-dyn-smallcap-band"); } + +#[test] +fn jq_strategy_emits_same_day_decision() { + let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo"); + let data = DataSet::from_csv_dir(&data_dir).expect("demo data"); + let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); + let portfolio = PortfolioState::new(1_000_000.0); + let mut cfg = JqMicroCapConfig::jq_microcap(); + cfg.benchmark_signal_symbol = "000001.SZ".to_string(); + cfg.benchmark_short_ma_days = 3; + cfg.benchmark_long_ma_days = 5; + cfg.stock_short_ma_days = 3; + cfg.stock_mid_ma_days = 4; + cfg.stock_long_ma_days = 5; + let mut strategy = JqMicroCapStrategy::new(cfg); + + let decision = strategy + .on_day(&StrategyContext { + execution_date, + decision_date: execution_date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + }) + .expect("jq decision"); + + assert!(!decision.rebalance); + assert!( + decision + .diagnostics + .iter() + .any(|line| line.contains("jq_microcap signal=")) + ); + assert!( + decision + .diagnostics + .iter() + .any(|line| line.contains("selected=")) + ); +} diff --git a/data/demo/聚宽微盘股策略.engine_spec.json b/data/demo/聚宽微盘股策略.engine_spec.json new file mode 100644 index 0000000..485ebe0 --- /dev/null +++ b/data/demo/聚宽微盘股策略.engine_spec.json @@ -0,0 +1,36 @@ +{ + "strategyId": "jq-microcap", + "benchmark": { + "instrumentId": "000852.SH", + "fallbackInstrumentId": "000852.SH" + }, + "signalSymbol": "000001.SH", + "engineConfig": { + "templateId": "joinquant-microcap-original", + "signalSymbol": "000001.SH", + "rankLimit": 40, + "refreshRate": 15, + "rsiRate": 1.0001, + "dynamicRange": { + "baseIndexLevel": 2000.0, + "baseCapFloor": 7.0, + "capSpan": 10.0, + "xs": 0.008 + }, + "stockMaFilter": { + "shortDays": 5, + "midDays": 10, + "longDays": 20, + "rsiRate": 1.0001 + }, + "indexThrottle": { + "shortDays": 5, + "longDays": 10, + "rsiRate": 1.0001, + "defensiveExposure": 0.5 + }, + "stopLossMultiplier": 0.93, + "takeProfitMultiplier": 1.07, + "skipWindows": [] + } +} diff --git a/data/demo/聚宽微盘股策略.engine_spec.md b/data/demo/聚宽微盘股策略.engine_spec.md new file mode 100644 index 0000000..c64357d --- /dev/null +++ b/data/demo/聚宽微盘股策略.engine_spec.md @@ -0,0 +1,21 @@ +聚宽原始脚本 `/Users/boris/WorkSpace/fidc-backtest-engine/聚宽微盘股策略.py` 已映射到 +`聚宽微盘股策略.engine_spec.json`。 + +映射关系: + +- `g.refresh_rate = 15` -> `engineConfig.refreshRate = 15` +- `g.stocknum = 40` -> `engineConfig.rankLimit = 40` +- `g.XS = 4 / 500` -> `engineConfig.dynamicRange.xs = 0.008` +- `current_price` 基准指数 `000001.XSHG` -> `signalSymbol = 000001.SH` +- `set_benchmark('000852.XSHG')` -> `benchmark.instrumentId = 000852.SH` +- `g.CloseRate = 1.07` -> `takeProfitMultiplier = 1.07` +- `g.LossRate = 0.93` -> `stopLossMultiplier = 0.93` +- `g.RSIRate = 1.0001` -> `rsiRate = 1.0001` +- `g.TradeRate = 0.5` -> `indexThrottle.defensiveExposure = 0.5` +- 个股均线过滤 `5/10/20` -> `stockMaFilter.short/mid/longDays` +- 指数均线过滤 `5/10` -> `indexThrottle.short/longDays` + +说明: + +- 原脚本里 `validate_date()` 虽然定义了停运窗口,但 `check_stocks()` 里紧接着把 `g.OpenYN = 1` 强制打开, + 所以实际执行日志中停运窗口无效。当前 spec 因此使用 `skipWindows = []`,以匹配真实聚宽执行结果。 diff --git a/聚宽微盘股策略.py b/聚宽微盘股策略.py new file mode 100644 index 0000000..ba95857 --- /dev/null +++ b/聚宽微盘股策略.py @@ -0,0 +1,654 @@ +''' +设定的市值17—26亿 可以根据指数的变化来更改 +比如3300点集中在15—25亿市值最小的四十只 3400点 集中在17—27亿 +对应指数乘以一个系数 对应市值选出40只 +''' +from jqdata import * +from datetime import datetime, timedelta + + +## 初始化函数,设定要操作的股票、基准等等 +def initialize(context): + + set_benchmark('000852.XSHG') #对标中证1000 + # True为开启动态复权模式,使用真实价格交易 + set_option('use_real_price', True) + # 设定成交量比例 + set_option('order_volume_ratio', 1) + # 股票类交易手续费是:买入时佣金万分之三,卖出时佣金万分之三加千分之一印花税, 每笔交易佣金最低扣5块钱 + set_order_cost(OrderCost(open_tax=0, close_tax=0.001, \ + open_commission=0.0003, close_commission=0.0003,\ + close_today_commission=0, min_commission=5), type='stock') + + # 交易日计时器 + g.days = 0 + # 分仓常量参数,无须人为修改 + g.TR = 1; + # 区间最高价 + g.summit = {} + g.muster = [] + # 运行状态 1/运行; 0/停运 + g.OpenYN = 1 + + # 开始范围 + #g.mystart = 13 + # 截至范围 + #g.myend = 23 + + # 调仓频率 + g.refresh_rate = 15 + # 运行函数1 + run_daily(trade, time='10:18') + # 运行函数2 + run_daily(CPtrade, time='10:17') + + # 持仓数量 + g.stocknum = 40 + # 上证指数对应系数 + g.XS = 4/500 + #止盈比率 + g.CloseRate = 1.07 + #止损比率 + g.LossRate = 0.93 + + # 均线上涨比率 + g.RSIRate = 1.0001 + + # 保证金仓位比率: 1/2为半仓 + g.TradeRate = 0.5 + # 调试日志展示数量 + g.debug_log_limit = 60 + g.debug_boundary_window = 5 + g.market_cap_map = {} + + +def _fmt_num(value, digits=2): + try: + return ('%.' + str(digits) + 'f') % float(value) + except Exception: + return str(value) + + +def _safe_bar_values(bar_data, field): + try: + values = bar_data[field] + except Exception: + values = getattr(bar_data, field) + try: + return [float(x) for x in list(values)] + except Exception: + try: + return list(values) + except Exception: + return [] + + +def _safe_bar_last(bar_data, field): + values = _safe_bar_values(bar_data, field) + if values: + return values[-1] + return None + + +def _market_cap_text(stock): + cap = getattr(g, 'market_cap_map', {}).get(stock) + if cap is None: + return 'None' + return _fmt_num(cap, 2) + '亿' + + +def _describe_stock(stock, extras=None): + parts = [stock, '市值=' + _market_cap_text(stock)] + if extras: + parts.extend(extras) + return ','.join(parts) + + +def _log_detail_items(label, details, limit=None, chunk=10): + total = len(details) + if total == 0: + log.info(label + ': 0') + return + + if limit is None: + limit = total + shown = details[:limit] + log.info('%s: 总数=%d, 展示=%d' % (label, total, len(shown))) + for idx in range(0, len(shown), chunk): + part = shown[idx:idx + chunk] + log.info('%s[%d-%d]: %s' % ( + label, + idx + 1, + idx + len(part), + ' | '.join(part) + )) + if total > limit: + log.info('%s: 其余%d项省略' % (label, total - limit)) + + +def _log_market_cap_snapshot(df, caller): + if df is None or len(df) == 0: + log.info('市值筛选快照[%s]: 无数据' % caller) + return + + top_n = min(len(df), g.debug_log_limit) + top_details = [] + for i in range(top_n): + row = df.iloc[i] + top_details.append('%d.%s:%s亿' % ( + i + 1, + row['code'], + _fmt_num(row['market_cap'], 2) + )) + _log_detail_items('市值排序前段[%s]' % caller, top_details, limit=top_n, chunk=8) + + start_idx = max(0, g.stocknum - g.debug_boundary_window - 1) + end_idx = min(len(df), g.stocknum + g.debug_boundary_window) + boundary_details = [] + for i in range(start_idx, end_idx): + row = df.iloc[i] + boundary_details.append('%d.%s:%s亿' % ( + i + 1, + row['code'], + _fmt_num(row['market_cap'], 2) + )) + _log_detail_items('市值排序边界[%s]' % caller, boundary_details, limit=len(boundary_details), chunk=5) + + +## 选出小市值股票 +def check_stocks(context, caller='unknown'): + + g.today = context.current_dt + validate_date() + + #不停运参数 + g.OpenYN = 1 + + log.info('选股开始[%s]: 日期=%s, g.days=%d, refresh_rate=%d, 持仓数=%d' % ( + caller, + context.current_dt.strftime('%Y-%m-%d %H:%M:%S'), + g.days, + g.refresh_rate, + len(context.portfolio.positions) + )) + + # 检查日期是否在范围内 + if g.OpenYN == 0: + log.warn("该时段属于停运==================范围") + return [] + else: + # g.OpenYN = 1 + + g.security = '000001.XSHG' + + #000852.XSHG + + close_data = get_bars(g.security, count=1, unit='1d', fields=['close']) + # 取得过去五天的平均价格 + MA5 = close_data['close'].mean() + + ################################################### + close5_data = get_bars(g.security, count=5, unit='1d', fields=['close']) + # 获取股票的收盘价 + close10_data = get_bars(g.security, count=10, unit='1d', fields=['close']) + # 取得过去五天的平均价格 + MA5 = close5_data['close'].mean() + # 取得过去十天的平均价格 + MA10 = close10_data['close'].mean() + + #5日线下穿,则半仓交易 + if MA5 < MA10*g.RSIRate: + g.TR = g.TradeRate + elif MA5 >= MA10*g.RSIRate: + g.TR = 1 + ################################################### + + close5_list = _safe_bar_values(close5_data, 'close') + log.info('指数均线调试[%s][%s] close[-5:]=%s, ma5=%s, ma10=%s, ma5 0: + #Y = (current_price - 3000) *g.XS + 14 + Y = (current_price - 2000) *g.XS + 7 + g.mystart = Y + g.myend = Y + 10 + + # mystart = g.start + # myend = g.end + mystart = round(g.mystart) + myend = round(g.myend) + + log.info('价格区间为:'+ str(mystart) + '~'+str(myend)) + + + # 设定查询条件 + q = query( + valuation.code, + valuation.market_cap + ).filter( + valuation.market_cap.between(mystart,myend) + ).order_by( + valuation.market_cap.asc() + ) + + # 选出低市值的股票,构成buylist + df = get_fundamentals(q) + g.market_cap_map = {} + if df is not None and len(df) > 0: + for _, row in df.iterrows(): + try: + g.market_cap_map[row['code']] = float(row['market_cap']) + except Exception: + g.market_cap_map[row['code']] = None + log.info('市值筛选结果[%s]: %d只股票' % (caller, 0 if df is None else len(df))) + _log_market_cap_snapshot(df, caller) + buylist =list(df['code']) + + # 过滤停牌,ST,科创,新股,1元股 + buylist = filter_paused_stock(buylist, caller=caller) + + final_list = buylist[:g.stocknum] + g.muster = final_list + _log_detail_items( + '最终选股[%s]' % caller, + [_describe_stock(stock) for stock in final_list], + limit=len(final_list), + chunk=8 + ) + boundary_slice = buylist[max(0, g.stocknum - g.debug_boundary_window): min(len(buylist), g.stocknum + g.debug_boundary_window)] + _log_detail_items( + '最终选股边界[%s]' % caller, + [_describe_stock(stock) for stock in boundary_slice], + limit=len(boundary_slice), + chunk=5 + ) + + return final_list + + +def before_trading_start(context): + # 取得当前日期 + g.todayDT = context.current_dt + g.today = context.current_dt.strftime('%Y-%m-%d') + g.start = context.current_dt + timedelta(-2) + g.market_cap_map = {} + +## 交易函数 +def CPtrade(context): + + ## 选股 + stock_list = check_stocks(context, caller='CPtrade') + if g.OpenYN == 0: + #log.warn("日期属于范围") + ## 获取持仓列表 + sell_list = list(context.portfolio.positions.keys()) + # 如果有持仓,则卖出 + if len(sell_list) > 0 : + for stock in sell_list: + order_target_value(stock, 0) + return + + +## 交易函数 +def trade(context): + ## 选股 + stock_list = check_stocks(context, caller='trade') + if g.OpenYN == 0: + #log.warn("日期属于范围") + ## 获取持仓列表 + sell_list = list(context.portfolio.positions.keys()) + # 如果有持仓,则卖出 + if len(sell_list) > 0 : + for stock in sell_list: + order_target_value(stock, 0) + return + + g.changeYN = 0 + curr_data = get_current_data() + log.info('交易调试[trade]: 日期=%s, g.days=%d, refresh_hit=%s, TR=%s, cash=%s, total_value=%s, 持仓数=%d, 目标池数=%d' % ( + context.current_dt.strftime('%Y-%m-%d %H:%M:%S'), + g.days, + str(g.days % g.refresh_rate == 0), + _fmt_num(g.TR, 4), + _fmt_num(context.portfolio.cash, 2), + _fmt_num(context.portfolio.total_value, 2), + len(context.portfolio.positions), + len(stock_list) + )) + _log_detail_items( + '交易目标池[trade]', + [_describe_stock(stock) for stock in stock_list], + limit=len(stock_list), + chunk=8 + ) + # -------------------------------------------------------------------------- + for stockPos in context.portfolio.positions: + # SS、记录股票峰值信息 + # if g.summit.get(stock, 0) g.CloseRate) if (avg_cost and grid_high_limit is not None) else False + log.info('止盈止损评估[%s] %s: qty=%s, avg_cost=%s, current=%s, return=%s%%, stop_threshold=%s, profit_threshold=%s, high_limit=%s, bar.close=%s, paused=%s, in_target=%s, stop_hit=%s, profit_hit=%s' % ( + context.current_dt.strftime('%Y-%m-%d'), + stockPos, + str(getattr(hold, 'total_amount', getattr(hold, 'amount', 'None'))), + _fmt_num(avg_cost, 4), + _fmt_num(current_price, 4), + _fmt_num(return_ratio * 100, 2), + _fmt_num(g.LossRate, 4), + _fmt_num(g.CloseRate, 4), + _fmt_num(grid_high_limit, 4), + _fmt_num(grid_close, 4), + str(grid_paused), + str(stockPos in stock_list), + str(stop_hit), + str(profit_hit) + )) + if stop_hit: + order_target_value(stockPos, 0) + g.changeYN = 1 + log.error('止损清仓:%s,当前价=%.2f,成本价=%.2f,收益率=%.2f%%,high_limit=%s,bar.close=%s' % ( + stockPos, + current_price, + hold.avg_cost, + return_ratio * 100, + _fmt_num(grid_high_limit, 4), + _fmt_num(grid_close, 4) + )) + # S3、今日高开、今日未涨停则清仓 + elif profit_hit: + order_target_value(stockPos, 0) + g.changeYN = 1 + log.warn('止盈清仓:%s,当前价=%s,成本价=%s,收益率=%s%%,high_limit=%s,bar.close=%s' % ( + stockPos, + _fmt_num(current_price, 4), + _fmt_num(hold.avg_cost, 4), + _fmt_num(return_ratio * 100, 2), + _fmt_num(grid_high_limit, 4), + _fmt_num(grid_close, 4) + )) + else: + g.changeYN = 0 + + if g.changeYN == 1: + sell_list = list(context.portfolio.positions.keys()) + replacement_candidates = [stock for stock in stock_list if stockPos != stock and stock not in sell_list] + _log_detail_items( + '补仓候选[%s][%s]' % (context.current_dt.strftime('%Y-%m-%d'), stockPos), + [_describe_stock(stock) for stock in replacement_candidates], + limit=min(len(replacement_candidates), g.debug_log_limit), + chunk=6 + ) + # log.warn('portfoliocash:' + str(context.portfolio.cash)) + for stock in stock_list: + if len(context.portfolio.positions.keys()) < g.stocknum: + ## 获取持仓列表 + sell_list = list(context.portfolio.positions.keys()) + if stockPos != stock and stock not in sell_list : + + ## 分配资金 + if len(context.portfolio.positions) < g.stocknum : + Num = g.stocknum - len(context.portfolio.positions) + Cash = context.portfolio.cash * g.TR / Num + + #gridbuy = get_price(stock, start_date=g.start, end_date=g.today, fields=['open', 'high', 'low_limit', 'close', 'high_limit']) + + #if gridbuy.open[-1] > gridbuy.low_limit[-1]: + log.info('补仓买入:卖出=%s,买入=%s,Cash=%s,Num=%d,TR=%s,持仓数=%d' % ( + stockPos, + stock, + _fmt_num(Cash, 2), + Num, + _fmt_num(g.TR, 4), + len(context.portfolio.positions) + )) + order_value(stock, Cash) + #else : + # log.warn("忽略跌停股票:" + stock) + break + else: + continue + + + + if g.days%g.refresh_rate == 0: + log.info('定期调仓触发:日期=%s,g.days=%d,refresh_rate=%d,TR=%s' % ( + context.current_dt.strftime('%Y-%m-%d'), + g.days, + g.refresh_rate, + _fmt_num(g.TR, 4) + )) + + ## 获取持仓列表 + sell_list = list(context.portfolio.positions.keys()) + rebalance_sell_list = [stock for stock in sell_list if stock not in stock_list] + _log_detail_items( + '定期调仓卖出名单', + [_describe_stock(stock) for stock in rebalance_sell_list], + limit=len(rebalance_sell_list), + chunk=8 + ) + # 如果有持仓,则卖出 + if len(sell_list) > 0 : + for stock in sell_list: + if stock not in stock_list: + log.info('定期调仓卖出:%s' % stock) + order_target_value(stock, 0) + + ## 分配资金 + if len(context.portfolio.positions) < g.stocknum : + Num = g.stocknum - len(context.portfolio.positions) + # Cash = context.portfolio.cash/Num * g.TR + Cash = context.portfolio.cash * g.TR / g.stocknum + else: + Cash = 0 + log.info('定期调仓资金分配:Cash=%s,Num=%s,cash=%s,total_value=%s' % ( + _fmt_num(Cash, 2), + str(Num if len(context.portfolio.positions) < g.stocknum else 0), + _fmt_num(context.portfolio.cash, 2), + _fmt_num(context.portfolio.total_value, 2) + )) + + ## 买入股票 + for stock in stock_list: + if len(context.portfolio.positions.keys()) < g.stocknum: + if stock not in sell_list: + log.info('定期调仓买入:%s,Cash=%s' % (stock, _fmt_num(Cash, 2))) + order_value(stock, Cash) + + # 天计数加一 + g.days = 1 + else: + g.days += 1 + +# 过滤日期 +# 一月十号到三十一号 +# 四月十号到四月二十九日 期间 +# 八月十日到八月三十一日 +# 十月二十日 到十月三十日 +def validate_date(): + + # date = g.todayDT.strftime('%Y-%m-%d') + # date = datetime.strptime(g.todayDT, "%Y-%m-%d") + date = g.today + # 检查是否是4月10日到4月29日之间 + if date.month == 1 and 15 <= date.day <= 30: + g.OpenYN = 0 + elif date.month == 4 and 15 <= date.day <= 29: + g.OpenYN = 0 + elif date.month == 8 and 15 <= date.day <= 31: + g.OpenYN = 0 + elif date.month == 10 and 20 <= date.day <= 30: + g.OpenYN = 0 + elif date.month == 12 and 20 <= date.day <= 30: + g.OpenYN = 0 + else : + g.OpenYN = 1 + +# 过滤停牌股票 +def filter_paused_stock(stock_list, caller='unknown'): + curr_data = get_current_data() + raw_count = len(stock_list) + + risk_filtered = [] + risk_removed = [] + for stock in stock_list: + reasons = [] + if curr_data[stock].day_open == curr_data[stock].high_limit: + reasons.append('涨停开盘') + if curr_data[stock].day_open == curr_data[stock].low_limit: + reasons.append('跌停开盘') + if curr_data[stock].last_price == curr_data[stock].high_limit: + reasons.append('当前涨停') + if curr_data[stock].last_price == curr_data[stock].low_limit: + reasons.append('当前跌停') + if curr_data[stock].paused: + reasons.append('停牌') + if curr_data[stock].is_st: + reasons.append('ST') + if 'ST' in curr_data[stock].name: + reasons.append('名称含ST') + if '*' in curr_data[stock].name: + reasons.append('名称含*') + if '退' in curr_data[stock].name: + reasons.append('名称含退') + if stock.startswith('688'): + reasons.append('科创板') + + if reasons: + risk_removed.append(_describe_stock(stock, [ + '原因=' + '/'.join(reasons), + 'open=' + _fmt_num(curr_data[stock].day_open, 2), + 'last=' + _fmt_num(curr_data[stock].last_price, 2), + 'high_limit=' + _fmt_num(curr_data[stock].high_limit, 2), + 'low_limit=' + _fmt_num(curr_data[stock].low_limit, 2), + 'name=' + str(curr_data[stock].name) + ])) + else: + risk_filtered.append(stock) + + log.info('风险过滤[%s]: %d -> %d, 移除=%d' % ( + caller, + raw_count, + len(risk_filtered), + len(risk_removed) + )) + _log_detail_items( + '风险过滤移除[%s]' % caller, + risk_removed, + limit=min(len(risk_removed), g.debug_log_limit), + chunk=4 + ) + + one_yuan_filtered = [] + one_yuan_removed = [] + for stock in risk_filtered: + if curr_data[stock].day_open > 1: + one_yuan_filtered.append(stock) + else: + one_yuan_removed.append(_describe_stock(stock, [ + '原因=1元股过滤', + 'open=' + _fmt_num(curr_data[stock].day_open, 2), + 'last=' + _fmt_num(curr_data[stock].last_price, 2) + ])) + + log.info('1元股过滤[%s]: %d -> %d, 移除=%d' % ( + caller, + len(risk_filtered), + len(one_yuan_filtered), + len(one_yuan_removed) + )) + _log_detail_items( + '1元股过滤移除[%s]' % caller, + one_yuan_removed, + limit=min(len(one_yuan_removed), g.debug_log_limit), + chunk=4 + ) + + new_list = [] + ma_pass_details = {} + ma_removed_details = [] + for stock in one_yuan_filtered: + # 获取股票的收盘价 + close5_data = get_bars(stock, count=5, unit='1d', fields=['close']) + # 获取股票的收盘价 + close10_data = get_bars(stock, count=10, unit='1d', fields=['close']) + # 获取股票的收盘价 + close20_data = get_bars(stock, count=20, unit='1d', fields=['close']) + # 取得过去五天的平均价格 + MA5 = close5_data['close'].mean() + # 取得过去十天的平均价格 + MA10 = close10_data['close'].mean() + # 取得过去二十天的平均价格 + MA20 = close20_data['close'].mean() + # 取得上一时间点价格 + # current_price = close_data['close'][-1] + close5_list = [round(float(v), 2) for v in _safe_bar_values(close5_data, 'close')] + ma_condition_1 = MA5 > MA10 * g.RSIRate + ma_condition_2 = MA10 > MA20 + detail = _describe_stock(stock, [ + 'close[-5:]=' + str(close5_list), + 'ma5=' + _fmt_num(MA5, 4), + 'ma10=' + _fmt_num(MA10, 4), + 'ma20=' + _fmt_num(MA20, 4), + 'ma5>ma10*RSIRate=' + str(ma_condition_1), + 'ma10>ma20=' + str(ma_condition_2) + ]) + if MA5 > MA10*g.RSIRate> MA20*g.RSIRate: + new_list.append(stock) + ma_pass_details[stock] = detail + else: + ma_removed_details.append(detail) + + log.info('均线过滤[%s]: %d -> %d, 移除=%d' % ( + caller, + len(one_yuan_filtered), + len(new_list), + len(ma_removed_details) + )) + _log_detail_items( + '均线过滤移除[%s]' % caller, + ma_removed_details, + limit=min(len(ma_removed_details), g.debug_log_limit), + chunk=3 + ) + ma_boundary_details = [ma_pass_details[stock] for stock in new_list[:min(len(new_list), g.stocknum + g.debug_boundary_window)]] + _log_detail_items( + '均线通过前段[%s]' % caller, + ma_boundary_details, + limit=len(ma_boundary_details), + chunk=3 + ) + + return new_list