初始化回测核心引擎骨架

This commit is contained in:
zsb
2026-04-06 23:56:37 -07:00
commit 334864cbc5
25 changed files with 2878 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
/output

344
Cargo.lock generated Normal file
View File

@@ -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",
]

17
Cargo.toml Normal file
View File

@@ -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"

156
README.md Normal file
View File

@@ -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 框架

View File

@@ -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" }

180
crates/bt-demo/src/main.rs Normal file
View File

@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
let mut file = fs::File::create(path)?;
writeln!(
file,
"date,symbol,side,quantity,price,gross_amount,commission,stamp_tax,net_cash_flow,reason"
)?;
for row in rows {
writeln!(
file,
"{},{},{:?},{},{:.2},{:.2},{:.2},{:.2},{:.2},{}",
row.date,
row.symbol,
row.side,
row.quantity,
row.price,
row.gross_amount,
row.commission,
row.stamp_tax,
row.net_cash_flow,
sanitize_csv_field(&row.reason),
)?;
}
Ok(())
}
fn write_holdings_csv(path: &Path, rows: &[HoldingSummary]) -> Result<(), Box<dyn Error>> {
let mut file = fs::File::create(path)?;
writeln!(
file,
"date,symbol,quantity,average_cost,last_price,market_value,unrealized_pnl,realized_pnl"
)?;
for row in rows {
writeln!(
file,
"{},{},{},{:.2},{:.2},{:.2},{:.2},{:.2}",
row.date,
row.symbol,
row.quantity,
row.average_cost,
row.last_price,
row.market_value,
row.unrealized_pnl,
row.realized_pnl,
)?;
}
Ok(())
}
fn sanitize_csv_field(text: &str) -> String {
text.replace(',', ";")
}
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::<Vec<_>>().into_iter().rev() {
println!(
" {} equity {:.2} cash {:.2} mv {:.2}",
point.date, point.total_equity, point.cash, point.market_value
);
}
if holdings.is_empty() {
println!("No holdings at the end of the demo run.");
} else {
println!("Ending holdings:");
for holding in holdings {
println!(
" {} qty {} mv {:.2} pnl {:.2}",
holding.symbol, holding.quantity, holding.market_value, holding.unrealized_pnl
);
}
}
}

View File

@@ -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

View File

@@ -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<OrderEvent>,
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
board_lot_size: u32,
}
impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
}
}
}
impl<C, R> BrokerSimulator<C, R>
where
C: CostModel,
R: EquityRuleHooks,
{
pub fn execute(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
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<String, f64>,
) -> Result<BTreeMap<String, u32>, 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<f64, BacktestError> {
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"
}
}

View File

@@ -0,0 +1,58 @@
use std::collections::HashMap;
use chrono::NaiveDate;
#[derive(Debug, Clone)]
pub struct TradingCalendar {
days: Vec<NaiveDate>,
index: HashMap<NaiveDate, usize>,
}
impl TradingCalendar {
pub fn new(mut days: Vec<NaiveDate>) -> 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<Item = NaiveDate> + '_ {
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<usize> {
self.index.get(&date).copied()
}
pub fn previous_day(&self, date: NaiveDate) -> Option<NaiveDate> {
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<NaiveDate> {
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()
}
}

View File

@@ -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,
}
}
}

View File

@@ -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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
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<String, Instrument>,
calendar: TradingCalendar,
market_by_date: BTreeMap<NaiveDate, Vec<DailyMarketSnapshot>>,
market_index: HashMap<(NaiveDate, String), DailyMarketSnapshot>,
factor_by_date: BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
factor_index: HashMap<(NaiveDate, String), DailyFactorSnapshot>,
candidate_by_date: BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
candidate_index: HashMap<(NaiveDate, String), CandidateEligibility>,
benchmark_by_date: BTreeMap<NaiveDate, BenchmarkSnapshot>,
benchmark_code: String,
}
impl DataSet {
pub fn from_csv_dir(path: &Path) -> Result<Self, DataSetError> {
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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::<HashMap<_, _>>();
let benchmark_by_date = benchmarks
.into_iter()
.map(|item| (item.date, item))
.collect::<BTreeMap<_, _>>();
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<String, Instrument> {
&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<BenchmarkSnapshot> {
self.benchmark_by_date.values().cloned().collect()
}
pub fn price(&self, date: NaiveDate, symbol: &str, field: PriceField) -> Option<f64> {
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<f64> {
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<Vec<Instrument>, 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<Vec<DailyMarketSnapshot>, 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<Vec<DailyFactorSnapshot>, 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<Vec<CandidateEligibility>, 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<Vec<BenchmarkSnapshot>, 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<String>,
}
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, DataSetError> {
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<f64, DataSetError> {
self.get(index)?
.parse::<f64>()
.map_err(|err| DataSetError::InvalidRow {
path: self.path.clone(),
line: self.line,
message: format!("invalid f64: {err}"),
})
}
fn parse_u64(&self, index: usize) -> Result<u64, DataSetError> {
self.get(index)?
.parse::<u64>()
.map_err(|err| DataSetError::InvalidRow {
path: self.path.clone(),
line: self.line,
message: format!("invalid u64: {err}"),
})
}
fn parse_bool(&self, index: usize) -> Result<bool, DataSetError> {
self.get(index)?
.parse::<bool>()
.map_err(|err| DataSetError::InvalidRow {
path: self.path.clone(),
line: self.line,
message: format!("invalid bool: {err}"),
})
}
}
fn read_rows(path: &Path) -> Result<Vec<CsvRow>, 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<T, F>(rows: Vec<T>, mut date_of: F) -> BTreeMap<NaiveDate, Vec<T>>
where
F: FnMut(&T) -> NaiveDate,
{
let mut grouped = BTreeMap::<NaiveDate, Vec<T>>::new();
for row in rows {
grouped.entry(date_of(&row)).or_default().push(row);
}
grouped
}
fn collect_benchmark_code(benchmarks: &[BenchmarkSnapshot]) -> Result<String, DataSetError> {
let mut codes = benchmarks
.iter()
.map(|row| row.benchmark.clone())
.collect::<Vec<_>>();
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
}

View File

@@ -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<DailyEquityPoint>,
pub benchmark_series: Vec<BenchmarkSnapshot>,
pub order_events: Vec<OrderEvent>,
pub fills: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
pub holdings_summary: Vec<HoldingSummary>,
}
pub struct BacktestEngine<S, C, R> {
data: DataSet,
strategy: S,
broker: BrokerSimulator<C, R>,
config: BacktestConfig,
}
impl<S, C, R> BacktestEngine<S, C, R> {
pub fn new(
data: DataSet,
strategy: S,
broker: BrokerSimulator<C, R>,
config: BacktestConfig,
) -> Self {
Self {
data,
strategy,
broker,
config,
}
}
}
impl<S, C, R> BacktestEngine<S, C, R>
where
S: Strategy,
C: CostModel,
R: EquityRuleHooks,
{
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
}

View File

@@ -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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
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,
}

View File

@@ -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,
}

View File

@@ -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,
};

View File

@@ -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<PositionLot>,
}
impl Position {
pub fn new(symbol: impl Into<String>) -> 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<f64, String> {
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<f64> {
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::<f64>();
self.average_cost = total_cost / self.quantity as f64;
}
}
#[derive(Debug, Clone)]
pub struct PortfolioState {
cash: f64,
positions: BTreeMap<String, Position>,
}
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<String, Position> {
&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<HoldingSummary> {
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<S>(date: &NaiveDate, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&date.format(FORMAT).to_string())
}
}

View File

@@ -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<String>,
}
impl RuleCheck {
pub fn allow() -> Self {
Self {
allowed: true,
reason: None,
}
}
pub fn reject(reason: impl Into<String>) -> 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()
}
}

View File

@@ -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<StrategyDecision, BacktestError>;
}
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<String, f64>,
pub exit_symbols: BTreeSet<String>,
pub notes: Vec<String>,
}
#[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<f64>,
}
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<BTreeSet<String>, 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<StrategyDecision, BacktestError> {
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,
})
}
}

View File

@@ -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<UniverseCandidate>;
}
#[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<UniverseCandidate> {
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::<Vec<_>>();
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
}
}

View File

@@ -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"));
}

10
data/demo/benchmark.csv Normal file
View File

@@ -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
1 date benchmark open close prev_close volume
2 2024-01-02 CSI300.DEMO 2990 3000 2980 100000000
3 2024-01-03 CSI300.DEMO 3005 3020 3000 102000000
4 2024-01-04 CSI300.DEMO 3025 3050 3020 105000000
5 2024-01-05 CSI300.DEMO 3055 3080 3050 108000000
6 2024-01-08 CSI300.DEMO 3085 3110 3080 109000000
7 2024-01-09 CSI300.DEMO 3100 3090 3110 107000000
8 2024-01-10 CSI300.DEMO 3080 3040 3090 111000000
9 2024-01-11 CSI300.DEMO 3030 2990 3040 115000000
10 2024-01-12 CSI300.DEMO 2980 2950 2990 118000000

View File

@@ -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
1 date symbol is_st is_new_listing is_paused allow_buy allow_sell
2 2024-01-02 000001.SZ false true false false true
3 2024-01-02 000002.SZ false false false true true
4 2024-01-02 000003.SZ false false false true true
5 2024-01-02 600001.SH false false false true true
6 2024-01-03 000001.SZ false true false false true
7 2024-01-03 000002.SZ false false false true true
8 2024-01-03 000003.SZ false false false true true
9 2024-01-03 600001.SH false false false true true
10 2024-01-04 000001.SZ false false false true true
11 2024-01-04 000002.SZ false false false true true
12 2024-01-04 000003.SZ false false false true true
13 2024-01-04 600001.SH false false false true true
14 2024-01-05 000001.SZ false false false true true
15 2024-01-05 000002.SZ false false false true true
16 2024-01-05 000003.SZ false false false true true
17 2024-01-05 600001.SH false false false true true
18 2024-01-08 000001.SZ false false false true true
19 2024-01-08 000002.SZ false false false true true
20 2024-01-08 000003.SZ false false false true true
21 2024-01-08 600001.SH false false false true true
22 2024-01-09 000001.SZ false false false true true
23 2024-01-09 000002.SZ false false false true true
24 2024-01-09 000003.SZ false false false true true
25 2024-01-09 600001.SH false false false true true
26 2024-01-10 000001.SZ false false false true true
27 2024-01-10 000002.SZ false false false true true
28 2024-01-10 000003.SZ false false false true true
29 2024-01-10 600001.SH false false false true true
30 2024-01-11 000001.SZ false false false true true
31 2024-01-11 000002.SZ false false false true true
32 2024-01-11 000003.SZ false false false true true
33 2024-01-11 600001.SH false false true false false
34 2024-01-12 000001.SZ false false false true true
35 2024-01-12 000002.SZ false false false true true
36 2024-01-12 000003.SZ false false false true true
37 2024-01-12 600001.SH false false false true true

37
data/demo/factors.csv Normal file
View File

@@ -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
1 date symbol market_cap_bn free_float_cap_bn pe_ttm
2 2024-01-02 000001.SZ 38 24 18
3 2024-01-02 000002.SZ 45 30 20
4 2024-01-02 000003.SZ 65 40 15
5 2024-01-02 600001.SH 85 55 13
6 2024-01-03 000001.SZ 39 24.5 18
7 2024-01-03 000002.SZ 46 30.5 20
8 2024-01-03 000003.SZ 64 39.5 15
9 2024-01-03 600001.SH 85 55 13
10 2024-01-04 000001.SZ 40 25 18
11 2024-01-04 000002.SZ 47 31 20
12 2024-01-04 000003.SZ 63 39 15
13 2024-01-04 600001.SH 86 55.5 13
14 2024-01-05 000001.SZ 41 25.5 18
15 2024-01-05 000002.SZ 48 32 20
16 2024-01-05 000003.SZ 62 38.5 15
17 2024-01-05 600001.SH 86 56 13
18 2024-01-08 000001.SZ 42 26 18
19 2024-01-08 000002.SZ 50 33 21
20 2024-01-08 000003.SZ 61 38 15
21 2024-01-08 600001.SH 87 56.5 13
22 2024-01-09 000001.SZ 44 27 19
23 2024-01-09 000002.SZ 52 34 21
24 2024-01-09 000003.SZ 60 37.5 15
25 2024-01-09 600001.SH 88 57 13
26 2024-01-10 000001.SZ 43 26.5 19
27 2024-01-10 000002.SZ 53 34.5 21
28 2024-01-10 000003.SZ 59 37 15
29 2024-01-10 600001.SH 89 57.5 13
30 2024-01-11 000001.SZ 42 26 18
31 2024-01-11 000002.SZ 52 34 21
32 2024-01-11 000003.SZ 58 36.5 15
33 2024-01-11 600001.SH 90 58 13
34 2024-01-12 000001.SZ 40 25 18
35 2024-01-12 000002.SZ 50 33 20
36 2024-01-12 000003.SZ 57 36 15
37 2024-01-12 600001.SH 92 59 13

View File

@@ -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
1 symbol name board
2 000001.SZ Alpha Components Main
3 000002.SZ Beta Precision Main
4 000003.SZ Charlie Materials Main
5 600001.SH Delta Industrials Main

37
data/demo/market.csv Normal file
View File

@@ -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
1 date symbol open high low close prev_close volume paused
2 2024-01-02 000001.SZ 10.0 10.2 9.9 10.1 9.8 1200000 false
3 2024-01-02 000002.SZ 11.0 11.3 10.9 11.2 10.8 1100000 false
4 2024-01-02 000003.SZ 8.0 8.1 7.8 7.9 8.0 900000 false
5 2024-01-02 600001.SH 15.0 15.2 14.9 15.1 15.0 800000 false
6 2024-01-03 000001.SZ 10.2 10.5 10.1 10.4 10.1 1250000 false
7 2024-01-03 000002.SZ 11.2 11.6 11.1 11.5 11.2 1120000 false
8 2024-01-03 000003.SZ 7.8 7.9 7.3 7.4 7.9 930000 false
9 2024-01-03 600001.SH 15.1 15.3 15.0 15.2 15.1 820000 false
10 2024-01-04 000001.SZ 10.5 10.8 10.4 10.7 10.4 1280000 false
11 2024-01-04 000002.SZ 11.4 11.9 11.3 11.8 11.5 1150000 false
12 2024-01-04 000003.SZ 7.3 7.4 7.0 7.1 7.4 940000 false
13 2024-01-04 600001.SH 15.2 15.5 15.1 15.4 15.2 830000 false
14 2024-01-05 000001.SZ 10.8 11.1 10.7 11.0 10.7 1300000 false
15 2024-01-05 000002.SZ 11.9 12.1 11.8 12.0 11.8 1180000 false
16 2024-01-05 000003.SZ 7.0 7.1 6.8 6.9 7.1 950000 false
17 2024-01-05 600001.SH 15.4 15.6 15.3 15.5 15.4 840000 false
18 2024-01-08 000001.SZ 11.1 11.6 11.0 11.5 11.0 1400000 false
19 2024-01-08 000002.SZ 12.1 12.5 12.0 12.4 12.0 1200000 false
20 2024-01-08 000003.SZ 7.0 7.3 6.9 7.2 6.9 980000 false
21 2024-01-08 600001.SH 15.5 15.7 15.4 15.6 15.5 850000 false
22 2024-01-09 000001.SZ 11.6 12.4 11.5 12.3 11.5 1500000 false
23 2024-01-09 000002.SZ 12.5 12.9 12.4 12.8 12.4 1250000 false
24 2024-01-09 000003.SZ 7.2 7.5 7.1 7.4 7.2 990000 false
25 2024-01-09 600001.SH 15.6 15.7 15.4 15.5 15.6 860000 false
26 2024-01-10 000001.SZ 12.2 12.3 11.9 12.0 12.3 1450000 false
27 2024-01-10 000002.SZ 12.7 12.8 12.5 12.6 12.8 1220000 false
28 2024-01-10 000003.SZ 7.5 7.6 7.4 7.5 7.4 1000000 false
29 2024-01-10 600001.SH 15.4 15.5 15.1 15.2 15.5 870000 false
30 2024-01-11 000001.SZ 12.0 12.1 11.5 11.6 12.0 1420000 false
31 2024-01-11 000002.SZ 12.5 12.6 12.1 12.2 12.6 1210000 false
32 2024-01-11 000003.SZ 7.4 7.5 7.2 7.3 7.5 980000 false
33 2024-01-11 600001.SH 15.2 15.2 15.2 15.2 15.2 0 true
34 2024-01-12 000001.SZ 11.5 11.6 11.1 11.2 11.6 1380000 false
35 2024-01-12 000002.SZ 12.1 12.2 11.8 11.9 12.2 1190000 false
36 2024-01-12 000003.SZ 7.2 7.2 6.9 7.0 7.3 960000 false
37 2024-01-12 600001.SH 14.8 15.0 14.7 14.9 15.2 850000 false