commit 334864cbc56601e0976553798a29b932352c1803 Author: zsb Date: Mon Apr 6 23:56:37 2026 -0700 初始化回测核心引擎骨架 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbe4996 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/output diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b37bdf3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,344 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bt-demo" +version = "0.1.0" +dependencies = [ + "fidc-core", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "fidc-core" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "thiserror", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2badf61 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +members = [ + "crates/fidc-core", + "crates/bt-demo", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +version = "0.1.0" +authors = ["OpenAI Codex"] + +[workspace.dependencies] +chrono = { version = "=0.4.44", features = ["serde"] } +serde = { version = "=1.0.228", features = ["derive"] } +thiserror = "=2.0.18" diff --git a/README.md b/README.md new file mode 100644 index 0000000..679cd85 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# fidc-backtest-engine + +一个面向中国 A 股长周期选股策略的 Rust 回测核心骨架。这个仓库的第一版目标不是“玩具回测器”,而是提供一个可以继续演化为平台化引擎的最小可用核心,方向参考 `nautilus_trader` 的分层架构和 `rqalpha` 的中国股票规则约束。 + +## 当前能力 + +- 日频交易日历与确定性逐日回放 +- A 股日频市场快照、估值/因子快照、基准快照、候选资格标记 +- 策略接口与引擎驱动,不直接模拟 `jqdata` API +- Universe 选择器:按指数位置动态切换市值带,再取最小市值 Top-N +- 风险节流:基于指数均线状态切换 100% / 50% / 0% 仓位 +- Broker Simulator:按次日开盘价撮合,支持手续费、印花税、最小佣金 +- 中国 A 股规则钩子:T+1、停牌、涨停不可买、跌停不可卖 +- 回测输出:权益曲线、成交记录、期末持仓摘要 +- `cargo run --bin bt-demo` 可直接运行仓库内置 demo 数据 + +## Workspace 布局 + +```text +. +├── Cargo.toml +├── crates +│ ├── bt-demo +│ │ └── src/main.rs +│ └── fidc-core +│ └── src +│ ├── broker.rs +│ ├── calendar.rs +│ ├── cost.rs +│ ├── data.rs +│ ├── engine.rs +│ ├── events.rs +│ ├── instrument.rs +│ ├── portfolio.rs +│ ├── rules.rs +│ ├── strategy.rs +│ └── universe.rs +└── data/demo +``` + +## 核心模块概览 + +- `calendar`: 交易日历和滚动窗口工具,负责日频迭代和均线 lookback。 +- `instrument`: 证券静态定义。 +- `data`: 日频市场、因子、基准、候选资格数据模型与 CSV loader。 +- `universe`: 动态市值带 Universe Selector。 +- `portfolio`: 现金、持仓、FIFO lots、T+1 可卖数量、盈亏汇总。 +- `rules`: 中国股票规则钩子,隔离停牌、涨跌停、T+1 检查。 +- `cost`: 佣金、印花税、最低佣金模型。 +- `broker`: 目标权重到订单执行的模拟器,先卖后买,买单按 100 股向下取整。 +- `strategy`: 引擎驱动的策略 trait 与具体策略实现。 +- `engine`: 确定性的逐日回测循环和结果收集。 + +## 策略实现 + +示例策略 `CnSmallCapRotationStrategy` 对应一类典型的 A 股小市值轮动逻辑: + +1. 用指数点位相对基准水平切换市值带: + - 强势区间:更偏小市值 + - 中性区间:中小市值 + - 弱势区间:偏大一些的防御市值带 +2. 在当前市值带内,按总市值升序取 Top-N。 +3. 用指数短均线/长均线关系控制总仓位: + - `1.0`: 风险偏好正常 + - `0.5`: 降半仓 + - `0.0`: 全部转现金 +4. 固定交易日频率再平衡。 +5. 非再平衡日也会检查止损/止盈钩子并触发退出。 + +这个接口不是 `jqdata` 风格的 `before_trading_start` / `handle_data` 直接脚本 API,而是: + +- 策略收到 `StrategyContext` +- 返回 `StrategyDecision` +- 引擎和 broker 负责把目标权重和退出指令变成实际成交 + +这更接近平台化引擎需要的“策略意图”和“执行语义”分离。 + +## 与原始 jqdata 策略族的映射 + +如果原始逻辑大致是: + +- 依据指数强弱切换可接受市值带 +- 从候选股票里选最小市值若干只 +- 按均线决定是否降仓 +- 周期性调仓 +- 带止损/止盈 + +那么本仓库中的映射关系是: + +- `get_fundamentals` / `valuation.market_cap` -> `DailyFactorSnapshot.market_cap_bn` +- `get_price` / `history` -> `DailyMarketSnapshot` + `BenchmarkSnapshot` +- `set_benchmark` -> `BacktestConfig.benchmark_code` +- `filter_paused` / `filter_st` / 新股过滤 -> `CandidateEligibility` +- `order_target_value` -> `StrategyDecision.target_weights` 由 `BrokerSimulator` 解释执行 +- 风险控制逻辑 -> `CnSmallCapRotationStrategy::gross_exposure` + +## V1 明确简化点 + +下面这些是刻意保留为 v1 简化,而不是遗漏: + +- 只支持日频,不做分钟级、集合竞价、盘中撮合。 +- 决策基于 `T-1` 收盘后可见数据,在 `T` 开盘价执行。 +- 不模拟盘口排队、成交量约束和滑点模型,成交默认按开盘价完成。 +- 买单按 100 股整手向下取整,卖单允许按实际持仓数量退出。 +- 未处理复权、分红送转、融资融券、可转债、科创板/北交所差异规则。 +- 止损止盈基于上一交易日收盘价相对持仓成本触发,下一交易日开盘执行。 + +这些简化都在代码结构上留了扩展位,不会阻断后续升级到更完整的执行层。 + +## 运行方式 + +```bash +cargo run --bin bt-demo +``` + +运行后会生成: + +- `output/demo/equity_curve.csv` +- `output/demo/trades.csv` +- `output/demo/holdings_summary.csv` + +## 测试与构建 + +```bash +cargo fmt +cargo test +cargo build +``` + +## 为什么这个设计适合后续做快 + +这个版本已经按“预计算后高速回放”的思路组织: + +- 因子与资格数据和市场行情解耦,适合把 `T x N` 的选股输入预先展开。 +- 快照结构是列式数据库友好的固定字段模型,后续可以自然对接 ClickHouse/Parquet。 +- Engine 逐日回放时只做: + - 取当天切片 + - 策略计算 target weights + - broker 做持仓差量执行 +- 不把查询逻辑塞进策略内部,避免回测时频繁回源数据层。 + +如果未来把日频因子、资格标记、可交易标记和开/收盘价全部预计算到列式存储,再按日期分块读入内存,6 年全市场回测在 5 分钟内是合理目标,原因是: + +- 回测时不再做昂贵的 SQL join +- 因子筛选可直接消费预先物化的 snapshot +- 组合调仓只关心“目标持仓”和“当前持仓”的差量 +- 事件流是 append-only,适合批量写出和后处理分析 + +## Roadmap + +- 引入更明确的事件总线和 portfolio/account ledger 分层 +- 增加多 benchmark、多 universe、多个 broker model +- 支持企业行为、前后复权与现金分红 +- 增加滑点、量比约束、成交量参与率 +- 增加 parquet / ClickHouse 数据源与预计算管线 +- 增加指标分析、分组收益、归因和 walk-forward 框架 diff --git a/crates/bt-demo/Cargo.toml b/crates/bt-demo/Cargo.toml new file mode 100644 index 0000000..c384fa3 --- /dev/null +++ b/crates/bt-demo/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "bt-demo" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +fidc-core = { path = "../fidc-core" } diff --git a/crates/bt-demo/src/main.rs b/crates/bt-demo/src/main.rs new file mode 100644 index 0000000..c865042 --- /dev/null +++ b/crates/bt-demo/src/main.rs @@ -0,0 +1,180 @@ +use std::error::Error; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use fidc_core::{ + BacktestConfig, + BacktestEngine, + BenchmarkSnapshot, + BrokerSimulator, + ChinaAShareCostModel, + ChinaEquityRuleHooks, + CnSmallCapRotationConfig, + CnSmallCapRotationStrategy, + DataSet, + DailyEquityPoint, + FillEvent, + HoldingSummary, +}; + +fn main() -> Result<(), Box> { + let root = workspace_root(); + let data_dir = root.join("data/demo"); + let output_dir = root.join("output/demo"); + + fs::create_dir_all(&output_dir)?; + + let data = DataSet::from_csv_dir(&data_dir)?; + let strategy = CnSmallCapRotationStrategy::new(CnSmallCapRotationConfig::demo()); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks::default()); + let config = BacktestConfig { + initial_cash: 1_000_000.0, + benchmark_code: data.benchmark_code().to_string(), + }; + + 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)?; + + print_summary( + &result.equity_curve, + &result.fills, + &result.holdings_summary, + result.benchmark_series.last(), + ); + + println!("Artifacts written under {}", output_dir.display()); + Ok(()) +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("workspace root") +} + +fn write_equity_curve_csv(path: &Path, rows: &[DailyEquityPoint]) -> Result<(), Box> { + let mut file = fs::File::create(path)?; + writeln!(file, "date,cash,market_value,total_equity,benchmark_close,notes")?; + for row in rows { + writeln!( + file, + "{},{:.2},{:.2},{:.2},{:.2},{}", + row.date, + row.cash, + row.market_value, + row.total_equity, + row.benchmark_close, + sanitize_csv_field(&row.notes), + )?; + } + Ok(()) +} + +fn write_trades_csv(path: &Path, rows: &[FillEvent]) -> Result<(), Box> { + let mut file = fs::File::create(path)?; + writeln!( + file, + "date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason" + )?; + for row in rows { + writeln!( + file, + "{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}", + row.date, + row.symbol, + row.side, + row.quantity, + row.price, + row.gross_amount, + row.commission, + row.stamp_tax, + row.net_cash_flow, + sanitize_csv_field(&row.reason), + )?; + } + Ok(()) +} + +fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box> { + let mut file = fs::File::create(path)?; + writeln!( + file, + "date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl" + )?; + for row in rows { + writeln!( + file, + "{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}", + row.date, + row.symbol, + row.quantity, + row.average_cost, + row.last_price, + row.market_value, + row.unrealized_pnl, + row.realized_pnl, + )?; + } + Ok(()) +} + +fn sanitize_csv_field(text: &str) -> String { + text.replace(',', ";") +} + +fn print_summary( + equity_curve: &[DailyEquityPoint], + fills: &[FillEvent], + holdings: &[HoldingSummary], + benchmark_last: Option<&BenchmarkSnapshot>, +) { + let Some(first) = equity_curve.first() else { + println!("No equity curve points generated."); + return; + }; + let Some(last) = equity_curve.last() else { + println!("No equity curve points generated."); + return; + }; + + let total_return = (last.total_equity / first.total_equity) - 1.0; + println!("Strategy: cn-smallcap-rotation"); + println!("Start equity: {:.2}", first.total_equity); + println!("Final equity: {:.2}", last.total_equity); + println!("Total return: {:.2}%", total_return * 100.0); + println!("Trades: {}", fills.len()); + println!("Final holdings: {}", holdings.len()); + + if let Some(benchmark) = benchmark_last { + println!( + "Benchmark last close: {} {:.2}", + benchmark.benchmark, benchmark.close + ); + } + + println!("Recent equity points:"); + for point in equity_curve.iter().rev().take(3).collect::>().into_iter().rev() { + println!( + " {} equity {:.2} cash {:.2} mv {:.2}", + point.date, point.total_equity, point.cash, point.market_value + ); + } + + if holdings.is_empty() { + println!("No holdings at the end of the demo run."); + } else { + println!("Ending holdings:"); + for holding in holdings { + println!( + " {} qty {} mv {:.2} pnl {:.2}", + holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl + ); + } + } +} diff --git a/crates/fidc-core/Cargo.toml b/crates/fidc-core/Cargo.toml new file mode 100644 index 0000000..ffc7848 --- /dev/null +++ b/crates/fidc-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "fidc-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true + +[dependencies] +chrono.workspace = true +serde.workspace = true +thiserror.workspace = true diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs new file mode 100644 index 0000000..08d3812 --- /dev/null +++ b/crates/fidc-core/src/broker.rs @@ -0,0 +1,390 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::NaiveDate; + +use crate::cost::CostModel; +use crate::data::{DataSet, 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; + +#[derive(Debug, Default)] +pub struct BrokerExecutionReport { + pub order_events: Vec, + pub fill_events: Vec, + pub position_events: Vec, + pub account_events: Vec, +} + +pub struct BrokerSimulator { + cost_model: C, + rules: R, + board_lot_size: u32, +} + +impl BrokerSimulator { + pub fn new(cost_model: C, rules: R) -> Self { + Self { + cost_model, + rules, + board_lot_size: 100, + } + } +} + +impl BrokerSimulator +where + C: CostModel, + R: EquityRuleHooks, +{ + pub fn execute( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + decision: &StrategyDecision, + ) -> Result { + let mut report = BrokerExecutionReport::default(); + let target_quantities = if decision.rebalance { + self.target_quantities(date, portfolio, data, &decision.target_weights)? + } else { + BTreeMap::new() + }; + + let mut sell_symbols = BTreeSet::new(); + sell_symbols.extend(portfolio.positions().keys().cloned()); + sell_symbols.extend(decision.exit_symbols.iter().cloned()); + 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); + if current_qty == 0 { + continue; + } + + let target_qty = if decision.exit_symbols.contains(&symbol) { + 0 + } else if decision.rebalance { + *target_quantities.get(&symbol).unwrap_or(&0) + } else { + current_qty + }; + + if current_qty > target_qty { + let requested_qty = current_qty - target_qty; + self.process_sell( + date, + portfolio, + data, + &symbol, + requested_qty, + sell_reason(decision, &symbol), + &mut report, + )?; + } + } + + if decision.rebalance { + for (symbol, target_qty) in target_quantities { + 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( + date, + portfolio, + data, + &symbol, + requested_qty, + "rebalance_buy", + &mut report, + )?; + } + } + } + + portfolio.prune_flat_positions(); + Ok(report) + } + + fn target_quantities( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + target_weights: &BTreeMap, + ) -> Result, BacktestError> { + let equity = self.total_equity_at(date, portfolio, data, PriceField::Open)?; + let mut targets = BTreeMap::new(); + + for (symbol, weight) in target_weights { + let price = data + .price(date, symbol, PriceField::Open) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: symbol.clone(), + field: "open", + })?; + let raw_qty = ((equity * weight) / price).floor() as u32; + let rounded_qty = self.round_buy_quantity(raw_qty); + targets.insert(symbol.clone(), rounded_qty); + } + + Ok(targets) + } + + fn process_sell( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + requested_qty: u32, + reason: &str, + report: &mut BrokerExecutionReport, + ) -> Result<(), BacktestError> { + let snapshot = data.require_market(date, symbol)?; + let candidate = data.require_candidate(date, symbol)?; + let Some(position) = portfolio.position(symbol) else { + return Ok(()); + }; + + let rule = self.rules.can_sell(date, snapshot, candidate, position); + if !rule.allowed { + 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}: {}", rule.reason.unwrap_or_default()), + }); + return Ok(()); + } + + let sellable = position.sellable_qty(date); + let filled_qty = requested_qty.min(sellable); + if filled_qty == 0 { + 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}: no sellable quantity"), + }); + return Ok(()); + } + + let cash_before = portfolio.cash(); + let gross_amount = snapshot.open * 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) + .map_err(BacktestError::Execution)?; + portfolio.apply_cash_delta(net_cash); + + let status = if filled_qty < requested_qty { + OrderStatus::PartiallyFilled + } else { + OrderStatus::Filled + }; + + report.order_events.push(OrderEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Sell, + requested_quantity: requested_qty, + filled_quantity: filled_qty, + status, + reason: reason.to_string(), + }); + report.fill_events.push(FillEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Sell, + quantity: filled_qty, + price: snapshot.open, + gross_amount, + commission: cost.commission, + stamp_tax: cost.stamp_tax, + net_cash_flow: net_cash, + reason: reason.to_string(), + }); + report.position_events.push(PositionEvent { + date, + symbol: symbol.to_string(), + delta_quantity: -(filled_qty as i32), + quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), + average_cost: portfolio + .position(symbol) + .map(|pos| pos.average_cost) + .unwrap_or(0.0), + realized_pnl_delta: realized_pnl, + reason: reason.to_string(), + }); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?, + note: format!("sell {symbol} {reason}"), + }); + Ok(()) + } + + fn process_buy( + &self, + date: NaiveDate, + portfolio: &mut PortfolioState, + data: &DataSet, + symbol: &str, + requested_qty: u32, + reason: &str, + 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); + if !rule.allowed { + 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}: {}", rule.reason.unwrap_or_default()), + }); + return Ok(()); + } + + let filled_qty = + self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty); + if filled_qty == 0 { + 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}: insufficient cash after fees"), + }); + return Ok(()); + } + + let cash_before = portfolio.cash(); + let gross_amount = snapshot.open * 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); + + let status = if filled_qty < requested_qty { + OrderStatus::PartiallyFilled + } else { + OrderStatus::Filled + }; + + report.order_events.push(OrderEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Buy, + requested_quantity: requested_qty, + filled_quantity: filled_qty, + status, + reason: reason.to_string(), + }); + report.fill_events.push(FillEvent { + date, + symbol: symbol.to_string(), + side: OrderSide::Buy, + quantity: filled_qty, + price: snapshot.open, + gross_amount, + commission: cost.commission, + stamp_tax: cost.stamp_tax, + net_cash_flow: -cash_out, + reason: reason.to_string(), + }); + report.position_events.push(PositionEvent { + date, + symbol: symbol.to_string(), + delta_quantity: filled_qty as i32, + quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0), + average_cost: portfolio + .position(symbol) + .map(|pos| pos.average_cost) + .unwrap_or(0.0), + realized_pnl_delta: 0.0, + reason: reason.to_string(), + }); + report.account_events.push(AccountEvent { + date, + cash_before, + cash_after: portfolio.cash(), + total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?, + note: format!("buy {symbol} {reason}"), + }); + Ok(()) + } + + fn total_equity_at( + &self, + date: NaiveDate, + portfolio: &PortfolioState, + data: &DataSet, + field: PriceField, + ) -> 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 { + date, + symbol: position.symbol.clone(), + field: match field { + PriceField::Open => "open", + PriceField::Close => "close", + }, + })?; + 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 affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 { + let mut quantity = self.round_buy_quantity(requested_qty); + while quantity > 0 { + let gross = price * quantity as f64; + 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); + } + 0 + } +} + +fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str { + if decision.exit_symbols.contains(symbol) { + "exit_hook_sell" + } else { + "rebalance_sell" + } +} diff --git a/crates/fidc-core/src/calendar.rs b/crates/fidc-core/src/calendar.rs new file mode 100644 index 0000000..9dc3adc --- /dev/null +++ b/crates/fidc-core/src/calendar.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use chrono::NaiveDate; + +#[derive(Debug, Clone)] +pub struct TradingCalendar { + days: Vec, + index: HashMap, +} + +impl TradingCalendar { + pub fn new(mut days: Vec) -> Self { + days.sort_unstable(); + days.dedup(); + + let index = days + .iter() + .copied() + .enumerate() + .map(|(idx, day)| (day, idx)) + .collect(); + + Self { days, index } + } + + pub fn days(&self) -> &[NaiveDate] { + &self.days + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.days.iter().copied() + } + + pub fn len(&self) -> usize { + self.days.len() + } + + pub fn is_empty(&self) -> bool { + self.days.is_empty() + } + + pub fn index_of(&self, date: NaiveDate) -> Option { + self.index.get(&date).copied() + } + + 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()) + } + + pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec { + let Some(end_idx) = self.index_of(end) else { + return Vec::new(); + }; + let start = end_idx.saturating_add(1).saturating_sub(lookback); + self.days[start..=end_idx].to_vec() + } +} diff --git a/crates/fidc-core/src/cost.rs b/crates/fidc-core/src/cost.rs new file mode 100644 index 0000000..f814191 --- /dev/null +++ b/crates/fidc-core/src/cost.rs @@ -0,0 +1,56 @@ +use crate::events::OrderSide; + +#[derive(Debug, Clone, Copy)] +pub struct TradingCost { + pub commission: f64, + pub stamp_tax: f64, +} + +impl TradingCost { + pub fn total(self) -> f64 { + self.commission + self.stamp_tax + } +} + +pub trait CostModel { + fn calculate(&self, side: OrderSide, gross_amount: f64) -> TradingCost; +} + +#[derive(Debug, Clone, Copy)] +pub struct ChinaAShareCostModel { + pub commission_rate: f64, + pub stamp_tax_rate: f64, + pub minimum_commission: f64, +} + +impl Default for ChinaAShareCostModel { + fn default() -> Self { + Self { + commission_rate: 0.0003, + stamp_tax_rate: 0.001, + minimum_commission: 5.0, + } + } +} + +impl CostModel for ChinaAShareCostModel { + fn calculate(&self, side: OrderSide, gross_amount: f64) -> TradingCost { + if gross_amount <= 0.0 { + return TradingCost { + commission: 0.0, + stamp_tax: 0.0, + }; + } + + let commission = (gross_amount * self.commission_rate).max(self.minimum_commission); + let stamp_tax = match side { + OrderSide::Buy => 0.0, + OrderSide::Sell => gross_amount * self.stamp_tax_rate, + }; + + TradingCost { + commission, + stamp_tax, + } + } +} diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs new file mode 100644 index 0000000..9799bb9 --- /dev/null +++ b/crates/fidc-core/src/data.rs @@ -0,0 +1,471 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::Path; + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::calendar::TradingCalendar; +use crate::instrument::Instrument; + +mod date_format { + use chrono::NaiveDate; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(date: &NaiveDate, 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)?; + NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Error)] +pub enum DataSetError { + #[error("failed to read file {path}: {source}")] + Io { + path: String, + #[source] + source: std::io::Error, + }, + #[error("invalid csv row in {path} at line {line}: {message}")] + InvalidRow { + path: String, + line: usize, + message: String, + }, + #[error("benchmark file contains multiple benchmark codes")] + MultipleBenchmarks, + #[error("missing data for {kind} on {date} / {symbol}")] + MissingSnapshot { + kind: &'static str, + date: NaiveDate, + symbol: String, + }, + #[error("benchmark snapshot missing for {date}")] + MissingBenchmark { date: NaiveDate }, +} + +#[derive(Debug, Clone, Copy)] +pub enum PriceField { + Open, + Close, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyMarketSnapshot { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub prev_close: f64, + pub volume: u64, + pub paused: bool, + pub upper_limit: f64, + pub lower_limit: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyFactorSnapshot { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub market_cap_bn: f64, + pub free_float_cap_bn: f64, + pub pe_ttm: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkSnapshot { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub benchmark: String, + pub open: f64, + pub close: f64, + pub prev_close: f64, + pub volume: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CandidateEligibility { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub is_st: bool, + pub is_new_listing: bool, + pub is_paused: bool, + pub allow_buy: bool, + pub allow_sell: bool, +} + +impl CandidateEligibility { + pub fn eligible_for_selection(&self) -> bool { + !self.is_st && !self.is_new_listing && !self.is_paused && self.allow_buy && self.allow_sell + } +} + +#[derive(Debug, Clone)] +pub struct DataSet { + instruments: HashMap, + calendar: TradingCalendar, + market_by_date: BTreeMap>, + market_index: HashMap<(NaiveDate, String), DailyMarketSnapshot>, + factor_by_date: BTreeMap>, + factor_index: HashMap<(NaiveDate, String), DailyFactorSnapshot>, + candidate_by_date: BTreeMap>, + candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>, + benchmark_by_date: BTreeMap, + benchmark_code: String, +} + +impl DataSet { + pub fn from_csv_dir(path: &Path) -> Result { + let instruments = read_instruments(&path.join("instruments.csv"))?; + let market = read_market(&path.join("market.csv"))?; + 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"))?; + + let benchmark_code = collect_benchmark_code(&benchmarks)?; + let calendar = TradingCalendar::new(benchmarks.iter().map(|item| item.date).collect()); + + let instruments = instruments + .into_iter() + .map(|instrument| (instrument.symbol.clone(), instrument)) + .collect::>(); + + let market_by_date = group_by_date(market.clone(), |item| item.date); + let market_index = market + .into_iter() + .map(|item| ((item.date, item.symbol.clone()), item)) + .collect::>(); + + let factor_by_date = group_by_date(factors.clone(), |item| item.date); + let factor_index = factors + .into_iter() + .map(|item| ((item.date, item.symbol.clone()), item)) + .collect::>(); + + let candidate_by_date = group_by_date(candidates.clone(), |item| item.date); + let candidate_index = candidates + .into_iter() + .map(|item| ((item.date, item.symbol.clone()), item)) + .collect::>(); + + let benchmark_by_date = benchmarks + .into_iter() + .map(|item| (item.date, item)) + .collect::>(); + + Ok(Self { + instruments, + calendar, + market_by_date, + market_index, + factor_by_date, + factor_index, + candidate_by_date, + candidate_index, + benchmark_by_date, + benchmark_code, + }) + } + + pub fn calendar(&self) -> &TradingCalendar { + &self.calendar + } + + pub fn benchmark_code(&self) -> &str { + &self.benchmark_code + } + + pub fn instruments(&self) -> &HashMap { + &self.instruments + } + + pub fn market(&self, date: NaiveDate, symbol: &str) -> Option<&DailyMarketSnapshot> { + self.market_index.get(&(date, symbol.to_string())) + } + + pub fn factor(&self, date: NaiveDate, symbol: &str) -> Option<&DailyFactorSnapshot> { + self.factor_index.get(&(date, symbol.to_string())) + } + + pub fn candidate(&self, date: NaiveDate, symbol: &str) -> Option<&CandidateEligibility> { + self.candidate_index.get(&(date, symbol.to_string())) + } + + pub fn benchmark(&self, date: NaiveDate) -> Option<&BenchmarkSnapshot> { + self.benchmark_by_date.get(&date) + } + + 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, + }) + } + + pub fn factor_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyFactorSnapshot> { + self.factor_by_date + .get(&date) + .map(|rows| rows.iter().collect()) + .unwrap_or_default() + } + + pub fn market_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyMarketSnapshot> { + self.market_by_date + .get(&date) + .map(|rows| rows.iter().collect()) + .unwrap_or_default() + } + + pub fn candidate_snapshots_on(&self, date: NaiveDate) -> Vec<&CandidateEligibility> { + self.candidate_by_date + .get(&date) + .map(|rows| rows.iter().collect()) + .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() + } + + pub fn require_market( + &self, + date: NaiveDate, + symbol: &str, + ) -> Result<&DailyMarketSnapshot, DataSetError> { + self.market(date, symbol).ok_or_else(|| DataSetError::MissingSnapshot { + kind: "market", + date, + symbol: symbol.to_string(), + }) + } + + pub fn require_candidate( + &self, + date: NaiveDate, + symbol: &str, + ) -> Result<&CandidateEligibility, DataSetError> { + self.candidate(date, symbol) + .ok_or_else(|| DataSetError::MissingSnapshot { + kind: "candidate", + date, + symbol: symbol.to_string(), + }) + } +} + +fn read_instruments(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut instruments = Vec::new(); + for row in rows { + instruments.push(Instrument { + symbol: row.get(0)?.to_string(), + name: row.get(1)?.to_string(), + board: row.get(2)?.to_string(), + }); + } + Ok(instruments) +} + +fn read_market(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut snapshots = Vec::new(); + for row in rows { + let prev_close = row.parse_f64(6)?; + snapshots.push(DailyMarketSnapshot { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + open: row.parse_f64(2)?, + high: row.parse_f64(3)?, + low: row.parse_f64(4)?, + close: row.parse_f64(5)?, + prev_close, + volume: row.parse_u64(7)?, + paused: row.parse_bool(8)?, + upper_limit: round2(prev_close * 1.10), + lower_limit: round2(prev_close * 0.90), + }); + } + Ok(snapshots) +} + +fn read_factors(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut snapshots = Vec::new(); + for row in rows { + snapshots.push(DailyFactorSnapshot { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + market_cap_bn: row.parse_f64(2)?, + free_float_cap_bn: row.parse_f64(3)?, + pe_ttm: row.parse_f64(4)?, + }); + } + Ok(snapshots) +} + +fn read_candidates(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut snapshots = Vec::new(); + for row in rows { + snapshots.push(CandidateEligibility { + date: row.parse_date(0)?, + symbol: row.get(1)?.to_string(), + is_st: row.parse_bool(2)?, + is_new_listing: row.parse_bool(3)?, + is_paused: row.parse_bool(4)?, + allow_buy: row.parse_bool(5)?, + allow_sell: row.parse_bool(6)?, + }); + } + Ok(snapshots) +} + +fn read_benchmarks(path: &Path) -> Result, DataSetError> { + let rows = read_rows(path)?; + let mut snapshots = Vec::new(); + for row in rows { + snapshots.push(BenchmarkSnapshot { + date: row.parse_date(0)?, + benchmark: row.get(1)?.to_string(), + open: row.parse_f64(2)?, + close: row.parse_f64(3)?, + prev_close: row.parse_f64(4)?, + volume: row.parse_u64(5)?, + }); + } + Ok(snapshots) +} + +struct CsvRow { + path: String, + line: usize, + fields: Vec, +} + +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}"), + }) + } + + 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}"), + }) + } + + fn parse_f64(&self, index: usize) -> Result { + self.get(index)? + .parse::() + .map_err(|err| DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid f64: {err}"), + }) + } + + fn parse_u64(&self, index: usize) -> Result { + self.get(index)? + .parse::() + .map_err(|err| DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid u64: {err}"), + }) + } + + fn parse_bool(&self, index: usize) -> Result { + self.get(index)? + .parse::() + .map_err(|err| DataSetError::InvalidRow { + path: self.path.clone(), + line: self.line, + message: format!("invalid bool: {err}"), + }) + } +} + +fn read_rows(path: &Path) -> Result, DataSetError> { + let content = fs::read_to_string(path).map_err(|source| DataSetError::Io { + path: path.display().to_string(), + source, + })?; + + let mut rows = Vec::new(); + for (line_idx, line) in content.lines().enumerate() { + let line_no = line_idx + 1; + if line_no == 1 || line.trim().is_empty() { + continue; + } + + rows.push(CsvRow { + path: path.display().to_string(), + line: line_no, + fields: line.split(',').map(|field| field.trim().to_string()).collect(), + }); + } + + Ok(rows) +} + +fn group_by_date(rows: Vec, mut date_of: F) -> BTreeMap> +where + F: FnMut(&T) -> NaiveDate, +{ + let mut grouped = BTreeMap::>::new(); + for row in rows { + grouped.entry(date_of(&row)).or_default().push(row); + } + grouped +} + +fn collect_benchmark_code(benchmarks: &[BenchmarkSnapshot]) -> Result { + let mut codes = benchmarks + .iter() + .map(|row| row.benchmark.clone()) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + if codes.len() == 1 { + Ok(codes.remove(0)) + } else { + Err(DataSetError::MultipleBenchmarks) + } +} + +fn round2(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs new file mode 100644 index 0000000..164a19d --- /dev/null +++ b/crates/fidc-core/src/engine.rs @@ -0,0 +1,167 @@ +use chrono::NaiveDate; +use serde::Serialize; +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::rules::EquityRuleHooks; +use crate::strategy::{Strategy, StrategyContext, StrategyDecision}; + +#[derive(Debug, Error)] +pub enum BacktestError { + #[error(transparent)] + Data(#[from] DataSetError), + #[error("missing {field} price for {symbol} on {date}")] + MissingPrice { + date: NaiveDate, + symbol: String, + field: &'static str, + }, + #[error("benchmark snapshot missing for {date}")] + MissingBenchmark { date: NaiveDate }, + #[error("{0}")] + Execution(String), +} + +#[derive(Debug, Clone)] +pub struct BacktestConfig { + pub initial_cash: f64, + pub benchmark_code: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DailyEquityPoint { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub cash: f64, + pub market_value: f64, + pub total_equity: f64, + pub benchmark_close: f64, + pub notes: String, +} + +#[derive(Debug, Clone)] +pub struct BacktestResult { + pub strategy_name: String, + pub equity_curve: Vec, + pub benchmark_series: Vec, + pub order_events: Vec, + pub fills: Vec, + pub position_events: Vec, + pub account_events: Vec, + pub holdings_summary: Vec, +} + +pub struct BacktestEngine { + data: DataSet, + strategy: S, + broker: BrokerSimulator, + config: BacktestConfig, +} + +impl BacktestEngine { + pub fn new( + data: DataSet, + strategy: S, + broker: BrokerSimulator, + config: BacktestConfig, + ) -> Self { + Self { + data, + strategy, + broker, + config, + } + } +} + +impl BacktestEngine +where + S: Strategy, + C: CostModel, + R: EquityRuleHooks, +{ + pub fn run(&mut self) -> Result { + let mut portfolio = PortfolioState::new(self.config.initial_cash); + let mut result = BacktestResult { + strategy_name: self.strategy.name().to_string(), + benchmark_series: self.data.benchmark_series(), + order_events: Vec::new(), + fills: Vec::new(), + position_events: Vec::new(), + account_events: Vec::new(), + equity_curve: Vec::new(), + holdings_summary: Vec::new(), + }; + + for execution_date in self.data.calendar().iter() { + let decision = match self.data.calendar().previous_day(execution_date) { + Some(decision_date) => { + let decision_index = self.data.calendar().index_of(decision_date).unwrap_or(0); + self.strategy.on_day(&StrategyContext { + execution_date, + decision_date, + decision_index, + data: &self.data, + portfolio: &portfolio, + })? + } + None => StrategyDecision::default(), + }; + + let report = self + .broker + .execute(execution_date, &mut portfolio, &self.data, &decision)?; + 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(" | "); + + result.equity_curve.push(DailyEquityPoint { + date: execution_date, + cash: portfolio.cash(), + market_value: portfolio.market_value(), + total_equity: portfolio.total_equity(), + benchmark_close: benchmark.close, + notes, + }); + } + + if let Some(last_date) = self.data.calendar().days().last().copied() { + result.holdings_summary = portfolio.holdings_summary(last_date); + } + + 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); + } +} + +mod date_format { + use chrono::NaiveDate; + use serde::Serializer; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(date: &NaiveDate, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.format(FORMAT).to_string()) + } +} diff --git a/crates/fidc-core/src/events.rs b/crates/fidc-core/src/events.rs new file mode 100644 index 0000000..9bd284f --- /dev/null +++ b/crates/fidc-core/src/events.rs @@ -0,0 +1,86 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; + +mod date_format { + use chrono::NaiveDate; + use serde::{self, Deserialize, Deserializer, Serializer}; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(date: &NaiveDate, 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)?; + NaiveDate::parse_from_str(&text, FORMAT).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum OrderSide { + Buy, + Sell, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum OrderStatus { + Filled, + PartiallyFilled, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderEvent { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub side: OrderSide, + pub requested_quantity: u32, + pub filled_quantity: u32, + pub status: OrderStatus, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FillEvent { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub price: f64, + pub gross_amount: f64, + pub commission: f64, + pub stamp_tax: f64, + pub net_cash_flow: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PositionEvent { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub delta_quantity: i32, + pub quantity_after: u32, + pub average_cost: f64, + pub realized_pnl_delta: f64, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountEvent { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub cash_before: f64, + pub cash_after: f64, + pub total_equity: f64, + pub note: String, +} diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs new file mode 100644 index 0000000..d921ccc --- /dev/null +++ b/crates/fidc-core/src/instrument.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Instrument { + pub symbol: String, + pub name: String, + pub board: String, +} diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs new file mode 100644 index 0000000..558ba78 --- /dev/null +++ b/crates/fidc-core/src/lib.rs @@ -0,0 +1,50 @@ +pub mod broker; +pub mod calendar; +pub mod cost; +pub mod data; +pub mod engine; +pub mod events; +pub mod instrument; +pub mod portfolio; +pub mod rules; +pub mod strategy; +pub mod universe; + +pub use broker::{BrokerExecutionReport, BrokerSimulator}; +pub use calendar::TradingCalendar; +pub use cost::{ChinaAShareCostModel, CostModel, TradingCost}; +pub use data::{ + BenchmarkSnapshot, + CandidateEligibility, + DailyFactorSnapshot, + DailyMarketSnapshot, + DataSet, + DataSetError, + PriceField, +}; +pub use engine::{BacktestConfig, 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 rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; +pub use strategy::{ + CnSmallCapRotationConfig, + CnSmallCapRotationStrategy, + Strategy, + StrategyContext, + StrategyDecision, +}; +pub use universe::{ + BandRegime, + DynamicMarketCapBandSelector, + SelectionContext, + UniverseCandidate, + UniverseSelector, +}; diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs new file mode 100644 index 0000000..89d5b76 --- /dev/null +++ b/crates/fidc-core/src/portfolio.rs @@ -0,0 +1,242 @@ +use std::collections::BTreeMap; + +use chrono::NaiveDate; +use serde::Serialize; + +use crate::data::{DataSet, DataSetError, PriceField}; + +#[derive(Debug, Clone)] +pub struct PositionLot { + pub acquired_date: NaiveDate, + pub quantity: u32, + pub price: f64, +} + +#[derive(Debug, Clone)] +pub struct Position { + pub symbol: String, + pub quantity: u32, + pub average_cost: f64, + pub last_price: f64, + pub realized_pnl: f64, + lots: Vec, +} + +impl Position { + pub fn new(symbol: impl Into) -> Self { + Self { + symbol: symbol.into(), + quantity: 0, + average_cost: 0.0, + last_price: 0.0, + realized_pnl: 0.0, + lots: Vec::new(), + } + } + + pub fn is_flat(&self) -> bool { + self.quantity == 0 + } + + pub fn buy(&mut self, date: NaiveDate, quantity: u32, price: f64) { + if quantity == 0 { + return; + } + + self.lots.push(PositionLot { + acquired_date: date, + quantity, + price, + }); + self.quantity += quantity; + self.last_price = price; + self.recalculate_average_cost(); + } + + pub fn sell(&mut self, quantity: u32, price: f64) -> Result { + if quantity > self.quantity { + return Err(format!( + "sell quantity {} exceeds current quantity {} for {}", + quantity, self.quantity, self.symbol + )); + } + + let mut remaining = quantity; + let mut realized = 0.0; + + while remaining > 0 { + let Some(first_lot) = self.lots.first_mut() else { + return Err(format!("position {} has no lots to sell", self.symbol)); + }; + + let lot_sell = remaining.min(first_lot.quantity); + realized += (price - first_lot.price) * lot_sell as f64; + first_lot.quantity -= lot_sell; + remaining -= lot_sell; + + if first_lot.quantity == 0 { + self.lots.remove(0); + } + } + + self.quantity -= quantity; + self.last_price = price; + self.realized_pnl += realized; + self.recalculate_average_cost(); + Ok(realized) + } + + pub fn sellable_qty(&self, date: NaiveDate) -> u32 { + self.lots + .iter() + .filter(|lot| lot.acquired_date < date) + .map(|lot| lot.quantity) + .sum() + } + + pub fn market_value(&self) -> f64 { + self.quantity as f64 * self.last_price + } + + pub fn unrealized_pnl(&self) -> f64 { + (self.last_price - self.average_cost) * self.quantity as f64 + } + + pub fn holding_return(&self, price: f64) -> Option { + if self.quantity == 0 || self.average_cost <= 0.0 { + None + } else { + Some((price / self.average_cost) - 1.0) + } + } + + fn recalculate_average_cost(&mut self) { + if self.quantity == 0 { + self.average_cost = 0.0; + return; + } + + let total_cost = self + .lots + .iter() + .map(|lot| lot.price * lot.quantity as f64) + .sum::(); + + self.average_cost = total_cost / self.quantity as f64; + } +} + +#[derive(Debug, Clone)] +pub struct PortfolioState { + cash: f64, + positions: BTreeMap, +} + +impl PortfolioState { + pub fn new(initial_cash: f64) -> Self { + Self { + cash: initial_cash, + positions: BTreeMap::new(), + } + } + + pub fn cash(&self) -> f64 { + self.cash + } + + pub fn positions(&self) -> &BTreeMap { + &self.positions + } + + pub fn position(&self, symbol: &str) -> Option<&Position> { + self.positions.get(symbol) + } + + pub fn position_mut(&mut self, symbol: &str) -> &mut Position { + self.positions + .entry(symbol.to_string()) + .or_insert_with(|| Position::new(symbol)) + } + + pub fn apply_cash_delta(&mut self, delta: f64) { + self.cash += delta; + } + + pub fn prune_flat_positions(&mut self) { + self.positions.retain(|_, position| !position.is_flat()); + } + + pub fn update_prices( + &mut self, + date: NaiveDate, + data: &DataSet, + field: PriceField, + ) -> Result<(), DataSetError> { + for position in self.positions.values_mut() { + let price = data + .price(date, &position.symbol, field) + .ok_or_else(|| DataSetError::MissingSnapshot { + kind: match field { + PriceField::Open => "open price", + PriceField::Close => "close price", + }, + date, + symbol: position.symbol.clone(), + })?; + position.last_price = price; + } + Ok(()) + } + + pub fn market_value(&self) -> f64 { + self.positions.values().map(Position::market_value).sum() + } + + pub fn total_equity(&self) -> f64 { + self.cash + self.market_value() + } + + pub fn holdings_summary(&self, date: NaiveDate) -> Vec { + self.positions + .values() + .filter(|position| position.quantity > 0) + .map(|position| HoldingSummary { + date, + symbol: position.symbol.clone(), + quantity: position.quantity, + average_cost: position.average_cost, + last_price: position.last_price, + market_value: position.market_value(), + unrealized_pnl: position.unrealized_pnl(), + realized_pnl: position.realized_pnl, + }) + .collect() + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct HoldingSummary { + #[serde(with = "date_format")] + pub date: NaiveDate, + pub symbol: String, + pub quantity: u32, + pub average_cost: f64, + pub last_price: f64, + pub market_value: f64, + pub unrealized_pnl: f64, + pub realized_pnl: f64, +} + +mod date_format { + use chrono::NaiveDate; + use serde::Serializer; + + const FORMAT: &str = "%Y-%m-%d"; + + pub fn serialize(date: &NaiveDate, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.format(FORMAT).to_string()) + } +} diff --git a/crates/fidc-core/src/rules.rs b/crates/fidc-core/src/rules.rs new file mode 100644 index 0000000..a9a45c2 --- /dev/null +++ b/crates/fidc-core/src/rules.rs @@ -0,0 +1,100 @@ +use chrono::NaiveDate; + +use crate::data::{CandidateEligibility, DailyMarketSnapshot}; +use crate::portfolio::Position; + +#[derive(Debug, Clone)] +pub struct RuleCheck { + pub allowed: bool, + pub reason: Option, +} + +impl RuleCheck { + pub fn allow() -> Self { + Self { + allowed: true, + reason: None, + } + } + + pub fn reject(reason: impl Into) -> Self { + Self { + allowed: false, + reason: Some(reason.into()), + } + } +} + +pub trait EquityRuleHooks { + fn can_buy( + &self, + execution_date: NaiveDate, + snapshot: &DailyMarketSnapshot, + candidate: &CandidateEligibility, + ) -> RuleCheck; + + fn can_sell( + &self, + execution_date: NaiveDate, + snapshot: &DailyMarketSnapshot, + candidate: &CandidateEligibility, + position: &Position, + ) -> RuleCheck; +} + +#[derive(Debug, Clone, Default)] +pub struct ChinaEquityRuleHooks; + +impl ChinaEquityRuleHooks { + fn at_upper_limit(snapshot: &DailyMarketSnapshot) -> bool { + snapshot.open >= snapshot.upper_limit - 1e-6 + } + + fn at_lower_limit(snapshot: &DailyMarketSnapshot) -> bool { + snapshot.open <= snapshot.lower_limit + 1e-6 + } +} + +impl EquityRuleHooks for ChinaEquityRuleHooks { + fn can_buy( + &self, + _execution_date: NaiveDate, + snapshot: &DailyMarketSnapshot, + candidate: &CandidateEligibility, + ) -> RuleCheck { + if snapshot.paused || candidate.is_paused { + return RuleCheck::reject("paused"); + } + if !candidate.allow_buy { + return RuleCheck::reject("buy disabled by eligibility flags"); + } + if Self::at_upper_limit(snapshot) { + return RuleCheck::reject("open at or above upper limit"); + } + + RuleCheck::allow() + } + + fn can_sell( + &self, + execution_date: NaiveDate, + snapshot: &DailyMarketSnapshot, + candidate: &CandidateEligibility, + position: &Position, + ) -> RuleCheck { + if snapshot.paused || candidate.is_paused { + return RuleCheck::reject("paused"); + } + if !candidate.allow_sell { + return RuleCheck::reject("sell disabled by eligibility flags"); + } + if Self::at_lower_limit(snapshot) { + return RuleCheck::reject("open at or below lower limit"); + } + if position.sellable_qty(execution_date) == 0 { + return RuleCheck::reject("t+1 sellable quantity is zero"); + } + + RuleCheck::allow() + } +} diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs new file mode 100644 index 0000000..4479e0e --- /dev/null +++ b/crates/fidc-core/src/strategy.rs @@ -0,0 +1,192 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::NaiveDate; + +use crate::data::{DataSet, PriceField}; +use crate::engine::BacktestError; +use crate::portfolio::PortfolioState; +use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector}; + +pub trait Strategy { + fn name(&self) -> &'static str; + fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result; +} + +pub struct StrategyContext<'a> { + pub execution_date: NaiveDate, + pub decision_date: NaiveDate, + pub decision_index: usize, + pub data: &'a DataSet, + pub portfolio: &'a PortfolioState, +} + +#[derive(Debug, Clone, Default)] +pub struct StrategyDecision { + pub rebalance: bool, + pub target_weights: BTreeMap, + pub exit_symbols: BTreeSet, + pub notes: Vec, +} + +#[derive(Debug, Clone)] +pub struct CnSmallCapRotationConfig { + pub rebalance_every_n_days: usize, + pub max_positions: usize, + pub short_ma_days: usize, + pub long_ma_days: usize, + pub stop_loss_pct: f64, + pub take_profit_pct: f64, +} + +impl CnSmallCapRotationConfig { + pub fn demo() -> Self { + Self { + rebalance_every_n_days: 3, + max_positions: 2, + short_ma_days: 3, + long_ma_days: 5, + stop_loss_pct: 0.08, + take_profit_pct: 0.10, + } + } +} + +pub struct CnSmallCapRotationStrategy { + config: CnSmallCapRotationConfig, + selector: DynamicMarketCapBandSelector, + last_gross_exposure: Option, +} + +impl CnSmallCapRotationStrategy { + pub fn new(config: CnSmallCapRotationConfig) -> Self { + Self { + selector: DynamicMarketCapBandSelector::demo(config.max_positions), + config, + last_gross_exposure: None, + } + } + + 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 + } + } + + fn gross_exposure(&self, closes: &[f64]) -> f64 { + if closes.is_empty() { + return 0.0; + } + + let current = *closes.last().unwrap_or(&0.0); + let short_ma = Self::moving_average(closes, self.config.short_ma_days); + let long_ma = Self::moving_average(closes, self.config.long_ma_days); + + if current >= long_ma && short_ma >= long_ma { + 1.0 + } else if current >= long_ma || short_ma >= long_ma { + 0.5 + } else { + 0.0 + } + } + + 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 { + continue; + } + + let close_price = ctx + .data + .price(ctx.decision_date, &position.symbol, PriceField::Close) + .ok_or_else(|| BacktestError::MissingPrice { + date: ctx.decision_date, + symbol: position.symbol.clone(), + field: "close", + })?; + + let Some(holding_return) = position.holding_return(close_price) else { + continue; + }; + + if holding_return <= -self.config.stop_loss_pct + || holding_return >= self.config.take_profit_pct + { + exits.insert(position.symbol.clone()); + } + } + + Ok(exits) + } +} + +impl Strategy for CnSmallCapRotationStrategy { + fn name(&self) -> &'static str { + "cn-smallcap-rotation" + } + + 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_closes = ctx + .data + .benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days); + let gross_exposure = self.gross_exposure(&benchmark_closes); + let periodic_rebalance = ctx.decision_index % self.config.rebalance_every_n_days == 0; + let exposure_changed = self + .last_gross_exposure + .map(|previous| (previous - gross_exposure).abs() > f64::EPSILON) + .unwrap_or(true); + let exit_symbols = self.stop_exit_symbols(ctx)?; + + let rebalance = periodic_rebalance || exposure_changed; + let mut target_weights = BTreeMap::new(); + let mut notes = vec![format!( + "decision={} exec={} exposure={:.2}", + ctx.decision_date, ctx.execution_date, gross_exposure + )]; + + if rebalance && gross_exposure > 0.0 { + let selected = self.selector.select(&SelectionContext { + decision_date: ctx.decision_date, + benchmark, + data: ctx.data, + }); + + if !selected.is_empty() { + let per_name_weight = gross_exposure / selected.len() as f64; + for candidate in selected { + target_weights.insert(candidate.symbol.clone(), per_name_weight); + } + } + + notes.push(format!("rebalance names={}", target_weights.len())); + } + + if !exit_symbols.is_empty() { + notes.push(format!("exit hooks={}", exit_symbols.len())); + } + if rebalance && gross_exposure == 0.0 { + notes.push("risk throttle forced all-cash".to_string()); + } + + self.last_gross_exposure = Some(gross_exposure); + + Ok(StrategyDecision { + rebalance, + target_weights, + exit_symbols, + notes, + }) + } +} diff --git a/crates/fidc-core/src/universe.rs b/crates/fidc-core/src/universe.rs new file mode 100644 index 0000000..ccac5e6 --- /dev/null +++ b/crates/fidc-core/src/universe.rs @@ -0,0 +1,110 @@ +use chrono::NaiveDate; + +use crate::data::{BenchmarkSnapshot, DataSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BandRegime { + Bullish, + Neutral, + Defensive, +} + +#[derive(Debug, Clone)] +pub struct UniverseCandidate { + pub symbol: String, + pub market_cap_bn: f64, + pub free_float_cap_bn: f64, +} + +pub struct SelectionContext<'a> { + pub decision_date: NaiveDate, + pub benchmark: &'a BenchmarkSnapshot, + pub data: &'a DataSet, +} + +pub trait UniverseSelector { + fn select(&self, ctx: &SelectionContext<'_>) -> Vec; +} + +#[derive(Debug, Clone)] +pub struct DynamicMarketCapBandSelector { + pub base_index_level: f64, + pub bullish_threshold: f64, + pub neutral_threshold: f64, + pub bullish_band: (f64, f64), + pub neutral_band: (f64, f64), + pub defensive_band: (f64, f64), + pub top_n: usize, +} + +impl DynamicMarketCapBandSelector { + pub fn demo(top_n: usize) -> Self { + Self { + base_index_level: 3000.0, + bullish_threshold: 1.02, + neutral_threshold: 1.0, + bullish_band: (30.0, 60.0), + neutral_band: (40.0, 90.0), + defensive_band: (60.0, 120.0), + top_n, + } + } + + pub fn regime(&self, benchmark_level: f64) -> BandRegime { + let ratio = benchmark_level / self.base_index_level; + if ratio >= self.bullish_threshold { + BandRegime::Bullish + } else if ratio >= self.neutral_threshold { + BandRegime::Neutral + } else { + BandRegime::Defensive + } + } + + fn band(&self, regime: BandRegime) -> (f64, f64) { + match regime { + BandRegime::Bullish => self.bullish_band, + BandRegime::Neutral => self.neutral_band, + BandRegime::Defensive => self.defensive_band, + } + } +} + +impl UniverseSelector for DynamicMarketCapBandSelector { + fn select(&self, ctx: &SelectionContext<'_>) -> Vec { + let regime = self.regime(ctx.benchmark.close); + let (min_cap, max_cap) = self.band(regime); + + let mut selected = ctx + .data + .factor_snapshots_on(ctx.decision_date) + .into_iter() + .filter_map(|factor| { + let candidate = ctx.data.candidate(ctx.decision_date, &factor.symbol)?; + let market = ctx.data.market(ctx.decision_date, &factor.symbol)?; + + if !candidate.eligible_for_selection() || market.paused { + return None; + } + if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap { + return None; + } + + Some(UniverseCandidate { + symbol: factor.symbol.clone(), + market_cap_bn: factor.market_cap_bn, + free_float_cap_bn: factor.free_float_cap_bn, + }) + }) + .collect::>(); + + 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)) + }); + selected.truncate(self.top_n); + selected + } +} diff --git a/crates/fidc-core/tests/core_rules.rs b/crates/fidc-core/tests/core_rules.rs new file mode 100644 index 0000000..a5d9b16 --- /dev/null +++ b/crates/fidc-core/tests/core_rules.rs @@ -0,0 +1,103 @@ +use chrono::NaiveDate; +use fidc_core::cost::CostModel; +use fidc_core::rules::EquityRuleHooks; +use fidc_core::{ + CandidateEligibility, + ChinaAShareCostModel, + ChinaEquityRuleHooks, + DailyMarketSnapshot, + OrderSide, + Position, +}; + +fn d(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).expect("valid date") +} + +fn candidate() -> CandidateEligibility { + CandidateEligibility { + date: d(2024, 1, 3), + symbol: "000001.SZ".to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + } +} + +fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot { + DailyMarketSnapshot { + date: d(2024, 1, 3), + symbol: "000001.SZ".to_string(), + open, + high: open, + low: open, + close: open, + prev_close: 10.0, + volume: 1_000_000, + paused: false, + upper_limit, + lower_limit, + } +} + +#[test] +fn china_cost_model_applies_minimum_commission_and_stamp_tax() { + let model = ChinaAShareCostModel::default(); + + let buy = model.calculate(OrderSide::Buy, 1_000.0); + assert!((buy.commission - 5.0).abs() < 1e-9); + assert_eq!(buy.stamp_tax, 0.0); + + let sell = model.calculate(OrderSide::Sell, 100_000.0); + assert!((sell.commission - 30.0).abs() < 1e-9); + assert!((sell.stamp_tax - 100.0).abs() < 1e-9); +} + +#[test] +fn china_rule_hooks_block_same_day_sell_under_t_plus_one() { + let hooks = ChinaEquityRuleHooks; + let mut position = Position::new("000001.SZ"); + let trade_date = d(2024, 1, 3); + position.buy(trade_date, 1_000, 10.0); + + let check = hooks.can_sell( + trade_date, + &snapshot(10.1, 11.0, 9.0), + &candidate(), + &position, + ); + + assert!(!check.allowed); + assert!(check + .reason + .as_deref() + .unwrap_or_default() + .contains("t+1")); +} + +#[test] +fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() { + let hooks = ChinaEquityRuleHooks; + let candidate = candidate(); + 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); + assert!(!buy_check.allowed); + 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); + assert!(!sell_check.allowed); + assert!(sell_check + .reason + .as_deref() + .unwrap_or_default() + .contains("lower limit")); +} diff --git a/data/demo/benchmark.csv b/data/demo/benchmark.csv new file mode 100644 index 0000000..df11a3c --- /dev/null +++ b/data/demo/benchmark.csv @@ -0,0 +1,10 @@ +date,benchmark,open,close,prev_close,volume +2024-01-02,CSI300.DEMO,2990,3000,2980,100000000 +2024-01-03,CSI300.DEMO,3005,3020,3000,102000000 +2024-01-04,CSI300.DEMO,3025,3050,3020,105000000 +2024-01-05,CSI300.DEMO,3055,3080,3050,108000000 +2024-01-08,CSI300.DEMO,3085,3110,3080,109000000 +2024-01-09,CSI300.DEMO,3100,3090,3110,107000000 +2024-01-10,CSI300.DEMO,3080,3040,3090,111000000 +2024-01-11,CSI300.DEMO,3030,2990,3040,115000000 +2024-01-12,CSI300.DEMO,2980,2950,2990,118000000 diff --git a/data/demo/candidate_flags.csv b/data/demo/candidate_flags.csv new file mode 100644 index 0000000..96e6098 --- /dev/null +++ b/data/demo/candidate_flags.csv @@ -0,0 +1,37 @@ +date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell +2024-01-02,000001.SZ,false,true,false,false,true +2024-01-02,000002.SZ,false,false,false,true,true +2024-01-02,000003.SZ,false,false,false,true,true +2024-01-02,600001.SH,false,false,false,true,true +2024-01-03,000001.SZ,false,true,false,false,true +2024-01-03,000002.SZ,false,false,false,true,true +2024-01-03,000003.SZ,false,false,false,true,true +2024-01-03,600001.SH,false,false,false,true,true +2024-01-04,000001.SZ,false,false,false,true,true +2024-01-04,000002.SZ,false,false,false,true,true +2024-01-04,000003.SZ,false,false,false,true,true +2024-01-04,600001.SH,false,false,false,true,true +2024-01-05,000001.SZ,false,false,false,true,true +2024-01-05,000002.SZ,false,false,false,true,true +2024-01-05,000003.SZ,false,false,false,true,true +2024-01-05,600001.SH,false,false,false,true,true +2024-01-08,000001.SZ,false,false,false,true,true +2024-01-08,000002.SZ,false,false,false,true,true +2024-01-08,000003.SZ,false,false,false,true,true +2024-01-08,600001.SH,false,false,false,true,true +2024-01-09,000001.SZ,false,false,false,true,true +2024-01-09,000002.SZ,false,false,false,true,true +2024-01-09,000003.SZ,false,false,false,true,true +2024-01-09,600001.SH,false,false,false,true,true +2024-01-10,000001.SZ,false,false,false,true,true +2024-01-10,000002.SZ,false,false,false,true,true +2024-01-10,000003.SZ,false,false,false,true,true +2024-01-10,600001.SH,false,false,false,true,true +2024-01-11,000001.SZ,false,false,false,true,true +2024-01-11,000002.SZ,false,false,false,true,true +2024-01-11,000003.SZ,false,false,false,true,true +2024-01-11,600001.SH,false,false,true,false,false +2024-01-12,000001.SZ,false,false,false,true,true +2024-01-12,000002.SZ,false,false,false,true,true +2024-01-12,000003.SZ,false,false,false,true,true +2024-01-12,600001.SH,false,false,false,true,true diff --git a/data/demo/factors.csv b/data/demo/factors.csv new file mode 100644 index 0000000..537868d --- /dev/null +++ b/data/demo/factors.csv @@ -0,0 +1,37 @@ +date,symbol,market_cap_bn,free_float_cap_bn,pe_ttm +2024-01-02,000001.SZ,38,24,18 +2024-01-02,000002.SZ,45,30,20 +2024-01-02,000003.SZ,65,40,15 +2024-01-02,600001.SH,85,55,13 +2024-01-03,000001.SZ,39,24.5,18 +2024-01-03,000002.SZ,46,30.5,20 +2024-01-03,000003.SZ,64,39.5,15 +2024-01-03,600001.SH,85,55,13 +2024-01-04,000001.SZ,40,25,18 +2024-01-04,000002.SZ,47,31,20 +2024-01-04,000003.SZ,63,39,15 +2024-01-04,600001.SH,86,55.5,13 +2024-01-05,000001.SZ,41,25.5,18 +2024-01-05,000002.SZ,48,32,20 +2024-01-05,000003.SZ,62,38.5,15 +2024-01-05,600001.SH,86,56,13 +2024-01-08,000001.SZ,42,26,18 +2024-01-08,000002.SZ,50,33,21 +2024-01-08,000003.SZ,61,38,15 +2024-01-08,600001.SH,87,56.5,13 +2024-01-09,000001.SZ,44,27,19 +2024-01-09,000002.SZ,52,34,21 +2024-01-09,000003.SZ,60,37.5,15 +2024-01-09,600001.SH,88,57,13 +2024-01-10,000001.SZ,43,26.5,19 +2024-01-10,000002.SZ,53,34.5,21 +2024-01-10,000003.SZ,59,37,15 +2024-01-10,600001.SH,89,57.5,13 +2024-01-11,000001.SZ,42,26,18 +2024-01-11,000002.SZ,52,34,21 +2024-01-11,000003.SZ,58,36.5,15 +2024-01-11,600001.SH,90,58,13 +2024-01-12,000001.SZ,40,25,18 +2024-01-12,000002.SZ,50,33,20 +2024-01-12,000003.SZ,57,36,15 +2024-01-12,600001.SH,92,59,13 diff --git a/data/demo/instruments.csv b/data/demo/instruments.csv new file mode 100644 index 0000000..f73bb3b --- /dev/null +++ b/data/demo/instruments.csv @@ -0,0 +1,5 @@ +symbol,name,board +000001.SZ,Alpha Components,Main +000002.SZ,Beta Precision,Main +000003.SZ,Charlie Materials,Main +600001.SH,Delta Industrials,Main diff --git a/data/demo/market.csv b/data/demo/market.csv new file mode 100644 index 0000000..f10bfd5 --- /dev/null +++ b/data/demo/market.csv @@ -0,0 +1,37 @@ +date,symbol,open,high,low,close,prev_close,volume,paused +2024-01-02,000001.SZ,10.0,10.2,9.9,10.1,9.8,1200000,false +2024-01-02,000002.SZ,11.0,11.3,10.9,11.2,10.8,1100000,false +2024-01-02,000003.SZ,8.0,8.1,7.8,7.9,8.0,900000,false +2024-01-02,600001.SH,15.0,15.2,14.9,15.1,15.0,800000,false +2024-01-03,000001.SZ,10.2,10.5,10.1,10.4,10.1,1250000,false +2024-01-03,000002.SZ,11.2,11.6,11.1,11.5,11.2,1120000,false +2024-01-03,000003.SZ,7.8,7.9,7.3,7.4,7.9,930000,false +2024-01-03,600001.SH,15.1,15.3,15.0,15.2,15.1,820000,false +2024-01-04,000001.SZ,10.5,10.8,10.4,10.7,10.4,1280000,false +2024-01-04,000002.SZ,11.4,11.9,11.3,11.8,11.5,1150000,false +2024-01-04,000003.SZ,7.3,7.4,7.0,7.1,7.4,940000,false +2024-01-04,600001.SH,15.2,15.5,15.1,15.4,15.2,830000,false +2024-01-05,000001.SZ,10.8,11.1,10.7,11.0,10.7,1300000,false +2024-01-05,000002.SZ,11.9,12.1,11.8,12.0,11.8,1180000,false +2024-01-05,000003.SZ,7.0,7.1,6.8,6.9,7.1,950000,false +2024-01-05,600001.SH,15.4,15.6,15.3,15.5,15.4,840000,false +2024-01-08,000001.SZ,11.1,11.6,11.0,11.5,11.0,1400000,false +2024-01-08,000002.SZ,12.1,12.5,12.0,12.4,12.0,1200000,false +2024-01-08,000003.SZ,7.0,7.3,6.9,7.2,6.9,980000,false +2024-01-08,600001.SH,15.5,15.7,15.4,15.6,15.5,850000,false +2024-01-09,000001.SZ,11.6,12.4,11.5,12.3,11.5,1500000,false +2024-01-09,000002.SZ,12.5,12.9,12.4,12.8,12.4,1250000,false +2024-01-09,000003.SZ,7.2,7.5,7.1,7.4,7.2,990000,false +2024-01-09,600001.SH,15.6,15.7,15.4,15.5,15.6,860000,false +2024-01-10,000001.SZ,12.2,12.3,11.9,12.0,12.3,1450000,false +2024-01-10,000002.SZ,12.7,12.8,12.5,12.6,12.8,1220000,false +2024-01-10,000003.SZ,7.5,7.6,7.4,7.5,7.4,1000000,false +2024-01-10,600001.SH,15.4,15.5,15.1,15.2,15.5,870000,false +2024-01-11,000001.SZ,12.0,12.1,11.5,11.6,12.0,1420000,false +2024-01-11,000002.SZ,12.5,12.6,12.1,12.2,12.6,1210000,false +2024-01-11,000003.SZ,7.4,7.5,7.2,7.3,7.5,980000,false +2024-01-11,600001.SH,15.2,15.2,15.2,15.2,15.2,0,true +2024-01-12,000001.SZ,11.5,11.6,11.1,11.2,11.6,1380000,false +2024-01-12,000002.SZ,12.1,12.2,11.8,11.9,12.2,1190000,false +2024-01-12,000003.SZ,7.2,7.2,6.9,7.0,7.3,960000,false +2024-01-12,600001.SH,14.8,15.0,14.7,14.9,15.2,850000,false