初步完成AI-AGENK开发框架

This commit is contained in:
2026-07-09 21:23:10 +08:00
commit a10a4df035
534 changed files with 152243 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Generated by Cargo
# will have compiled files and executables
debug
target
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Python
.venv
__pycache__/
*.pyc
*.pyo
*.egg-info/
.env
# RaptorBT 编译产物 (PyO3 扩展)
python/raptorbt/_raptorbt.*.pyd
python/raptorbt/_raptorbt.*.so
# 运行时输出
backtest_output/
deliverables/
# 策略研究数据 (用户私有, 不提交)
data/
# 第三方源码依赖 (vendor/) — 必须提交, 保证项目可移植
# vendor/ 目录下的源码是项目编译依赖, 不可忽略
+200
View File
@@ -0,0 +1,200 @@
# AGENTS.md — AI Agent 项目规则
> 本文件会被 AI agent 自动加载为系统上下文。开发策略前请完整阅读。
## 项目概述
**RaptorBT** — Rust 高性能回测引擎 + Python 绑定 + MT5 桥接,目标是让 AI agent 自主开发、优化并交付交易策略。
- 引擎层: Rust (PyO3 绑定,亚毫秒级,7 种回测类型,33 项绩效指标)
- 应用层: `app/` (Python,relative imports)
- 策略层: `strategies/` (自动发现)
- 第三方源码: `vendor/ferro-ta-main/` (80+ 指标的 Rust 原生实现,**必须随项目提交**)
- 文档: `docs/`
- 输出: `backtest_output/` `deliverables/` (gitignore)
## 编译
项目用 maturin 编译 Rust → Python 扩展。换电脑或修改 Rust 源码后:
```bash
# 生成 whl 包 (会用到 vendor/ferro-ta-main/ 下的 ferro_ta_core 源码)
maturin build --release
# 产物: target/wheels/raptorbt-*.whl
# 安装到当前 Python 环境
pip install --force-reinstall target/wheels/raptorbt-*.whl
# 或在虚拟环境中开发模式 (会编译到 python/raptorbt/_raptorbt.*.pyd)
maturin develop --release
```
**前置依赖**: Rust toolchain (cargo/rustc)、Python 3.10+、maturin。
## 硬约束 (必须遵守)
1. **CLI 入口**: 必须用 `python -m app.main <command>`,禁止 `python main.py`
2. **包结构**: 应用层模块在 `app/`,策略在 `strategies/`,文档在 `docs/`
3. **导入**: 应用层模块用相对导入 (`from .xxx import yyy`),策略用 `from .base import Strategy, SignalResult`
4. **API Key**: 用环境变量 `MT5_BRIDGE_KEY`,禁止 hardcode
5. **数据源**: 默认 `--source csv`(离线研究),MT5 仅用于最终验证
6. **输出目录**: `backtest_output/` `deliverables/` `data/` 必须在 `.gitignore`
## AI agent 策略开发流程 (0→8)
```
0. list --indicators --json 查询 80 个可用指标 (签名/默认值/返回值)
1. scaffold --name XXX 生成策略模板 (自带前视警告头部)
2. 编辑 strategies/XXX.py 填入信号逻辑 (用 raptorbt.<指标名>)
3. check --strategy XXX 前视偏差检测 ★强制 (静态+动态)
4. optimize --strategy XXX 参数网格搜索
5. walkforward --strategy XXX Walk-Forward 验证
6. validate --strategy XXX 验收 (4 条标准 + 失败诊断)
7. 未通过 → 读 suggestions 调整, 回到 2/4
通过 → 继续 8
8. deliver --strategy XXX 交付 (再次强制前视检测, 失败拒绝生成)
```
**所有命令支持 `--json` 输出**,AI agent 应优先用 `--json` 解析结构化结果。
## 前视偏差禁令 (最重要)
信号生成只能用**当前 bar 及之前**的数据,严禁访问未来 bar。
**禁止模式** (会被 `check` 检测并拒绝交付):
- `.shift(-N)` (N>0) — 访问未来 bar
- `close[-1]` / `high[-1]` — 负索引访问未来
- `df.iloc[i+N:]` — 切片到未来索引
- 滚动统计后 `shift` 负值
- `np.roll(arr, -N)` — 循环移位可能引入前视
**正确做法**:
- 用基类的 `cross_above` / `cross_below` (已内置前视安全)
- 信号在 bar 收盘后生成,引擎默认 `upon_bar_close=True`,以 close 成交
- 检测命令: `python -m app.main check --strategy XXX --dynamic`
## 指标调用
```python
import raptorbt
import numpy as np
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
# 单返回
sma = raptorbt.sma(close, period=14)
rsi = raptorbt.rsi(close, period=14)
# 多返回
adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14)
upper, middle, lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0)
macd, signal, hist = raptorbt.macd(close, fast_period=12, slow_period=26, signal_period=9)
```
**查询全部 80 个指标**:
```bash
python -m app.main list --indicators # 文本版
python -m app.main list --indicators --json # JSON 版 (AI agent 用)
```
## 策略模板结构
```python
"""<name> — <description>
⚠️ 前视偏差注意事项: (scaffold 自动生成, 不要删除)
"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class MyStrategy(Strategy):
name = "my_strategy"
def __init__(self, period: int = 14):
self.period = period
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
# ... 指标计算和信号生成 (只用当前及之前的数据) ...
entries = np.zeros(len(close), dtype=bool)
exits = np.zeros(len(close), dtype=bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(entries=entries, exits=exits, direction=1)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_fixed_stop(0.02)
config.set_fixed_target(0.04)
return config
def description(self) -> str:
return "..."
STRATEGY_CLASS = MyStrategy # 必须暴露此变量供自动发现
```
## 验收标准 (4 条)
| 标准 | 阈值 | 失败建议方向 |
|------|------|-------------|
| OOS 夏普比率 | ≥ 1.0 | 放宽止损 / 加趋势过滤 / 反向信号 / 切大周期 |
| OOS 最大回撤 | ≤ 15% | 收紧止损 / ATR 止损 / 追踪止损 / 降仓位 |
| OOS 总交易数 | ≥ 30 | 缩短指标周期 / 降阈值 / 切小周期 / 检查 warmup |
| IS/OOS 衰减比 | ≥ 0.5 | 缩小参数空间 / 增 WF 窗口 / 简化策略 / 检查前视 |
`validate --json` 输出中每条失败标准都带 `suggestions` 数组(5 条具体建议)。
## 常用命令速查
```bash
# 查询
python -m app.main list # 策略列表
python -m app.main list --indicators --json # 指标目录 (80 个)
# 开发
python -m app.main scaffold --name XXX --template breakout
python -m app.main check --strategy XXX --dynamic --bars 500 --json
# 测试和优化
python -m app.main run --strategy XXX --bars 500 --json
python -m app.main optimize --strategy XXX --param period=10,15,20 --json
python -m app.main walkforward --strategy XXX --param period=10,15,20 --bars 2000 --json
# 验收和交付
python -m app.main validate --strategy XXX --param period=10,15,20 --bars 2000 --json
python -m app.main deliver --strategy XXX --param period=10,15 --bars 2000 --json
```
## 命名规范
- 策略文件: `snake_case.py` (如 `sma_cross.py`)
- 策略类: `PascalCaseStrategy` (如 `SmaCrossStrategy`)
- 应用模块: 语义命名 (如 `indicators.py` 而非 `my_indicators.py`)
- 必须暴露 `STRATEGY_CLASS` 变量供 `strategies/__init__.py` 自动发现
## 文档
- [README.md](README.md) — 项目总览
- [docs/策略自动化框架.md](docs/策略自动化框架.md) — 完整框架文档 (CLI/策略/优化/验证/前视/指标目录)
- [docs/RaptorBT使用手册.md](docs/RaptorBT使用手册.md) — 引擎 API 和指标详细说明
- [docs/Mt5Bridge使用指南.md](docs/Mt5Bridge使用指南.md) — MT5 桥接使用
Generated
+833
View File
@@ -0,0 +1,833 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta_core"
version = "1.2.0"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "js-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "ndarray"
version = "0.15.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"rawpointer",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "numpy"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef41cbb417ea83b30525259e30ccef6af39b31c240bda578889494c5392d331"
dependencies = [
"libc",
"ndarray",
"num-complex",
"num-integer",
"num-traits",
"pyo3",
"rustc-hash",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[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 = "pyo3"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"parking_lot",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "raptorbt"
version = "0.4.1"
dependencies = [
"approx",
"criterion",
"ferro_ta_core",
"numpy",
"pyo3",
"rayon",
"serde",
"thiserror",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[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 = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "raptorbt"
version = "0.4.1"
edition = "2021"
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
authors = ["Alphabench <contact@alphabench.in>"]
license = "MIT"
repository = "https://github.com/alphabench/raptorbt"
homepage = "https://www.alphabench.in/raptorbt"
readme = "README.md"
keywords = ["backtesting", "trading", "quantitative-finance", "rust", "python"]
categories = ["finance", "simulation"]
[lib]
name = "raptorbt"
crate-type = ["cdylib", "rlib"]
[dependencies]
pyo3 = { version = "0.20", features = ["extension-module"] }
numpy = "0.20"
rayon = "1.8"
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
ferro_ta_core = { path = "vendor/ferro-ta-main/crates/ferro_ta_core", default-features = false }
[dev-dependencies]
criterion = "0.5"
approx = "0.5"
[[bench]]
name = "backtest_benchmark"
harness = false
[profile.release]
lto = true
codegen-units = 1
opt-level = 3
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Alphabench
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+993
View File
@@ -0,0 +1,993 @@
# RaptorBT
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://uppercase.org/licenses/MIT)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Rust](https://img.shields.io/badge/rust-1.70+-red.svg)](https://www.rust-lang.org/)
**高性能 Rust 回测引擎 + 策略自动化框架,亚毫秒级回测,80+ 技术指标,位级确定性执行。**
RaptorBT 是一个用 Rust 编写的高性能回测引擎,通过 PyO3 提供 Python 绑定。支持单标的、篮子、配对、期权、价差、多策略、Tick 级回测,在亚毫秒级时间内返回完整的 33 项绩效指标报告。项目还内置完整的策略自动化框架(参数优化 / Walk-Forward 验证 / 验收检查 / 交付包导出),支持 AI agent 自主开发与交付策略。
<p align="center">
<strong>亚毫秒级回测</strong> · <strong>编译后 < 1 MB</strong> · <strong>80+ 技术指标</strong> · <strong>位级确定性</strong> · <strong>原生并行</strong> · <strong>策略自动化框架</strong>
</p>
---
## 快速开始
### 安装
```bash
pip install raptorbt
```
### 30 秒示例
```python
import numpy as np
import raptorbt
# 配置回测
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
# 运行回测
result = raptorbt.run_single_backtest(
timestamps=timestamps,
open=open, high=high, low=low, close=close, volume=volume,
entries=entries, exits=exits,
direction=1, weight=1.0, symbol="AAPL",
config=config,
)
# 查看结果
print(f"收益率: {result.metrics.total_return_pct:.2f}%")
print(f"夏普比率: {result.metrics.sharpe_ratio:.2f}")
print(f"最大回撤: {result.metrics.max_drawdown_pct:.2f}%")
```
---
## 目录
- [概述](#概述)
- [策略自动化框架](#策略自动化框架) ★
- [项目结构](#项目结构) ★
- [性能](#性能)
- [策略类型](#策略类型)
- [技术指标](#技术指标)
- [止损与止盈](#止损与止盈)
- [蒙特卡洛组合模拟](#蒙特卡洛组合模拟)
- [回测结果与指标](#回测结果与指标)
- [API 参考](#api-参考)
- [从源码构建](#从源码构建)
- [版本历史](#版本历史)
---
## 概述
RaptorBT 编译为单一原生扩展,完全在 Rust 中运行。在典型 K 线数量下,完整的回测加上所有 33 项绩效指标的执行时间不足 1 毫秒。在 Apple M4 上测量的基准数据(raptorbt 0.4.1):
| 指标 | RaptorBT |
|------|----------|
| **编译引擎大小** | < 1 MB |
| **回测速度 (1K 根 K 线)** | ~0.03 ms |
| **回测速度 (10K 根 K 线)** | ~0.25 ms |
| **回测速度 (50K 根 K 线)** | ~1.4 ms |
| **内存使用** | 低(原生内存管理) |
### 核心特性
- **7 种策略类型**:单标的、篮子/集体、配对交易、期权、价差、多策略、Tick 级
- **资产与券商无关**:从任何数据源传入 NumPy OHLCV 或 Tick 数组——股票、期货、外汇、加密货币、期权,RaptorBT 从不假设市场或数据供应商
- **80+ 技术指标**:集成 ferro-ta 指标库,覆盖趋势、动量、波动率、强度、成交量、价格变换、统计、周期变换、市场状态检测、投资组合工具
- **Tick 级模拟**:全 Tick 分辨率,支持日内期权动量、剥头皮和微观结构策略
- **批量价差回测**:通过 Rayon 并行运行多个价差回测,释放 GIL
- **蒙特卡洛模拟**:基于 GBM + Cholesky 分解的相关多资产前向投影
- **33 项绩效指标**:夏普、索提诺、卡玛、Omega、SQN、盈亏比、恢复因子等
- **止损/止盈管理**:固定、ATR 基于和追踪止损,风险回报目标
- **位级确定性**:相同输入产生 bit-for-bit 相同结果——无 JIT 编译偏差
- **原生并行**Rayon 并行处理 + SIMD 优化
---
## 策略自动化框架
RaptorBT 内置完整的策略自动化框架,覆盖从策略开发到交付的完整流程:
```
scaffold 生成模板 → 编写信号逻辑 → check 前视检测 → optimize 参数优化
→ walkforward 验证 → acceptance 验收 → deliver 生成交付包
```
### 数据源
框架支持两种数据源,默认用 CSV 离线数据:
- **CSV(默认)**:把 MT5 History Center / quant data manager 导出的 M1 CSV 放到 `data/` 目录,加载器自动识别品种、时区、重采样、spread→slippage 转换。适合策略研究。
- **Mt5Bridge**:用 `--source mt5` 切换,从远程 MT5 拉取实时数据。适合最终策略验证。
### CLI 入口
所有命令通过 `python -m app.main` 调用:
```bash
# 列出所有已注册策略 (同时列出 data/ 下可用 CSV 品种)
python -m app.main list
# 运行单个策略回测 (默认 CSV 离线数据)
python -m app.main run --strategy sma_cross --symbol XAUUSD --bars 500
# 对比所有策略表现
python -m app.main compare --symbol XAUUSD
# 参数网格搜索优化
python -m app.main optimize --strategy sma_cross \
--param fast=5,10,15 --param slow=20,30 --metric sharpe_ratio --export
# Walk-Forward 验证 (滚动 IS/OOS + 过拟合检测)
python -m app.main walkforward --strategy sma_cross \
--param fast=5,10 --param slow=20,30 --bars 1000 --train-size 300 --test-size 100
# 列出所有可用策略 / 可用指标 (80 个)
python -m app.main list
python -m app.main list --indicators --json # AI agent 开发前先查询指标目录
# 策略验收检查 (盈利优先三层标准 + 前视偏差强制检测)
python -m app.main validate --strategy sma_cross \
--param fast=5,10 --param slow=20,30
# 前视偏差检测 (静态 AST 扫描 + 动态扰动验证)
python -m app.main check --strategy sma_cross --dynamic --bars 500
python -m app.main check --strategy all # 扫描所有策略
# 生成策略模板文件 (自带前视警告头部)
python -m app.main scaffold --name my_rsi --template mean_reversion
# 生成完整交付包 (强制前视检测, 未通过则拒绝生成)
python -m app.main deliver --strategy sma_cross \
--param fast=5,10 --param slow=20,30
# 所有命令支持 --json 输出 (供 AI agent 解析, 含失败诊断建议)
python -m app.main validate --strategy sma_cross \
--param fast=5,10 --param slow=20,30 --json
```
### 9 个自动化模块
| 模块 | 作用 | 关键能力 |
|------|------|----------|
| [app/main.py](app/main.py) | CLI 入口 | 9 个子命令 (list/run/compare/optimize/walkforward/validate/check/scaffold/deliver),全部支持 `--json` 结构化输出 |
| [app/data_loader.py](app/data_loader.py) | CSV 数据加载 | MT5 格式 M1 CSV,17 品种识别,13 周期重采样,spread→slippage |
| [app/lookahead_check.py](app/lookahead_check.py) | 前视偏差防护 | 静态 AST 扫描 (11 规则) + 动态扰动验证,强制集成到 validate/deliver |
| [app/indicator_catalog.py](app/indicator_catalog.py) | 指标目录 | 80 个原生指标的可查询目录,含签名/输入/默认值/返回值,`list --indicators` 输出 |
| [app/optimizer.py](app/optimizer.py) | 参数网格搜索 | 遍历参数组合,按指标排序,导出完整响应面 |
| [app/walk_forward.py](app/walk_forward.py) | Walk-Forward 验证 | 滚动 IS/OOS 窗口,衰减比,参数稳定性分析,过拟合检测 |
| [app/acceptance.py](app/acceptance.py) | 验收检查 | 盈利优先三层标准 (L1 盈利性必须 / L2 风险可控 / L3 健壮性) + 失败诊断建议 (按层分级) |
| [app/scaffold.py](app/scaffold.py) | 策略模板生成器 | 5 种模板 (crossover/mean_reversion/trend_following/breakout/custom),自带前视警告 |
| [app/exporter.py](app/exporter.py) | 交付包打包导出 | 策略源码 + 8 章节 Markdown 报告 + 3 个 CSV |
### 前视偏差防护链
防止 AI agent 自动开发策略时引入前视偏差(使用未来 bar 数据导致回测虚高)。完整防护链:
```
scaffold (头部警告) → check (静态+动态检测) → validate (强制前置) → deliver (强制前置, 拒绝生成)
```
| 防护点 | 行为 |
|------|------|
| scaffold 模板 | 生成的策略文件头部自带 ⚠️ 前视警告,列出禁用模式 |
| `check` 命令 | 单独运行静态 AST 扫描 + 动态扰动验证 |
| `validate` 命令 | 前置前视检测,未通过则**终止验收** |
| `deliver` 命令 | 前置前视检测,未通过则**拒绝生成交付包** |
| 引擎层 | `upon_bar_close=True`(默认)确保信号在 bar 收盘后生成 |
检测的常见前视模式:`.shift(-N)``close[-1]` 负索引、`df.iloc[i+N:]` 切片未来、`np.roll` 循环移位、`future`/`lookahead` 关键词等 11 条规则。
### JSON 输出 + 失败驱动迭代 ★
所有 CLI 命令支持 `--json` 标志,输出结构化 JSON(自动清理 NaN/Inf,不转义中文),AI agent 可直接解析无需正则匹配表格:
```bash
python -m app.main validate --strategy sma_cross \
--param fast=5,10 --param slow=20,30 --json
```
**失败驱动的自动迭代**:当 `validate` 未通过时,JSON 中 `acceptance.criteria` 数组的每条失败标准都带 `suggestions` 字段(5 条具体可执行建议,按 L1/L2/L3 分级)。AI agent 按**优先级决策**
- `l1_passed=false` → 策略不赚钱,**不要在参数优化上浪费时间**,按 L1 建议换策略逻辑/品种/周期
- `l1_passed=true && (l2|l3)=false` → 按 L2/L3 建议调整风控或统计性问题
- 全过 → `deliver --json` 生成交付包
| 失败标准 | 层 | 建议方向 |
|---------|----|---------|
| OOS 盈利因子 < 1.3 | **L1** | 重审信号逻辑、切换策略类型、切换品种/周期、检查止损、反向信号 |
| OOS 净收益 ≤ 0 | **L1** | 检查 IS 是否也亏、评估成本侵蚀、减少频率、切换顺势品种、反向信号 |
| OOS 每笔期望 ≤ 0 | **L1** | 检查胜率×盈亏比、放大止盈/追踪止损、加过滤提高胜率、放宽止损 |
| OOS 最大回撤 > 20% | L2 | 收紧止损、ATR 动态止损、追踪止损、降仓位、加趋势过滤 |
| OOS 夏普 < 0.7 | L3 | 放宽止损、加趋势过滤、切大周期、加成交量过滤(已降为参考) |
| OOS 交易数 < 30 | L3 | 缩短指标周期、降低入场阈值、切更小周期、放宽过滤、检查 warmup |
| 衰减比 < 0.5 | L3 | 缩小参数空间、增加 WF 窗口数、简化策略、用中位数参数、检查前视 |
### 验收标准 (盈利优先三层)
> ⚠️ **设计原则**:策略的最终目的是赚钱,不是为了优化指标而优化指标。盈利性(PF/收益/期望)是 L1 必须层,先确认真赚钱再看风险/健壮性。避免"Sharpe=1.06 但年化 2.5%"这种假阳性,也避免"IS/OOS 都亏但衰减比 3.0"这种误导性通过。
| 层级 | 标准 | 阈值 | 性质 |
|------|------|------|------|
| **L1 盈利性** | OOS 盈利因子 | ≥ 1.3 | 必须(任一失败即拒收) |
| | OOS 净收益率 | > 0% | 必须 |
| | OOS 每笔期望 | > 0 | 必须 |
| **L2 风险可控** | OOS 最大回撤 | ≤ 20% | 应该 |
| **L3 健壮性** | OOS 夏普比率 | ≥ 0.7 | 建议(参考性,已放宽原 1.0) |
| | OOS 总交易数 | ≥ 30 | 建议 |
| | IS/OOS 衰减比 | ≥ 0.5 | 建议(注:IS 也亏时高衰减比无意义) |
> 💡 详见 [docs/策略自动化框架.md](docs/策略自动化框架.md) 的"验收检查"章节。
### 策略框架 (strategies/)
所有策略继承 `Strategy` 基类,实现 3 个方法即可自动注册:
```python
from .base import Strategy, SignalResult
import raptorbt, numpy as np
class MyStrategy(Strategy):
name = "my_strategy"
def __init__(self, period: int = 14):
self.period = period
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
rsi = raptorbt.rsi(close, period=self.period)
entries = (rsi < 30).astype(bool)
exits = (rsi > 70).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(entries=entries, exits=exits, direction=1)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(initial_capital=100000.0, fees=0.001)
config.set_fixed_stop(0.02)
return config
def description(self) -> str:
return f"RSI({self.period}) 均值回归"
STRATEGY_CLASS = MyStrategy # 暴露此常量即可自动注册
```
> 详细文档见 [docs/策略自动化框架.md](docs/策略自动化框架.md)
---
## 项目结构
```
my-python-backteat/
├── app/ # 应用层 (Python)
│ ├── __init__.py # 包入口 + 版本号
│ ├── main.py # CLI 入口 (9 个子命令)
│ ├── data_loader.py # CSV 数据加载器 (M1→多周期重采样)
│ ├── indicators.py # 自定义指标库 (转发 ferro-ta 原生)
│ ├── lookahead_check.py # 前视偏差检测器 (静态 AST + 动态扰动)
│ ├── optimizer.py # 参数网格搜索优化器
│ ├── walk_forward.py # Walk-Forward 验证 + 过拟合检测
│ ├── acceptance.py # 策略验收标准
│ ├── scaffold.py # 策略模板生成器 (自带前视警告)
│ └── exporter.py # 交付包打包导出
├── strategies/ # 策略框架 (用户扩展区)
│ ├── __init__.py # 自动发现注册表
│ ├── base.py # Strategy 基类 + SignalResult
│ ├── sma_cross.py # 示例: SMA 交叉
│ ├── rsi_mean_reversion.py # 示例: RSI 均值回归
│ ├── sar_adx_cci.py # 示例: SAR+ADX+CCI
│ └── atr_stop_rr.py # 示例: ATR 止损 + 风险回报
├── data/ # CSV 数据目录 (用户放置, gitignore)
├── docs/ # 文档
│ ├── RaptorBT使用手册.md
│ ├── Mt5Bridge使用指南.md
│ └── 策略自动化框架.md
├── src/ # RaptorBT 引擎 (Rust)
├── python/raptorbt/ # PyO3 Python 绑定
├── benches/ # Rust 基准测试
├── tests/ # Rust 单元测试
├── vendor/ # 第三方源码依赖 ★必须随项目提交 (保证可移植)
│ ├── README.md # 依赖管理说明
│ └── ferro-ta-main/ # ferro-ta v1.2.0 — 80+ 指标的 Rust 原生实现
├── backtest_output/ # 运行时输出 (gitignore)
├── deliverables/ # 交付包 (运行时创建, gitignore)
├── Cargo.toml # Rust 依赖 (通过 path 引用 vendor/ferro-ta-main/)
├── pyproject.toml # Python 项目配置
├── requirements.txt # Python 依赖
└── README.md
```
**分层职责**
- **引擎层** (`src/`, `python/raptorbt/`)Rust 高性能回测核心 + Python 绑定
- **应用层** (`app/`):CLI、优化器、验证器、导出器、数据加载器、前视检测器
- **策略层** (`strategies/`):策略定义与自动注册
- **文档层** (`docs/`):使用手册与指南
- **依赖层** (`vendor/`):第三方源码依赖(ferro-ta-main),随项目提交以保证换电脑即可编译
> ⚠️ **可移植性**`vendor/ferro-ta-main/` 是 `ferro_ta_core` 的源码依赖(在 [Cargo.toml](Cargo.toml) 中通过 `path = "vendor/ferro-ta-main/crates/ferro_ta_core"` 引用),**必须随项目提交**。换电脑后无需联网拉取外部 crate,直接 clone 即可编译。详见 [vendor/README.md](vendor/README.md)。
---
## 技术指标
RaptorBT 提供 **80+ 个技术指标**,其中 12 个为原版指标,其余 68 个来自 ferro-ta 指标库。所有指标均以原生 Rust 实现,接受 NumPy `float64` 数组并返回 NumPy 数组。预热期返回 NaN。
### 原版指标 (12 个)
| 类别 | 指标 |
|------|------|
| **趋势** | SMA、EMA、Supertrend |
| **动量** | RSI、MACD、Stochastic |
| **波动率** | ATR、Bollinger Bands |
| **强度** | ADX |
| **成交量** | VWAP |
| **滚动** | Rolling Min、Rolling Max |
### ferro-ta 扩展指标 (68 个)
#### 趋势类 (15 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `wma` | `wma(data, period)` | `ndarray` | 加权移动平均 |
| `dema` | `dema(data, period)` | `ndarray` | 双重指数移动平均 |
| `tema` | `tema(data, period)` | `ndarray` | 三重指数移动平均 |
| `kama` | `kama(data, period)` | `ndarray` | Kaufman 自适应移动平均 |
| `t3` | `t3(data, period=5, vfactor=0.7)` | `ndarray` | Tillson T3 移动平均 |
| `trima` | `trima(data, period)` | `ndarray` | 三角移动平均 |
| `midpoint` | `midpoint(data, period)` | `ndarray` | 周期内中点值 |
| `midprice` | `midprice(high, low, period)` | `ndarray` | 周期内最高/最低均价 |
| `sar` | `sar(high, low, acceleration=0.02, maximum=0.2)` | `ndarray` | 抛物线 SAR |
| `hull_ma` | `hull_ma(data, period)` | `ndarray` | Hull 移动平均 |
| `donchian` | `donchian(high, low, period)` | `(upper, middle, lower)` | 唐奇安通道 |
| `choppiness_index` | `choppiness_index(high, low, close, period=14)` | `ndarray` | 混沌指标 (0=趋势, 100=震荡) |
| `chandelier_exit` | `chandelier_exit(high, low, close, period=22, multiplier=3.0)` | `(long_exit, short_exit)` | 吊灯止损 (ATR 追踪) |
| `ichimoku` | `ichimoku(high, low, close, tenkan=9, kijun=26, senkou_b=52, displacement=26)` | `(tenkan, kijun, senkou_a, senkou_b, chikou)` | 一目均衡表 |
| `pivot_points` | `pivot_points(high, low, close, method="classic")` | `(pivot, r1, s1, r2, s2)` | 枢轴点 (classic/fibonacci/camarilla) |
#### 动量类 (14 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `cci` | `cci(high, low, close, period)` | `ndarray` | 商品通道指数 |
| `willr` | `willr(high, low, close, period)` | `ndarray` | 威廉指标 (-100~0) |
| `roc` | `roc(data, period)` | `ndarray` | 变化率 |
| `mom` | `mom(data, period)` | `ndarray` | 动量 |
| `cmo` | `cmo(data, period)` | `ndarray` | 钱德动量振荡器 |
| `trix` | `trix(data, period)` | `ndarray` | 三重指数平滑变化率 |
| `stochrsi` | `stochrsi(data, timeperiod=14, fastk_period=5, fastd_period=3)` | `(fastk, fastd)` | 随机 RSI (0~100) |
| `aroon` | `aroon(high, low, period)` | `(up, down)` | Aroon 上升/下降 (0~100) |
| `aroonosc` | `aroonosc(high, low, period)` | `ndarray` | Aroon 振荡器 (-100~100) |
| `bop` | `bop(open, high, low, close)` | `ndarray` | 力量平衡 (-1~1) |
| `ultosc` | `ultosc(high, low, close, period1=7, period2=14, period3=28)` | `ndarray` | 终极振荡器 (0~100) |
| `ppo` | `ppo(data, fastperiod=12, slowperiod=26, signalperiod=9)` | `(line, signal, hist)` | 百分比价格振荡器 |
| `apo` | `apo(data, fastperiod=12, slowperiod=26)` | `ndarray` | 绝对价格振荡器 |
| `adx_all` | `adx_all(high, low, close, period)` | `(adx, +di, -di)` | ADX + DI+ + DI- |
#### 波动率类 (5 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `natr` | `natr(high, low, close, period)` | `ndarray` | 归一化 ATR (%) |
| `trange` | `trange(high, low, close)` | `ndarray` | 真实波幅 |
| `stddev` | `stddev(data, period, nbdev=1.0)` | `ndarray` | 标准差 |
| `var` | `var(data, period, nbdev=1.0)` | `ndarray` | 方差 |
| `atr` | `atr(high, low, close, period)` | `ndarray` | 平均真实波幅 (原版) |
#### 强度类 (3 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `adx` | `adx(high, low, close, period)` | `ndarray` | ADX (0~100) (原版) |
| `plus_di` | `plus_di(high, low, close, period)` | `ndarray` | +DI 方向指标 |
| `minus_di` | `minus_di(high, low, close, period)` | `ndarray` | -DI 方向指标 |
| `adxr` | `adxr(high, low, close, period)` | `ndarray` | ADX 评级 |
#### 成交量类 (5 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `ad` | `ad(high, low, close, volume)` | `ndarray` | 累积/派发线 |
| `adosc` | `adosc(high, low, close, volume, fastperiod=3, slowperiod=10)` | `ndarray` | 累积/派发振荡器 |
| `obv` | `obv(close, volume)` | `ndarray` | 能量潮 |
| `mfi` | `mfi(high, low, close, volume, period)` | `ndarray` | 资金流量指数 (0~100) |
| `vwma` | `vwma(data, volume, period=20)` | `ndarray` | 成交量加权移动平均 |
#### 价格变换类 (4 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `typprice` | `typprice(high, low, close)` | `ndarray` | 典型价格 (H+L+C)/3 |
| `medprice` | `medprice(high, low)` | `ndarray` | 中间价格 (H+L)/2 |
| `avgprice` | `avgprice(open, high, low, close)` | `ndarray` | 平均价格 (O+H+L+C)/4 |
| `wclprice` | `wclprice(high, low, close)` | `ndarray` | 加权收盘价 (H+L+C*2)/4 |
#### 统计类 (7 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `linearreg` | `linearreg(data, period)` | `ndarray` | 线性回归 |
| `linearreg_slope` | `linearreg_slope(data, period)` | `ndarray` | 线性回归斜率 |
| `linearreg_angle` | `linearreg_angle(data, period)` | `ndarray` | 线性回归角度 |
| `linearreg_intercept` | `linearreg_intercept(data, period)` | `ndarray` | 线性回归截距 |
| `tsf` | `tsf(data, period)` | `ndarray` | 时间序列预测 |
| `beta` | `beta(data0, data1, period)` | `ndarray` | Beta 系数 |
| `correl` | `correl(data0, data1, period)` | `ndarray` | 相关系数 |
#### Hilbert 变换 (6 个)
基于希尔伯特变换的周期分析工具(需要至少 32 根 K 线):
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `ht_trendline` | `ht_trendline(data)` | `ndarray` | 希尔伯特瞬时趋势线 |
| `ht_dcperiod` | `ht_dcperiod(data)` | `ndarray` | 主导周期周期 |
| `ht_dcphase` | `ht_dcphase(data)` | `ndarray` | 主导周期相位(度) |
| `ht_phasor` | `ht_phasor(data)` | `(in_phase, quadrature)` | 相量分量 |
| `ht_sine` | `ht_sine(data)` | `(sine, lead_sine)` | 正弦波(含超前信号) |
| `ht_trendmode` | `ht_trendmode(data)` | `ndarray[i32]` | 趋势/周期模式 (1=趋势, 0=周期) |
#### 市场状态检测 (4 个)
用于判断当前市场处于趋势或震荡状态,以及检测结构性突变:
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `regime_adx` | `regime_adx(adx, threshold=25.0)` | `ndarray[i8]` | 基于 ADX 的趋势/震荡标签 (1=趋势, 0=震荡, -1=预热) |
| `regime_combined` | `regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=2.0)` | `ndarray[i8]` | ADX+ATR 组合判断 |
| `detect_breaks_cusum` | `detect_breaks_cusum(data, window, threshold, slack)` | `ndarray[i8]` | CUSUM 结构性突变检测 (1=突变点) |
| `rolling_variance_break` | `rolling_variance_break(data, short_window, long_window, threshold)` | `ndarray[i8]` | 滚动方差比突变检测 (1=突变点) |
#### 投资组合工具 (6 个)
跨序列分析工具,用于配对交易、风险管理、相对强度计算:
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `rolling_beta` | `rolling_beta(asset, benchmark, window)` | `ndarray` | 滚动 Beta 系数 |
| `drawdown_series` | `drawdown_series(equity)` | `(dd_series, max_dd)` | 回撤序列 + 最大回撤 |
| `zscore_series` | `zscore_series(data, window)` | `ndarray` | 滚动 Z-Score |
| `relative_strength` | `relative_strength(asset_returns, benchmark_returns)` | `ndarray` | 相对强度 (excess return 风格) |
| `spread` | `spread(a, b, hedge)` | `ndarray` | 价差序列 (a - hedge*b) |
| `ratio` | `ratio(a, b)` | `ndarray` | 比率序列 (a/b) |
#### Tick 微结构函数 (8 个)
| 函数 | 签名 | 返回 | 说明 |
|------|------|------|------|
| `tick_spread_pct` | `tick_spread_pct(bid, ask)` | `ndarray` | 每 Tick 买卖价差百分比 |
| `buy_sell_imbalance_delta` | `buy_sell_imbalance_delta(buy_cum, sell_cum)` | `ndarray` | 每 Tick 买卖失衡 |
| `return_window` | `return_window(timestamps_ns, ltp, window_seconds=60.0)` | `ndarray` | 时间窗口回看收益率 |
| `realized_vol_rolling` | `realized_vol_rolling(timestamps_ns, ltp, window_seconds=300.0)` | `ndarray` | 滚动已实现波动率 |
| `oi_position_pct` | `oi_position_pct(oi, oi_day_high, oi_day_low)` | `ndarray` | OI 位置百分比 [0, 100] |
| `tick_velocity` | `tick_velocity(timestamps_ns, window_seconds=60.0)` | `ndarray` | Tick 速度 (ticks/min) |
| `compute_tick_entry_signals` | `compute_tick_entry_signals(spread_pct, bsi_delta, return_1m, ...)` | `ndarray[bool]` | Tick 入场信号 |
| `compute_tick_exit_signals` | `compute_tick_exit_signals(timestamps_ns, eod_exit_time_ns=0)` | `ndarray[bool]` | Tick 出场信号 |
### 用法示例
```python
import raptorbt
import numpy as np
close = np.array([...], dtype=np.float64)
high = np.array([...], dtype=np.float64)
low = np.array([...], dtype=np.float64)
open_ = np.array([...], dtype=np.float64)
volume = np.array([...], dtype=np.float64)
# 趋势
sma20 = raptorbt.sma(close, 20)
hull_ma = raptorbt.hull_ma(close, 14)
donchian_upper, donchian_middle, donchian_lower = raptorbt.donchian(high, low, 20)
ichimoku = raptorbt.ichimoku(high, low, close)
# 动量
rsi14 = raptorbt.rsi(close, 14)
macd_line, macd_signal, macd_hist = raptorbt.macd(close, 12, 26, 9)
cci20 = raptorbt.cci(high, low, close, 20)
ppo_line, ppo_signal, ppo_hist = raptorbt.ppo(close)
# 波动率
atr14 = raptorbt.atr(high, low, close, 14)
natr14 = raptorbt.natr(high, low, close, 14)
bb_upper, bb_middle, bb_lower = raptorbt.bollinger_bands(close, 20, 2.0)
# 成交量
ad_line = raptorbt.ad(high, low, close, volume)
mfi14 = raptorbt.mfi(high, low, close, volume, 14)
# Hilbert
ht_trendline = raptorbt.ht_trendline(close)
sine, lead_sine = raptorbt.ht_sine(close)
# 市场状态
adx_vals = raptorbt.adx(high, low, close, 14)
regime = raptorbt.regime_adx(adx_vals, threshold=25.0)
# 投资组合
rolling_beta = raptorbt.rolling_beta(close, benchmark, 20)
dd_series, max_dd = raptorbt.drawdown_series(equity_curve)
```
---
## 策略类型
### 1. 单标的回测
```python
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001, slippage=0.0005)
config.set_fixed_stop(0.02) # 2% 止损
config.set_fixed_target(0.04) # 4% 止盈
result = raptorbt.run_single_backtest(
timestamps=timestamps, open=open, high=high, low=low, close=close, volume=volume,
entries=entries, exits=exits, direction=1, weight=1.0, symbol="AAPL", config=config,
instrument_config=raptorbt.PyInstrumentConfig(lot_size=1.0), # 可选
)
```
### 2. 篮子回测
多标的同步信号交易:
```python
instruments = [
(ts, o1, h1, l1, c1, v1, ent1, ext1, 1, 0.33, "AAPL"),
(ts, o2, h2, l2, c2, v2, ent2, ext2, 1, 0.33, "GOOGL"),
(ts, o3, h3, l3, c3, v3, ent3, ext3, 1, 0.34, "MSFT"),
]
result = raptorbt.run_basket_backtest(
instruments=instruments,
config=config,
sync_mode="all", # "all" | "any" | "majority" | "master"
)
```
### 3. 配对交易
做多一个标的,做空另一个:
```python
result = raptorbt.run_pairs_backtest(
leg1_timestamps=ts, leg1_open=o1, leg1_high=h1, leg1_low=l1, leg1_close=c1, leg1_volume=v1,
leg2_timestamps=ts, leg2_open=o2, leg2_high=h2, leg2_low=l2, leg2_close=c2, leg2_volume=v2,
entries=entries, exits=exits, direction=1, symbol="PAIR", config=config,
hedge_ratio=1.5, # 空头 1.5 倍
dynamic_hedge=False,
)
```
### 4. 期权回测
```python
result = raptorbt.run_options_backtest(
timestamps=ts, open=o, high=h, low=l, close=c, volume=v,
option_prices=option_premiums, entries=entries, exits=exits,
direction=1, symbol="NIFTY_CE", config=config,
option_type="call", # "call" | "put"
strike_selection="atm", # "atm" | "otm1" | "otm2" | "itm1" | "itm2"
size_type="percent", # "percent" | "contracts" | "notional" | "risk"
size_value=0.1,
lot_size=50,
)
```
### 5. 多策略回测
同一标的上组合多个策略:
```python
strategies = [
(entries_sma, exits_sma, 1, 0.4, "SMA_Cross"),
(entries_rsi, exits_rsi, 1, 0.35, "RSI_MeanRev"),
(entries_bb, exits_bb, 1, 0.25, "BB_Break"),
]
result = raptorbt.run_multi_backtest(
timestamps=ts, open=o, high=h, low=l, close=c, volume=v,
strategies=strategies,
config=config,
combine_mode="any", # "any" | "all" | "majority" | "weighted" | "independent"
)
```
### 6. 批量价差回测
Rayon 并行执行多个价差回测,GIL 释放:
```python
items = [
raptorbt.PyBatchSpreadItem(
strategy_id="straddle_24000",
legs_premiums=[call_premiums, put_premiums],
leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)],
entries=entries, exits=exits,
spread_type="straddle",
max_loss=5000.0, target_profit=3000.0,
),
]
results = raptorbt.batch_spread_backtest(
timestamps=ts, underlying_close=close,
items=items, config=config,
)
for sid, result in results:
print(f"{sid}: {result.metrics.total_return_pct:.2f}%")
```
### 7. Tick 级回测
全 Tick 分辨率模拟,无 K 线重采样:
```python
result = raptorbt.run_tick_backtest(
timestamps=timestamps_ns, # int64 纳秒
ltp=ltp_arr, bid=bid_arr, ask=ask_arr,
buy_qty_delta=buy_delta, sell_qty_delta=sell_delta,
oi=oi_arr,
entries=entry_signals, exits=exit_signals,
symbol="TICK",
initial_capital=100000.0, fees=0.001, slippage=0.0005,
stop_loss_pct=5.0, take_profit_pct=10.0,
max_hold_seconds=1800, entry_cooldown_ticks=10, max_trades=50,
)
```
> **Zerodha 数据注意**`total_buy_qty` / `total_sell_qty` 是累计值,需先转换:
> `buy_delta = np.diff(buy_cum, prepend=0).clip(min=0)`
---
## 止损与止盈
### 固定百分比
```python
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
config.set_fixed_stop(0.02) # 2% 止损
config.set_fixed_target(0.04) # 4% 止盈
```
### ATR 动态止损
```python
config.set_atr_stop(multiplier=2.0, period=14) # 2倍 ATR 止损
config.set_atr_target(multiplier=3.0, period=14) # 3倍 ATR 止盈
```
### 追踪止损
```python
config.set_trailing_stop(0.02) # 2% 追踪止损
```
### 风险回报比止盈
```python
config.set_risk_reward_target(ratio=2.0) # 2:1 风险回报比
```
### 出场原因
| 值 | 含义 |
|----|------|
| `Signal` | 策略信号出场 |
| `StopLoss` | 触发止损 |
| `TakeProfit` | 触发止盈 |
| `TrailingStop` | 触发追踪止损 |
| `EndOfData` | 数据结束 |
| `Settlement` | 期权结算 |
| `TimeExit` | 超时出场(Tick 级) |
---
## 蒙特卡洛组合模拟
```python
result = raptorbt.simulate_portfolio_mc(
returns=[ret1, ret2], # 各策略/资产的历史日收益率数组
weights=np.array([0.6, 0.4]), # 组合权重(和为 1
correlation_matrix=[ # N×N 相关系数矩阵
np.array([1.0, 0.3]),
np.array([0.3, 1.0]),
],
initial_value=100000.0,
n_simulations=10000, # 模拟路径数
horizon_days=252, # 前瞻天数
seed=42, # 随机种子
)
print(f"预期收益: {result['expected_return']:.2f}%")
print(f"亏损概率: {result['probability_of_loss']:.2%}")
print(f"VaR (95%): {result['var_95']:.2f}%")
print(f"CVaR (95%): {result['cvar_95']:.2f}%")
```
---
## 回测结果与指标
### PyBacktestResult
```python
result.metrics # PyBacktestMetrics 对象
result.equity_curve() # 权益曲线 ndarray
result.drawdown_curve() # 回撤曲线 ndarray
result.returns() # 收益率序列 ndarray
result.trades() # 交易列表 List[PyTrade]
```
### PyBacktestMetrics33 个字段)
**核心绩效**`total_return_pct``sharpe_ratio``sortino_ratio``calmar_ratio``omega_ratio`
**回撤**`max_drawdown_pct``max_drawdown_duration`
**交易统计**`total_trades``total_closed_trades``total_open_trades``winning_trades``losing_trades``win_rate_pct`
**交易绩效**`profit_factor``expectancy``sqn``avg_trade_return_pct``avg_win_pct``avg_loss_pct``best_trade_pct``worst_trade_pct`
**持仓**`avg_holding_period``avg_winning_duration``avg_losing_duration`
**连战**`max_consecutive_wins``max_consecutive_losses`
**其他**`start_value``end_value``total_fees_paid``open_trade_pnl``exposure_pct``payoff_ratio``recovery_factor`
```python
m = result.metrics
stats = m.to_dict() # 24 个常用指标的字典(带中文友好标签)
```
### PyTrade
```python
for trade in result.trades():
trade.id # 交易 ID
trade.symbol # 标的
trade.entry_idx # 入场 bar 索引
trade.exit_idx # 出场 bar 索引
trade.entry_price # 入场价
trade.exit_price # 出场价
trade.size # 仓位大小
trade.direction # 1=多, -1=空
trade.pnl # 盈亏金额
trade.return_pct # 收益率 (%)
trade.fees # 手续费
trade.exit_reason # 出场原因
```
---
## API 参考
### PyBacktestConfig
```python
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, # 初始资金
fees=0.001, # 手续费率
slippage=0.0, # 滑点
upon_bar_close=True, # K 线收盘后执行(防止前视偏差)
)
# 止损方法
config.set_fixed_stop(percent: float)
config.set_atr_stop(multiplier: float, period: int)
config.set_trailing_stop(percent: float)
# 止盈方法
config.set_fixed_target(percent: float)
config.set_atr_target(multiplier: float, period: int)
config.set_risk_reward_target(ratio: float)
```
### PyInstrumentConfig
每标的配置:
```python
inst = raptorbt.PyInstrumentConfig(
lot_size=1.0, # 最小交易单位
alloted_capital=50000.0, # 分配资金(可选)
existing_qty=None, # 现有持仓(预留)
avg_price=None, # 现有均价(预留)
)
# 可选:每标的止损/止盈覆盖
inst.set_fixed_stop(0.02)
inst.set_trailing_stop(0.03)
```
### PyBatchSpreadItem
```python
item = raptorbt.PyBatchSpreadItem(
strategy_id="straddle_24000",
legs_premiums=[call_premiums, put_premiums],
leg_configs=[("CE", 24000.0, -1, 50), ("PE", 24000.0, -1, 50)],
entries=entries, exits=exits, spread_type="straddle",
max_loss=5000.0, target_profit=3000.0,
)
```
### simulate_portfolio_mc
```python
result = raptorbt.simulate_portfolio_mc(
returns=List[np.ndarray], # 各资产日收益率 (N 个数组)
weights=np.ndarray, # 组合权重 (长度 N, 和为 1)
correlation_matrix=List[np.ndarray], # N×N 相关系数矩阵
initial_value=float, # 初始组合价值
n_simulations=int=10000, # 模拟路径数
horizon_days=int=252, # 前瞻天数
seed=int=42, # 随机种子
) -> dict
```
返回字典包含:`expected_return``probability_of_loss``var_95``cvar_95``percentile_paths``final_values`
---
## 从源码构建
大多数用户应使用 `pip install raptorbt`。要自行构建,需要 Rust 1.70+、Python 3.10+ 和 maturin
```powershell
cd raptorbt
$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe"
maturin develop --release # 开发安装到当前虚拟环境
cargo test # 运行 Rust 测试套件
```
> **注意**:如果在 Windows 上遇到「拒绝访问 (os error 5)」错误,需要设置 `CARGO` 环境变量指向 `cargo.exe` 可执行文件(而非目录)。参见 [使用手册 - 常见编译问题](https://github.com/your-repo/raptorbt/blob/main/RaptorBT使用手册.md#常见编译问题)。
### 前置依赖
| 依赖 | 版本 | 说明 |
|------|------|------|
| Rust toolchain | 1.70+ | `cargo``rustc`(从 https://rustup.rs 安装) |
| Python | 3.10+ | 与编译时 Python 版本一致 |
| maturin | latest | `pip install maturin`Rust → Python 扩展构建工具 |
| `vendor/ferro-ta-main/` | — | **已随项目提交**,提供 `ferro_ta_core` crate(80+ 指标),无需联网拉取 |
> 💡 **可移植性**`vendor/ferro-ta-main/` 是 [Cargo.toml](Cargo.toml) 中 `ferro_ta_core` 的 path 源码依赖,换电脑或部署新环境时 clone 项目即可编译,**无需担心外部 crate 源缺失**。
### 构建并安装到全局
```powershell
# 构建 whl (会自动编译 vendor/ferro-ta-main/ 下的 ferro_ta_core)
maturin build --release
# 产物: target/wheels/raptorbt-0.4.1-cp312-cp312-win_amd64.whl
# 全局 pip 安装
pip install --force-reinstall target\wheels\raptorbt-0.4.1-cp312-cp312-win_amd64.whl
```
### 开发模式(虚拟环境)
```powershell
# 在已激活的虚拟环境中编译,直接安装到 python/raptorbt/_raptorbt.*.pyd
maturin develop --release
```
> ⚠️ `maturin develop` 需要在已激活的虚拟环境(venv/conda)中运行,否则会报 "Couldn't find a virtualenv or conda environment"。若需全局安装,用 `maturin build` + `pip install` 方式。
### 验证测试
一个带种子的冒烟测试——运行两次,结果完全一致:
```python
import numpy as np
import raptorbt
np.random.seed(42)
n = 500
close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100
entries = np.zeros(n, dtype=bool); entries[::20] = True
exits = np.zeros(n, dtype=bool); exits[10::20] = True
config = raptorbt.PyBacktestConfig(initial_capital=100000, fees=0.001)
result = raptorbt.run_single_backtest(
timestamps=np.arange(n, dtype=np.int64),
open=close, high=close, low=close, close=close, volume=np.ones(n),
entries=entries, exits=exits, direction=1, weight=1.0, symbol="TEST",
config=config,
)
print(f"总收益率: {result.metrics.total_return_pct:.4f}%") # -30.6192%
print(f"夏普比率: {result.metrics.sharpe_ratio:.4f}") # -0.9086
```
---
## 版本历史
### v0.4.1
- **新增 68 个扩展指标**:集成 ferro-ta 指标库
- P0 扩展:VWMA、Donchian、Choppiness Index、Hull MA、Chandelier Exit、Ichimoku、Pivot Points
- Hilbert 变换:HT Trendline、DCPeriod、DCPhase、Phasor、Sine、TrendMode
- 市场状态检测:Regime ADX、Regime Combined、CUSUM Breaks、Variance Ratio Breaks
- 投资组合工具:Rolling Beta、Drawdown Series、Z-Score Series、Relative Strength、Spread、Ratio
- 指标总数从 12 扩展到 80+
### v0.4.0
- **Tick 级回测**:全 Tick 分辨率,无需 K 线重采样
- `TickData` 结构:`timestamps``ltp``bid``ask``buy_qty_delta``sell_qty_delta``oi`
- `ExitReason::TimeExit`:最大持仓时间超时退出
- `run_tick_backtest`Tick 原生模拟引擎
- `compute_tick_entry_signals`:从特征数组计算入场信号
- `compute_tick_exit_signals`:基于时间的出场信号
- `tick_spread_pct``buy_sell_imbalance_delta``return_window``realized_vol_rolling``oi_position_pct``tick_velocity`
- 公开 `compute_backtest_metrics` 函数
### v0.3.4
- 单腿期权价差:`LongCall``LongPut``NakedCall``NakedPut`
- `ExitReason::Settlement`:期权到期结算退出
- `leg_expiry_timestamps`:每条腿的到期时间追踪
### v0.3.3
- `batch_spread_backtest`:通过 Rayon 并行运行多个价差回测
- `PyBatchSpreadItem`:批量价差回测项定义
- GIL 释放,最大 Python 并发
### v0.3.2
- `payoff_ratio` 指标:平均盈利交易收益 / 平均亏损交易收益(绝对值)
- `recovery_factor` 指标:净利润 / 最大回撤(绝对值)
### v0.3.1
- 蒙特卡洛组合模拟(`simulate_portfolio_mc`
- 几何布朗运动 + Cholesky 分解,Rayon 并行
### v0.3.0
- `PyInstrumentConfig`:每标的的配置(lot_size、分配资金、止损/止盈覆盖)
- 仓位大小正确取整到 lot_size 的倍数
### v0.2.2
- 导出 `run_spread_backtest``rolling_min``rolling_max`
### v0.2.1
- 添加 `rolling_min``rolling_max`LLV / HHV
### v0.2.0
- 多腿价差回测(straddle、strangle、vertical、iron condor、iron butterfly、butterfly、calendar、diagonal
- 会话跟踪器(NSE 股票、MCX 商品、CDS 货币)
- `StreamingMetrics`:权益/回撤追踪、交易记录、`finalize()`
### v0.1.0
- 初始版本
- 5 种策略类型、30+ 绩效指标、10 个技术指标
- 止损管理:固定、ATR、追踪
- 止盈管理:固定、ATR、风险回报
- PyO3 Python 绑定
---
## 许可证
MIT License - 详见 [LICENSE](LICENSE)
+20
View File
@@ -0,0 +1,20 @@
"""RaptorBT 策略应用层
包含 CLI 入口、数据加载、指标库、优化器、Walk-Forward 验证、
验收检查、模板生成、交付导出等应用层模块。
入口:
python -m app.main <command>
模块:
main — CLI 入口 (list/run/compare/optimize/walkforward/validate/scaffold/deliver)
data — Mt5Bridge 数据加载
indicators — 自定义指标库 (转发到 ferro-ta 原生实现)
optimizer — 策略参数网格搜索
walk_forward — 滚动 IS/OOS 验证 + 过拟合检测
acceptance — 配置化验收清单
scaffold — 策略模板生成器
exporter — 策略交付包打包导出
"""
__version__ = "0.5.0"
+388
View File
@@ -0,0 +1,388 @@
"""
策略验收标准 — 盈利优先的三层量化判断
设计哲学:
策略的最终目的是赚钱, 不是为了优化指标而优化指标。
好的夏普不等于赚钱 (高波动率 0 收益也能算出夏普), 但赚钱的策略
一定有正向期望和大于 1 的盈利因子。因此本验收采用"盈利优先"三层结构:
L1 盈利性 (必须, 任一失败即拒收):
- OOS 盈利因子 ≥ 1.3 (总盈利比总亏损多 30%)
- OOS 净收益率 > 0% (样本外真的赚到钱)
- OOS 每笔期望 > 0 (平均每单为正期望)
L2 风险可控 (应该):
- OOS 最大回撤 ≤ 20% (放宽原 15%, 赚钱策略可能回撤更大)
L3 健壮性 (建议, 参考性指标):
- OOS 夏普比率 ≥ 0.7 (放宽原 1.0, 作为参考而非硬指标)
- OOS 总交易数 ≥ 30 (统计显著性)
- IS/OOS 衰减比 ≥ 0.5 (非过拟合)
判定逻辑:
L1 全过是必要条件, L1 任一失败 → 立即拒收, L2/L3 不再掩盖 L1 问题。
总判定 passed = L1 全过 AND L2 全过 AND L3 全过 (保持严格)。
但分层让 AI agent 能一眼看出"问题严重程度", 优先解决 L1。
用法:
from acceptance import StrategyAcceptance
checker = StrategyAcceptance()
report = checker.check(wf_result)
print(report.summary())
if report.passed:
print("策略通过验收, 可交付")
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
# 默认验收标准 — 盈利优先三层结构
ACCEPTANCE_DEFAULTS = {
# L1 盈利性 (必须): 不赚钱的策略直接拒收, 不让 Sharpe/衰减比等健壮性
# 指标掩盖"稳定亏损"假象 (例: IS/OOS 都亏, 衰减比反而很高)
"oos_profit_factor_min": 1.3, # OOS 盈利因子下限 (总盈利/总亏损)
"oos_return_min_pct": 0.0, # OOS 平均收益率下限 (真赚到钱)
"oos_expectancy_min": 0.0, # OOS 每笔期望值下限 (平均每单为正)
# L2 风险可控 (应该): 放宽阈值, 让能赚钱但回撤稍大的策略通过
"oos_max_drawdown_max_pct": 20.0, # OOS 最大回撤上限 (放宽原 15%)
# L3 健壮性 (建议): 参考性指标, 不再作为拒收主力
"oos_sharpe_min": 0.7, # OOS 夏普比率下限 (放宽原 1.0, 降为参考)
"oos_total_trades_min": 30, # OOS 总交易数下限 (统计显著性)
"is_oos_decay_min": 0.5, # IS/OOS 衰减比下限 (非过拟合)
}
# 三层定义 (用于输出和 AI agent 决策)
LAYERS = {
"L1": {
"name": "盈利性",
"level": "必须",
"description": "策略是否真的赚钱, 任一失败即拒收",
},
"L2": {
"name": "风险可控",
"level": "应该",
"description": "回撤是否在可接受范围",
},
"L3": {
"name": "健壮性",
"level": "建议",
"description": "统计显著性和非过拟合, 参考性指标",
},
}
@dataclass
class CriterionResult:
"""单条验收标准的结果"""
name: str # 标准名称
layer: str # 所属层级: "L1" / "L2" / "L3"
threshold: float # 阈值
actual: float # 实际值
passed: bool # 是否通过
description: str = "" # 人类可读说明
suggestions: list = field(default_factory=list) # 失败时的修复建议
# 失败诊断建议表 — 按层分级
# L1 失败: 根本性调整 (换策略逻辑/换市场/换周期), 不是小修小补
# L2 失败: 调整风控 (止损/仓位/追踪)
# L3 失败: 统计性问题 (样本不足/过拟合)
_SUGGESTIONS = {
# ── L1 盈利性: 不赚钱的根本原因 ──────────────────────
"OOS 盈利因子": [
"策略逻辑本身可能不盈利, 重新审视入场/出场条件是否真的捕捉到正向期望",
"切换策略类型 (趋势跟踪在震荡市会持续亏 PF<1, 均值回归在趋势市会亏)",
"切换品种或周期 (当前 XAUUSD M5 可能不适合本策略逻辑)",
"检查止损过紧是否被频繁扫损 (PF 低的常见原因是止损<ATR, 噪音打掉止损)",
"尝试反向信号 (若 PF 明显 <1, 反向可能 PF>1)",
],
"OOS 净收益率": [
"策略在样本外是亏损的, 首先检查 IS 是否也亏损 (若 IS 也亏, 是策略逻辑问题而非过拟合)",
"评估交易成本是否吃掉所有收益 (尝试降低 fees/slippage 看是否转正, 若转正说明逻辑太边缘)",
"减少交易频率 (提高入场门槛, 只做高确信度信号, 降低成本侵蚀)",
"切换到更顺势的品种/周期 (震荡市做趋势必亏)",
"反向信号 (若平均收益明显为负, 反向可能为正)",
],
"OOS 每笔期望": [
"每笔平均收益为负, 优先检查胜率×盈亏比是否 >1 (期望 = 胜率×平均盈利 - 败率×平均亏损)",
"若胜率高但期望为负, 是盈亏比失衡 (盈利单太小, 放大止盈或加追踪止损)",
"若胜率低且期望为负, 是入场质量差 (增加过滤条件提高胜率)",
"止损过紧会同时降低胜率和期望, 评估是否被噪音扫损 (放宽止损或换 ATR 止损)",
"切换策略逻辑 (期望为负通常意味着信号方向与市场不匹配)",
],
# ── L2 风险可控 ──────────────────────────────────────
"OOS 最大回撤": [
"收紧止损比例 (如 2%→1.5%)",
"启用 ATR 动态止损 (基于波动率自适应)",
"启用追踪止损锁定利润",
"降低仓位 (减少单笔风险暴露)",
"增加趋势过滤, 避免逆势交易",
],
# ── L3 健壮性 ────────────────────────────────────────
"OOS 夏普比率": [
"放宽止损/止盈比例 (当前可能止损过紧导致频繁止损)",
"增加趋势过滤条件 (如 ADX>25 才交易, 避免震荡市亏损)",
"切换到更大周期 (如 H1→H4, 减少噪音)",
"增加信号过滤条件 (如要求成交量放大才入场)",
"注意: 夏普已降为参考指标, L1 全过时即使夏普略低也可接受",
],
"OOS 总交易数": [
"缩短指标周期 (如 RSI 14→7, 增加信号频率)",
"降低入场阈值 (如 RSI<30 → RSI<35)",
"切换到更小周期 (如 H1→M15)",
"放宽信号过滤条件",
"检查 warmup_bars 是否过大 (导致 OOS 窗口被吃掉)",
],
"IS/OOS 衰减比": [
"参数过拟合: 缩小参数空间 (如 fast=5,10,15 → fast=8,10,12)",
"增加 walk-forward 窗口数 (更小的 train_size/test_size)",
"简化策略逻辑 (减少可调参数数量)",
"增加正则化: 用中位数参数而非最优参数",
"检查是否使用了未来数据 (python -m app.main check --strategy XXX --dynamic)",
],
}
@dataclass
class AcceptanceReport:
"""验收报告 — 分层呈现"""
passed: bool # 总体是否通过
criteria: list = field(default_factory=list) # 各标准结果 (按 L1→L2→L3 顺序)
l1_passed: bool = True # L1 盈利性是否全过 (关键标志)
l2_passed: bool = True # L2 风险可控是否全过
l3_passed: bool = True # L3 健壮性是否全过
def summary(self) -> str:
# 顶部结论 (突出 L1)
if not self.l1_passed:
verdict = "❌ 未通过 (L1 盈利性未达标 — 策略不赚钱, 无需看 L2/L3)"
elif not self.l2_passed:
verdict = "❌ 未通过 (L2 风险可控未达标)"
elif not self.l3_passed:
verdict = "❌ 未通过 (L3 健壮性参考指标未达标)"
else:
verdict = "✅ 通过 (策略在样本外真的赚到钱, 且风险可控)"
lines = [
f"验收结果: {verdict}",
f"{'' * 70}",
]
# 按层分组输出
current_layer = None
for c in self.criteria:
if c.layer != current_layer:
current_layer = c.layer
layer_info = LAYERS[c.layer]
layer_passed = (c.layer == "L1" and self.l1_passed) or \
(c.layer == "L2" and self.l2_passed) or \
(c.layer == "L3" and self.l3_passed)
status = "" if layer_passed else ""
lines.append(
f" [{c.layer} {layer_info['name']}] {layer_info['level']} "
f"{layer_info['description']} {status}"
)
lines.append(f" {'' * 66}")
status = "" if c.passed else ""
lines.append(
f" {c.name:<18} 阈值={c.threshold:>8.2f} "
f"实际={c.actual:>8.2f} {status}"
)
if c.description:
lines.append(f"{c.description}")
# 失败时附加修复建议
if not c.passed and c.suggestions:
lines.append(f" └ 修复建议:")
for i, s in enumerate(c.suggestions, 1):
lines.append(f" {i}. {s}")
lines.append(f"{'' * 70}")
return "\n".join(lines)
def to_dict(self) -> dict:
"""转为可 JSON 序列化的字典"""
return {
"passed": self.passed,
"l1_passed": self.l1_passed,
"l2_passed": self.l2_passed,
"l3_passed": self.l3_passed,
"verdict": self._verdict_text(),
"criteria": [
{
"name": c.name,
"layer": c.layer,
"layer_name": LAYERS[c.layer]["name"],
"level": LAYERS[c.layer]["level"],
"threshold": c.threshold,
"actual": c.actual,
"passed": c.passed,
"description": c.description,
"suggestions": c.suggestions if not c.passed else [],
}
for c in self.criteria
],
}
def _verdict_text(self) -> str:
if not self.l1_passed:
return "L1 盈利性未达标 — 策略不赚钱, 无需看 L2/L3"
if not self.l2_passed:
return "L2 风险可控未达标"
if not self.l3_passed:
return "L3 健壮性参考指标未达标"
return "全部通过 — 策略在样本外真的赚到钱, 且风险可控"
class StrategyAcceptance:
"""
策略验收检查器 — 盈利优先三层
参数:
criteria: 验收标准字典, None 时使用 ACCEPTANCE_DEFAULTS
用法:
checker = StrategyAcceptance()
report = checker.check(wf_result)
if report.passed:
print("策略通过验收, 可以交付")
elif not report.l1_passed:
print("策略不赚钱, 必须重新设计而非调参")
"""
def __init__(self, criteria: Optional[dict] = None):
self.criteria = criteria or ACCEPTANCE_DEFAULTS.copy()
def check(self, wf_result) -> AcceptanceReport:
"""
检查 walk-forward 结果是否满足所有验收标准
参数:
wf_result: WalkForwardResult 对象
返回:
AcceptanceReport (分层结构)
"""
results = []
l1_passed = True
l2_passed = True
l3_passed = True
# ════════════════════════════════════════════════════
# L1 盈利性 (必须) — 优先检查, 不赚钱直接拒收
# ════════════════════════════════════════════════════
# L1.1 OOS 盈利因子 (Profit Factor = 总盈利/总亏损)
pf = wf_result.oos_profit_factor_avg
if np.isnan(pf):
pf = 0.0
threshold = self.criteria["oos_profit_factor_min"]
passed = pf >= threshold
results.append(CriterionResult(
"OOS 盈利因子", "L1", threshold, pf, passed,
f"总盈利/总亏损 = {pf:.2f}, "
f"{'' if passed else '<'} 阈值 {threshold} "
f"({'正期望' if passed else '亏损或勉强盈利'})",
suggestions=[] if passed else _SUGGESTIONS["OOS 盈利因子"],
))
l1_passed &= passed
# L1.2 OOS 净收益率 (真赚到钱)
oos_return = wf_result.oos_return_avg
if np.isnan(oos_return):
oos_return = -999.0
threshold = self.criteria["oos_return_min_pct"]
passed = oos_return > threshold
results.append(CriterionResult(
"OOS 净收益率", "L1", threshold, oos_return, passed,
f"OOS 平均收益 {oos_return:.2f}% "
f"{'盈利' if passed else '亏损'}",
suggestions=[] if passed else _SUGGESTIONS["OOS 净收益率"],
))
l1_passed &= passed
# L1.3 OOS 每笔期望值 (平均每单为正)
exp_val = wf_result.oos_expectancy_avg
if np.isnan(exp_val):
exp_val = -999.0
threshold = self.criteria["oos_expectancy_min"]
passed = exp_val > threshold
results.append(CriterionResult(
"OOS 每笔期望", "L1", threshold, exp_val, passed,
f"每笔平均期望 {exp_val:.4f} "
f"{'为正' if passed else '为负/为零'}",
suggestions=[] if passed else _SUGGESTIONS["OOS 每笔期望"],
))
l1_passed &= passed
# ════════════════════════════════════════════════════
# L2 风险可控 (应该)
# ════════════════════════════════════════════════════
# L2.1 OOS 最大回撤 (放宽到 20%)
oos_dd = abs(wf_result.oos_max_drawdown_avg)
if np.isnan(oos_dd):
oos_dd = 999.0
threshold = self.criteria["oos_max_drawdown_max_pct"]
passed = oos_dd <= threshold
results.append(CriterionResult(
"OOS 最大回撤", "L2", threshold, oos_dd, passed,
f"{'低于' if passed else '超过'}最大回撤限制 {threshold}%",
suggestions=[] if passed else _SUGGESTIONS["OOS 最大回撤"],
))
l2_passed &= passed
# ════════════════════════════════════════════════════
# L3 健壮性 (建议, 参考性指标)
# ════════════════════════════════════════════════════
# L3.1 OOS 夏普比率 (放宽到 0.7, 降为参考)
oos_sharpe = wf_result.oos_sharpe_avg
if np.isnan(oos_sharpe):
oos_sharpe = -999.0
threshold = self.criteria["oos_sharpe_min"]
passed = oos_sharpe >= threshold
results.append(CriterionResult(
"OOS 夏普比率", "L3", threshold, oos_sharpe, passed,
f"{'达到' if passed else '未达到'}参考夏普要求 {threshold} "
f"(已降为参考指标, L1 全过时可接受略低)",
suggestions=[] if passed else _SUGGESTIONS["OOS 夏普比率"],
))
l3_passed &= passed
# L3.2 OOS 总交易数 (统计显著性)
total_trades = wf_result.oos_trades_total
threshold = self.criteria["oos_total_trades_min"]
passed = total_trades >= threshold
results.append(CriterionResult(
"OOS 总交易数", "L3", threshold, float(total_trades), passed,
f"{'达到' if passed else '不足'}最小交易数 {threshold} (统计显著性)",
suggestions=[] if passed else _SUGGESTIONS["OOS 总交易数"],
))
l3_passed &= passed
# L3.3 IS/OOS 衰减比 (过拟合检测)
decay = wf_result.decay_ratio
if np.isnan(decay):
decay = 0.0
threshold = self.criteria["is_oos_decay_min"]
passed = decay >= threshold
overfit_str = "非过拟合" if passed else "过拟合风险"
results.append(CriterionResult(
"IS/OOS 衰减比", "L3", threshold, decay, passed,
f"OOS/IS = {decay:.1%}, {overfit_str} "
f"(注意: 若 IS 也是亏损, 高衰减比不代表策略好)",
suggestions=[] if passed else _SUGGESTIONS["IS/OOS 衰减比"],
))
l3_passed &= passed
return AcceptanceReport(
passed=bool(l1_passed and l2_passed and l3_passed),
criteria=results,
l1_passed=bool(l1_passed),
l2_passed=bool(l2_passed),
l3_passed=bool(l3_passed),
)
+336
View File
@@ -0,0 +1,336 @@
"""
CSV 数据加载器 — 支持 MT5 History Center 格式 (quant data manager 导出)
功能:
- 加载 M1 CSV (无表头 9 列格式)
- 多周期重采样 (M5/M15/M30/H1/H4/D1)
- 品种自动识别 (从文件名)
- spread 自动转 slippage (百分比)
- 时区处理 (UTC+2/UTC-3 等, 从文件名提取)
CSV 格式 (MT5 History Center 标准):
date,time,open,high,low,close,vol,vol_real,spread
2021.07.06,01:00,1791.37,1791.37,1790.55,1791.27,25850.0,25850.0,98
用法:
from app.data_loader import load_csv, find_csv_for_symbol, compute_slippage
# 自动查找 + 加载
path = find_csv_for_symbol("XAUUSD")
df = load_csv(path, timeframe="H1")
# 显式指定文件
df = load_csv("data/XAUUSD.csv", symbol="XAUUSD", timeframe="H1")
# 计算 slippage (注入 PyBacktestConfig)
slippage = compute_slippage(df["spread"], df["close"], "XAUUSD")
"""
from __future__ import annotations
import os
import re
from datetime import timedelta, timezone
from typing import Optional
import numpy as np
import pandas as pd
# ============================================================================
# 品种配置 — 点值 (最小变动单位)
# ============================================================================
SYMBOL_TICK_SIZE = {
"XAUUSD": 0.01, # 黄金: 0.01 美元/点
"XAGUSD": 0.001, # 白银
"EURUSD": 0.00001, # 5 位报价
"GBPUSD": 0.00001,
"AUDUSD": 0.00001,
"NZDUSD": 0.00001,
"USDCAD": 0.00001,
"USDCHF": 0.00001,
"EURJPY": 0.001, # 3 位报价 (JPY 货币对)
"USDJPY": 0.001,
"GBPJPY": 0.001,
"AUDJPY": 0.001,
"EURGBP": 0.00001,
"EURAUD": 0.00001,
"EURCHF": 0.00001,
"GBPCHF": 0.00001,
"CHFJPY": 0.001,
}
# MT5 周期 → pandas 频率
TIMEFRAME_MAP = {
"M1": "1min",
"M5": "5min",
"M15": "15min",
"M30": "30min",
"H1": "1h",
"H2": "2h",
"H4": "4h",
"H6": "6h",
"H8": "8h",
"H12": "12h",
"D1": "1D",
"W1": "1W",
"MN1": "1ME",
}
# ============================================================================
# 文件名解析
# ============================================================================
# 已知品种列表 (按长度降序匹配, 避免 GBPUSD 匹配 USD)
_KNOWN_SYMBOLS = sorted(SYMBOL_TICK_SIZE.keys(), key=len, reverse=True)
def extract_symbol_from_filename(filename: str) -> Optional[str]:
"""
从文件名提取品种代码
文件名示例:
2021.7.6-2026.07.03M1XAUUSD_TICK_UTCPlus02.csv → XAUUSD
2021.7.6-2026.07.03_M1USDJPY_TICK_UTCPlus02.csv → USDJPY
2021.7.6-2026.07.03EURUSD_M1_TICK_UTCPlus02.csv → EURUSD
"""
name = os.path.basename(filename).upper()
for sym in _KNOWN_SYMBOLS:
if sym in name:
return sym
return None
def extract_timezone_from_filename(filename: str) -> timezone:
"""
从文件名提取时区
文件名示例:
...UTCPlus02 → UTC+2 (MT5 标准服务器时间)
...UTCPlus03 → UTC+3 (夏令时)
...UTCMINUS05 → UTC-5
"""
name = os.path.basename(filename).upper()
m = re.search(r"UTCPLUS(\d+)", name)
if m:
return timezone(timedelta(hours=int(m.group(1))))
m = re.search(r"UTCMINUS(\d+)", name)
if m:
return timezone(timedelta(hours=-int(m.group(1))))
return timezone.utc
# ============================================================================
# CSV 加载
# ============================================================================
def load_csv(
file_path: str,
symbol: Optional[str] = None,
timeframe: str = "M1",
drop_weekend: bool = False,
) -> pd.DataFrame:
"""
加载 MT5 History Center 格式 CSV
参数:
file_path: CSV 文件路径
symbol: 品种代码 (None 时自动从文件名识别)
timeframe: 目标周期 (M1/M5/M15/M30/H1/H4/D1/W1/MN1)
drop_weekend: 是否过滤周末行 (默认 False, 因为外汇 CSV 已无周末数据)
返回:
DataFrame, 列: time, open, high, low, close, tick_volume, spread
time 列为 datetime64[ns, tz]
"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"CSV 文件不存在: {file_path}")
# 自动识别品种
if symbol is None:
symbol = extract_symbol_from_filename(file_path)
# 解析时区
tz = extract_timezone_from_filename(file_path)
# 加载 CSV (无表头, 9 列格式)
df = pd.read_csv(
file_path,
header=None,
names=["date", "time", "open", "high", "low", "close",
"volume", "real_volume", "spread"],
dtype={
"open": np.float64, "high": np.float64,
"low": np.float64, "close": np.float64,
"volume": np.float64, "real_volume": np.float64,
"spread": np.int32,
},
)
# 合并 date + time → datetime
df["time"] = pd.to_datetime(
df["date"] + " " + df["time"],
format="%Y.%m.%d %H:%M",
utc=False,
)
df["time"] = df["time"].dt.tz_localize(tz)
# 清理列
df = df.drop(columns=["date", "real_volume"])
df = df.rename(columns={"volume": "tick_volume"})
# 排序 + 去重 + 建索引
df = df.sort_values("time").drop_duplicates(subset=["time"])
df = df.set_index("time")
# 可选: 过滤周末 (外汇 CSV 通常已无周末行, 此项保险用)
if drop_weekend:
df = df[df.index.dayofweek < 5]
# 重采样到目标周期
if timeframe != "M1":
df = _resample(df, timeframe)
return df.reset_index()
def _resample(df: pd.DataFrame, timeframe: str) -> pd.DataFrame:
"""重采样 M1 到更高周期"""
if timeframe == "M1":
return df
freq = TIMEFRAME_MAP.get(timeframe)
if not freq:
raise ValueError(
f"不支持的周期: {timeframe}, 可选: {', '.join(TIMEFRAME_MAP.keys())}"
)
resampled = df.resample(freq, label="left", closed="left").agg({
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"tick_volume": "sum",
"spread": "mean",
}).dropna()
return resampled
# ============================================================================
# 点差 → slippage 转换
# ============================================================================
def compute_slippage(
spread_series: pd.Series,
close: pd.Series,
symbol: str,
fallback: float = 0.0005,
) -> float:
"""
根据平均 spread 和价格计算 slippage (百分比)
公式: slippage = (avg_spread × tick_size) / avg_price
参数:
spread_series: spread 列 (点数, 如 98 表示 98 个 tick)
close: 收盘价 (用于计算平均价格)
symbol: 品种代码 (决定 tick_size)
fallback: 无法识别品种时的默认值 (默认 0.05%)
返回:
slippage 百分比 (如 0.00055 表示 0.055%)
示例:
XAUUSD: avg_spread=98, tick_size=0.01, avg_price=1791
slippage = 98 × 0.01 / 1791 = 0.000547 ≈ 0.055%
EURUSD: avg_spread=43, tick_size=0.00001, avg_price=1.186
slippage = 43 × 0.00001 / 1.186 = 0.000363 ≈ 0.036%
"""
tick_size = SYMBOL_TICK_SIZE.get(symbol)
if tick_size is None:
return fallback
avg_spread = float(spread_series.mean())
avg_price = float(close.mean())
if avg_price == 0:
return fallback
spread_cost = avg_spread * tick_size
return spread_cost / avg_price
def get_tick_size(symbol: str) -> float:
"""获取品种的 tick_size (最小变动单位)"""
return SYMBOL_TICK_SIZE.get(symbol, 0.0001)
# ============================================================================
# 自动查找
# ============================================================================
def find_csv_for_symbol(
symbol: str,
data_dir: Optional[str] = None,
) -> Optional[str]:
"""
在 data/ 目录查找匹配品种的 CSV
匹配规则: 文件名 (大写) 包含品种代码 (大写)
如查找 XAUUSD, 匹配 "2021.7.6-2026.07.03M1XAUUSD_TICK_UTCPlus02.csv"
返回:
第一个匹配的文件路径, 未找到返回 None
"""
if data_dir is None:
data_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data",
)
if not os.path.exists(data_dir):
return None
symbol_upper = symbol.upper()
# 优先匹配 M1 文件 (原始数据, 精度最高)
files = [f for f in os.listdir(data_dir) if f.endswith(".csv")]
# 先找 M1 文件
for f in files:
if symbol_upper in f.upper() and "M1" in f.upper():
return os.path.join(data_dir, f)
# 再找任意匹配
for f in files:
if symbol_upper in f.upper():
return os.path.join(data_dir, f)
return None
def list_available_symbols(data_dir: Optional[str] = None) -> list:
"""
列出 data/ 目录下所有可用品种
返回:
[(symbol, filename), ...] 列表
"""
if data_dir is None:
data_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"data",
)
if not os.path.exists(data_dir):
return []
result = []
for f in sorted(os.listdir(data_dir)):
if not f.endswith(".csv"):
continue
symbol = extract_symbol_from_filename(f)
if symbol:
result.append((symbol, f))
return result
+319
View File
@@ -0,0 +1,319 @@
"""
策略交付包导出器 — 生成策略文件 + Markdown 报告 + CSV 明细
交付包结构:
deliverables/{strategy_name}_{timestamp}/
├── {strategy_name}.py 策略源文件 (从 strategies/ 复制)
├── STRATEGY_REPORT.md 策略交付报告 (完整分析)
├── optimization.csv 参数优化响应面
├── walkforward.csv Walk-forward 逐窗口明细
└── backtest_metrics.csv 最优参数回测指标
用法:
from exporter import StrategyExporter
exporter = StrategyExporter()
path = exporter.deliver(
strategy_name="sma_cross",
opt_result=opt_result,
wf_result=wf_result,
accept_report=accept_report,
df=df,
symbol="XAUUSD",
)
print(f"交付包: {path}")
"""
from __future__ import annotations
import os
import shutil
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
import raptorbt
from strategies import get_strategy
from strategies.base import Strategy
from .optimizer import OptimizationResult
from .walk_forward import WalkForwardResult
from .acceptance import AcceptanceReport
class StrategyExporter:
"""
策略交付包导出器
将策略文件 + 优化结果 + walk-forward 结果 + 验收报告
打包成一个完整的交付目录
"""
def __init__(self, output_dir: str = "deliverables"):
self.output_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), output_dir
)
def deliver(
self,
strategy_name: str,
df: pd.DataFrame,
symbol: str,
opt_result: OptimizationResult,
wf_result: WalkForwardResult,
accept_report: AcceptanceReport,
param_grid: Optional[dict] = None,
data_info: Optional[dict] = None,
) -> str:
"""
生成完整交付包
参数:
strategy_name: 策略名称
df: K 线数据
symbol: 标的
opt_result: 优化结果
wf_result: walk-forward 结果
accept_report: 验收报告
param_grid: 参数搜索空间 (用于报告)
data_info: 数据信息 (如 {"source": "Mt5Bridge", "range": "2026-01~2026-06"})
返回:
交付包目录路径
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
pkg_name = f"{strategy_name}_{timestamp}"
pkg_dir = os.path.join(self.output_dir, pkg_name)
os.makedirs(pkg_dir, exist_ok=True)
strategy = get_strategy(strategy_name)
# 1. 复制策略源文件
src_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"strategies", f"{strategy_name}.py",
)
if os.path.exists(src_path):
shutil.copy2(src_path, os.path.join(pkg_dir, f"{strategy_name}.py"))
# 2. 导出 CSV 明细
opt_result.export(os.path.join(pkg_dir, "optimization.csv"))
wf_result.export(os.path.join(pkg_dir, "walkforward.csv"))
# 3. 用最优参数跑完整回测, 导出指标
best_metrics = self._run_best_backtest(strategy_name, opt_result.best_params, df, symbol)
if best_metrics:
pd.DataFrame(list(best_metrics.items()), columns=["metric", "value"]).to_csv(
os.path.join(pkg_dir, "backtest_metrics.csv"),
index=False, encoding="utf-8-sig",
)
# 4. 生成 Markdown 报告
report = self._generate_report(
strategy_name=strategy_name,
strategy=strategy,
symbol=symbol,
opt_result=opt_result,
wf_result=wf_result,
accept_report=accept_report,
param_grid=param_grid,
data_info=data_info,
best_metrics=best_metrics,
)
report_path = os.path.join(pkg_dir, "STRATEGY_REPORT.md")
with open(report_path, "w", encoding="utf-8") as f:
f.write(report)
return pkg_dir
def _run_best_backtest(self, strategy_name, best_params, df, symbol) -> Optional[dict]:
"""用最优参数跑完整回测, 返回指标字典"""
try:
strategy = get_strategy(strategy_name)
# 用最优参数重新实例化
strategy_cls = type(strategy)
strategy = strategy_cls(**best_params)
signals = strategy.generate_signals(df)
config = strategy.build_config()
arr = Strategy.to_arrays(df)
result = raptorbt.run_single_backtest(
timestamps=arr["timestamps"],
open=arr["open"], high=arr["high"], low=arr["low"], close=arr["close"],
volume=arr["volume"],
entries=signals.entries, exits=signals.exits,
direction=signals.direction, weight=1.0, symbol=symbol,
config=config,
)
m = result.metrics
return {
"total_return_pct": m.total_return_pct,
"sharpe_ratio": m.sharpe_ratio,
"max_drawdown_pct": m.max_drawdown_pct,
"total_trades": m.total_trades,
"win_rate_pct": m.win_rate_pct,
"profit_factor": m.profit_factor,
}
except Exception as e:
print(f" ⚠️ 最优参数回测失败: {e}")
return None
def _generate_report(
self,
strategy_name,
strategy,
symbol,
opt_result,
wf_result,
accept_report,
param_grid,
data_info,
best_metrics,
) -> str:
"""生成 Markdown 交付报告"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
params_str = ", ".join(f"{k}={v}" for k, v in opt_result.best_params.items())
grid_str = ", ".join(
f'{k}={v}' for k, v in (param_grid or {}).items()
) if param_grid else "N/A"
data_info = data_info or {}
# 验收结果表
accept_lines = []
for c in accept_report.criteria:
status = "" if c.passed else ""
accept_lines.append(
f"| {c.name} | {c.threshold:.2f} | {c.actual:.2f} | {status} |"
)
accept_table = "\n".join(accept_lines) or "| (无) | | | |"
# 参数稳定性
stability_lines = []
for param, dist in wf_result.param_stability.items():
dist_str = ", ".join(
f"{k}×{v}" for k, v in sorted(dist.items(), key=lambda x: -x[1])
)
stability_lines.append(f"- **{param}**: {dist_str}")
stability_str = "\n".join(stability_lines) or "- (无)"
# 最优参数回测指标
if best_metrics:
metrics_lines = [
f"| {k} | {v:.4f} |" for k, v in best_metrics.items()
]
metrics_table = "\n".join(metrics_lines)
else:
metrics_table = "| (回测失败) | |"
# Top 5 参数组合
top5 = opt_result.top_n(5)
if len(top5) > 0:
cols = opt_result.param_names + [opt_result.metric, "total_trades"]
cols = [c for c in cols if c in top5.columns]
top5_lines = ["| " + " | ".join(cols) + " |",
"|" + "|".join(["---"] * len(cols)) + "|"]
for _, row in top5.iterrows():
vals = []
for c in cols:
v = row[c]
if isinstance(v, float):
vals.append(f"{v:.4f}")
else:
vals.append(str(v))
top5_lines.append("| " + " | ".join(vals) + " |")
top5_table = "\n".join(top5_lines)
else:
top5_table = "(无有效结果)"
verdict = "✅ 通过验收,可交付" if accept_report.passed else "❌ 未通过验收,不可交付"
return f"""# 策略交付报告: {strategy_name}
> 生成时间: {now}
> 引擎: RaptorBT v{raptorbt.__version__}
## 1. 策略概述
| 项目 | 内容 |
|------|------|
| 策略名称 | `{strategy_name}` |
| 描述 | {strategy.description()} |
| 交易标的 | {symbol} |
| 最优参数 | {params_str} |
| 参数搜索空间 | {grid_str} |
| 预热期 | {strategy.warmup_bars()} bars |
## 2. 参数优化
**目标指标**: {opt_result.metric} ({opt_result.direction})
**搜索组合数**: {len(opt_result.results)}
**有效结果数**: {opt_result.results[opt_result.metric].notna().sum()}
### Top 5 参数组合
{top5_table}
## 3. Walk-Forward 验证
| 指标 | IS (训练集) | OOS (测试集) |
|------|------------|-------------|
| 平均夏普比率 | {wf_result.is_sharpe_avg:.4f} | {wf_result.oos_sharpe_avg:.4f} |
| 平均收益率 | - | {wf_result.oos_return_avg:.2f}% |
| 平均最大回撤 | - | {wf_result.oos_max_drawdown_avg:.2f}% |
| 总交易数 | - | {wf_result.oos_trades_total} |
| 平均胜率 | - | {wf_result.oos_win_rate_avg:.1f}% |
**衰减比 (OOS/IS)**: {wf_result.decay_ratio:.2%}
**过拟合判定**: {'⚠️ 是 (衰减比 < 50%)' if wf_result.is_overfit else '✅ 否'}
**验证窗口数**: {wf_result.n_windows}
### 参数稳定性
{stability_str}
## 4. 验收结果
**总体结论**: {verdict}
| 标准 | 阈值 | 实际值 | 结果 |
|------|------|--------|------|
{accept_table}
## 5. 最优参数完整回测
| 指标 | 值 |
|------|-----|
{metrics_table}
## 6. 数据信息
| 项目 | 内容 |
|------|------|
| 数据源 | {data_info.get('source', 'Mt5Bridge')} |
| 数据范围 | {data_info.get('range', 'N/A')} |
| K 线数量 | {data_info.get('bars', 'N/A')} |
| 时间周期 | {data_info.get('timeframe', 'N/A')} |
## 7. 风险提示
1. **回测不等于实盘**: 历史表现不保证未来收益,滑点和延迟可能影响实际执行
2. **参数敏感性**: 请关注参数稳定性分析,参数值频繁变化的策略稳健性较差
3. **市场环境**: 策略可能在特定市场条件下表现优异,其他条件下表现不佳
4. **过拟合风险**: {'⚠️ 检测到过拟合,OOS 表现显著低于 IS' if wf_result.is_overfit else '未检测到明显过拟合'}
5. **交易成本**: 回测已包含手续费和滑点,但实际成本可能更高
## 8. 复现方式
```bash
# 使用最优参数回测
python main.py run --strategy {strategy_name} --symbol {symbol} --export
# 重新验证
python main.py validate --strategy {strategy_name} --param <参数>=<值>
```
---
*本报告由 RaptorBT 策略自动化框架自动生成*
"""
+479
View File
@@ -0,0 +1,479 @@
"""
指标目录 — AI agent 可查询的指标清单
所有指标都通过 raptorbt.<name>(...) 直接调用, 输入为 numpy array (float64),
返回 numpy array 或 tuple of arrays。
调用示例:
import raptorbt
sma = raptorbt.sma(close, period=14) # 单返回
adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) # 多返回
可用 list --indicators 查看本目录, 加 --json 输出结构化版本。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
@dataclass
class IndicatorInfo:
"""单个指标的元信息"""
name: str # raptorbt.xxx 的函数名
category: str # 分类
inputs: List[str] # 输入数组 (close/high/low/open/volume)
params: List[str] # 参数名列表
defaults: List[str] # 参数默认值 (与 params 一一对应, "—" 表示必填)
returns: str # 返回说明, 如 "1 array" 或 "3 arrays (macd, signal, hist)"
description: str # 简短中文说明
usage: str # 完整调用示例
def signature(self) -> str:
"""生成可读的函数签名, 如 sma(close, period=14)"""
param_parts = []
for inp in self.inputs:
param_parts.append(inp)
for p, d in zip(self.params, self.defaults):
if d == "":
param_parts.append(p)
else:
param_parts.append(f"{p}={d}")
return f"{self.name}({', '.join(param_parts)})"
def to_dict(self) -> dict:
return {
"name": self.name,
"category": self.category,
"inputs": self.inputs,
"params": self.params,
"defaults": self.defaults,
"returns": self.returns,
"description": self.description,
"signature": self.signature(),
"usage": self.usage,
}
# 完整指标目录 (按分类排序)
# 输入约定: c=close, h=high, l=low, o=open, v=volume (都接受 numpy float64 array)
CATALOG: List[IndicatorInfo] = [
# ───────── 趋势 (Trend) ─────────
IndicatorInfo("sma", "趋势", ["close"], ["period"], [""],
"1 array",
"简单移动平均, 最基础的趋势指标",
"raptorbt.sma(close, period=14)"),
IndicatorInfo("ema", "趋势", ["close"], ["period"], [""],
"1 array",
"指数移动平均, 近期数据权重更高",
"raptorbt.ema(close, period=14)"),
IndicatorInfo("wma", "趋势", ["close"], ["period"], [""],
"1 array",
"加权移动平均, 线性权重",
"raptorbt.wma(close, period=14)"),
IndicatorInfo("dema", "趋势", ["close"], ["period"], [""],
"1 array",
"双指数移动平均, 滞后更小",
"raptorbt.dema(close, period=14)"),
IndicatorInfo("tema", "趋势", ["close"], ["period"], [""],
"1 array",
"三指数移动平均, 滞后最小",
"raptorbt.tema(close, period=14)"),
IndicatorInfo("kama", "趋势", ["close"], ["period"], [""],
"1 array",
"Kaufman 自适应均线, 根据市场噪音调整平滑度",
"raptorbt.kama(close, period=14)"),
IndicatorInfo("vwap", "量价", ["high", "low", "close", "volume"], [], [],
"1 array",
"成交量加权平均价, 日内常用",
"raptorbt.vwap(high, low, close, volume)"),
IndicatorInfo("supertrend", "趋势", ["high", "low", "close"], ["period", "multiplier"],
["10", "3.0"],
"2 arrays (supertrend, direction)",
"Supertrend 趋势线 + 方向 (1=涨, -1=跌)",
"st, dir = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0)"),
IndicatorInfo("sar", "趋势", ["high", "low"], ["acceleration", "maximum"],
["0.02", "0.2"],
"1 array",
"Parabolic SAR, 趋势跟踪止损点",
"raptorbt.sar(high, low, acceleration=0.02, maximum=0.2)"),
IndicatorInfo("macd", "动量", ["close"], ["fast_period", "slow_period", "signal_period"],
["12", "26", "9"],
"3 arrays (macd, signal, histogram)",
"MACD 异同移动平均线",
"macd, signal, hist = raptorbt.macd(close, fast_period=12, slow_period=26, signal_period=9)"),
IndicatorInfo("ppo", "动量", ["close"], ["fastperiod", "slowperiod", "signalperiod"],
["12", "26", "9"],
"3 arrays (ppo, signal, histogram)",
"价格百分比振荡器, 类 MACD 但归一化",
"ppo, signal, hist = raptorbt.ppo(close, fastperiod=12, slowperiod=26, signalperiod=9)"),
# ───────── 动量 (Momentum) ─────────
IndicatorInfo("rsi", "动量", ["close"], ["period"], [""],
"1 array",
"相对强弱指数, 0-100 区间, >70 超买 / <30 超卖",
"raptorbt.rsi(close, period=14)"),
IndicatorInfo("stochrsi", "动量", ["close"], ["timeperiod", "fastk_period", "fastd_period"],
["14", "5", "3"],
"2 arrays (fastk, fastd)",
"随机 RSI, 对 RSI 再做随机处理",
"fastk, fastd = raptorbt.stochrsi(close, timeperiod=14, fastk_period=5, fastd_period=3)"),
IndicatorInfo("stochastic", "动量", ["high", "low", "close"], ["k_period", "d_period"],
["14", "3"],
"2 arrays (k, d)",
"随机振荡器, %K 和 %D",
"k, d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3)"),
IndicatorInfo("cci", "动量", ["high", "low", "close"], ["period"], [""],
"1 array",
"商品通道指数, 偏离均值程度, ±100 为阈值",
"raptorbt.cci(high, low, close, period=20)"),
IndicatorInfo("willr", "动量", ["high", "low", "close"], ["period"], [""],
"1 array",
"威廉指标, -100~0, 接近 -100 为超卖",
"raptorbt.willr(high, low, close, period=14)"),
IndicatorInfo("roc", "动量", ["close"], ["period"], [""],
"1 array",
"变化率, 当前 / N 期前 - 1",
"raptorbt.roc(close, period=10)"),
IndicatorInfo("mom", "动量", ["close"], ["period"], [""],
"1 array",
"动量, 当前 - N 期前",
"raptorbt.mom(close, period=10)"),
IndicatorInfo("trix", "动量", ["close"], ["period"], [""],
"1 array",
"三重平滑均线变化率",
"raptorbt.trix(close, period=12)"),
IndicatorInfo("cmo", "动量", ["close"], ["period"], [""],
"1 array",
"Chande 动量振荡器, -100~100",
"raptorbt.cmo(close, period=14)"),
IndicatorInfo("bop", "动量", ["open", "high", "low", "close"], [], [],
"1 array",
"力量平衡, -1~1, 衡量买卖力量",
"raptorbt.bop(open, high, low, close)"),
IndicatorInfo("ultosc", "动量", ["high", "low", "close"],
["period1", "period2", "period3"], ["7", "14", "28"],
"1 array",
"终极振荡器, 3 周期加权平均",
"raptorbt.ultosc(high, low, close, period1=7, period2=14, period3=28)"),
# ───────── 强度 / 方向 (Strength) ─────────
IndicatorInfo("adx", "强度", ["high", "low", "close"], ["period"], [""],
"1 array",
"平均方向指数, >25 视为强趋势",
"raptorbt.adx(high, low, close, period=14)"),
IndicatorInfo("adx_all", "强度", ["high", "low", "close"], ["period"], [""],
"3 arrays (adx, plus_di, minus_di)",
"ADX + +DI - DI, 一次拿全方向信息",
"adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14)"),
IndicatorInfo("plus_di", "强度", ["high", "low", "close"], ["period"], [""],
"1 array",
"正向方向指标 +DI",
"raptorbt.plus_di(high, low, close, period=14)"),
IndicatorInfo("minus_di", "强度", ["high", "low", "close"], ["period"], [""],
"1 array",
"负向方向指标 -DI",
"raptorbt.minus_di(high, low, close, period=14)"),
IndicatorInfo("adxr", "强度", ["high", "low", "close"], ["period"], [""],
"1 array",
"ADX 评级, ADX 的平滑版",
"raptorbt.adxr(high, low, close, period=14)"),
IndicatorInfo("aroon", "强度", ["high", "low"], ["period"], [""],
"2 arrays (up, down)",
"Aroon 上/下, 0~100, 判定趋势起始",
"up, down = raptorbt.aroon(high, low, period=14)"),
IndicatorInfo("aroonosc", "强度", ["high", "low"], ["period"], [""],
"1 array",
"Aroon 振荡器, up - down, -100~100",
"raptorbt.aroonosc(high, low, period=14)"),
# ───────── 波动率 (Volatility) ─────────
IndicatorInfo("atr", "波动率", ["high", "low", "close"], ["period"], [""],
"1 array",
"真实波动幅度均值, 常用于动态止损",
"raptorbt.atr(high, low, close, period=14)"),
IndicatorInfo("natr", "波动率", ["high", "low", "close"], ["period"], [""],
"1 array",
"归一化 ATR, ATR/close×100",
"raptorbt.natr(high, low, close, period=14)"),
IndicatorInfo("trange", "波动率", ["high", "low", "close"], [], [],
"1 array",
"真实波幅, ATR 的单期版",
"raptorbt.trange(high, low, close)"),
IndicatorInfo("bollinger_bands", "波动率", ["close"], ["period", "std_dev"],
["20", "2.0"],
"3 arrays (upper, middle, lower)",
"布林带, 常用突破和回归",
"upper, middle, lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0)"),
IndicatorInfo("stddev", "波动率", ["close"], ["period", "nbdev"], ["", "1.0"],
"1 array",
"标准差",
"raptorbt.stddev(close, period=5, nbdev=1.0)"),
IndicatorInfo("var", "波动率", ["close"], ["period", "nbdev"], ["", "1.0"],
"1 array",
"方差",
"raptorbt.var(close, period=5, nbdev=1.0)"),
# ───────── 成交量 (Volume) ─────────
IndicatorInfo("obv", "成交量", ["close", "volume"], [], [],
"1 array",
"能量潮, 累积成交量",
"raptorbt.obv(close, volume)"),
IndicatorInfo("mfi", "成交量", ["high", "low", "close", "volume"], ["period"], [""],
"1 array",
"资金流量指标, 0~100, 类 RSI 但含成交量",
"raptorbt.mfi(high, low, close, volume, period=14)"),
IndicatorInfo("ad", "成交量", ["high", "low", "close", "volume"], [], [],
"1 array",
"累积/派发线, 价量关系",
"raptorbt.ad(high, low, close, volume)"),
IndicatorInfo("adosc", "成交量", ["high", "low", "close", "volume"],
["fastperiod", "slowperiod"], ["3", "10"],
"1 array",
"累积/派发振荡器",
"raptorbt.adosc(high, low, close, volume, fastperiod=3, slowperiod=10)"),
# ───────── 统计 / 回归 (Statistics) ─────────
IndicatorInfo("linearreg", "统计", ["close"], ["period"], [""],
"1 array",
"线性回归值",
"raptorbt.linearreg(close, period=14)"),
IndicatorInfo("linearreg_slope", "统计", ["close"], ["period"], [""],
"1 array",
"线性回归斜率, 趋势强度",
"raptorbt.linearreg_slope(close, period=14)"),
IndicatorInfo("linearreg_intercept", "统计", ["close"], ["period"], [""],
"1 array",
"线性回归截距",
"raptorbt.linearreg_intercept(close, period=14)"),
IndicatorInfo("linearreg_angle", "统计", ["close"], ["period"], [""],
"1 array",
"线性回归角度 (弧度)",
"raptorbt.linearreg_angle(close, period=14)"),
IndicatorInfo("tsf", "统计", ["close"], ["period"], [""],
"1 array",
"时间序列预测, 线性回归外推一期",
"raptorbt.tsf(close, period=14)"),
IndicatorInfo("beta", "统计", ["data0", "data1"], ["period"], [""],
"1 array",
"贝塔系数, 两序列的相对波动",
"raptorbt.beta(asset_close, benchmark_close, period=20)"),
IndicatorInfo("correl", "统计", ["data0", "data1"], ["period"], [""],
"1 array",
"皮尔逊相关系数, -1~1",
"raptorbt.correl(asset_close, benchmark_close, period=20)"),
# ───────── 滚动 (Rolling) ─────────
IndicatorInfo("rolling_min", "滚动", ["close"], ["period"], [""],
"1 array",
"滚动最小值, 如最低低",
"raptorbt.rolling_min(low, period=20)"),
IndicatorInfo("rolling_max", "滚动", ["close"], ["period"], [""],
"1 array",
"滚动最大值, 如最高高",
"raptorbt.rolling_max(high, period=20)"),
# ───────── 价格变换 (Price Transform) ─────────
IndicatorInfo("typprice", "价格变换", ["high", "low", "close"], [], [],
"1 array",
"典型价格 (high+low+close)/3",
"raptorbt.typprice(high, low, close)"),
IndicatorInfo("medprice", "价格变换", ["high", "low"], [], [],
"1 array",
"中位价 (high+low)/2",
"raptorbt.medprice(high, low)"),
IndicatorInfo("avgprice", "价格变换", ["open", "high", "low", "close"], [], [],
"1 array",
"均价 (open+high+low+close)/4",
"raptorbt.avgprice(open, high, low, close)"),
IndicatorInfo("wclprice", "价格变换", ["high", "low", "close"], [], [],
"1 array",
"加权收盘价 (high+low+close×2)/4",
"raptorbt.wclprice(high, low, close)"),
IndicatorInfo("midpoint", "价格变换", ["close"], ["period"], [""],
"1 array",
"滚动中点价 (max+min)/2",
"raptorbt.midpoint(close, period=14)"),
IndicatorInfo("midprice", "价格变换", ["high", "low"], ["period"], [""],
"1 array",
"滚动中价 (highest_high+lowest_low)/2",
"raptorbt.midprice(high, low, period=14)"),
# ───────── 高阶均线 (Advanced MA) ─────────
IndicatorInfo("t3", "高阶均线", ["close"], ["period", "vfactor"], ["5", "0.7"],
"1 array",
"T3 三重指数平滑均线, 可调 vfactor 控制平滑度",
"raptorbt.t3(close, period=5, vfactor=0.7)"),
IndicatorInfo("trima", "高阶均线", ["close"], ["period"], [""],
"1 array",
"三角均线, 对 SMA 再做 SMA",
"raptorbt.trima(close, period=20)"),
IndicatorInfo("vwma", "高阶均线", ["close", "volume"], ["period"], ["20"],
"1 array",
"成交量加权移动平均",
"raptorbt.vwma(close, volume, period=20)"),
IndicatorInfo("hull_ma", "高阶均线", ["close"], ["period"], [""],
"1 array",
"Hull 均线, 滞后极小的趋势线",
"raptorbt.hull_ma(close, period=20)"),
IndicatorInfo("apo", "高阶均线", ["close"], ["fastperiod", "slowperiod"], ["12", "26"],
"1 array",
"绝对价格振荡器, EMA 差值",
"raptorbt.apo(close, fastperiod=12, slowperiod=26)"),
# ───────── 通道 (Channels) ─────────
IndicatorInfo("donchian", "通道", ["high", "low"], ["period"], [""],
"3 arrays (upper, middle, lower)",
"唐奇安通道, 海龟交易系统核心",
"upper, middle, lower = raptorbt.donchian(high, low, period=20)"),
IndicatorInfo("chandelier_exit", "通道", ["high", "low", "close"],
["period", "multiplier"], ["22", "3.0"],
"2 arrays (long_exit, short_exit)",
"吊灯止损, 基于 ATR 的追踪止损",
"long_exit, short_exit = raptorbt.chandelier_exit(high, low, close, period=22, multiplier=3.0)"),
IndicatorInfo("ichimoku", "通道", ["high", "low", "close"],
["tenkan_period", "kijun_period", "senkou_b_period", "displacement"],
["9", "26", "52", "26"],
"5 arrays (tenkan, kijun, senkou_a, senkou_b, chikou)",
"一目均衡表, 5 条线的完整系统",
"tenkan, kijun, senkou_a, senkou_b, chikou = raptorbt.ichimoku(high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26)"),
IndicatorInfo("pivot_points", "通道", ["high", "low", "close"], ["method"], [""],
"5 arrays (pivot, r1, s1, r2, s2)",
"枢轴点, method='classic' / 'fibonacci' / 'camarilla'",
"pivot, r1, s1, r2, s2 = raptorbt.pivot_points(high, low, close, method='classic')"),
# ───────── Hilbert 变换 (Cycle) ─────────
IndicatorInfo("ht_trendline", "Hilbert", ["close"], [], [],
"1 array",
"瞬时趋势线, 去除周期成分的趋势",
"raptorbt.ht_trendline(close)"),
IndicatorInfo("ht_dcperiod", "Hilbert", ["close"], [], [],
"1 array",
"主导周期长度, 识别市场周期",
"raptorbt.ht_dcperiod(close)"),
IndicatorInfo("ht_dcphase", "Hilbert", ["close"], [], [],
"1 array",
"主导周期相位 (度)",
"raptorbt.ht_dcphase(close)"),
IndicatorInfo("ht_phasor", "Hilbert", ["close"], [], [],
"2 arrays (in_phase, quadrature)",
"相位分量, 用于周期分析",
"in_phase, quadrature = raptorbt.ht_phasor(close)"),
IndicatorInfo("ht_sine", "Hilbert", ["close"], [], [],
"2 arrays (sine, lead_sine)",
"正弦波, 周期交易信号",
"sine, lead_sine = raptorbt.ht_sine(close)"),
IndicatorInfo("ht_trendmode", "Hilbert", ["close"], [], [],
"1 array (i32: 1=趋势, 0=周期)",
"趋势/周期模式判定",
"mode = raptorbt.ht_trendmode(close)"),
# ───────── 市场状态 (Regime) ─────────
IndicatorInfo("choppiness_index", "市场状态", ["high", "low", "close"], ["period"],
["14"],
"1 array",
"震荡指数, >61.5 震荡 / <38.5 趋势",
"raptorbt.choppiness_index(high, low, close, period=14)"),
IndicatorInfo("regime_adx", "市场状态", ["adx"], ["threshold"], [""],
"1 array (i8: 1=趋势, 0=震荡, -1=预热)",
"基于 ADX 的市场状态",
"regime = raptorbt.regime_adx(adx, threshold=25.0)"),
IndicatorInfo("regime_combined", "市场状态",
["adx", "atr", "close"], ["adx_threshold", "atr_pct_threshold"], ["", ""],
"1 array (i8: 1=趋势, 0=震荡, -1=NaN)",
"ADX + ATR 比率的组合市场状态",
"regime = raptorbt.regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.01)"),
IndicatorInfo("detect_breaks_cusum", "市场状态", ["close"],
["window", "threshold", "slack"], ["", "", ""],
"1 array (i8: 1 在结构变化点)",
"CUSUM 结构变化检测",
"breaks = raptorbt.detect_breaks_cusum(close, window=20, threshold=3.0, slack=0.5)"),
IndicatorInfo("rolling_variance_break", "市场状态", ["close"],
["short_window", "long_window", "threshold"], ["", "", ""],
"1 array (i8: 1 在方差变化点)",
"滚动方差变化检测",
"breaks = raptorbt.rolling_variance_break(close, short_window=10, long_window=50, threshold=2.0)"),
# ───────── 投资组合 (Portfolio) ─────────
IndicatorInfo("rolling_beta", "投资组合", ["data0", "data1"], ["period"], [""],
"1 array",
"滚动贝塔, 资产 vs 基准的相对波动",
"raptorbt.rolling_beta(asset_close, benchmark_close, window=60)"),
IndicatorInfo("drawdown_series", "投资组合", ["equity"], [], [],
"2 values (per_bar_dd array, max_dd float)",
"从权益曲线计算回撤序列和最大回撤",
"dd, max_dd = raptorbt.drawdown_series(equity)"),
IndicatorInfo("zscore_series", "投资组合", ["close"], ["window"], [""],
"1 array",
"滚动 z-score, 均值回归策略常用",
"raptorbt.zscore_series(close, window=20)"),
IndicatorInfo("relative_strength", "投资组合", ["data0", "data1"], [], [],
"1 array",
"相对强度 = asset - beta×benchmark (超额收益)",
"raptorbt.relative_strength(asset_returns, benchmark_returns)"),
IndicatorInfo("spread", "投资组合", ["data0", "data1"], ["hedge"], [""],
"1 array",
"价差 = a - hedge×b, 配对交易用",
"raptorbt.spread(asset_a, asset_b, hedge=0.8)"),
IndicatorInfo("ratio", "投资组合", ["data0", "data1"], [], [],
"1 array",
"比率 = a/b 逐元素",
"raptorbt.ratio(asset_a, asset_b)"),
]
def list_by_category() -> dict:
"""按分类组织指标, 返回 {category: [IndicatorInfo, ...]}"""
out = {}
for ind in CATALOG:
out.setdefault(ind.category, []).append(ind)
return out
def find(name: str) -> IndicatorInfo | None:
"""按名称查找指标"""
for ind in CATALOG:
if ind.name == name:
return ind
return None
def format_text() -> str:
"""生成可读的文本目录"""
by_cat = list_by_category()
lines = [
f"可用指标目录 ({len(CATALOG)} 个)",
f"{'' * 70}",
f"调用方式: import raptorbt; raptorbt.<name>(numpy_array, ...)",
f"输入约定: close/high/low/open/volume 都是 float64 numpy array",
f"",
]
for cat in ["趋势", "动量", "强度", "波动率", "成交量", "统计", "滚动", "量价"]:
if cat not in by_cat:
continue
inds = by_cat[cat]
lines.append(f"── {cat} ({len(inds)} 个) ──")
for ind in inds:
lines.append(f" {ind.signature()}")
lines.append(f"{ind.returns}")
lines.append(f" {ind.description}")
lines.append("")
lines.append(f"{'' * 70}")
lines.append(f"提示: 加 --json 输出结构化目录, 便于 AI agent 解析")
return "\n".join(lines)
def format_json() -> dict:
"""生成 JSON 结构化目录"""
by_cat = list_by_category()
return {
"total": len(CATALOG),
"categories": {
cat: [ind.to_dict() for ind in inds]
for cat, inds in sorted(by_cat.items())
},
"all_names": [ind.name for ind in CATALOG],
"usage_note": "调用方式: import raptorbt; raptorbt.<name>(numpy_array, ...)",
}
+125
View File
@@ -0,0 +1,125 @@
"""
自定义指标库 — 已全面接入 ferro-ta 原生 Rust 实现。
v2 变更: 所有在 raptorbt (ferro-ta) 中已有原生实现的指标, 不再用 Python
重新计算, 而是直接转发到原生函数, 获得亚毫秒级性能。
原生转发 (10 个): typical_price→typprice, cci, williams_r→willr,
roc, trix, dmi→adx_all, ichimoku, parabolic_sar→sar, mfi, obv
原生组合 (1 个): awesome_oscillator = sma(medprice) - sma(medprice)
使用方式:
from my_indicators import cci, awesome_oscillator
cci_values = cci(high, low, close, period=20)
"""
import numpy as np
import raptorbt
# ============================================================================
# 原生转发层 — 直接调用 ferro-ta Rust 实现
# ============================================================================
def typical_price(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> np.ndarray:
"""典型价格 (H+L+C)/3 — 转发 raptorbt.typprice"""
return raptorbt.typprice(high, low, close)
def cci(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20) -> np.ndarray:
"""商品通道指数 — 转发 raptorbt.cci"""
return raptorbt.cci(high, low, close, period)
def williams_r(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
"""威廉指标 (-100~0) — 转发 raptorbt.willr"""
return raptorbt.willr(high, low, close, period)
def roc(data: np.ndarray, period: int = 12) -> np.ndarray:
"""变化率 — 转发 raptorbt.roc"""
return raptorbt.roc(data, period)
def trix(data: np.ndarray, period: int = 15) -> np.ndarray:
"""三重指数平滑变化率 — 转发 raptorbt.trix"""
return raptorbt.trix(data, period)
def dmi(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14):
"""
方向运动指标 (DMI) — 转发 raptorbt.adx_all
返回: (plus_di, minus_di, adx)
"""
adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period)
return plus_di, minus_di, adx
def ichimoku(
high: np.ndarray,
low: np.ndarray,
close: np.ndarray,
tenkan_period: int = 9,
kijun_period: int = 26,
senkou_b_period: int = 52,
displacement: int = 26,
):
"""一目均衡表 — 转发 raptorbt.ichimoku
返回: (tenkan, kijun, senkou_a, senkou_b, chikou)
"""
return raptorbt.ichimoku(
high, low, close, tenkan_period, kijun_period, senkou_b_period, displacement
)
def parabolic_sar(
high: np.ndarray,
low: np.ndarray,
close: np.ndarray,
step: float = 0.02,
max_step: float = 0.2,
) -> np.ndarray:
"""抛物线 SAR — 转发 raptorbt.sar (close 参数仅用于保持签名兼容, 不参与计算)"""
return raptorbt.sar(high, low, acceleration=step, maximum=max_step)
def mfi(
high: np.ndarray,
low: np.ndarray,
close: np.ndarray,
volume: np.ndarray,
period: int = 14,
) -> np.ndarray:
"""资金流量指数 (0~100) — 转发 raptorbt.mfi"""
return raptorbt.mfi(high, low, close, volume, period)
def obv(close: np.ndarray, volume: np.ndarray) -> np.ndarray:
"""能量潮 — 转发 raptorbt.obv"""
return raptorbt.obv(close, volume)
# ============================================================================
# 原生组合层 — 基于 raptorbt 原生函数组合 (无需新增 Rust 代码)
# ============================================================================
def awesome_oscillator(
high: np.ndarray,
low: np.ndarray,
fast_period: int = 5,
slow_period: int = 34,
) -> np.ndarray:
"""
鳄鱼振荡器 (Awesome Oscillator)
AO = SMA(median_price, fast) - SMA(median_price, slow)
其中 median_price = (high + low) / 2
基于 raptorbt.medprice + raptorbt.sma 原生 Rust 实现。
"""
midpoint = raptorbt.medprice(high, low) # 原生: (H+L)/2
sma_fast = raptorbt.sma(midpoint, fast_period) # 原生 SMA
sma_slow = raptorbt.sma(midpoint, slow_period) # 原生 SMA
return sma_fast - sma_slow
+460
View File
@@ -0,0 +1,460 @@
"""
前视偏差 (Look-Ahead Bias) 检测器
防止 AI agent 自动开发策略时引入前视偏差。两层防护:
1. 静态扫描 (AST): 扫描策略源码, 检测危险模式
- .shift(-N) 使用未来 bar
- df.iloc[i+N:] 切片未来数据
- close[-N] 负索引访问未来
- rolling(...).mean().shift(-1) 等
- 标准指标函数未来参数 (未来函数)
2. 动态验证 (运行时): 给策略喂"打乱后的未来", 看信号是否变化
- 修改 bar N+1..N+K 的 OHLC, 信号 entries[:N] 应保持不变
- 若变化 → 存在前视
用法:
from app.lookahead_check import check_strategy_file, check_strategy_code
# 静态扫描
report = check_strategy_file("strategies/my_strategy.py")
print(report.passed, report.issues)
# 动态验证
from app.lookahead_check import dynamic_check
result = dynamic_check(MyStrategy, df, params={"period": 14})
"""
from __future__ import annotations
import ast
import os
import re
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
# ============================================================================
# 静态扫描规则
# ============================================================================
# 危险模式: (正则, 严重度, 描述)
# 严重度: HIGH=确定前视, MEDIUM=可疑, LOW=建议检查
STATIC_RULES = [
# ── HIGH: 确定使用未来数据 ──
(r"\.shift\s*\(\s*-\s*\d", "HIGH",
"使用 .shift(-N) 访问未来 bar, 这是明确的前视偏差"),
(r"\.iloc\s*\[\s*:.*[+].*\d\s*\]", "HIGH",
"iloc 切片包含未来索引"),
(r"\.loc\s*\[\s*:.*[+].*\d\s*\]", "HIGH",
"loc 切片包含未来索引"),
(r"\bclose\s*\[\s*-\s*\d", "HIGH",
"close 负索引访问 (numpy 从末尾取, 可能是未来)"),
(r"\bhigh\s*\[\s*-\s*\d", "HIGH",
"high 负索引访问"),
(r"\blow\s*\[\s*-\s*\d", "HIGH",
"low 负索引访问"),
(r"\bopen\s*\[\s*-\s*\d", "HIGH",
"open 负索引访问"),
(r"rolling\s*\([^)]*\)\s*\.\w+\s*\(\s*\)\s*\.shift\s*\(\s*-\s*\d", "HIGH",
"滚动统计后 shift 负值 (使用未来统计)"),
# ── MEDIUM: 可疑模式, 需人工确认 ──
(r"\.shift\s*\(\s*0\s*\)", "MEDIUM",
".shift(0) 无意义, 可能是 .shift(-N) 改写错误"),
(r"future|lookahead|tomorrow|next_bar|nextbar", "MEDIUM",
"代码中出现 future/lookahead 等关键词, 检查是否使用未来数据"),
(r"np\.roll\s*\([^,]+,\s*-?\d", "MEDIUM",
"np.roll 可能把末尾元素移到开头, 引入未来数据 (本项目已修复此 bug)"),
# ── LOW: 建议检查 ──
(r"\.values\[i\s*\+", "LOW",
"基于索引 i 访问 i+N, 确认 N 方向是过去而非未来"),
(r"df\[.+\]\.values\[i\s*\+", "LOW",
"基于索引 i 访问未来元素"),
]
# ============================================================================
# 检测结果
# ============================================================================
@dataclass
class Issue:
"""单个检测问题"""
line: int
col: int
severity: str # HIGH / MEDIUM / LOW
rule: str
message: str
code_snippet: str = ""
def to_dict(self) -> dict:
return {
"line": self.line,
"col": self.col,
"severity": self.severity,
"rule": self.rule,
"message": self.message,
"code_snippet": self.code_snippet,
}
@dataclass
class LookaheadReport:
"""前视偏差检测报告"""
file_path: str
issues: list = field(default_factory=list)
passed: bool = True
@property
def has_high(self) -> bool:
return any(i.severity == "HIGH" for i in self.issues)
@property
def has_medium(self) -> bool:
return any(i.severity == "MEDIUM" for i in self.issues)
def summary(self) -> str:
n_high = sum(1 for i in self.issues if i.severity == "HIGH")
n_med = sum(1 for i in self.issues if i.severity == "MEDIUM")
n_low = sum(1 for i in self.issues if i.severity == "LOW")
if not self.issues:
return (
f"前视偏差检测: ✓ 通过\n"
f" 未检测到前视风险模式"
)
status = "❌ 失败" if self.has_high else "⚠️ 警告"
lines = [
f"前视偏差检测: {status}",
f" HIGH (确定前视): {n_high}",
f" MEDIUM (可疑): {n_med}",
f" LOW (建议检查): {n_low}",
"",
"问题明细:",
]
for issue in self.issues:
icon = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🟢"}[issue.severity]
lines.append(
f" {icon} L{issue.line}: {issue.message}"
)
if issue.code_snippet:
lines.append(f" {issue.code_snippet}")
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"file_path": self.file_path,
"passed": self.passed,
"n_high": sum(1 for i in self.issues if i.severity == "HIGH"),
"n_medium": sum(1 for i in self.issues if i.severity == "MEDIUM"),
"n_low": sum(1 for i in self.issues if i.severity == "LOW"),
"issues": [i.to_dict() for i in self.issues],
}
# ============================================================================
# 静态扫描
# ============================================================================
def check_strategy_code(code: str, file_path: str = "<string>") -> LookaheadReport:
"""
静态扫描策略源码, 检测前视偏差模式
参数:
code: Python 源码字符串
file_path: 文件路径 (用于报告)
返回:
LookaheadReport
"""
report = LookaheadReport(file_path=file_path)
lines = code.split("\n")
for line_no, line in enumerate(lines, 1):
# 跳过注释行
stripped = line.strip()
if stripped.startswith("#"):
continue
# 跳过 __future__ import (误报: 包含 "future" 关键词)
if stripped.startswith("from __future__") or stripped.startswith("import __future__"):
continue
for pattern, severity, message in STATIC_RULES:
for m in re.finditer(pattern, line, re.IGNORECASE):
col = m.start()
# 取上下文 (前后 20 字符)
ctx_start = max(0, col - 20)
ctx_end = min(len(line), m.end() + 20)
snippet = line[ctx_start:ctx_end].strip()
issue = Issue(
line=line_no,
col=col,
severity=severity,
rule=pattern,
message=message,
code_snippet=f"...{snippet}...",
)
report.issues.append(issue)
# 检测 AST 层面的危险: 赋值后用未来索引
try:
tree = ast.parse(code)
for node in ast.walk(tree):
# 检测 Subscript with negative index (如 close[-1])
if isinstance(node, ast.Subscript):
if isinstance(node.slice, ast.UnaryOp):
if isinstance(node.slice.op, ast.USub):
if isinstance(node.slice.operand, ast.Constant):
# 找到负索引, 但需确认是 close/high/low/open
if isinstance(node.value, ast.Name):
if node.value.id in ("close", "high", "low", "open"):
report.issues.append(Issue(
line=node.lineno,
col=node.col_offset,
severity="HIGH",
rule="ast:negative_index",
message=f"{node.value.id} 负索引访问 (可能是未来数据)",
))
except SyntaxError:
pass # 语法错误由其他工具报
# 判定通过/失败: 只要有 HIGH 就失败
report.passed = not report.has_high
return report
def check_strategy_file(file_path: str) -> LookaheadReport:
"""扫描策略文件"""
with open(file_path, "r", encoding="utf-8") as f:
code = f.read()
return check_strategy_code(code, file_path)
def check_all_strategies(strategies_dir: str) -> list:
"""
扫描 strategies/ 目录下所有 .py 文件
返回:
[(file_path, report), ...]
"""
results = []
if not os.path.exists(strategies_dir):
return results
for fname in sorted(os.listdir(strategies_dir)):
if not fname.endswith(".py") or fname.startswith("_"):
continue
path = os.path.join(strategies_dir, fname)
report = check_strategy_file(path)
results.append((path, report))
return results
# ============================================================================
# 动态验证 — 修改未来 bar, 检查历史信号是否变化
# ============================================================================
def dynamic_check(
strategy_class,
df: pd.DataFrame,
params: Optional[dict] = None,
check_bars: int = 50,
perturb_range: int = 10,
) -> "DynamicCheckResult":
"""
动态前视检测: 修改 bar N+1..N+K 的 OHLC, 信号 entries[:N] 应不变
原理:
策略生成信号只用过去+当前数据, 所以修改未来 bar 不应影响历史信号。
若历史信号变化 → 存在前视。
参数:
strategy_class: 策略类
df: 原始数据
params: 策略参数 dict
check_bars: 检查前 N 根 bar 的信号是否变化
perturb_range: 修改未来多少根 bar
返回:
DynamicCheckResult
"""
params = params or {}
n = len(df)
if n < check_bars + perturb_range + 100:
return DynamicCheckResult(
passed=False,
reason=f"数据不足: {n} bars, 至少需要 {check_bars + perturb_range + 100}",
changed_bars=[],
)
# 1. 用原始数据生成基准信号
strategy = strategy_class(**params)
base_signals = strategy.generate_signals(df)
base_entries = base_signals.entries.copy()
base_exits = base_signals.exits.copy()
# 2. 修改未来 bar (check_bars 之后的 perturb_range 根)
df_perturbed = df.copy()
perturb_start = check_bars
perturb_end = min(check_bars + perturb_range, n)
# 显著修改未来 OHLC (±5%)
for col in ["open", "high", "low", "close"]:
if col in df_perturbed.columns:
original = df_perturbed[col].values.copy()
noise = np.random.uniform(0.95, 1.05, size=perturb_end - perturb_start)
original[perturb_start:perturb_end] *= noise
df_perturbed[col] = original
# 3. 用扰动后数据生成信号
strategy2 = strategy_class(**params)
perturbed_signals = strategy2.generate_signals(df_perturbed)
perturbed_entries = perturbed_signals.entries
perturbed_exits = perturbed_signals.exits
# 4. 比较前 check_bars 根的信号
changed_entries = np.where(
base_entries[:check_bars] != perturbed_entries[:check_bars]
)[0]
changed_exits = np.where(
base_exits[:check_bars] != perturbed_exits[:check_bars]
)[0]
changed_bars = sorted(set(changed_entries.tolist() + changed_exits.tolist()))
passed = len(changed_bars) == 0
if not passed:
reason = (
f"修改 bar {perturb_start}-{perturb_end} 后, "
f"{check_bars} 根 bar 中有 {len(changed_bars)} 根信号变化, "
f"存在前视偏差"
)
else:
reason = (
f"修改未来 {perturb_range} 根 bar 后, "
f"{check_bars} 根 bar 的信号无变化, 无前视偏差"
)
return DynamicCheckResult(
passed=passed,
reason=reason,
changed_bars=changed_bars,
)
@dataclass
class DynamicCheckResult:
"""动态前视检测结果"""
passed: bool
reason: str
changed_bars: list # 信号变化的 bar 索引
def summary(self) -> str:
icon = "" if self.passed else ""
lines = [
f"动态前视检测: {icon} {'通过' if self.passed else '失败'}",
f" {self.reason}",
]
if self.changed_bars:
lines.append(
f" 信号变化的 bar: {self.changed_bars[:10]}"
+ ("..." if len(self.changed_bars) > 10 else "")
)
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"passed": self.passed,
"reason": self.reason,
"changed_bars": self.changed_bars,
"n_changed": len(self.changed_bars),
}
# ============================================================================
# 综合检查 (静态 + 动态)
# ============================================================================
def full_check(
strategy_class,
strategy_file: str,
df: pd.DataFrame,
params: Optional[dict] = None,
run_dynamic: bool = True,
) -> "FullCheckReport":
"""
综合前视检查: 静态扫描源码 + 动态验证信号
参数:
strategy_class: 策略类
strategy_file: 策略源码文件路径
df: 测试数据
params: 策略参数
run_dynamic: 是否运行动态检测 (默认 True)
返回:
FullCheckReport
"""
static_report = check_strategy_file(strategy_file)
dynamic_result = None
if run_dynamic:
try:
dynamic_result = dynamic_check(strategy_class, df, params)
except Exception as e:
dynamic_result = DynamicCheckResult(
passed=False,
reason=f"动态检测异常: {e}",
changed_bars=[],
)
return FullCheckReport(
static_report=static_report,
dynamic_result=dynamic_result,
)
@dataclass
class FullCheckReport:
"""综合检查报告"""
static_report: LookaheadReport
dynamic_result: Optional[DynamicCheckResult]
@property
def passed(self) -> bool:
if not self.static_report.passed:
return False
if self.dynamic_result and not self.dynamic_result.passed:
return False
return True
def summary(self) -> str:
lines = [
"=" * 60,
"前视偏差综合检测",
"=" * 60,
"",
self.static_report.summary(),
"",
]
if self.dynamic_result:
lines.append(self.dynamic_result.summary())
lines.append("")
lines.append(f"总结: {'✓ 通过' if self.passed else '❌ 未通过'}")
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"passed": self.passed,
"static": self.static_report.to_dict(),
"dynamic": self.dynamic_result.to_dict() if self.dynamic_result else None,
}
+1077
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
"""
策略参数优化器 — 网格搜索 + 响应面分析
核心功能:
- 给定策略类 + 参数空间,自动遍历所有组合
- 每组参数跑一次回测,收集关键指标
- 按指定指标排序,返回最优参数 + 完整响应面
- 支持错误恢复(0 信号/异常参数不会中断搜索)
用法:
from optimizer import StrategyOptimizer
from strategies.sma_cross import SmaCrossStrategy
opt = StrategyOptimizer(metric="sharpe_ratio")
result = opt.optimize(
strategy_class=SmaCrossStrategy,
df=df,
param_grid={"fast": [5,10,15], "slow": [20,30,40]},
)
print(result.best_params) # {"fast": 10, "slow": 30}
result.export("optimization.csv")
"""
from __future__ import annotations
import itertools
import os
from typing import Type
import numpy as np
import pandas as pd
import raptorbt
from strategies.base import Strategy
# 这些指标的值越小越好
_MINIMIZE_METRICS = {"max_drawdown_pct"}
class OptimizationResult:
"""优化结果容器"""
def __init__(self, results: pd.DataFrame, metric: str, direction: str, param_names: list):
self.results = results
self.metric = metric
self.direction = direction
self.param_names = param_names
# 找最优行
valid = results[results[metric].notna()]
if len(valid) == 0:
self.best_params = {}
self.best_score = np.nan
return
if direction == "maximize":
best_idx = valid[metric].idxmax()
else:
best_idx = valid[metric].idxmin()
best_row = results.loc[best_idx]
self.best_params = {p: best_row[p] for p in param_names}
self.best_score = best_row[metric]
def top_n(self, n=10) -> pd.DataFrame:
"""返回前 N 个参数组合"""
valid = self.results[self.results[self.metric].notna()]
if len(valid) == 0:
return valid
if self.direction == "maximize":
return valid.nlargest(n, self.metric)
return valid.nsmallest(n, self.metric)
def export(self, path: str):
"""导出完整结果到 CSV"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
self.results.to_csv(path, index=False, encoding="utf-8-sig")
def summary(self) -> str:
params_str = ", ".join(f"{k}={v}" for k, v in self.best_params.items())
n = len(self.results)
valid = self.results[self.metric].notna().sum()
failed = n - valid
return (
f"优化完成: {n} 个组合, {valid} 个有效, {failed} 个失败\n"
f"最优参数: {params_str}\n"
f"最优 {self.metric}: {self.best_score:.4f}"
)
def to_dict(self) -> dict:
"""转为可 JSON 序列化的字典 (含最优参数 + Top 10 组合)"""
top = self.top_n(10)
# 处理 NaN, 让 json.dumps 能序列化
top_records = top.to_dict(orient="records")
return {
"metric": self.metric,
"direction": self.direction,
"n_combinations": len(self.results),
"n_valid": int(self.results[self.metric].notna().sum()),
"n_failed": int(self.results[self.metric].isna().sum()),
"best_params": self.best_params,
"best_score": None if pd.isna(self.best_score) else self.best_score,
"top_10": [
{k: (None if pd.isna(v) else v) for k, v in r.items()}
for r in top_records
],
}
class StrategyOptimizer:
"""
策略参数网格搜索优化器
参数:
metric: 优化的目标指标 (如 sharpe_ratio, total_return_pct)
direction: "maximize""minimize", None 时自动推断
"""
def __init__(self, metric: str = "sharpe_ratio", direction: str | None = None):
self.metric = metric
if direction is None:
direction = "minimize" if metric in _MINIMIZE_METRICS else "maximize"
self.direction = direction
def optimize(
self,
strategy_class: Type[Strategy],
df: pd.DataFrame,
param_grid: dict,
symbol: str = "OPT",
verbose: bool = True,
) -> OptimizationResult:
"""
执行网格搜索
参数:
strategy_class: Strategy 子类
df: K 线数据 DataFrame
param_grid: {参数名: [可选值列表]}
symbol: 回测标的标签
verbose: 是否打印进度
返回:
OptimizationResult
"""
param_names = list(param_grid.keys())
param_values = list(param_grid.values())
combinations = list(itertools.product(*param_values))
total = len(combinations)
results = []
for i, combo in enumerate(combinations):
params = dict(zip(param_names, combo))
row = self._run_single(strategy_class, params, df, symbol)
row["combo_id"] = i
results.append(row)
if verbose and ((i + 1) % 50 == 0 or i + 1 == total):
print(f" 进度: {i+1}/{total} ({100*(i+1)/total:.0f}%)")
results_df = pd.DataFrame(results)
return OptimizationResult(results_df, self.metric, self.direction, param_names)
def _run_single(self, strategy_class, params: dict, df, symbol) -> dict:
"""执行单次回测,返回指标字典"""
row = {**params, "error": ""}
# 实例化策略
try:
strategy = strategy_class(**params)
except Exception as e:
row[self.metric] = np.nan
row["error"] = f"init: {e}"
row["total_trades"] = 0
return row
# 检查预热期
warmup = strategy.warmup_bars()
if warmup >= len(df):
row[self.metric] = np.nan
row["error"] = "warmup >= data"
row["total_trades"] = 0
return row
# 生成信号
try:
signals = strategy.generate_signals(df)
except Exception as e:
row[self.metric] = np.nan
row["error"] = f"signals: {e}"
row["total_trades"] = 0
return row
n_entries = int(signals.entries.sum())
row["n_entries"] = n_entries
if n_entries == 0:
row[self.metric] = np.nan
row["error"] = "0 signals"
row["total_trades"] = 0
return row
# 执行回测
try:
config = strategy.build_config()
arr = Strategy.to_arrays(df)
result = raptorbt.run_single_backtest(
timestamps=arr["timestamps"],
open=arr["open"], high=arr["high"], low=arr["low"], close=arr["close"],
volume=arr["volume"],
entries=signals.entries, exits=signals.exits,
direction=signals.direction, weight=1.0, symbol=symbol,
config=config,
)
m = result.metrics
row[self.metric] = getattr(m, self.metric, np.nan)
# 收集常用指标
row["total_trades"] = m.total_trades
row["total_return_pct"] = m.total_return_pct
row["sharpe_ratio"] = m.sharpe_ratio
row["sortino_ratio"] = getattr(m, "sortino_ratio", np.nan)
row["max_drawdown_pct"] = m.max_drawdown_pct
row["win_rate_pct"] = m.win_rate_pct
row["profit_factor"] = m.profit_factor
row["expectancy"] = getattr(m, "expectancy", np.nan)
except Exception as e:
row[self.metric] = np.nan
row["error"] = f"backtest: {e}"
row["total_trades"] = 0
return row
+364
View File
@@ -0,0 +1,364 @@
"""
策略模板生成器 — 为 AI agent 提供标准化起点
支持模板:
- crossover: 均线交叉 (SMA/EMA)
- mean_reversion: RSI/Bollinger 均值回归
- trend_following: ADX + DI 趋势跟踪
- breakout: Donchian 通道突破
- custom: 空白模板
用法:
from scaffold import scaffold_strategy
path = scaffold_strategy("my_rsi", template="mean_reversion",
description="RSI 超卖反弹策略")
print(f"模板已生成: {path}")
# 接着编辑 strategies/my_rsi.py 填入具体逻辑
"""
from __future__ import annotations
import os
def _to_class_name(name: str) -> str:
"""snake_case → PascalCase (my_strategy → MyStrategy)"""
return "".join(w.capitalize() for w in name.split("_"))
# 策略文件头部注释 (包含前视偏差警告, 提醒 AI agent 遵守)
_HEADER = '''"""{name}{description}
⚠️ 前视偏差 (Look-Ahead Bias) 注意事项:
信号生成时只能用当前 bar 及之前的数据, 严禁使用未来 bar。
以下模式会引入前视偏差, 必须避免:
- .shift(-N) # 访问未来 bar (N>0)
- close[-1] / high[-1] # 负索引访问未来
- df.iloc[i+N:] # 切片到未来索引
- 滚动统计后 shift 负值
正确做法:
- 用 cross_above / cross_below (基类已内置前视安全)
- 信号在 bar 收盘后生成, 用 close 成交 (引擎默认 upon_bar_close=True)
- 检测: python -m app.main check {name}
"""
'''
_TEMPLATES = {
"crossover": '''"""{name}{description}"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class {ClassName}Strategy(Strategy):
"""{description}"""
name = "{name}"
def __init__(self, fast: int = 10, slow: int = 20):
self.fast = fast
self.slow = slow
def warmup_bars(self) -> int:
return self.slow + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
ma_fast = raptorbt.sma(close, period=self.fast)
ma_slow = raptorbt.sma(close, period=self.slow)
entries = self.cross_above(ma_fast, ma_slow).astype(bool)
exits = self.cross_below(ma_fast, ma_slow).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries, exits=exits, direction=1,
extra={{"ma_fast": ma_fast, "ma_slow": ma_slow}},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_fixed_stop(0.02)
config.set_fixed_target(0.04)
return config
def description(self) -> str:
return f"SMA({{self.fast}})/SMA({{self.slow}}) 交叉, 2% 止损/4% 止盈"
STRATEGY_CLASS = {ClassName}Strategy
''',
"mean_reversion": '''"""{name}{description}"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class {ClassName}Strategy(Strategy):
"""{description}"""
name = "{name}"
def __init__(self, period: int = 14, oversold: float = 30.0, overbought: float = 70.0):
self.period = period
self.oversold = oversold
self.overbought = overbought
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
rsi = raptorbt.rsi(close, period=self.period)
entries = (rsi < self.oversold).astype(bool)
exits = (rsi > self.overbought).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries, exits=exits, direction=1,
extra={{"rsi": rsi}},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_trailing_stop(0.03)
return config
def description(self) -> str:
return f"RSI({{self.period}}) 均值回归, <{{self.oversold}} 买入 / >{{self.overbought}} 卖出"
STRATEGY_CLASS = {ClassName}Strategy
''',
"trend_following": '''"""{name}{description}"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class {ClassName}Strategy(Strategy):
"""{description}"""
name = "{name}"
def __init__(self, adx_period: int = 14, adx_threshold: float = 25.0):
self.adx_period = adx_period
self.adx_threshold = adx_threshold
def warmup_bars(self) -> int:
return 2 * self.adx_period + 10
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=self.adx_period)
adx_strong = adx > self.adx_threshold
entries = (adx_strong & (plus_di > minus_di)).astype(bool)
exits = (adx_strong & (minus_di > plus_di)).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries, exits=exits, direction=1,
extra={{"adx": adx, "plus_di": plus_di, "minus_di": minus_di}},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_atr_stop(multiplier=2.5, period=14)
config.set_fixed_target(0.05)
return config
def description(self) -> str:
return f"ADX({{self.adx_period}})>{{self.adx_threshold}} + DI 方向确认, 2.5×ATR 止损"
STRATEGY_CLASS = {ClassName}Strategy
''',
"breakout": '''"""{name}{description}"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class {ClassName}Strategy(Strategy):
"""{description}"""
name = "{name}"
def __init__(self, period: int = 20):
self.period = period
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
upper, middle, lower = raptorbt.donchian(high, low, period=self.period)
entries = (close > np.roll(upper, 1)).astype(bool)
exits = (close < np.roll(lower, 1)).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries, exits=exits, direction=1,
extra={{"donchian_upper": upper, "donchian_lower": lower}},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_trailing_stop(0.05)
return config
def description(self) -> str:
return f"Donchian({{self.period}}) 通道突破, 5% 追踪止损"
STRATEGY_CLASS = {ClassName}Strategy
''',
"custom": '''"""{name}{description}"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class {ClassName}Strategy(Strategy):
"""{description}"""
name = "{name}"
def __init__(self, period: int = 14):
self.period = period
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
volume = df.get("tick_volume", df.get("volume")).values.astype(np.float64)
# TODO: 在此添加指标计算和信号生成逻辑
# 例如:
# rsi = raptorbt.rsi(close, period=self.period)
# entries = rsi < 30
# exits = rsi > 70
entries = np.zeros(len(close), dtype=bool)
exits = np.zeros(len(close), dtype=bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(entries=entries, exits=exits, direction=1)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
config.set_fixed_stop(0.02)
config.set_fixed_target(0.04)
return config
def description(self) -> str:
return "{description}"
STRATEGY_CLASS = {ClassName}Strategy
''',
}
def scaffold_strategy(
name: str,
template: str = "custom",
description: str = "",
overwrite: bool = False,
) -> str:
"""
生成策略模板文件
参数:
name: 策略名称 (snake_case, 如 "my_rsi")
template: 模板类型 (crossover/mean_reversion/trend_following/breakout/custom)
description: 策略描述文字
overwrite: 是否覆盖已存在的文件
返回:
生成的文件路径
"""
if template not in _TEMPLATES:
raise ValueError(
f"未知模板 '{template}', 可选: {', '.join(_TEMPLATES.keys())}"
)
class_name = _to_class_name(name)
if not description:
description = f"{template} 策略"
# 组装: 头部警告 + 模板正文 (去掉模板自带的 docstring 行)
header = _HEADER.format(name=name, description=description)
template_body = _TEMPLATES[template].format(
name=name,
ClassName=class_name,
description=description,
)
# 去掉模板第一行的 docstring (已被 _HEADER 取代)
template_body = template_body.split("\n", 1)[1] if template_body.startswith('"""') else template_body
code = header + template_body
file_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"strategies",
f"{name}.py",
)
if os.path.exists(file_path) and not overwrite:
raise FileExistsError(
f"策略文件已存在: {file_path}\n使用 overwrite=True 覆盖"
)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(code)
return file_path
+389
View File
@@ -0,0 +1,389 @@
"""
Walk-Forward 验证 — 滚动 IS/OOS + 过拟合检测
核心流程:
1. 将数据切分为滚动窗口 (train + test)
2. 在每个 train 窗口上做参数优化 (IS)
3. 用最优参数在 test 窗口上回测 (OOS)
4. 汇总: IS/OOS 性能对比、衰减比、参数稳定性
判定过拟合: OOS 夏普 / IS 夏普 < 0.5 → 过拟合
用法:
from walk_forward import WalkForwardValidator
from strategies.sma_cross import SmaCrossStrategy
wf = WalkForwardValidator(train_size=300, test_size=100)
result = wf.validate(
strategy_class=SmaCrossStrategy,
df=df,
param_grid={"fast": [5,10], "slow": [20,30]},
)
print(result.summary())
print(f"过拟合: {result.is_overfit}")
"""
from __future__ import annotations
import os
from typing import Type
import numpy as np
import pandas as pd
import raptorbt
from .optimizer import StrategyOptimizer
from strategies.base import Strategy
class WalkForwardWindow:
"""单个 walk-forward 窗口的结果"""
def __init__(self, idx, train_start, train_end, test_start, test_end,
best_params, is_metrics, oos_metrics):
self.idx = idx
self.train_start = train_start
self.train_end = train_end
self.test_start = test_start
self.test_end = test_end
self.best_params = best_params
self.is_metrics = is_metrics # dict
self.oos_metrics = oos_metrics # dict
class WalkForwardResult:
"""Walk-forward 验证汇总结果"""
def __init__(self, windows: list, metric: str):
self.windows = windows
self.metric = metric
self.n_windows = len(windows)
@property
def is_sharpe_avg(self) -> float:
vals = [w.is_metrics.get("sharpe_ratio", np.nan) for w in self.windows]
return float(np.nanmean(vals)) if vals else np.nan
@property
def oos_sharpe_avg(self) -> float:
vals = [w.oos_metrics.get("sharpe_ratio", np.nan) for w in self.windows]
return float(np.nanmean(vals)) if vals else np.nan
@property
def oos_return_avg(self) -> float:
vals = [w.oos_metrics.get("total_return_pct", np.nan) for w in self.windows]
return float(np.nanmean(vals)) if vals else np.nan
@property
def oos_max_drawdown_avg(self) -> float:
vals = [w.oos_metrics.get("max_drawdown_pct", np.nan) for w in self.windows]
return float(np.nanmean(vals)) if vals else np.nan
@property
def oos_trades_total(self) -> int:
return sum(w.oos_metrics.get("total_trades", 0) for w in self.windows)
@property
def oos_win_rate_avg(self) -> float:
vals = [w.oos_metrics.get("win_rate_pct", np.nan) for w in self.windows]
return float(np.nanmean(vals)) if vals else np.nan
@property
def oos_profit_factor_avg(self) -> float:
"""OOS 平均盈利因子 (总盈利/总亏损, >1 为正期望)
注意: 单窗口 PF 可能是 inf (只有盈利单无亏损单) 或 nan (无交易),
这两种都过滤掉, 只对有效窗口求平均。
"""
vals = [w.oos_metrics.get("profit_factor", np.nan) for w in self.windows]
vals = [v for v in vals if not np.isnan(v) and not np.isinf(v)]
return float(np.mean(vals)) if vals else np.nan
@property
def oos_expectancy_avg(self) -> float:
"""OOS 平均每笔期望值 (单位: 百分比, >0 为正期望)
注意: 无交易窗口的 expectancy 是 nan, 过滤掉。
"""
vals = [w.oos_metrics.get("expectancy", np.nan) for w in self.windows]
vals = [v for v in vals if not np.isnan(v) and not np.isinf(v)]
return float(np.mean(vals)) if vals else np.nan
@property
def decay_ratio(self) -> float:
"""OOS/IS 夏普衰减比, 越接近 1.0 越好, < 0.5 判定过拟合"""
is_val = self.is_sharpe_avg
oos_val = self.oos_sharpe_avg
if np.isnan(is_val) or np.isnan(oos_val) or is_val == 0:
return np.nan
return oos_val / is_val
@property
def is_overfit(self) -> bool:
"""衰减比 < 0.5 判定为过拟合"""
decay = self.decay_ratio
if np.isnan(decay):
return True
return decay < 0.5
@property
def param_stability(self) -> dict:
"""参数稳定性: 各参数值被选中的频次分布"""
stability = {}
for w in self.windows:
for k, v in w.best_params.items():
if k not in stability:
stability[k] = {}
key = str(v)
stability[k][key] = stability[k].get(key, 0) + 1
return stability
def export(self, path: str):
"""导出逐窗口明细到 CSV"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
rows = []
for w in self.windows:
row = {
"window": w.idx,
"train_start": w.train_start, "train_end": w.train_end,
"test_start": w.test_start, "test_end": w.test_end,
"best_params": str(w.best_params),
}
for k, v in w.is_metrics.items():
row[f"is_{k}"] = v
for k, v in w.oos_metrics.items():
row[f"oos_{k}"] = v
rows.append(row)
pd.DataFrame(rows).to_csv(path, index=False, encoding="utf-8-sig")
def summary(self) -> str:
decay_str = f"{self.decay_ratio:.2%}" if not np.isnan(self.decay_ratio) else "N/A"
pf_str = f"{self.oos_profit_factor_avg:.2f}" if not np.isnan(self.oos_profit_factor_avg) else "N/A"
exp_str = f"{self.oos_expectancy_avg:.4f}" if not np.isnan(self.oos_expectancy_avg) else "N/A"
lines = [
f"Walk-Forward 验证: {self.n_windows} 个窗口",
f" IS 平均夏普: {self.is_sharpe_avg:>8.4f}",
f" OOS 平均夏普: {self.oos_sharpe_avg:>8.4f}",
f" 衰减比: {decay_str:>8}",
f" 过拟合判定: {'是 ⚠️' if self.is_overfit else '否 ✓'}",
f" OOS 总交易数: {self.oos_trades_total:>8d}",
f" OOS 平均收益: {self.oos_return_avg:>8.2f}%",
f" OOS 盈利因子: {pf_str:>8}",
f" OOS 每笔期望: {exp_str:>8}",
f" OOS 平均回撤: {self.oos_max_drawdown_avg:>8.2f}%",
f" OOS 平均胜率: {self.oos_win_rate_avg:>8.1f}%",
f" 参数稳定性:",
]
for param, dist in self.param_stability.items():
dist_str = ", ".join(
f"{k}:{v}" for k, v in sorted(dist.items(), key=lambda x: -x[1])
)
lines.append(f" {param}: {dist_str}")
return "\n".join(lines)
def to_dict(self) -> dict:
"""转为可 JSON 序列化的字典 (不含完整 windows 明细, 仅汇总)"""
decay = self.decay_ratio
return {
"n_windows": self.n_windows,
"metric": self.metric,
"is_sharpe_avg": self.is_sharpe_avg,
"oos_sharpe_avg": self.oos_sharpe_avg,
"oos_return_avg": self.oos_return_avg,
"oos_max_drawdown_avg": self.oos_max_drawdown_avg,
"oos_trades_total": self.oos_trades_total,
"oos_win_rate_avg": self.oos_win_rate_avg,
"oos_profit_factor_avg": self.oos_profit_factor_avg,
"oos_expectancy_avg": self.oos_expectancy_avg,
"decay_ratio": None if np.isnan(decay) else decay,
"is_overfit": self.is_overfit,
"param_stability": self.param_stability,
"windows": [
{
"idx": w.idx,
"train_start": w.train_start, "train_end": w.train_end,
"test_start": w.test_start, "test_end": w.test_end,
"best_params": w.best_params,
"is_metrics": w.is_metrics,
"oos_metrics": w.oos_metrics,
}
for w in self.windows
],
}
class WalkForwardValidator:
"""
Walk-Forward 验证器
参数:
train_size: 训练窗口大小 (bars)
test_size: 测试窗口大小 (bars)
step: 滚动步长 (默认 = test_size, 即非重叠)
用法:
wf = WalkForwardValidator(train_size=300, test_size=100)
result = wf.validate(
strategy_class=SmaCrossStrategy,
df=df,
param_grid={"fast": [5,10], "slow": [20,30]},
)
"""
def __init__(self, train_size: int = 300, test_size: int = 100, step: int | None = None):
self.train_size = train_size
self.test_size = test_size
self.step = step or test_size
def validate(
self,
strategy_class: Type[Strategy],
df: pd.DataFrame,
param_grid: dict,
metric: str = "sharpe_ratio",
symbol: str = "WF",
verbose: bool = True,
) -> WalkForwardResult:
"""
执行 walk-forward 验证
返回:
WalkForwardResult
"""
n = len(df)
min_required = self.train_size + self.test_size
if n < min_required:
raise ValueError(
f"数据不足: {n} bars, 至少需要 {min_required} bars "
f"(train={self.train_size} + test={self.test_size})"
)
windows = []
start = 0
window_idx = 0
while start + min_required <= n:
train_start = start
train_end = start + self.train_size
test_start = train_end
test_end = min(train_end + self.test_size, n)
if verbose:
print(f"\n 窗口 {window_idx}: "
f"train=[{train_start}:{train_end}] "
f"test=[{test_start}:{test_end}]")
train_df = df.iloc[train_start:train_end].reset_index(drop=True)
test_df = df.iloc[test_start:test_end].reset_index(drop=True)
# Step 1: 在训练集上优化参数
optimizer = StrategyOptimizer(metric=metric, direction=None)
opt_result = optimizer.optimize(
strategy_class=strategy_class,
df=train_df,
param_grid=param_grid,
symbol=f"{symbol}_train",
verbose=False,
)
best_params = opt_result.best_params
if not best_params:
if verbose:
print(f" ⚠️ 训练集无有效参数, 跳过")
start += self.step
window_idx += 1
continue
if verbose:
params_str = ", ".join(f"{k}={v}" for k, v in best_params.items())
print(f" 最优参数: {params_str}")
# Step 2: 提取 IS 指标 (从优化结果)
is_metrics = self._extract_is_metrics(opt_result)
# Step 3: 在测试集上用最优参数回测
oos_metrics = self._run_with_params(
strategy_class, best_params, test_df, f"{symbol}_test"
)
if oos_metrics is None:
oos_metrics = {
"sharpe_ratio": np.nan, "total_return_pct": np.nan,
"max_drawdown_pct": np.nan, "total_trades": 0,
"win_rate_pct": np.nan, "profit_factor": np.nan,
"expectancy": np.nan,
}
if verbose:
is_sharpe = is_metrics.get("sharpe_ratio", np.nan)
oos_sharpe = oos_metrics.get("sharpe_ratio", np.nan)
print(f" IS 夏普: {is_sharpe:.4f} OOS 夏普: {oos_sharpe:.4f}")
windows.append(WalkForwardWindow(
window_idx, train_start, train_end, test_start, test_end,
best_params, is_metrics, oos_metrics,
))
start += self.step
window_idx += 1
return WalkForwardResult(windows, metric)
def _run_with_params(self, strategy_class, params: dict, df, symbol) -> dict | None:
"""用指定参数运行回测, 返回指标字典"""
try:
strategy = strategy_class(**params)
if strategy.warmup_bars() >= len(df):
return None
signals = strategy.generate_signals(df)
if int(signals.entries.sum()) == 0:
return None
config = strategy.build_config()
arr = Strategy.to_arrays(df)
result = raptorbt.run_single_backtest(
timestamps=arr["timestamps"],
open=arr["open"], high=arr["high"], low=arr["low"], close=arr["close"],
volume=arr["volume"],
entries=signals.entries, exits=signals.exits,
direction=signals.direction, weight=1.0, symbol=symbol,
config=config,
)
m = result.metrics
return {
"sharpe_ratio": m.sharpe_ratio,
"total_return_pct": m.total_return_pct,
"max_drawdown_pct": m.max_drawdown_pct,
"total_trades": m.total_trades,
"win_rate_pct": m.win_rate_pct,
"profit_factor": m.profit_factor,
"expectancy": getattr(m, "expectancy", np.nan),
}
except Exception as e:
print(f" ⚠️ OOS 回测失败: {e}")
return None
def _extract_is_metrics(self, opt_result) -> dict:
"""从优化结果中提取最优行的完整指标"""
if not opt_result.best_params:
return {opt_result.metric: np.nan}
results = opt_result.results
# 找到匹配最优参数的行
mask = pd.Series([True] * len(results))
for k, v in opt_result.best_params.items():
mask &= (results[k] == v)
matched = results[mask]
if len(matched) == 0:
return {opt_result.metric: opt_result.best_score}
row = matched.iloc[0]
return {
"sharpe_ratio": row.get("sharpe_ratio", np.nan),
"total_return_pct": row.get("total_return_pct", np.nan),
"max_drawdown_pct": row.get("max_drawdown_pct", np.nan),
"total_trades": row.get("total_trades", 0),
"win_rate_pct": row.get("win_rate_pct", np.nan),
"profit_factor": row.get("profit_factor", np.nan),
"expectancy": row.get("expectancy", np.nan),
}
+123
View File
@@ -0,0 +1,123 @@
//! Benchmark for RaptorBT backtesting performance.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use raptorbt::core::types::{BacktestConfig, CompiledSignals, Direction, OhlcvData};
use raptorbt::indicators::trend::{ema, sma};
use raptorbt::portfolio::engine::PortfolioEngine;
/// Generate sample OHLCV data.
fn generate_sample_data(n: usize) -> OhlcvData {
let mut open = vec![100.0; n];
let mut high = vec![101.0; n];
let mut low = vec![99.0; n];
let mut close = vec![100.0; n];
// Create a trending pattern
for i in 1..n {
let change = (i as f64 * 0.1).sin() * 2.0;
close[i] = close[i - 1] + change;
open[i] = close[i - 1];
high[i] = close[i].max(open[i]) + 1.0;
low[i] = close[i].min(open[i]) - 1.0;
}
OhlcvData {
timestamps: (0..n as i64).collect(),
open,
high,
low,
close,
volume: vec![1000.0; n],
}
}
/// Generate sample trading signals based on SMA crossover.
fn generate_sample_signals(
close: &[f64],
fast_period: usize,
slow_period: usize,
) -> CompiledSignals {
let n = close.len();
let fast_sma = sma(close, fast_period).unwrap_or_else(|_| vec![0.0; n]);
let slow_sma = sma(close, slow_period).unwrap_or_else(|_| vec![0.0; n]);
let mut entries = vec![false; n];
let mut exits = vec![false; n];
for i in 1..n {
// Entry: fast crosses above slow
if fast_sma[i] > slow_sma[i] && fast_sma[i - 1] <= slow_sma[i - 1] {
entries[i] = true;
}
// Exit: fast crosses below slow
if fast_sma[i] < slow_sma[i] && fast_sma[i - 1] >= slow_sma[i - 1] {
exits[i] = true;
}
}
CompiledSignals {
symbol: "BENCH".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
}
}
fn bench_single_backtest(c: &mut Criterion) {
let mut group = c.benchmark_group("single_backtest");
for size in [1000, 5000, 10000, 50000].iter() {
group.bench_with_input(BenchmarkId::new("bars", size), size, |b, &size| {
let ohlcv = generate_sample_data(size);
let signals = generate_sample_signals(&ohlcv.close, 10, 30);
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
b.iter(|| {
let result = engine.run_single(black_box(&ohlcv), black_box(&signals));
black_box(result)
});
});
}
group.finish();
}
fn bench_sma(c: &mut Criterion) {
let mut group = c.benchmark_group("sma");
for size in [1000, 5000, 10000, 50000].iter() {
group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| {
let ohlcv = generate_sample_data(size);
b.iter(|| {
let result = sma(black_box(&ohlcv.close), black_box(20));
black_box(result)
});
});
}
group.finish();
}
fn bench_ema(c: &mut Criterion) {
let mut group = c.benchmark_group("ema");
for size in [1000, 5000, 10000, 50000].iter() {
group.bench_with_input(BenchmarkId::new("data_size", size), size, |b, &size| {
let ohlcv = generate_sample_data(size);
b.iter(|| {
let result = ema(black_box(&ohlcv.close), black_box(20));
black_box(result)
});
});
}
group.finish();
}
criterion_group!(benches, bench_single_backtest, bench_sma, bench_ema);
criterion_main!(benches);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
[build-system]
requires = ["maturin>=1.4,<2.0"]
build-backend = "maturin"
[project]
name = "raptorbt"
version = "0.4.1"
description = "High-performance Rust backtesting engine with Python bindings. Bar-level and tick-level simulation with sub-millisecond execution and a minimal footprint."
readme = "README.md"
requires-python = ">=3.10"
license = {file = "LICENSE"}
authors = [
{name = "Alphabench", email = "contact@alphabench.in"}
]
keywords = [
"backtesting",
"trading",
"quantitative-finance",
"algorithmic-trading",
"rust",
"high-performance",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Financial and Insurance Industry",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business :: Financial :: Investment",
"Topic :: Scientific/Engineering :: Information Analysis",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://www.alphabench.in/raptorbt"
Repository = "https://github.com/alphabench/raptorbt"
Documentation = "https://www.alphabench.in/raptorbt"
"Bug Tracker" = "https://github.com/alphabench/raptorbt/issues"
[tool.maturin]
features = ["pyo3/extension-module"]
python-source = "python"
module-name = "raptorbt._raptorbt"
+257
View File
@@ -0,0 +1,257 @@
"""
RaptorBT - High-performance Rust backtesting engine.
Provides Python bindings for a Rust-based backtesting engine built for
production quantitative trading:
- Sub-millisecond execution on thousands of bars
- Disk footprint: <10MB, startup latency: <10ms
- 100% deterministic execution (no JIT cache)
- Native parallelism via Rayon + explicit SIMD
- Full tick-level simulation (no bar resampling required)
- 80+ technical indicators from ferro-ta (P0 batch: PPO, APO, ADOSC,
OBV, CMO, ARONOSC, BOP, ULTOSC, and more)
"""
from raptorbt._raptorbt import (
# Config classes
PyBacktestConfig,
PyInstrumentConfig,
PyStopConfig,
PyTargetConfig,
# Result classes
PyBacktestResult,
PyBacktestMetrics,
PyTrade,
# Backtest functions
run_single_backtest,
run_basket_backtest,
run_options_backtest,
run_pairs_backtest,
run_multi_backtest,
run_spread_backtest,
run_tick_backtest,
# Batch backtest
PyBatchSpreadItem,
batch_spread_backtest,
# Monte Carlo simulation
simulate_portfolio_mc,
# Tick signal functions
compute_tick_entry_signals,
compute_tick_exit_signals,
# Tick feature functions
tick_spread_pct,
buy_sell_imbalance_delta,
return_window,
realized_vol_rolling,
oi_position_pct,
tick_velocity,
# Indicator functions
sma,
ema,
rsi,
macd,
stochastic,
atr,
bollinger_bands,
adx,
vwap,
supertrend,
rolling_min,
rolling_max,
# ferro-ta indicator functions
cci,
willr,
sar,
plus_di,
minus_di,
adx_all,
adxr,
roc,
mfi,
wma,
dema,
tema,
kama,
stochrsi,
aroon,
trix,
natr,
trange,
stddev,
var,
linearreg,
linearreg_slope,
linearreg_intercept,
linearreg_angle,
tsf,
beta,
correl,
ad,
adosc,
obv,
mom,
ppo,
cmo,
aroonosc,
bop,
ultosc,
typprice,
medprice,
avgprice,
wclprice,
midpoint,
midprice,
t3,
trima,
apo,
# P0 batch
vwma,
donchian,
choppiness_index,
hull_ma,
chandelier_exit,
ichimoku,
pivot_points,
# Hilbert Transform (cycle)
ht_trendline,
ht_dcperiod,
ht_dcphase,
ht_phasor,
ht_sine,
ht_trendmode,
# Market regime detection
regime_adx,
regime_combined,
detect_breaks_cusum,
rolling_variance_break,
# Portfolio / cross-series tools
rolling_beta,
drawdown_series,
zscore_series,
relative_strength,
spread,
ratio,
)
__version__ = "0.4.1"
__all__ = [
# Config classes
"PyBacktestConfig",
"PyInstrumentConfig",
"PyStopConfig",
"PyTargetConfig",
# Result classes
"PyBacktestResult",
"PyBacktestMetrics",
"PyTrade",
# Backtest functions
"run_single_backtest",
"run_basket_backtest",
"run_options_backtest",
"run_pairs_backtest",
"run_multi_backtest",
"run_spread_backtest",
"run_tick_backtest",
# Batch backtest
"PyBatchSpreadItem",
"batch_spread_backtest",
# Monte Carlo simulation
"simulate_portfolio_mc",
# Tick signal functions
"compute_tick_entry_signals",
"compute_tick_exit_signals",
# Tick feature functions
"tick_spread_pct",
"buy_sell_imbalance_delta",
"return_window",
"realized_vol_rolling",
"oi_position_pct",
"tick_velocity",
# Indicator functions
"sma",
"ema",
"rsi",
"macd",
"stochastic",
"atr",
"bollinger_bands",
"adx",
"vwap",
"supertrend",
"rolling_min",
"rolling_max",
# ferro-ta indicator functions
"cci",
"willr",
"sar",
"plus_di",
"minus_di",
"adx_all",
"adxr",
"roc",
"mfi",
"wma",
"dema",
"tema",
"kama",
"stochrsi",
"aroon",
"trix",
"natr",
"trange",
"stddev",
"var",
"linearreg",
"linearreg_slope",
"linearreg_intercept",
"linearreg_angle",
"tsf",
"beta",
"correl",
"ad",
"adosc",
"obv",
"mom",
"ppo",
"cmo",
"aroonosc",
"bop",
"ultosc",
"typprice",
"medprice",
"avgprice",
"wclprice",
"midpoint",
"midprice",
"t3",
"trima",
"apo",
# P0 batch
"vwma",
"donchian",
"choppiness_index",
"hull_ma",
"chandelier_exit",
"ichimoku",
"pivot_points",
# Hilbert Transform (cycle)
"ht_trendline",
"ht_dcperiod",
"ht_dcphase",
"ht_phasor",
"ht_sine",
"ht_trendmode",
# Market regime detection
"regime_adx",
"regime_combined",
"detect_breaks_cusum",
"rolling_variance_break",
# Portfolio / cross-series tools
"rolling_beta",
"drawdown_series",
"zscore_series",
"relative_strength",
"spread",
"ratio",
]
+3
View File
@@ -0,0 +1,3 @@
numpy
pandas
requests
+6
View File
@@ -0,0 +1,6 @@
edition = "2021"
max_width = 100
use_small_heuristics = "Max"
# Note: imports_granularity and group_imports require nightly Rust
# imports_granularity = "Module"
# group_imports = "StdExternalCrate"
+80
View File
@@ -0,0 +1,80 @@
//! Error types for RaptorBT.
use thiserror::Error;
/// Result type alias for RaptorBT operations.
pub type Result<T> = std::result::Result<T, RaptorError>;
/// Error types for the backtesting engine.
#[derive(Error, Debug)]
pub enum RaptorError {
/// Data length mismatch between arrays.
#[error("Data length mismatch: expected {expected}, got {actual}")]
LengthMismatch { expected: usize, actual: usize },
/// Invalid parameter value.
#[error("Invalid parameter: {message}")]
InvalidParameter { message: String },
/// Insufficient data for calculation.
#[error("Insufficient data: need at least {required} elements, got {available}")]
InsufficientData { required: usize, available: usize },
/// Invalid configuration.
#[error("Invalid configuration: {message}")]
InvalidConfig { message: String },
/// Division by zero error.
#[error("Division by zero in {context}")]
DivisionByZero { context: String },
/// Empty data error.
#[error("Empty data provided for {context}")]
EmptyData { context: String },
/// Invalid index access.
#[error("Index {index} out of bounds for length {length}")]
IndexOutOfBounds { index: usize, length: usize },
/// Python conversion error.
#[error("Python conversion error: {message}")]
PythonError { message: String },
}
impl RaptorError {
/// Create a length mismatch error.
pub fn length_mismatch(expected: usize, actual: usize) -> Self {
Self::LengthMismatch { expected, actual }
}
/// Create an invalid parameter error.
pub fn invalid_parameter(message: impl Into<String>) -> Self {
Self::InvalidParameter { message: message.into() }
}
/// Create an insufficient data error.
pub fn insufficient_data(required: usize, available: usize) -> Self {
Self::InsufficientData { required, available }
}
/// Create an invalid config error.
pub fn invalid_config(message: impl Into<String>) -> Self {
Self::InvalidConfig { message: message.into() }
}
/// Create a division by zero error.
pub fn division_by_zero(context: impl Into<String>) -> Self {
Self::DivisionByZero { context: context.into() }
}
/// Create an empty data error.
pub fn empty_data(context: impl Into<String>) -> Self {
Self::EmptyData { context: context.into() }
}
}
impl From<RaptorError> for pyo3::PyErr {
fn from(err: RaptorError) -> pyo3::PyErr {
pyo3::exceptions::PyValueError::new_err(err.to_string())
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Core types and utilities for RaptorBT.
pub mod error;
pub mod session;
pub mod timeseries;
pub mod types;
pub use error::{RaptorError, Result};
pub use session::{SessionConfig, SessionTracker};
pub use timeseries::TimeSeries;
pub use types::*;
+397
View File
@@ -0,0 +1,397 @@
//! Session tracking for intraday strategies.
//!
//! Handles:
//! - Session boundary detection (market open/close)
//! - Squareoff time enforcement
//! - Session high/low tracking for ORB and session-based indicators
//! - Timezone handling for IST (India Standard Time)
use serde::{Deserialize, Serialize};
/// Session configuration for trading hours.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
/// Market open hour (24-hour format).
pub market_open_hour: u32,
/// Market open minute.
pub market_open_minute: u32,
/// Market close hour (24-hour format).
pub market_close_hour: u32,
/// Market close minute.
pub market_close_minute: u32,
/// Squareoff minutes before market close.
pub squareoff_minutes_before_close: u32,
/// Timezone offset in hours from UTC (5 for IST = UTC+5:30).
pub timezone_offset_hours: i32,
/// Timezone offset minutes (30 for IST).
pub timezone_offset_minutes: i32,
}
impl Default for SessionConfig {
fn default() -> Self {
// Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25)
Self {
market_open_hour: 9,
market_open_minute: 15,
market_close_hour: 15,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
}
impl SessionConfig {
/// Create NSE equity session config (9:15 - 15:30).
pub fn nse_equity() -> Self {
Self::default()
}
/// Create MCX commodity session config (9:00 - 23:30).
pub fn mcx_commodity() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 23,
market_close_minute: 30,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Create CDS currency session config (9:00 - 17:00).
pub fn cds_currency() -> Self {
Self {
market_open_hour: 9,
market_open_minute: 0,
market_close_hour: 17,
market_close_minute: 0,
squareoff_minutes_before_close: 5,
timezone_offset_hours: 5,
timezone_offset_minutes: 30,
}
}
/// Get market open time in minutes from midnight.
pub fn market_open_minutes(&self) -> u32 {
self.market_open_hour * 60 + self.market_open_minute
}
/// Get market close time in minutes from midnight.
pub fn market_close_minutes(&self) -> u32 {
self.market_close_hour * 60 + self.market_close_minute
}
/// Get squareoff time in minutes from midnight.
pub fn squareoff_minutes(&self) -> u32 {
self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close)
}
/// Get timezone offset in seconds.
pub fn timezone_offset_seconds(&self) -> i64 {
(self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60)
}
}
/// Session tracker for managing intraday session state.
#[derive(Debug, Clone)]
pub struct SessionTracker {
config: SessionConfig,
/// Current session date (days since epoch in local timezone).
current_session_date: i64,
/// Session high price.
session_high: f64,
/// Session low price.
session_low: f64,
/// Session open price.
session_open: f64,
/// Bar index at session start.
session_start_idx: usize,
/// Whether we're currently in a trading session.
in_session: bool,
/// Whether squareoff has been triggered today.
squareoff_triggered: bool,
}
impl SessionTracker {
/// Create a new session tracker.
pub fn new(config: SessionConfig) -> Self {
Self {
config,
current_session_date: -1,
session_high: f64::NEG_INFINITY,
session_low: f64::INFINITY,
session_open: 0.0,
session_start_idx: 0,
in_session: false,
squareoff_triggered: false,
}
}
/// Convert nanosecond timestamp to local time components.
fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) {
// Convert to seconds
let timestamp_s = timestamp_ns / 1_000_000_000;
// Apply timezone offset
let local_s = timestamp_s + self.config.timezone_offset_seconds();
// Calculate date (days since epoch)
let days = local_s / 86400;
// Calculate time within day
let time_in_day = (local_s % 86400) as u32;
let hours = time_in_day / 3600;
let minutes = (time_in_day % 3600) / 60;
let seconds = time_in_day % 60;
(days, hours, minutes, seconds)
}
/// Get minutes from midnight for a timestamp.
fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 {
let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
hours * 60 + minutes
}
/// Check if timestamp is within trading hours.
pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes()
}
/// Check if it's squareoff time.
pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool {
let minutes = self.get_minutes_from_midnight(timestamp_ns);
minutes >= self.config.squareoff_minutes()
}
/// Check if this bar starts a new session.
pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool {
let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns);
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
// New day
if curr_date != prev_date {
let curr_minutes = curr_h * 60 + curr_m;
return curr_minutes >= self.config.market_open_minutes();
}
// Same day, but crossed market open
let prev_minutes = prev_h * 60 + prev_m;
let curr_minutes = curr_h * 60 + curr_m;
prev_minutes < self.config.market_open_minutes()
&& curr_minutes >= self.config.market_open_minutes()
}
/// Check if this bar ends the session.
pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option<i64>) -> bool {
let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns);
let curr_minutes = curr_h * 60 + curr_m;
// At or past market close
if curr_minutes >= self.config.market_close_minutes() {
return true;
}
// Check if next bar is in a new session
if let Some(next_ts) = next_ts_ns {
let (next_date, _, _, _) = self.timestamp_to_local(next_ts);
if next_date != curr_date {
return true;
}
}
false
}
/// Update session state for a new bar.
///
/// Returns tuple of (is_new_session, is_squareoff_time, is_session_end).
pub fn update(
&mut self,
idx: usize,
timestamp_ns: i64,
open: f64,
high: f64,
low: f64,
_close: f64,
prev_timestamp_ns: Option<i64>,
next_timestamp_ns: Option<i64>,
) -> (bool, bool, bool) {
let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns);
let time_minutes = hours * 60 + minutes;
// Check for new session
let is_new_session = if self.current_session_date != date {
// New date - check if within trading hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
} else if let Some(prev_ts) = prev_timestamp_ns {
if self.is_session_start(prev_ts, timestamp_ns) {
self.reset_session(idx, date, open);
true
} else {
false
}
} else {
// First bar - start session if within hours
if time_minutes >= self.config.market_open_minutes()
&& time_minutes < self.config.market_close_minutes()
{
self.reset_session(idx, date, open);
true
} else {
false
}
};
// Update session high/low
if self.in_session {
if high > self.session_high {
self.session_high = high;
}
if low < self.session_low {
self.session_low = low;
}
}
// Check squareoff time
let is_squareoff =
if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered {
self.squareoff_triggered = true;
self.in_session
} else {
false
};
// Check session end
let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns);
if is_session_end {
self.in_session = false;
}
(is_new_session, is_squareoff, is_session_end)
}
/// Reset session state for a new trading day.
fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) {
self.current_session_date = date;
self.session_start_idx = idx;
self.session_open = open_price;
self.session_high = open_price;
self.session_low = open_price;
self.in_session = true;
self.squareoff_triggered = false;
}
/// Get current session high.
pub fn session_high(&self) -> f64 {
self.session_high
}
/// Get current session low.
pub fn session_low(&self) -> f64 {
self.session_low
}
/// Get current session open.
pub fn session_open(&self) -> f64 {
self.session_open
}
/// Get session start index.
pub fn session_start_idx(&self) -> usize {
self.session_start_idx
}
/// Check if currently in a trading session.
pub fn in_session(&self) -> bool {
self.in_session
}
/// Get opening range (high - low) for the session.
pub fn opening_range(&self) -> f64 {
self.session_high - self.session_low
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 {
// Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds
// This is approximate for testing
let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64;
let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60;
// Subtract IST offset to get UTC
let utc_seconds = seconds - (5 * 3600 + 30 * 60);
utc_seconds * 1_000_000_000
}
#[test]
fn test_session_config_defaults() {
let config = SessionConfig::default();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_open_minute, 15);
assert_eq!(config.market_close_hour, 15);
assert_eq!(config.market_close_minute, 30);
assert_eq!(config.squareoff_minutes_before_close, 5);
}
#[test]
fn test_squareoff_minutes() {
let config = SessionConfig::default();
// 15:30 - 5 minutes = 15:25 = 925 minutes
assert_eq!(config.squareoff_minutes(), 925);
}
#[test]
fn test_mcx_session() {
let config = SessionConfig::mcx_commodity();
assert_eq!(config.market_open_hour, 9);
assert_eq!(config.market_close_hour, 23);
assert_eq!(config.market_close_minute, 30);
}
#[test]
fn test_session_tracker_new_session() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// Simulate market open at 9:15 IST
let ts = make_timestamp(2024, 1, 15, 9, 15);
let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None);
assert!(is_new);
assert!(tracker.in_session());
assert_eq!(tracker.session_open(), 100.0);
}
#[test]
fn test_session_high_low() {
let config = SessionConfig::default();
let mut tracker = SessionTracker::new(config);
// First bar
let ts1 = make_timestamp(2024, 1, 15, 9, 15);
tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None);
// Second bar
let ts2 = make_timestamp(2024, 1, 15, 9, 30);
tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None);
assert_eq!(tracker.session_high(), 110.0);
assert_eq!(tracker.session_low(), 95.0);
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Time-indexed array wrapper for efficient operations.
use super::types::Timestamp;
/// A time-indexed series of values.
#[derive(Debug, Clone)]
pub struct TimeSeries<T> {
/// Timestamps for each value.
pub timestamps: Vec<Timestamp>,
/// Values.
pub values: Vec<T>,
}
impl<T: Clone> TimeSeries<T> {
/// Create a new time series.
pub fn new(timestamps: Vec<Timestamp>, values: Vec<T>) -> Self {
debug_assert_eq!(timestamps.len(), values.len());
Self { timestamps, values }
}
/// Create from values only (no timestamps).
pub fn from_values(values: Vec<T>) -> Self {
let timestamps = (0..values.len() as i64).collect();
Self { timestamps, values }
}
/// Get the length.
#[inline]
pub fn len(&self) -> usize {
self.values.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
/// Get value at index.
#[inline]
pub fn get(&self, index: usize) -> Option<&T> {
self.values.get(index)
}
/// Get timestamp at index.
#[inline]
pub fn get_timestamp(&self, index: usize) -> Option<Timestamp> {
self.timestamps.get(index).copied()
}
/// Get slice of values.
pub fn slice(&self, start: usize, end: usize) -> Self {
Self {
timestamps: self.timestamps[start..end].to_vec(),
values: self.values[start..end].to_vec(),
}
}
/// Map values to a new type.
pub fn map<U, F>(&self, f: F) -> TimeSeries<U>
where
F: Fn(&T) -> U,
{
TimeSeries {
timestamps: self.timestamps.clone(),
values: self.values.iter().map(f).collect(),
}
}
/// Iterator over (timestamp, value) pairs.
pub fn iter(&self) -> impl Iterator<Item = (Timestamp, &T)> {
self.timestamps.iter().copied().zip(self.values.iter())
}
}
impl<T: Clone + Default> TimeSeries<T> {
/// Create with default values.
pub fn with_default(timestamps: Vec<Timestamp>) -> Self {
let len = timestamps.len();
Self { timestamps, values: vec![T::default(); len] }
}
}
impl TimeSeries<f64> {
/// Create a series filled with NaN.
pub fn with_nan(len: usize) -> Self {
Self { timestamps: (0..len as i64).collect(), values: vec![f64::NAN; len] }
}
/// Calculate sum of all values.
pub fn sum(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).sum()
}
/// Calculate mean of all values.
pub fn mean(&self) -> f64 {
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
if valid.is_empty() {
return f64::NAN;
}
valid.iter().copied().sum::<f64>() / valid.len() as f64
}
/// Calculate standard deviation.
pub fn std(&self) -> f64 {
let mean = self.mean();
if mean.is_nan() {
return f64::NAN;
}
let valid: Vec<_> = self.values.iter().filter(|v| !v.is_nan()).collect();
if valid.len() < 2 {
return f64::NAN;
}
let variance =
valid.iter().map(|v| (*v - mean).powi(2)).sum::<f64>() / (valid.len() - 1) as f64;
variance.sqrt()
}
/// Get minimum value.
pub fn min(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::INFINITY, f64::min)
}
/// Get maximum value.
pub fn max(&self) -> f64 {
self.values.iter().filter(|v| !v.is_nan()).copied().fold(f64::NEG_INFINITY, f64::max)
}
/// Shift values by n positions (positive = shift forward, fill with NaN).
pub fn shift(&self, n: isize) -> Self {
let len = self.values.len();
let mut result = vec![f64::NAN; len];
if n >= 0 {
let n = n as usize;
if n < len {
for i in n..len {
result[i] = self.values[i - n];
}
}
} else {
let n = (-n) as usize;
if n < len {
for i in 0..len - n {
result[i] = self.values[i + n];
}
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate difference from previous value.
pub fn diff(&self) -> Self {
let mut result = vec![f64::NAN; self.values.len()];
for i in 1..self.values.len() {
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() {
result[i] = self.values[i] - self.values[i - 1];
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate percentage change from previous value.
pub fn pct_change(&self) -> Self {
let mut result = vec![f64::NAN; self.values.len()];
for i in 1..self.values.len() {
if !self.values[i].is_nan() && !self.values[i - 1].is_nan() && self.values[i - 1] != 0.0
{
result[i] = (self.values[i] - self.values[i - 1]) / self.values[i - 1];
}
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Apply rolling window function.
pub fn rolling<F>(&self, window: usize, f: F) -> Self
where
F: Fn(&[f64]) -> f64,
{
let mut result = vec![f64::NAN; self.values.len()];
if window == 0 || window > self.values.len() {
return Self { timestamps: self.timestamps.clone(), values: result };
}
for i in (window - 1)..self.values.len() {
let slice = &self.values[i + 1 - window..=i];
result[i] = f(slice);
}
Self { timestamps: self.timestamps.clone(), values: result }
}
/// Calculate rolling sum.
pub fn rolling_sum(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().sum())
}
/// Calculate rolling mean.
pub fn rolling_mean(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().sum::<f64>() / slice.len() as f64)
}
/// Calculate rolling standard deviation.
pub fn rolling_std(&self, window: usize) -> Self {
self.rolling(window, |slice| {
let mean = slice.iter().sum::<f64>() / slice.len() as f64;
let variance =
slice.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (slice.len() - 1) as f64;
variance.sqrt()
})
}
/// Calculate rolling maximum.
pub fn rolling_max(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().copied().fold(f64::NEG_INFINITY, f64::max))
}
/// Calculate rolling minimum.
pub fn rolling_min(&self, window: usize) -> Self {
self.rolling(window, |slice| slice.iter().copied().fold(f64::INFINITY, f64::min))
}
}
impl TimeSeries<bool> {
/// Count true values.
pub fn count_true(&self) -> usize {
self.values.iter().filter(|&&v| v).count()
}
/// Get indices of true values.
pub fn true_indices(&self) -> Vec<usize> {
self.values
.iter()
.enumerate()
.filter_map(|(i, &v)| if v { Some(i) } else { None })
.collect()
}
/// Logical AND with another series.
pub fn and(&self, other: &Self) -> Self {
debug_assert_eq!(self.len(), other.len());
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a && b).collect(),
}
}
/// Logical OR with another series.
pub fn or(&self, other: &Self) -> Self {
debug_assert_eq!(self.len(), other.len());
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().zip(other.values.iter()).map(|(&a, &b)| a || b).collect(),
}
}
/// Logical NOT.
pub fn not(&self) -> Self {
Self {
timestamps: self.timestamps.clone(),
values: self.values.iter().map(|&v| !v).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_mean() {
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let result = ts.rolling_mean(3);
assert!(result.values[0].is_nan());
assert!(result.values[1].is_nan());
assert!((result.values[2] - 2.0).abs() < 1e-10);
assert!((result.values[3] - 3.0).abs() < 1e-10);
assert!((result.values[4] - 4.0).abs() < 1e-10);
}
#[test]
fn test_shift() {
let ts = TimeSeries::from_values(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
let shifted = ts.shift(2);
assert!(shifted.values[0].is_nan());
assert!(shifted.values[1].is_nan());
assert!((shifted.values[2] - 1.0).abs() < 1e-10);
assert!((shifted.values[3] - 2.0).abs() < 1e-10);
assert!((shifted.values[4] - 3.0).abs() < 1e-10);
}
#[test]
fn test_pct_change() {
let ts = TimeSeries::from_values(vec![100.0, 110.0, 99.0]);
let pct = ts.pct_change();
assert!(pct.values[0].is_nan());
assert!((pct.values[1] - 0.1).abs() < 1e-10);
assert!((pct.values[2] - (-0.1)).abs() < 1e-10);
}
}
+595
View File
@@ -0,0 +1,595 @@
//! Core data types for RaptorBT.
use serde::{Deserialize, Serialize};
/// Type alias for price values.
pub type Price = f64;
/// Type alias for timestamp values (nanoseconds since epoch).
pub type Timestamp = i64;
/// Trading direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(i8)]
pub enum Direction {
/// Long position (buy to open, sell to close).
Long = 1,
/// Short position (sell to open, buy to close).
Short = -1,
}
impl Direction {
/// Convert direction to multiplier for P&L calculations.
#[inline]
pub fn multiplier(self) -> f64 {
self as i8 as f64
}
/// Create direction from integer.
pub fn from_int(value: i32) -> Option<Self> {
match value {
1 => Some(Direction::Long),
-1 => Some(Direction::Short),
_ => None,
}
}
}
impl Default for Direction {
fn default() -> Self {
Direction::Long
}
}
/// OHLCV data for a single bar.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct OhlcvBar {
pub timestamp: Timestamp,
pub open: Price,
pub high: Price,
pub low: Price,
pub close: Price,
pub volume: f64,
}
/// OHLCV data series.
#[derive(Debug, Clone)]
pub struct OhlcvData {
pub timestamps: Vec<Timestamp>,
pub open: Vec<Price>,
pub high: Vec<Price>,
pub low: Vec<Price>,
pub close: Vec<Price>,
pub volume: Vec<f64>,
}
impl OhlcvData {
/// Create new OHLCV data from vectors.
pub fn new(
timestamps: Vec<Timestamp>,
open: Vec<Price>,
high: Vec<Price>,
low: Vec<Price>,
close: Vec<Price>,
volume: Vec<f64>,
) -> Self {
Self { timestamps, open, high, low, close, volume }
}
/// Get the number of bars.
#[inline]
pub fn len(&self) -> usize {
self.close.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.close.is_empty()
}
/// Get a single bar at index.
pub fn get_bar(&self, index: usize) -> Option<OhlcvBar> {
if index >= self.len() {
return None;
}
Some(OhlcvBar {
timestamp: self.timestamps[index],
open: self.open[index],
high: self.high[index],
low: self.low[index],
close: self.close[index],
volume: self.volume[index],
})
}
}
/// Raw tick data series for tick-level backtesting.
///
/// All fields are parallel arrays of length N (one entry per tick).
/// `buy_qty_delta` and `sell_qty_delta` must be per-tick deltas, not
/// cumulative session totals — callers are responsible for converting
/// Zerodha-style running sums before passing them here.
#[derive(Debug, Clone)]
pub struct TickData {
/// Nanoseconds-since-epoch timestamp for each tick.
pub timestamps: Vec<Timestamp>,
/// Last traded price at each tick.
pub ltp: Vec<Price>,
/// Best bid price at each tick (0.0 if unavailable).
pub bid: Vec<Price>,
/// Best ask price at each tick (0.0 if unavailable).
pub ask: Vec<Price>,
/// Per-tick buy quantity delta (not cumulative).
pub buy_qty_delta: Vec<f64>,
/// Per-tick sell quantity delta (not cumulative).
pub sell_qty_delta: Vec<f64>,
/// Open interest at each tick (0 if unavailable).
pub oi: Vec<f64>,
}
impl TickData {
/// Number of ticks.
#[inline]
pub fn len(&self) -> usize {
self.ltp.len()
}
/// Whether the series is empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.ltp.is_empty()
}
}
/// Compiled trading signals from strategy.
#[derive(Debug, Clone)]
pub struct CompiledSignals {
/// Symbol identifier.
pub symbol: String,
/// Entry signals (true = enter position).
pub entries: Vec<bool>,
/// Exit signals (true = exit position).
pub exits: Vec<bool>,
/// Optional position sizes (fraction of capital).
pub position_sizes: Option<Vec<f64>>,
/// Trading direction.
pub direction: Direction,
/// Weight for portfolio allocation.
pub weight: f64,
}
impl CompiledSignals {
/// Create new compiled signals.
pub fn new(
symbol: String,
entries: Vec<bool>,
exits: Vec<bool>,
direction: Direction,
weight: f64,
) -> Self {
Self { symbol, entries, exits, position_sizes: None, direction, weight }
}
/// Set position sizes.
pub fn with_position_sizes(mut self, sizes: Vec<f64>) -> Self {
self.position_sizes = Some(sizes);
self
}
/// Get the number of bars.
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
/// Check if empty.
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
/// A single executed trade.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trade {
/// Trade identifier.
pub id: u64,
/// Symbol traded.
pub symbol: String,
/// Entry bar index.
pub entry_idx: usize,
/// Exit bar index.
pub exit_idx: usize,
/// Entry price.
pub entry_price: Price,
/// Exit price.
pub exit_price: Price,
/// Position size (number of shares/contracts).
pub size: f64,
/// Trading direction.
pub direction: Direction,
/// Realized profit/loss.
pub pnl: f64,
/// Return percentage.
pub return_pct: f64,
/// Entry timestamp.
pub entry_time: Timestamp,
/// Exit timestamp.
pub exit_time: Timestamp,
/// Fees paid.
pub fees: f64,
/// Exit reason.
pub exit_reason: ExitReason,
}
impl Trade {
/// Check if trade was profitable.
#[inline]
pub fn is_winning(&self) -> bool {
self.pnl > 0.0
}
/// Get holding period in bars.
#[inline]
pub fn holding_period(&self) -> usize {
self.exit_idx - self.entry_idx
}
}
/// Reason for exiting a trade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ExitReason {
/// Normal exit signal.
Signal,
/// Stop-loss hit.
StopLoss,
/// Take-profit hit.
TakeProfit,
/// Trailing stop hit.
TrailingStop,
/// End of data.
EndOfData,
/// Option expiry settlement.
Settlement,
/// Max hold time exceeded (tick backtest).
TimeExit,
}
/// Backtest configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestConfig {
/// Initial capital.
pub initial_capital: f64,
/// Transaction fees as fraction (0.001 = 0.1%).
pub fees: f64,
/// Slippage as fraction.
pub slippage: f64,
/// Stop-loss configuration.
pub stop: StopConfig,
/// Take-profit configuration.
pub target: TargetConfig,
/// Whether to execute on bar close.
pub upon_bar_close: bool,
}
impl Default for BacktestConfig {
fn default() -> Self {
Self {
initial_capital: 100_000.0,
fees: 0.001,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
}
}
}
/// Per-instrument configuration for position sizing and risk management.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstrumentConfig {
/// Minimum tradeable quantity (1.0 for NSE EQ, 50.0 for NIFTY F&O, 0.01 for forex).
pub lot_size: Option<f64>,
/// Per-instrument capital cap.
pub alloted_capital: Option<f64>,
/// Per-instrument stop override.
pub stop: Option<StopConfig>,
/// Per-instrument target override.
pub target: Option<TargetConfig>,
/// Existing position quantity (future use).
pub existing_qty: Option<f64>,
/// Existing position average price (future use).
pub avg_price: Option<f64>,
}
impl InstrumentConfig {
/// Round a raw position size down to the nearest lot_size multiple.
/// Returns raw_size unchanged if lot_size is None or <= 0.
pub fn round_to_lot(&self, raw_size: f64) -> f64 {
match self.lot_size {
Some(lot) if lot > 0.0 => (raw_size / lot).floor() * lot,
_ => raw_size,
}
}
}
impl Default for InstrumentConfig {
fn default() -> Self {
Self {
lot_size: None,
alloted_capital: None,
stop: None,
target: None,
existing_qty: None,
avg_price: None,
}
}
}
/// Stop-loss configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum StopConfig {
/// No stop-loss.
None,
/// Fixed percentage stop.
Fixed { percent: f64 },
/// ATR-based stop.
Atr { multiplier: f64, period: usize },
/// Trailing stop.
Trailing { percent: f64 },
}
/// Take-profit configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum TargetConfig {
/// No take-profit.
None,
/// Fixed percentage target.
Fixed { percent: f64 },
/// ATR-based target.
Atr { multiplier: f64, period: usize },
/// Risk-reward ratio target.
RiskReward { ratio: f64 },
}
/// Backtest metrics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BacktestMetrics {
/// Total return percentage.
pub total_return_pct: f64,
/// Sharpe ratio (annualized).
pub sharpe_ratio: f64,
/// Sortino ratio (annualized).
pub sortino_ratio: f64,
/// Calmar ratio.
pub calmar_ratio: f64,
/// Omega ratio.
pub omega_ratio: f64,
/// Maximum drawdown percentage.
pub max_drawdown_pct: f64,
/// Maximum drawdown duration in bars.
pub max_drawdown_duration: usize,
/// Win rate percentage.
pub win_rate_pct: f64,
/// Profit factor.
pub profit_factor: f64,
/// Expectancy (average expected profit per trade).
pub expectancy: f64,
/// System Quality Number (SQN).
pub sqn: f64,
/// Total number of trades.
pub total_trades: usize,
/// Number of closed trades.
pub total_closed_trades: usize,
/// Number of open trades at end.
pub total_open_trades: usize,
/// PnL of open trades.
pub open_trade_pnl: f64,
/// Number of winning trades.
pub winning_trades: usize,
/// Number of losing trades.
pub losing_trades: usize,
/// Starting portfolio value.
pub start_value: f64,
/// Ending portfolio value.
pub end_value: f64,
/// Total fees paid.
pub total_fees_paid: f64,
/// Best trade return percentage.
pub best_trade_pct: f64,
/// Worst trade return percentage.
pub worst_trade_pct: f64,
/// Average trade return percentage.
pub avg_trade_return_pct: f64,
/// Average winning trade return percentage.
pub avg_win_pct: f64,
/// Average losing trade return percentage.
pub avg_loss_pct: f64,
/// Average winning trade duration in bars.
pub avg_winning_duration: f64,
/// Average losing trade duration in bars.
pub avg_losing_duration: f64,
/// Maximum consecutive wins.
pub max_consecutive_wins: usize,
/// Maximum consecutive losses.
pub max_consecutive_losses: usize,
/// Average holding period in bars.
pub avg_holding_period: f64,
/// Exposure time percentage (time in market).
pub exposure_pct: f64,
/// Payoff ratio (avg win / avg loss).
pub payoff_ratio: f64,
/// Recovery factor (net profit / max drawdown).
pub recovery_factor: f64,
}
/// Complete backtest result.
#[derive(Debug, Clone)]
pub struct BacktestResult {
/// Computed metrics.
pub metrics: BacktestMetrics,
/// Equity curve (portfolio value over time).
pub equity_curve: Vec<f64>,
/// Drawdown curve (drawdown percentage over time).
pub drawdown_curve: Vec<f64>,
/// List of executed trades.
pub trades: Vec<Trade>,
/// Daily returns.
pub returns: Vec<f64>,
}
impl BacktestResult {
/// Create a new backtest result.
pub fn new(
metrics: BacktestMetrics,
equity_curve: Vec<f64>,
drawdown_curve: Vec<f64>,
trades: Vec<Trade>,
returns: Vec<f64>,
) -> Self {
Self { metrics, equity_curve, drawdown_curve, trades, returns }
}
}
/// Position state during backtest.
#[derive(Debug, Clone)]
pub struct Position {
/// Whether position is open.
pub is_open: bool,
/// Entry bar index.
pub entry_idx: usize,
/// Entry price.
pub entry_price: Price,
/// Position size.
pub size: f64,
/// Trading direction.
pub direction: Direction,
/// Current stop price.
pub stop_price: Option<Price>,
/// Current target price.
pub target_price: Option<Price>,
/// Highest price since entry (for trailing stops).
pub highest_since_entry: Price,
/// Lowest price since entry (for trailing stops).
pub lowest_since_entry: Price,
/// Entry fees included in trade PnL.
pub entry_fees: f64,
}
impl Position {
/// Create a new closed position state.
pub fn new() -> Self {
Self {
is_open: false,
entry_idx: 0,
entry_price: 0.0,
size: 0.0,
direction: Direction::Long,
stop_price: None,
target_price: None,
highest_since_entry: 0.0,
lowest_since_entry: f64::MAX,
entry_fees: 0.0,
}
}
/// Open a new position.
pub fn open(
&mut self,
idx: usize,
price: Price,
size: f64,
direction: Direction,
stop_price: Option<Price>,
target_price: Option<Price>,
entry_fees: f64,
) {
self.is_open = true;
self.entry_idx = idx;
self.entry_price = price;
self.size = size;
self.direction = direction;
self.stop_price = stop_price;
self.target_price = target_price;
self.highest_since_entry = price;
self.lowest_since_entry = price;
self.entry_fees = entry_fees;
}
/// Close the position.
pub fn close(&mut self) {
self.is_open = false;
}
/// Update highest/lowest prices for trailing stops.
pub fn update_extremes(&mut self, high: Price, low: Price) {
if high > self.highest_since_entry {
self.highest_since_entry = high;
}
if low < self.lowest_since_entry {
self.lowest_since_entry = low;
}
}
/// Calculate unrealized P&L at given price.
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
if !self.is_open {
return 0.0;
}
let price_change = current_price - self.entry_price;
price_change * self.size * self.direction.multiplier()
}
}
impl Default for Position {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_round_to_lot_whole_shares() {
let config = InstrumentConfig { lot_size: Some(1.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.0);
assert_eq!(config.round_to_lot(1.0), 1.0);
assert_eq!(config.round_to_lot(0.5), 0.0);
}
#[test]
fn test_round_to_lot_nifty_fo() {
let config = InstrumentConfig { lot_size: Some(50.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.0), 200.0);
assert_eq!(config.round_to_lot(50.0), 50.0);
assert_eq!(config.round_to_lot(49.0), 0.0);
assert_eq!(config.round_to_lot(150.0), 150.0);
}
#[test]
fn test_round_to_lot_fractional() {
let config = InstrumentConfig { lot_size: Some(0.01), ..Default::default() };
assert!((config.round_to_lot(1.234) - 1.23).abs() < 1e-10);
}
#[test]
fn test_round_to_lot_none() {
let config = InstrumentConfig::default();
assert_eq!(config.round_to_lot(242.47), 242.47);
}
#[test]
fn test_round_to_lot_zero() {
let config = InstrumentConfig { lot_size: Some(0.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.47);
}
#[test]
fn test_round_to_lot_negative() {
let config = InstrumentConfig { lot_size: Some(-1.0), ..Default::default() };
assert_eq!(config.round_to_lot(242.47), 242.47);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Fee calculation models.
use crate::core::types::{Direction, Price};
/// Fee model for calculating transaction costs.
#[derive(Debug, Clone)]
pub enum FeeModel {
/// No fees.
None,
/// Fixed percentage of trade value.
Percentage(f64),
/// Fixed fee per trade.
Fixed(f64),
/// Per-share/contract fee.
PerShare(f64),
/// Tiered fee structure based on trade value.
Tiered(Vec<(f64, f64)>), // (threshold, rate)
/// Custom fee function (stored as percentage for simplicity).
Custom { base: f64, per_share: f64 },
}
impl Default for FeeModel {
fn default() -> Self {
FeeModel::Percentage(0.001) // 0.1% default
}
}
impl FeeModel {
/// Create a new percentage fee model.
pub fn percentage(rate: f64) -> Self {
FeeModel::Percentage(rate)
}
/// Create a new fixed fee model.
pub fn fixed(amount: f64) -> Self {
FeeModel::Fixed(amount)
}
/// Create a new per-share fee model.
pub fn per_share(rate: f64) -> Self {
FeeModel::PerShare(rate)
}
/// Calculate fee for a trade.
///
/// # Arguments
/// * `price` - Trade price
/// * `size` - Position size (shares/contracts)
/// * `direction` - Trade direction (for asymmetric fees if needed)
///
/// # Returns
/// Fee amount
pub fn calculate(&self, price: Price, size: f64, _direction: Direction) -> f64 {
let trade_value = price * size.abs();
match self {
FeeModel::None => 0.0,
FeeModel::Percentage(rate) => trade_value * rate,
FeeModel::Fixed(amount) => *amount,
FeeModel::PerShare(rate) => size.abs() * rate,
FeeModel::Tiered(tiers) => {
// Find applicable tier
let mut applicable_rate = 0.0;
for (threshold, rate) in tiers {
if trade_value >= *threshold {
applicable_rate = *rate;
} else {
break;
}
}
trade_value * applicable_rate
}
FeeModel::Custom { base, per_share } => base + size.abs() * per_share,
}
}
/// Calculate round-trip fees (entry + exit).
pub fn round_trip(
&self,
entry_price: Price,
exit_price: Price,
size: f64,
direction: Direction,
) -> f64 {
self.calculate(entry_price, size, direction) + self.calculate(exit_price, size, direction)
}
}
/// Broker-specific fee configurations.
pub struct BrokerFees;
impl BrokerFees {
/// Interactive Brokers tiered pricing (approximate).
pub fn interactive_brokers() -> FeeModel {
FeeModel::Custom { base: 1.0, per_share: 0.005 }
}
/// Zero commission broker (like Robinhood).
pub fn zero_commission() -> FeeModel {
FeeModel::None
}
/// Indian broker (Zerodha-like).
pub fn india_equity() -> FeeModel {
// 0.03% or Rs 20 per trade, whichever is lower
// Simplified as 0.03%
FeeModel::Percentage(0.0003)
}
/// Crypto exchange (typical).
pub fn crypto_exchange() -> FeeModel {
FeeModel::Percentage(0.001) // 0.1% maker/taker
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percentage_fee() {
let fee = FeeModel::percentage(0.001);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 10.0).abs() < 1e-10); // 100 * 100 * 0.001 = 10
}
#[test]
fn test_fixed_fee() {
let fee = FeeModel::fixed(5.0);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 5.0).abs() < 1e-10);
}
#[test]
fn test_per_share_fee() {
let fee = FeeModel::per_share(0.01);
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 1.0).abs() < 1e-10); // 100 * 0.01 = 1
}
#[test]
fn test_round_trip() {
let fee = FeeModel::percentage(0.001);
let result = fee.round_trip(100.0, 110.0, 100.0, Direction::Long);
// Entry: 100 * 100 * 0.001 = 10
// Exit: 110 * 100 * 0.001 = 11
// Total: 21
assert!((result - 21.0).abs() < 1e-10);
}
#[test]
fn test_no_fee() {
let fee = FeeModel::None;
let result = fee.calculate(100.0, 100.0, Direction::Long);
assert!((result - 0.0).abs() < 1e-10);
}
}
+361
View File
@@ -0,0 +1,361 @@
//! Order fill simulation models.
use crate::core::types::{Direction, OhlcvBar, Price};
/// Fill price model determining at what price orders are executed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FillPrice {
/// Execute at close price (end of bar).
Close,
/// Execute at open price (start of next bar).
Open,
/// Execute at OHLC average.
Average,
/// Execute at typical price (H+L+C)/3.
Typical,
/// Execute at VWAP (if available, otherwise typical).
Vwap,
/// Execute at worst price (high for buys, low for sells).
Worst,
/// Execute at best price (low for buys, high for sells).
Best,
}
impl Default for FillPrice {
fn default() -> Self {
FillPrice::Close
}
}
impl FillPrice {
/// Get execution price from OHLCV bar.
///
/// # Arguments
/// * `bar` - OHLCV bar data
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Execution price
pub fn get_price(&self, bar: &OhlcvBar, direction: Direction, is_entry: bool) -> Price {
match self {
FillPrice::Close => bar.close,
FillPrice::Open => bar.open,
FillPrice::Average => (bar.open + bar.high + bar.low + bar.close) / 4.0,
FillPrice::Typical => (bar.high + bar.low + bar.close) / 3.0,
FillPrice::Vwap => (bar.high + bar.low + bar.close) / 3.0, // Simplified
FillPrice::Worst => {
// Worst price for the trade
match (direction, is_entry) {
(Direction::Long, true) => bar.high, // Buy high
(Direction::Long, false) => bar.low, // Sell low
(Direction::Short, true) => bar.low, // Short at low (bad)
(Direction::Short, false) => bar.high, // Cover at high (bad)
}
}
FillPrice::Best => {
// Best price for the trade
match (direction, is_entry) {
(Direction::Long, true) => bar.low, // Buy low
(Direction::Long, false) => bar.high, // Sell high
(Direction::Short, true) => bar.high, // Short at high (good)
(Direction::Short, false) => bar.low, // Cover at low (good)
}
}
}
}
/// Get execution price from separate arrays.
///
/// # Arguments
/// * `open` - Open price
/// * `high` - High price
/// * `low` - Low price
/// * `close` - Close price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Execution price
pub fn get_price_from_arrays(
&self,
open: Price,
high: Price,
low: Price,
close: Price,
direction: Direction,
is_entry: bool,
) -> Price {
match self {
FillPrice::Close => close,
FillPrice::Open => open,
FillPrice::Average => (open + high + low + close) / 4.0,
FillPrice::Typical => (high + low + close) / 3.0,
FillPrice::Vwap => (high + low + close) / 3.0,
FillPrice::Worst => match (direction, is_entry) {
(Direction::Long, true) => high,
(Direction::Long, false) => low,
(Direction::Short, true) => low,
(Direction::Short, false) => high,
},
FillPrice::Best => match (direction, is_entry) {
(Direction::Long, true) => low,
(Direction::Long, false) => high,
(Direction::Short, true) => high,
(Direction::Short, false) => low,
},
}
}
}
/// Fill model combining price model with execution rules.
#[derive(Debug, Clone)]
pub struct FillModel {
/// Price model for fills.
pub fill_price: FillPrice,
/// Whether to delay execution to next bar.
pub delay_to_next_bar: bool,
/// Partial fill ratio (1.0 = full fill).
pub fill_ratio: f64,
}
impl Default for FillModel {
fn default() -> Self {
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
}
}
impl FillModel {
/// Create a fill model that executes at close.
pub fn at_close() -> Self {
Self { fill_price: FillPrice::Close, delay_to_next_bar: false, fill_ratio: 1.0 }
}
/// Create a fill model that executes at next bar's open.
pub fn at_next_open() -> Self {
Self { fill_price: FillPrice::Open, delay_to_next_bar: true, fill_ratio: 1.0 }
}
/// Set partial fill ratio.
pub fn with_fill_ratio(mut self, ratio: f64) -> Self {
self.fill_ratio = ratio.clamp(0.0, 1.0);
self
}
/// Check if a limit order would be filled.
///
/// # Arguments
/// * `limit_price` - Limit price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// True if order would be filled
pub fn would_fill_limit(
&self,
limit_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> bool {
match (direction, is_entry) {
// Long entry: buy at or below limit
(Direction::Long, true) => bar.low <= limit_price,
// Long exit: sell at or above limit
(Direction::Long, false) => bar.high >= limit_price,
// Short entry: sell at or above limit
(Direction::Short, true) => bar.high >= limit_price,
// Short exit: buy at or below limit
(Direction::Short, false) => bar.low <= limit_price,
}
}
/// Get fill price for a limit order.
///
/// Returns limit price if filled, None if not filled.
///
/// # Arguments
/// * `limit_price` - Limit price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Fill price or None
pub fn get_limit_fill_price(
&self,
limit_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> Option<Price> {
if self.would_fill_limit(limit_price, bar, direction, is_entry) {
// For limit orders, fill at limit price (or better if gap)
Some(limit_price)
} else {
None
}
}
/// Check if a stop order would be triggered.
///
/// # Arguments
/// * `stop_price` - Stop price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// True if stop would be triggered
pub fn would_trigger_stop(
&self,
stop_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> bool {
match (direction, is_entry) {
// Long entry stop: buy when price rises to stop
(Direction::Long, true) => bar.high >= stop_price,
// Long exit stop: sell when price falls to stop
(Direction::Long, false) => bar.low <= stop_price,
// Short entry stop: sell when price falls to stop
(Direction::Short, true) => bar.low <= stop_price,
// Short exit stop: buy when price rises to stop
(Direction::Short, false) => bar.high >= stop_price,
}
}
/// Get fill price for a stop order.
///
/// Returns fill price if triggered, None if not.
/// Uses worst-case scenario (stop price or worse).
///
/// # Arguments
/// * `stop_price` - Stop price
/// * `bar` - OHLCV bar
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
///
/// # Returns
/// Fill price or None
pub fn get_stop_fill_price(
&self,
stop_price: Price,
bar: &OhlcvBar,
direction: Direction,
is_entry: bool,
) -> Option<Price> {
if !self.would_trigger_stop(stop_price, bar, direction, is_entry) {
return None;
}
// Check for gap through stop
match (direction, is_entry) {
(Direction::Long, true) => {
// Buy stop: fill at stop or worse (gap up through stop)
if bar.open >= stop_price {
Some(bar.open) // Gap up, fill at open
} else {
Some(stop_price)
}
}
(Direction::Long, false) => {
// Sell stop: fill at stop or worse (gap down through stop)
if bar.open <= stop_price {
Some(bar.open) // Gap down, fill at open
} else {
Some(stop_price)
}
}
(Direction::Short, true) => {
// Short stop: fill at stop or worse (gap down through stop)
if bar.open <= stop_price {
Some(bar.open)
} else {
Some(stop_price)
}
}
(Direction::Short, false) => {
// Cover stop: fill at stop or worse (gap up through stop)
if bar.open >= stop_price {
Some(bar.open)
} else {
Some(stop_price)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_bar() -> OhlcvBar {
OhlcvBar { timestamp: 0, open: 100.0, high: 105.0, low: 95.0, close: 102.0, volume: 1000.0 }
}
#[test]
fn test_fill_price_close() {
let bar = test_bar();
let fp = FillPrice::Close;
assert!((fp.get_price(&bar, Direction::Long, true) - 102.0).abs() < 1e-10);
}
#[test]
fn test_fill_price_worst() {
let bar = test_bar();
let fp = FillPrice::Worst;
// Long entry: high (105)
assert!((fp.get_price(&bar, Direction::Long, true) - 105.0).abs() < 1e-10);
// Long exit: low (95)
assert!((fp.get_price(&bar, Direction::Long, false) - 95.0).abs() < 1e-10);
}
#[test]
fn test_limit_fill() {
let fill = FillModel::default();
let bar = test_bar();
// Limit buy at 96 should fill (low is 95)
assert!(fill.would_fill_limit(96.0, &bar, Direction::Long, true));
// Limit buy at 94 should not fill (low is 95)
assert!(!fill.would_fill_limit(94.0, &bar, Direction::Long, true));
}
#[test]
fn test_stop_fill() {
let fill = FillModel::default();
let bar = test_bar();
// Stop sell at 96 should trigger (low is 95)
assert!(fill.would_trigger_stop(96.0, &bar, Direction::Long, false));
// Stop sell at 94 should not trigger (low is 95)
assert!(!fill.would_trigger_stop(94.0, &bar, Direction::Long, false));
}
#[test]
fn test_gap_through_stop() {
let fill = FillModel::default();
// Gap down through stop
let gap_bar = OhlcvBar {
timestamp: 0,
open: 90.0, // Gap down from stop at 95
high: 92.0,
low: 88.0,
close: 91.0,
volume: 1000.0,
};
let fill_price = fill.get_stop_fill_price(95.0, &gap_bar, Direction::Long, false);
// Should fill at open (90) not stop (95)
assert_eq!(fill_price, Some(90.0));
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Order execution simulation for RaptorBT.
pub mod fees;
pub mod fill;
pub mod slippage;
pub use fees::FeeModel;
pub use fill::{FillModel, FillPrice};
pub use slippage::SlippageModel;
+204
View File
@@ -0,0 +1,204 @@
//! Slippage models for realistic trade execution.
use crate::core::types::{Direction, Price};
/// Slippage model for simulating execution price deviation.
#[derive(Debug, Clone)]
pub enum SlippageModel {
/// No slippage.
None,
/// Fixed percentage slippage.
Percentage(f64),
/// Fixed point slippage.
Fixed(f64),
/// Volume-based slippage (higher volume = lower slippage).
VolumeBased { base: f64, volume_factor: f64 },
/// Spread-based slippage (uses bid-ask spread).
SpreadBased { half_spread: f64 },
}
impl Default for SlippageModel {
fn default() -> Self {
SlippageModel::None
}
}
impl SlippageModel {
/// Create a new percentage slippage model.
pub fn percentage(rate: f64) -> Self {
SlippageModel::Percentage(rate)
}
/// Create a new fixed slippage model.
pub fn fixed(points: f64) -> Self {
SlippageModel::Fixed(points)
}
/// Create a volume-based slippage model.
pub fn volume_based(base: f64, volume_factor: f64) -> Self {
SlippageModel::VolumeBased { base, volume_factor }
}
/// Calculate slippage for a trade.
///
/// For long entries and short exits: slippage is ADDED to price (pay more/receive less)
/// For short entries and long exits: slippage is SUBTRACTED from price
///
/// # Arguments
/// * `price` - Base execution price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
/// * `volume` - Optional volume for volume-based models
///
/// # Returns
/// Slippage amount (positive = unfavorable)
pub fn calculate(
&self,
price: Price,
direction: Direction,
is_entry: bool,
volume: Option<f64>,
) -> f64 {
let base_slippage = match self {
SlippageModel::None => 0.0,
SlippageModel::Percentage(rate) => price * rate,
SlippageModel::Fixed(points) => *points,
SlippageModel::VolumeBased { base, volume_factor } => {
if let Some(vol) = volume {
if vol > 0.0 {
base * (1.0 / (1.0 + vol * volume_factor))
} else {
*base
}
} else {
*base
}
}
SlippageModel::SpreadBased { half_spread } => *half_spread,
};
// Determine sign based on trade type
// Long entry: pay higher price (positive slippage)
// Long exit: receive lower price (negative slippage)
// Short entry: receive higher price (negative slippage means worse)
// Short exit: pay higher price
match (direction, is_entry) {
(Direction::Long, true) => base_slippage, // Pay more
(Direction::Long, false) => -base_slippage, // Receive less
(Direction::Short, true) => -base_slippage, // Receive less
(Direction::Short, false) => base_slippage, // Pay more
}
}
/// Apply slippage to get execution price.
///
/// # Arguments
/// * `price` - Base price
/// * `direction` - Trade direction
/// * `is_entry` - Whether this is an entry or exit
/// * `volume` - Optional volume for volume-based models
///
/// # Returns
/// Execution price after slippage
pub fn apply(
&self,
price: Price,
direction: Direction,
is_entry: bool,
volume: Option<f64>,
) -> Price {
price + self.calculate(price, direction, is_entry, volume)
}
}
/// Market impact model for large orders.
#[derive(Debug, Clone)]
pub struct MarketImpact {
/// Temporary impact coefficient.
pub temporary_impact: f64,
/// Permanent impact coefficient.
pub permanent_impact: f64,
/// Average daily volume for normalization.
pub avg_daily_volume: f64,
}
impl MarketImpact {
/// Create a new market impact model.
pub fn new(temporary: f64, permanent: f64, adv: f64) -> Self {
Self { temporary_impact: temporary, permanent_impact: permanent, avg_daily_volume: adv }
}
/// Calculate market impact for an order.
///
/// Uses simplified square-root model: impact = sigma * sqrt(Q / ADV)
///
/// # Arguments
/// * `order_size` - Number of shares/contracts
/// * `price` - Current price
/// * `volatility` - Price volatility (sigma)
///
/// # Returns
/// Total market impact in price terms
pub fn calculate(&self, order_size: f64, price: Price, volatility: f64) -> f64 {
if self.avg_daily_volume <= 0.0 {
return 0.0;
}
let participation_rate = order_size / self.avg_daily_volume;
let sqrt_participation = participation_rate.sqrt();
let temporary = self.temporary_impact * volatility * price * sqrt_participation;
let permanent = self.permanent_impact * volatility * price * participation_rate;
temporary + permanent
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_percentage_slippage() {
let slip = SlippageModel::percentage(0.001);
// Long entry: pay more
let entry_slip = slip.calculate(100.0, Direction::Long, true, None);
assert!((entry_slip - 0.1).abs() < 1e-10);
// Long exit: receive less
let exit_slip = slip.calculate(100.0, Direction::Long, false, None);
assert!((exit_slip - (-0.1)).abs() < 1e-10);
}
#[test]
fn test_apply_slippage() {
let slip = SlippageModel::percentage(0.001);
// Long entry at 100 should pay 100.1
let entry_price = slip.apply(100.0, Direction::Long, true, None);
assert!((entry_price - 100.1).abs() < 1e-10);
// Long exit at 100 should receive 99.9
let exit_price = slip.apply(100.0, Direction::Long, false, None);
assert!((exit_price - 99.9).abs() < 1e-10);
}
#[test]
fn test_no_slippage() {
let slip = SlippageModel::None;
let result = slip.apply(100.0, Direction::Long, true, None);
assert!((result - 100.0).abs() < 1e-10);
}
#[test]
fn test_volume_based_slippage() {
let slip = SlippageModel::volume_based(0.1, 0.0001);
// High volume should have lower slippage
let high_vol = slip.calculate(100.0, Direction::Long, true, Some(100000.0));
let low_vol = slip.calculate(100.0, Direction::Long, true, Some(1000.0));
assert!(high_vol < low_vol);
}
}
+847
View File
@@ -0,0 +1,847 @@
use crate::core::error::RaptorError;
use crate::core::Result;
pub struct AroonResult {
pub up: Vec<f64>,
pub down: Vec<f64>,
}
pub struct AdxAllResult {
pub adx: Vec<f64>,
pub plus_di: Vec<f64>,
pub minus_di: Vec<f64>,
}
fn ema_nan_safe(data: &[f64], period: usize) -> Vec<f64> {
let n = data.len();
let mut result = vec![f64::NAN; n];
if period == 0 || n < period {
return result;
}
let k = 2.0 / (period as f64 + 1.0);
let mut seed_sum = 0.0;
let mut seed_count = 0usize;
let mut first_valid = None;
for i in 0..n {
if !data[i].is_nan() {
seed_sum += data[i];
seed_count += 1;
if seed_count == period {
first_valid = Some(i);
result[i] = seed_sum / period as f64;
break;
}
}
}
if let Some(start) = first_valid {
for i in (start + 1)..n {
if !data[i].is_nan() {
result[i] = data[i] * k + result[i - 1] * (1.0 - k);
}
}
}
result
}
pub fn cci(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("CCI period must be > 0"));
}
Ok(ferro_ta_core::momentum::cci(high, low, close, period))
}
pub fn willr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Williams %R period must be > 0"));
}
Ok(ferro_ta_core::momentum::willr(high, low, close, period))
}
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Result<Vec<f64>> {
if acceleration <= 0.0 || maximum <= 0.0 {
return Err(RaptorError::invalid_parameter(
"SAR acceleration and maximum must be > 0",
));
}
Ok(ferro_ta_core::overlap::sar(high, low, acceleration, maximum))
}
pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("+DI period must be > 0"));
}
Ok(ferro_ta_core::momentum::plus_di(high, low, close, period))
}
pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("-DI period must be > 0"));
}
Ok(ferro_ta_core::momentum::minus_di(high, low, close, period))
}
pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<AdxAllResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ADX period must be > 0"));
}
let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) =
ferro_ta_core::momentum::adx_all(high, low, close, period);
Ok(AdxAllResult { adx, plus_di, minus_di })
}
pub fn adxr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ADXR period must be > 0"));
}
Ok(ferro_ta_core::momentum::adxr(high, low, close, period))
}
pub fn roc(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("ROC period must be > 0"));
}
Ok(ferro_ta_core::momentum::roc(close, period))
}
pub fn mfi(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
period: usize,
) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("MFI period must be > 0"));
}
Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period))
}
pub fn wma(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("WMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::wma(close, period))
}
pub fn dema(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("DEMA period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
for i in 0..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() {
result[i] = 2.0 * ema1[i] - ema2[i];
}
}
Ok(result)
}
pub fn tema(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TEMA period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
let ema3 = ema_nan_safe(&ema2, period);
for i in 0..n {
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
}
}
Ok(result)
}
pub fn kama(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("KAMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::kama(close, period))
}
pub fn stochrsi(
close: &[f64],
timeperiod: usize,
fastk_period: usize,
fastd_period: usize,
) -> Result<(Vec<f64>, Vec<f64>)> {
if timeperiod == 0 {
return Err(RaptorError::invalid_parameter(
"StochRSI timeperiod must be > 0",
));
}
Ok(ferro_ta_core::momentum::stochrsi(
close,
timeperiod,
fastk_period,
fastd_period,
))
}
pub fn aroon(high: &[f64], low: &[f64], period: usize) -> Result<AroonResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Aroon period must be > 0"));
}
let (down, up) = ferro_ta_core::momentum::aroon(high, low, period);
Ok(AroonResult { up, down })
}
pub fn trix(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TRIX period must be > 0"));
}
let n = close.len();
let mut result = vec![f64::NAN; n];
let ema1 = ferro_ta_core::overlap::ema(close, period);
let ema2 = ema_nan_safe(&ema1, period);
let ema3 = ema_nan_safe(&ema2, period);
for i in 1..n {
let prev = ema3[i - 1];
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
result[i] = (ema3[i] - prev) / prev * 100.0;
}
}
Ok(result)
}
pub fn natr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("NATR period must be > 0"));
}
Ok(ferro_ta_core::volatility::natr(high, low, close, period))
}
pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volatility::trange(high, low, close))
}
pub fn stddev(real: &[f64], period: usize, nbdev: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("StdDev period must be > 0"));
}
Ok(ferro_ta_core::statistic::stddev(real, period, nbdev))
}
pub fn var(real: &[f64], period: usize, nbdev: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("VAR period must be > 0"));
}
Ok(ferro_ta_core::statistic::var(real, period, nbdev))
}
pub fn linearreg(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg(close, period))
}
pub fn linearreg_slope(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Slope period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_slope(close, period))
}
pub fn beta(real0: &[f64], real1: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Beta period must be > 0"));
}
Ok(ferro_ta_core::statistic::beta(real0, real1, period))
}
pub fn correl(real0: &[f64], real1: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Correl period must be > 0"));
}
Ok(ferro_ta_core::statistic::correl(real0, real1, period))
}
pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volume::ad(high, low, close, volume))
}
pub fn adosc(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
fastperiod: usize,
slowperiod: usize,
) -> Result<Vec<f64>> {
if fastperiod == 0 || slowperiod == 0 {
return Err(RaptorError::invalid_parameter(
"ADOSC fast/slow period must be > 0",
));
}
Ok(ferro_ta_core::volume::adosc(
high, low, close, volume, fastperiod, slowperiod,
))
}
pub fn obv(close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::volume::obv(close, volume))
}
pub fn mom(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Momentum period must be > 0"));
}
Ok(ferro_ta_core::momentum::mom(close, period))
}
pub struct PpoResult {
pub ppo_line: Vec<f64>,
pub signal_line: Vec<f64>,
pub histogram: Vec<f64>,
}
pub fn ppo(
close: &[f64],
fastperiod: usize,
slowperiod: usize,
signalperiod: usize,
) -> Result<PpoResult> {
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 {
return Err(RaptorError::invalid_parameter(
"PPO fast/slow/signal period must be > 0",
));
}
let n = close.len();
let fast_ema = ferro_ta_core::overlap::ema(close, fastperiod);
let slow_ema = ferro_ta_core::overlap::ema(close, slowperiod);
let mut ppo_line = vec![f64::NAN; n];
for i in 0..n {
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() && slow_ema[i] != 0.0 {
ppo_line[i] = (fast_ema[i] - slow_ema[i]) / slow_ema[i] * 100.0;
}
}
let signal_line = ema_nan_safe(&ppo_line, signalperiod);
let mut histogram = vec![f64::NAN; n];
for i in 0..n {
if !ppo_line[i].is_nan() && !signal_line[i].is_nan() {
histogram[i] = ppo_line[i] - signal_line[i];
}
}
Ok(PpoResult { ppo_line, signal_line, histogram })
}
pub fn cmo(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("CMO period must be > 0"));
}
Ok(ferro_ta_core::momentum::cmo(close, period))
}
pub fn aroonosc(high: &[f64], low: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Aroon Osc period must be > 0",
));
}
Ok(ferro_ta_core::momentum::aroonosc(high, low, period))
}
pub fn bop(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Vec<f64>> {
Ok(ferro_ta_core::momentum::bop(open, high, low, close))
}
pub fn ultosc(
high: &[f64],
low: &[f64],
close: &[f64],
period1: usize,
period2: usize,
period3: usize,
) -> Result<Vec<f64>> {
if period1 == 0 || period2 == 0 || period3 == 0 {
return Err(RaptorError::invalid_parameter(
"Ultimate Osc periods must be > 0",
));
}
Ok(ferro_ta_core::momentum::ultosc(high, low, close, period1, period2, period3))
}
pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::typprice(high, low, close))
}
pub fn medprice(high: &[f64], low: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::medprice(high, low))
}
pub fn avgprice(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::avgprice(open, high, low, close))
}
pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Result<Vec<f64>> {
Ok(ferro_ta_core::price_transform::wclprice(high, low, close))
}
pub fn midpoint(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Midpoint period must be > 0"));
}
Ok(ferro_ta_core::overlap::midpoint(close, period))
}
pub fn midprice(high: &[f64], low: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Midprice period must be > 0"));
}
Ok(ferro_ta_core::overlap::midprice(high, low, period))
}
pub fn t3(close: &[f64], period: usize, vfactor: f64) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("T3 period must be > 0"));
}
Ok(ferro_ta_core::overlap::t3(close, period, vfactor))
}
pub fn trima(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TRIMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::trima(close, period))
}
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Result<Vec<f64>> {
if fastperiod == 0 || slowperiod == 0 {
return Err(RaptorError::invalid_parameter(
"APO fast/slow period must be > 0",
));
}
Ok(ferro_ta_core::momentum::apo(close, fastperiod, slowperiod))
}
pub fn tsf(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("TSF period must be > 0"));
}
Ok(ferro_ta_core::statistic::tsf(close, period))
}
pub fn linearreg_angle(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Angle period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_angle(close, period))
}
pub fn linearreg_intercept(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"LinearReg Intercept period must be > 0",
));
}
Ok(ferro_ta_core::statistic::linearreg_intercept(close, period))
}
// ============================================================================
// Extended indicators (from ferro_ta_core::extended)
// ============================================================================
/// Volume-Weighted Moving Average.
///
/// # Returns
/// Vector of VWMA values (NaN for warmup period).
pub fn vwma(close: &[f64], volume: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("VWMA period must be > 0"));
}
Ok(ferro_ta_core::extended::vwma(close, volume, period))
}
/// Donchian Channels result.
pub struct DonchianResult {
pub upper: Vec<f64>,
pub middle: Vec<f64>,
pub lower: Vec<f64>,
}
/// Donchian Channels — rolling highest high / lowest low.
///
/// # Returns
/// `(upper, middle, lower)` arrays.
pub fn donchian(high: &[f64], low: &[f64], period: usize) -> Result<DonchianResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Donchian period must be > 0"));
}
let (upper, middle, lower) = ferro_ta_core::extended::donchian(high, low, period);
Ok(DonchianResult { upper, middle, lower })
}
/// Choppiness Index — measures choppy vs trending market (0 = trend, 100 = chop).
///
/// # Returns
/// Vector of CI values (NaN for warmup period).
pub fn choppiness_index(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Choppiness period must be > 0",
));
}
Ok(ferro_ta_core::extended::choppiness_index(high, low, close, period))
}
/// Hull Moving Average.
///
/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`.
pub fn hull_ma(close: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Hull MA period must be > 0"));
}
Ok(ferro_ta_core::extended::hull_ma(close, period))
}
/// Chandelier Exit result — ATR-based trailing stops.
pub struct ChandelierResult {
pub long_exit: Vec<f64>,
pub short_exit: Vec<f64>,
}
/// Chandelier Exit — ATR-based trailing stop levels.
///
/// # Returns
/// `(long_exit, short_exit)` arrays.
pub fn chandelier_exit(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
multiplier: f64,
) -> Result<ChandelierResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter(
"Chandelier period must be > 0",
));
}
if multiplier <= 0.0 {
return Err(RaptorError::invalid_parameter(
"Chandelier multiplier must be > 0",
));
}
let (long_exit, short_exit) =
ferro_ta_core::extended::chandelier_exit(high, low, close, period, multiplier);
Ok(ChandelierResult { long_exit, short_exit })
}
/// Ichimoku Cloud (Ichimoku Kinko Hyo) result.
pub struct IchimokuResult {
pub tenkan: Vec<f64>,
pub kijun: Vec<f64>,
pub senkou_a: Vec<f64>,
pub senkou_b: Vec<f64>,
pub chikou: Vec<f64>,
}
/// Ichimoku Cloud — `(tenkan, kijun, senkou_a, senkou_b, chikou)`.
pub fn ichimoku(
high: &[f64],
low: &[f64],
close: &[f64],
tenkan_period: usize,
kijun_period: usize,
senkou_b_period: usize,
displacement: usize,
) -> Result<IchimokuResult> {
if tenkan_period == 0 || kijun_period == 0 || senkou_b_period == 0 {
return Err(RaptorError::invalid_parameter(
"Ichimoku periods must be > 0",
));
}
let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku(
high,
low,
close,
tenkan_period,
kijun_period,
senkou_b_period,
displacement,
);
Ok(IchimokuResult { tenkan, kijun, senkou_a, senkou_b, chikou })
}
/// Pivot Points result — supports classic / fibonacci / camarilla.
pub struct PivotPointsResult {
pub pivot: Vec<f64>,
pub r1: Vec<f64>,
pub s1: Vec<f64>,
pub r2: Vec<f64>,
pub s2: Vec<f64>,
}
/// Pivot Points — classic, fibonacci, or camarilla methodology.
///
/// `method` must be one of: `"classic"`, `"fibonacci"`, `"camarilla"`.
/// Index 0 of each array is always NaN (no previous bar).
pub fn pivot_points(
high: &[f64],
low: &[f64],
close: &[f64],
method: &str,
) -> Result<PivotPointsResult> {
let m = method.to_lowercase();
if !matches!(m.as_str(), "classic" | "fibonacci" | "camarilla") {
return Err(RaptorError::invalid_parameter(
"Pivot method must be 'classic', 'fibonacci', or 'camarilla'",
));
}
let (pivot, r1, s1, r2, s2) =
ferro_ta_core::extended::pivot_points(high, low, close, &m);
Ok(PivotPointsResult { pivot, r1, s1, r2, s2 })
}
// ============================================================================
// Cycle (Hilbert Transform) indicators (from ferro_ta_core::cycle)
// ============================================================================
/// Hilbert Transform — Instantaneous Trendline.
pub fn ht_trendline(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Trendline requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_trendline(close))
}
/// Hilbert Transform — Dominant Cycle Period.
pub fn ht_dcperiod(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT DCPeriod requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_dcperiod(close))
}
/// Hilbert Transform — Dominant Cycle Phase.
pub fn ht_dcphase(close: &[f64]) -> Result<Vec<f64>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT DCPhase requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_dcphase(close))
}
/// Hilbert Transform — Phasor Components result.
pub struct HtPhasorResult {
pub in_phase: Vec<f64>,
pub quadrature: Vec<f64>,
}
/// Hilbert Transform — Phasor Components `(in_phase, quadrature)`.
pub fn ht_phasor(close: &[f64]) -> Result<HtPhasorResult> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Phasor requires at least 32 bars",
));
}
let (in_phase, quadrature) = ferro_ta_core::cycle::ht_phasor(close);
Ok(HtPhasorResult { in_phase, quadrature })
}
/// Hilbert Transform — Sine Wave result.
pub struct HtSineResult {
pub sine: Vec<f64>,
pub lead_sine: Vec<f64>,
}
/// Hilbert Transform — Sine Wave `(sine, lead_sine)`.
pub fn ht_sine(close: &[f64]) -> Result<HtSineResult> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT Sine requires at least 32 bars",
));
}
let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close);
Ok(HtSineResult { sine, lead_sine })
}
/// Hilbert Transform — Trend vs Cycle Mode.
///
/// Returns `Vec<i32>`: `1` = trend mode, `0` = cycle mode.
pub fn ht_trendmode(close: &[f64]) -> Result<Vec<i32>> {
if close.len() < 32 {
return Err(RaptorError::invalid_parameter(
"HT TrendMode requires at least 32 bars",
));
}
Ok(ferro_ta_core::cycle::ht_trendmode(close))
}
// ============================================================================
// Market regime detection (from ferro_ta_core::regime)
// ============================================================================
/// Trend/range regime labels based on ADX threshold.
///
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = warmup/NaN.
pub fn regime_adx(adx: &[f64], threshold: f64) -> Result<Vec<i8>> {
Ok(ferro_ta_core::regime::regime_adx(adx, threshold))
}
/// Trend/range regime using ADX + ATR-ratio rule.
///
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = NaN.
pub fn regime_combined(
adx: &[f64],
atr: &[f64],
close: &[f64],
adx_threshold: f64,
atr_pct_threshold: f64,
) -> Result<Vec<i8>> {
Ok(ferro_ta_core::regime::regime_combined(
adx,
atr,
close,
adx_threshold,
atr_pct_threshold,
))
}
/// Detect structural breaks via CUSUM test.
///
/// Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
pub fn detect_breaks_cusum(
series: &[f64],
window: usize,
threshold: f64,
slack: f64,
) -> Result<Vec<i8>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"CUSUM window must be >= 2",
));
}
Ok(ferro_ta_core::regime::detect_breaks_cusum(
series, window, threshold, slack,
))
}
/// Detect volatility regime breaks using rolling variance ratio.
///
/// `long_window` must be > `short_window`. Returns `Vec<i8>`:
/// `1` at break bars, `0` elsewhere.
pub fn rolling_variance_break(
series: &[f64],
short_window: usize,
long_window: usize,
threshold: f64,
) -> Result<Vec<i8>> {
if short_window < 2 {
return Err(RaptorError::invalid_parameter(
"Variance break short_window must be >= 2",
));
}
if long_window <= short_window {
return Err(RaptorError::invalid_parameter(
"Variance break long_window must be > short_window",
));
}
Ok(ferro_ta_core::regime::rolling_variance_break(
series,
short_window,
long_window,
threshold,
))
}
// ============================================================================
// Portfolio / cross-series tools (from ferro_ta_core::portfolio)
// ============================================================================
/// Rolling beta — `cov(asset, benchmark) / var(benchmark)` over a sliding window.
pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Result<Vec<f64>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"Rolling beta window must be >= 2",
));
}
Ok(ferro_ta_core::portfolio::rolling_beta(asset, benchmark, window))
}
/// Drawdown series result.
pub struct DrawdownResult {
/// Per-bar drawdown as a non-positive fraction of peak equity.
pub series: Vec<f64>,
/// Maximum drawdown over the full input range (non-positive).
pub max_drawdown: f64,
}
/// Drawdown series — `(per_bar_drawdown, max_drawdown)` from an equity curve.
pub fn drawdown_series(equity: &[f64]) -> Result<DrawdownResult> {
let (series, max_drawdown) = ferro_ta_core::portfolio::drawdown_series(equity);
Ok(DrawdownResult { series, max_drawdown })
}
/// Rolling Z-Score over a window.
pub fn zscore_series(x: &[f64], window: usize) -> Result<Vec<f64>> {
if window < 2 {
return Err(RaptorError::invalid_parameter(
"Z-score window must be >= 2",
));
}
Ok(ferro_ta_core::portfolio::zscore_series(x, window))
}
/// Relative strength — `asset - beta * benchmark` (excess return style).
pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Result<Vec<f64>> {
if asset_returns.len() != benchmark_returns.len() {
return Err(RaptorError::invalid_parameter(
"Relative strength inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::relative_strength(asset_returns, benchmark_returns))
}
/// Spread between two series: `a - hedge * b`.
pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Result<Vec<f64>> {
if a.len() != b.len() {
return Err(RaptorError::invalid_parameter(
"Spread inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::spread(a, b, hedge))
}
/// Ratio between two series element-wise: `a / b`.
pub fn ratio(a: &[f64], b: &[f64]) -> Result<Vec<f64>> {
if a.len() != b.len() {
return Err(RaptorError::invalid_parameter(
"Ratio inputs must have the same length",
));
}
Ok(ferro_ta_core::portfolio::ratio(a, b))
}
+36
View File
@@ -0,0 +1,36 @@
//! Technical indicators for RaptorBT.
//!
//! All indicators are implemented as pure functions that take slice inputs
//! and return Vec outputs. NaN values are used for the warmup period.
pub mod ferro_bridge;
pub mod momentum;
pub mod rolling;
pub mod strength;
pub mod tick_features;
pub mod trend;
pub mod volatility;
pub mod volume;
pub use ferro_bridge::{
AdxAllResult, AroonResult, ChandelierResult, DonchianResult, DrawdownResult, HtPhasorResult,
HtSineResult, IchimokuResult, PivotPointsResult, PpoResult, ad, adosc, adx_all, aroon,
aroonosc, apo, avgprice, beta, bop, cci, chandelier_exit, choppiness_index, correl, dema,
detect_breaks_cusum, donchian, drawdown_series, ht_dcperiod, ht_dcphase, ht_phasor,
ht_sine, ht_trendline, ht_trendmode, hull_ma, ichimoku, kama, linearreg, linearreg_angle,
linearreg_intercept, linearreg_slope, medprice, mfi, midpoint, midprice, minus_di, mom,
natr, obv, pivot_points, plus_di, ppo, ratio, regime_adx, regime_combined,
relative_strength, roc, rolling_beta, rolling_variance_break, sar, spread, stddev,
stochrsi, t3, tema, trange, trix, trima, tsf, typprice, ultosc, var, vwma, wclprice, willr,
wma, zscore_series,
};
pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult};
pub use rolling::{rolling_max, rolling_min};
pub use strength::adx;
pub use tick_features::{
buy_sell_imbalance_delta, oi_position_pct, realized_vol_rolling, return_window, spread_pct,
tick_velocity,
};
pub use trend::{ema, sma, supertrend, SupertrendResult};
pub use volatility::{atr, bollinger_bands, BollingerBandsResult};
pub use volume::{obv as obv_native, vwap};
+147
View File
@@ -0,0 +1,147 @@
//! Momentum indicators: RSI, MACD, Stochastic.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Relative Strength Index (RSI).
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of RSI values (0-100 scale, NaN for warmup period)
pub fn rsi(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("RSI period must be > 0"));
}
Ok(ferro_ta_core::momentum::rsi(data, period))
}
/// MACD result structure.
#[derive(Debug, Clone)]
pub struct MacdResult {
/// MACD line (fast EMA - slow EMA).
pub macd_line: Vec<f64>,
/// Signal line (EMA of MACD line).
pub signal_line: Vec<f64>,
/// Histogram (MACD line - signal line).
pub histogram: Vec<f64>,
}
/// Moving Average Convergence Divergence (MACD).
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `fast_period` - Fast EMA period (default: 12)
/// * `slow_period` - Slow EMA period (default: 26)
/// * `signal_period` - Signal line EMA period (default: 9)
///
/// # Returns
/// MacdResult with MACD line, signal line, and histogram
pub fn macd(
data: &[f64],
fast_period: usize,
slow_period: usize,
signal_period: usize,
) -> Result<MacdResult> {
if fast_period == 0 || slow_period == 0 || signal_period == 0 {
return Err(RaptorError::invalid_parameter("MACD periods must be > 0"));
}
if fast_period >= slow_period {
return Err(RaptorError::invalid_parameter("MACD fast period must be < slow period"));
}
let (macd_line, signal_line, histogram) =
ferro_ta_core::overlap::macd(data, fast_period, slow_period, signal_period);
Ok(MacdResult { macd_line, signal_line, histogram })
}
/// Stochastic oscillator result.
#[derive(Debug, Clone)]
pub struct StochasticResult {
/// %K line (fast stochastic).
pub k: Vec<f64>,
/// %D line (slow stochastic, SMA of %K).
pub d: Vec<f64>,
}
/// Stochastic Oscillator.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `k_period` - %K lookback period (default: 14)
/// * `d_period` - %D smoothing period (default: 3)
///
/// # Returns
/// StochasticResult with %K and %D lines (0-100 scale)
pub fn stochastic(
high: &[f64],
low: &[f64],
close: &[f64],
k_period: usize,
d_period: usize,
) -> Result<StochasticResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if k_period == 0 || d_period == 0 {
return Err(RaptorError::invalid_parameter("Stochastic periods must be > 0"));
}
let (k, d) = ferro_ta_core::momentum::stoch(high, low, close, k_period, d_period, d_period);
Ok(StochasticResult { k, d })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rsi() {
// Test with simple increasing data
let data = vec![
44.0, 44.25, 44.5, 43.75, 44.5, 44.25, 44.0, 44.0, 44.25, 45.0, 45.5, 46.0, 46.5, 47.0,
47.5,
];
let result = rsi(&data, 14).unwrap();
// RSI should be valid from index 14
assert!(result[13].is_nan());
assert!(!result[14].is_nan());
assert!(result[14] >= 0.0 && result[14] <= 100.0);
}
#[test]
fn test_macd() {
let data: Vec<f64> = (1..=50).map(|x| x as f64).collect();
let result = macd(&data, 12, 26, 9).unwrap();
// MACD line should be valid from index 25 (slow_period - 1)
assert!(result.macd_line[24].is_nan());
assert!(!result.macd_line[25].is_nan());
// Signal line starts at index slow_period-1 + signal_period-1 = 25+8 = 33
assert!(result.signal_line[32].is_nan());
assert!(!result.signal_line[33].is_nan());
}
#[test]
fn test_stochastic() {
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
let result = stochastic(&high, &low, &close, 5, 3).unwrap();
// %K should be valid from index 4
assert!(result.k[3].is_nan());
assert!(!result.k[4].is_nan());
assert!(result.k[4] >= 0.0 && result.k[4] <= 100.0);
// %D should be valid from index 6
assert!(result.d[5].is_nan());
assert!(!result.d[6].is_nan());
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Rolling min/max indicators for LLV/HHV support.
//!
//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV)
//! and Highest High Value (HHV) expressions.
use crate::core::error::RaptorError;
/// Calculate rolling minimum (Lowest Low Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling minimum values
pub fn rolling_min(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let min_val =
data[start..=i]
.iter()
.fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) });
result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val };
}
Ok(result)
}
/// Calculate rolling maximum (Highest High Value) over a period.
///
/// Returns NaN for the first (period - 1) values where insufficient data exists.
///
/// # Arguments
/// * `data` - Input data slice
/// * `period` - Lookback period
///
/// # Returns
/// Vec of rolling maximum values
pub fn rolling_max(data: &[f64], period: usize) -> Result<Vec<f64>, RaptorError> {
if period == 0 {
return Err(RaptorError::invalid_parameter("period must be at least 1"));
}
let n = data.len();
let mut result = vec![f64::NAN; n];
for i in (period - 1)..n {
let start = i + 1 - period;
let max_val =
data[start..=i]
.iter()
.fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) });
result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val };
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_min() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_min(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8)
assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2)
assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7)
assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1)
assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9)
}
#[test]
fn test_rolling_max() {
let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0];
let result = rolling_max(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8)
assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2)
assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7)
assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1)
assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9)
}
#[test]
fn test_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
assert!(rolling_min(&data, 0).is_err());
assert!(rolling_max(&data, 0).is_err());
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Strength indicators: ADX.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Average Directional Index (ADX).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of ADX values (0-100 scale, NaN for warmup period)
pub fn adx(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("ADX period must be > 0"));
}
Ok(ferro_ta_core::momentum::adx(high, low, close, period))
}
/// Directional Index result including +DI, -DI, and ADX.
#[derive(Debug, Clone)]
pub struct DirectionalIndexResult {
/// +DI values.
pub plus_di: Vec<f64>,
/// -DI values.
pub minus_di: Vec<f64>,
/// ADX values.
pub adx: Vec<f64>,
}
/// Full Directional Movement System (DI+, DI-, ADX).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// DirectionalIndexResult with +DI, -DI, and ADX
pub fn directional_movement(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
) -> Result<DirectionalIndexResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("Period must be > 0"));
}
let (_pdm_s, _mdm_s, plus_di, minus_di, _dx, adx) =
ferro_ta_core::momentum::adx_all(high, low, close, period);
Ok(DirectionalIndexResult { plus_di, minus_di, adx })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_adx() {
// Generate some trending data
let n = 50;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let result = adx(&high, &low, &close, 14).unwrap();
// ADX should be valid from index 27 (2 * period - 1)
assert!(result[26].is_nan());
assert!(!result[27].is_nan());
// ADX should be positive and <= 100
assert!(result[27] >= 0.0 && result[27] <= 100.0);
}
#[test]
fn test_directional_movement() {
let n = 50;
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect();
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect();
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
let result = directional_movement(&high, &low, &close, 14).unwrap();
// Check DI values are valid
assert!(!result.plus_di[20].is_nan());
assert!(!result.minus_di[20].is_nan());
// In an uptrend, +DI should be greater than -DI
assert!(result.plus_di[40] > result.minus_di[40]);
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Tick-level feature extraction functions.
//!
//! All functions accept parallel arrays (one element per tick) and return a
//! Vec<f64> of the same length. NaN is used where the feature is undefined
//! (e.g. insufficient history for a lookback window).
//!
//! These are building blocks for the signal generation layer — compute features
//! once on the full tick window, then pass the resulting arrays to
//! `tick_signals::tick_momentum_entry`.
/// Per-tick bid/ask spread as a percentage of the mid price.
///
/// Returns 0.0 where both bid and ask are zero.
pub fn spread_pct(bid: &[f64], ask: &[f64]) -> Vec<f64> {
bid.iter()
.zip(ask.iter())
.map(|(&b, &a)| {
let mid = (b + a) / 2.0;
if mid > 0.0 {
(a - b) / mid * 100.0
} else {
0.0
}
})
.collect()
}
/// Per-tick delta BSI from Zerodha cumulative session totals.
///
/// Zerodha's `total_buy_qty` / `total_sell_qty` are running sums that grow
/// monotonically from market open. Computing BSI from raw cumulative values
/// yields ~0.95 for the whole day (artefact of early-session buy-side dominance).
///
/// This function computes the imbalance of the most recent tick's activity only:
/// `bsi[i] = Δbuy[i] / (Δbuy[i] + Δsell[i])` where `Δbuy[i] = max(0, buy[i] - buy[i-1])`
///
/// Returns 0.5 (neutral) where the total delta is zero (no activity).
pub fn buy_sell_imbalance_delta(
buy_qty_cumulative: &[f64],
sell_qty_cumulative: &[f64],
) -> Vec<f64> {
let n = buy_qty_cumulative.len();
let mut out = vec![0.5_f64; n];
for i in 1..n {
let db = (buy_qty_cumulative[i] - buy_qty_cumulative[i - 1]).max(0.0);
let ds = (sell_qty_cumulative[i] - sell_qty_cumulative[i - 1]).max(0.0);
let total = db + ds;
if total > 0.0 {
out[i] = db / total;
}
}
out
}
/// Per-tick lookback return over a fixed time window.
///
/// For each tick i, finds the latest tick whose timestamp is at most
/// `timestamps_ns[i] - window_seconds * 1e9` and computes:
/// `(ltp[i] - ltp_ref) / ltp_ref * 100`
///
/// Returns `f64::NAN` for ticks where no reference tick exists (start of series
/// or insufficient history).
///
/// Uses binary search → O(N log N) total.
pub fn return_window(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec<f64> {
let n = timestamps_ns.len();
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
let mut out = vec![f64::NAN; n];
for i in 0..n {
let cutoff = timestamps_ns[i] - window_ns;
// Binary search for the last index with ts <= cutoff
let pos = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff);
// pos is the first index > cutoff; we want pos.saturating_sub(1)
if pos > 0 {
let ref_idx = pos - 1;
let ltp_ref = ltp[ref_idx];
if ltp_ref > 0.0 {
out[i] = (ltp[i] - ltp_ref) / ltp_ref * 100.0;
}
}
}
out
}
/// Rolling realized volatility proxy: annualized stddev of log returns.
///
/// For each tick i, computes stddev of log-returns over all ticks within
/// the preceding `window_seconds`. Returns `f64::NAN` if fewer than 2 ticks
/// in the window.
///
/// O(N²) worst case but typical windows are short (60300 s at ~80 ticks/min
/// = 80400 ticks), making the inner loop fast in practice.
pub fn realized_vol_rolling(timestamps_ns: &[i64], ltp: &[f64], window_seconds: f64) -> Vec<f64> {
let n = timestamps_ns.len();
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
let mut out = vec![f64::NAN; n];
for i in 1..n {
let cutoff = timestamps_ns[i] - window_ns;
// Find the first tick inside the window
let start = timestamps_ns[..i].partition_point(|&ts| ts < cutoff);
// We need log returns from start..=i
let count = i - start;
if count < 1 {
continue;
}
let mut log_rets = Vec::with_capacity(count);
for j in (start + 1)..=i {
if ltp[j - 1] > 0.0 {
log_rets.push((ltp[j] / ltp[j - 1]).ln());
}
}
if log_rets.len() < 2 {
continue;
}
let mean = log_rets.iter().sum::<f64>() / log_rets.len() as f64;
let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::<f64>()
/ (log_rets.len() - 1) as f64;
out[i] = variance.sqrt() * 100.0; // as percentage of price
}
out
}
/// Per-tick OI position within the day's high/low range.
///
/// Returns `(oi[i] - oi_day_low) / (oi_day_high - oi_day_low) * 100` ∈ [0, 100].
/// Returns `f64::NAN` where `oi_day_high <= oi_day_low`.
pub fn oi_position_pct(oi: &[f64], oi_day_high: f64, oi_day_low: f64) -> Vec<f64> {
let range = oi_day_high - oi_day_low;
if range <= 0.0 {
return vec![f64::NAN; oi.len()];
}
oi.iter()
.map(|&o| (o - oi_day_low) / range * 100.0)
.collect()
}
/// Rolling tick velocity: number of ticks per minute in the preceding window.
///
/// For each tick i, counts ticks in (timestamps_ns[i] - window_seconds*1e9, timestamps_ns[i]].
/// Returns 0.0 for the first tick.
pub fn tick_velocity(timestamps_ns: &[i64], window_seconds: f64) -> Vec<f64> {
let n = timestamps_ns.len();
let window_ns = (window_seconds * 1_000_000_000.0) as i64;
let mut out = vec![0.0_f64; n];
for i in 1..n {
let cutoff = timestamps_ns[i] - window_ns;
let start = timestamps_ns[..i].partition_point(|&ts| ts <= cutoff);
let count = (i - start + 1) as f64; // include current tick
let minutes = window_seconds / 60.0;
out[i] = if minutes > 0.0 { count / minutes } else { 0.0 };
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spread_pct_basic() {
let bid = vec![100.0, 200.0];
let ask = vec![101.0, 202.0];
let s = spread_pct(&bid, &ask);
// (101-100)/100.5 * 100 ≈ 0.995
assert!((s[0] - 0.9950248756218905).abs() < 1e-9);
// (202-200)/201 * 100 ≈ 0.995
assert!((s[1] - 0.9950248756218905).abs() < 1e-9);
}
#[test]
fn test_spread_pct_zero_bid_ask() {
let bid = vec![0.0];
let ask = vec![0.0];
let s = spread_pct(&bid, &ask);
assert_eq!(s[0], 0.0);
}
#[test]
fn test_bsi_delta_basic() {
// Cumulative: buy grows by 100, sell by 0 → bsi = 1.0
let buy = vec![1000.0, 1100.0, 1100.0, 1150.0];
let sell = vec![800.0, 800.0, 850.0, 850.0];
let bsi = buy_sell_imbalance_delta(&buy, &sell);
assert_eq!(bsi[0], 0.5); // first tick always neutral
assert_eq!(bsi[1], 1.0); // all buy
assert_eq!(bsi[2], 0.0); // all sell
assert_eq!(bsi[3], 1.0); // all buy
}
#[test]
fn test_bsi_delta_no_activity() {
// No change → neutral 0.5
let buy = vec![1000.0, 1000.0];
let sell = vec![800.0, 800.0];
let bsi = buy_sell_imbalance_delta(&buy, &sell);
assert_eq!(bsi[1], 0.5);
}
#[test]
fn test_return_window_basic() {
// Ticks at 0s, 30s, 61s, 90s (nanoseconds)
let sec = 1_000_000_000_i64;
let ts = vec![0, 30 * sec, 61 * sec, 90 * sec];
let ltp = vec![100.0, 102.0, 101.0, 105.0];
let ret = return_window(&ts, &ltp, 60.0);
// ts[0]: no history → NAN
assert!(ret[0].is_nan());
// ts[1] at 30s: no tick <= -30s → NAN
assert!(ret[1].is_nan());
// ts[2] at 61s: cutoff = 1s, ts[0]=0 ≤ 1s → ref = ltp[0]=100.0
// (101 - 100) / 100 * 100 = 1.0
assert!((ret[2] - 1.0).abs() < 1e-9);
// ts[3] at 90s: cutoff = 30s, ts[1]=30s ≤ 30s → ref = ltp[1]=102.0
// (105 - 102) / 102 * 100 ≈ 2.941
assert!((ret[3] - (3.0 / 102.0 * 100.0)).abs() < 1e-9);
}
#[test]
fn test_oi_position_pct() {
let oi = vec![50.0, 100.0, 150.0];
let result = oi_position_pct(&oi, 200.0, 0.0);
assert_eq!(result, vec![25.0, 50.0, 75.0]);
}
#[test]
fn test_oi_position_pct_no_range() {
let oi = vec![100.0, 100.0];
let result = oi_position_pct(&oi, 100.0, 100.0);
assert!(result[0].is_nan());
assert!(result[1].is_nan());
}
#[test]
fn test_tick_velocity_basic() {
// 4 ticks at 0s, 10s, 20s, 30s; window=60s
let sec = 1_000_000_000_i64;
let ts = vec![0, 10 * sec, 20 * sec, 30 * sec];
let vel = tick_velocity(&ts, 60.0);
// At i=3 (30s): ticks in (30s, 30s] = all 4 → 4 ticks / 1 min = 4.0
assert_eq!(vel[0], 0.0);
assert!((vel[3] - 4.0).abs() < 1e-9);
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Trend indicators: SMA, EMA, Supertrend.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Simple Moving Average.
///
/// # Arguments
/// * `data` - Price data
/// * `period` - Lookback period
///
/// # Returns
/// Vector of SMA values (NaN for warmup period)
pub fn sma(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("SMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::sma(data, period))
}
/// Exponential Moving Average.
///
/// # Arguments
/// * `data` - Price data
/// * `period` - Lookback period (used to calculate smoothing factor)
///
/// # Returns
/// Vector of EMA values (NaN for warmup period)
pub fn ema(data: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("EMA period must be > 0"));
}
Ok(ferro_ta_core::overlap::ema(data, period))
}
/// EMA with custom smoothing factor (internal use).
#[allow(dead_code)]
pub(crate) fn ema_with_alpha(data: &[f64], alpha: f64, initial: f64) -> Vec<f64> {
let n = data.len();
let mut result = vec![f64::NAN; n];
if n == 0 {
return result;
}
result[0] = initial;
for i in 1..n {
if data[i].is_nan() {
result[i] = result[i - 1];
} else {
result[i] = alpha * data[i] + (1.0 - alpha) * result[i - 1];
}
}
result
}
/// Supertrend indicator result.
#[derive(Debug, Clone)]
pub struct SupertrendResult {
/// Supertrend line values.
pub supertrend: Vec<f64>,
/// Direction: 1 = bullish (below price), -1 = bearish (above price).
pub direction: Vec<i8>,
}
/// Supertrend indicator.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - ATR period
/// * `multiplier` - ATR multiplier
///
/// # Returns
/// SupertrendResult with supertrend line and direction
pub fn supertrend(
high: &[f64],
low: &[f64],
close: &[f64],
period: usize,
multiplier: f64,
) -> Result<SupertrendResult> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("Supertrend period must be > 0"));
}
let mut supertrend = vec![f64::NAN; n];
let mut direction = vec![0i8; n];
if period >= n {
return Ok(SupertrendResult { supertrend, direction });
}
// Calculate ATR
let atr_values = super::volatility::atr(high, low, close, period)?;
// Calculate basic upper and lower bands
let mut upper_band = vec![f64::NAN; n];
let mut lower_band = vec![f64::NAN; n];
for i in (period - 1)..n {
let hl2 = (high[i] + low[i]) / 2.0;
let atr_val = atr_values[i];
if !atr_val.is_nan() {
upper_band[i] = hl2 + multiplier * atr_val;
lower_band[i] = hl2 - multiplier * atr_val;
}
}
// Calculate final bands with carryover logic
let mut final_upper = vec![f64::NAN; n];
let mut final_lower = vec![f64::NAN; n];
for i in (period - 1)..n {
if i == period - 1 {
final_upper[i] = upper_band[i];
final_lower[i] = lower_band[i];
} else {
// Final upper band: use lower of current upper or previous final upper
// if previous close was below previous final upper
if !upper_band[i].is_nan() && !final_upper[i - 1].is_nan() {
if close[i - 1] <= final_upper[i - 1] {
final_upper[i] = upper_band[i].min(final_upper[i - 1]);
} else {
final_upper[i] = upper_band[i];
}
} else {
final_upper[i] = upper_band[i];
}
// Final lower band: use higher of current lower or previous final lower
// if previous close was above previous final lower
if !lower_band[i].is_nan() && !final_lower[i - 1].is_nan() {
if close[i - 1] >= final_lower[i - 1] {
final_lower[i] = lower_band[i].max(final_lower[i - 1]);
} else {
final_lower[i] = lower_band[i];
}
} else {
final_lower[i] = lower_band[i];
}
}
}
// Calculate supertrend and direction
for i in (period - 1)..n {
if i == period - 1 {
// Initial direction based on price vs bands
if close[i] <= final_upper[i] {
supertrend[i] = final_upper[i];
direction[i] = -1; // bearish
} else {
supertrend[i] = final_lower[i];
direction[i] = 1; // bullish
}
} else {
let _prev_st = supertrend[i - 1];
let prev_dir = direction[i - 1];
if prev_dir == 1 {
// Was bullish
if close[i] < final_lower[i] {
// Switch to bearish
supertrend[i] = final_upper[i];
direction[i] = -1;
} else {
// Stay bullish
supertrend[i] = final_lower[i];
direction[i] = 1;
}
} else {
// Was bearish
if close[i] > final_upper[i] {
// Switch to bullish
supertrend[i] = final_lower[i];
direction[i] = 1;
} else {
// Stay bearish
supertrend[i] = final_upper[i];
direction[i] = -1;
}
}
}
}
Ok(SupertrendResult { supertrend, direction })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sma() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = sma(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!((result[2] - 2.0).abs() < 1e-10);
assert!((result[3] - 3.0).abs() < 1e-10);
assert!((result[4] - 4.0).abs() < 1e-10);
}
#[test]
fn test_ema() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = ema(&data, 3).unwrap();
assert!(result[0].is_nan());
assert!(result[1].is_nan());
assert!(!result[2].is_nan());
assert!(!result[3].is_nan());
assert!(!result[4].is_nan());
// EMA should be between min and max of data
assert!(result[4] >= 1.0 && result[4] <= 5.0);
}
#[test]
fn test_sma_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
let result = sma(&data, 0);
assert!(result.is_err());
}
#[test]
fn test_ema_period_larger_than_data() {
let data = vec![1.0, 2.0, 3.0];
let result = ema(&data, 10).unwrap();
assert!(result.iter().all(|v| v.is_nan()));
}
}
+179
View File
@@ -0,0 +1,179 @@
//! Volatility indicators: ATR, Bollinger Bands.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Average True Range (ATR).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of ATR values (NaN for warmup period)
pub fn atr(high: &[f64], low: &[f64], close: &[f64], period: usize) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("ATR period must be > 0"));
}
Ok(ferro_ta_core::volatility::atr(high, low, close, period))
}
/// True Range calculation (single bar).
#[inline]
pub fn true_range(high: f64, low: f64, prev_close: f64) -> f64 {
let hl = high - low;
let hc = (high - prev_close).abs();
let lc = (low - prev_close).abs();
hl.max(hc).max(lc)
}
/// Bollinger Bands result.
#[derive(Debug, Clone)]
pub struct BollingerBandsResult {
/// Middle band (SMA).
pub middle: Vec<f64>,
/// Upper band (SMA + std_dev * multiplier).
pub upper: Vec<f64>,
/// Lower band (SMA - std_dev * multiplier).
pub lower: Vec<f64>,
/// Bandwidth: (upper - lower) / middle.
pub bandwidth: Vec<f64>,
/// %B: (price - lower) / (upper - lower).
pub percent_b: Vec<f64>,
}
/// Bollinger Bands.
///
/// # Arguments
/// * `data` - Price data (typically close prices)
/// * `period` - Lookback period (default: 20)
/// * `std_dev` - Standard deviation multiplier (default: 2.0)
///
/// # Returns
/// BollingerBandsResult with middle, upper, lower bands, bandwidth, and %B
pub fn bollinger_bands(data: &[f64], period: usize, std_dev: f64) -> Result<BollingerBandsResult> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Bollinger Bands period must be > 0"));
}
if std_dev <= 0.0 {
return Err(RaptorError::invalid_parameter("Bollinger Bands std_dev must be > 0"));
}
let n = data.len();
let (upper, middle, lower) = ferro_ta_core::overlap::bbands(data, period, std_dev, std_dev);
let mut bandwidth = vec![f64::NAN; n];
let mut percent_b = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && middle[i].abs() > f64::EPSILON {
bandwidth[i] = (upper[i] - lower[i]) / middle[i].abs();
}
let band_width = upper[i] - lower[i];
if band_width > f64::EPSILON {
percent_b[i] = (data[i] - lower[i]) / band_width;
}
}
Ok(BollingerBandsResult { middle, upper, lower, bandwidth, percent_b })
}
/// Keltner Channels (ATR-based bands).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `ema_period` - EMA period for middle band
/// * `atr_period` - ATR period
/// * `multiplier` - ATR multiplier
///
/// # Returns
/// Tuple of (middle, upper, lower) bands
pub fn keltner_channels(
high: &[f64],
low: &[f64],
close: &[f64],
ema_period: usize,
atr_period: usize,
multiplier: f64,
) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>)> {
let n = close.len();
if n != high.len() || n != low.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
// Calculate EMA for middle band
let middle = super::trend::ema(close, ema_period)?;
// Calculate ATR
let atr_values = atr(high, low, close, atr_period)?;
// Calculate bands
let mut upper = vec![f64::NAN; n];
let mut lower = vec![f64::NAN; n];
for i in 0..n {
if !middle[i].is_nan() && !atr_values[i].is_nan() {
upper[i] = middle[i] + multiplier * atr_values[i];
lower[i] = middle[i] - multiplier * atr_values[i];
}
}
Ok((middle, upper, lower))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atr() {
let high = vec![50.0, 51.0, 52.0, 51.5, 50.5, 51.0, 52.0, 53.0, 52.5, 51.5];
let low = vec![48.0, 49.0, 50.0, 49.5, 48.5, 49.0, 50.0, 51.0, 50.5, 49.5];
let close = vec![49.0, 50.0, 51.0, 50.0, 49.0, 50.0, 51.0, 52.0, 51.0, 50.0];
let result = atr(&high, &low, &close, 5).unwrap();
// ATR should be valid from index 4
assert!(result[3].is_nan());
assert!(!result[4].is_nan());
assert!(result[4] > 0.0);
}
#[test]
fn test_bollinger_bands() {
let data: Vec<f64> = (1..=30).map(|x| x as f64 + (x as f64 * 0.1).sin()).collect();
let result = bollinger_bands(&data, 20, 2.0).unwrap();
// Bands should be valid from index 19
assert!(result.middle[18].is_nan());
assert!(!result.middle[19].is_nan());
// Upper > Middle > Lower
assert!(result.upper[19] > result.middle[19]);
assert!(result.middle[19] > result.lower[19]);
// %B should be between 0 and 1 for data within bands
assert!(result.percent_b[19] >= -0.5 && result.percent_b[19] <= 1.5);
}
#[test]
fn test_true_range() {
// Simple case
assert!((true_range(52.0, 48.0, 50.0) - 4.0).abs() < 1e-10);
// Gap up case
assert!((true_range(55.0, 53.0, 50.0) - 5.0).abs() < 1e-10);
// Gap down case
assert!((true_range(48.0, 45.0, 50.0) - 5.0).abs() < 1e-10);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Volume indicators: VWAP, OBV.
use crate::core::error::RaptorError;
use crate::core::Result;
/// Volume Weighted Average Price (VWAP).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of VWAP values
pub fn vwap(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if n == 0 {
return Ok(vec![]);
}
let mut result = vec![f64::NAN; n];
let mut cumulative_tp_vol = 0.0;
let mut cumulative_vol = 0.0;
for i in 0..n {
// Typical price
let tp = (high[i] + low[i] + close[i]) / 3.0;
cumulative_tp_vol += tp * volume[i];
cumulative_vol += volume[i];
if cumulative_vol > 0.0 {
result[i] = cumulative_tp_vol / cumulative_vol;
}
}
Ok(result)
}
/// VWAP with session reset (e.g., daily reset).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
/// * `session_starts` - Boolean array indicating session start (true = reset VWAP)
///
/// # Returns
/// Vector of VWAP values with session resets
pub fn vwap_session(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
session_starts: &[bool],
) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() || n != session_starts.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if n == 0 {
return Ok(vec![]);
}
let mut result = vec![f64::NAN; n];
let mut cumulative_tp_vol = 0.0;
let mut cumulative_vol = 0.0;
for i in 0..n {
// Reset on session start
if session_starts[i] {
cumulative_tp_vol = 0.0;
cumulative_vol = 0.0;
}
// Typical price
let tp = (high[i] + low[i] + close[i]) / 3.0;
cumulative_tp_vol += tp * volume[i];
cumulative_vol += volume[i];
if cumulative_vol > 0.0 {
result[i] = cumulative_tp_vol / cumulative_vol;
}
}
Ok(result)
}
/// On Balance Volume (OBV).
///
/// # Arguments
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of OBV values
pub fn obv(close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != volume.len() {
return Err(RaptorError::length_mismatch(n, volume.len()));
}
Ok(ferro_ta_core::volume::obv(close, volume))
}
/// Volume Rate of Change.
///
/// # Arguments
/// * `volume` - Volume data
/// * `period` - Lookback period
///
/// # Returns
/// Vector of volume rate of change values
pub fn volume_roc(volume: &[f64], period: usize) -> Result<Vec<f64>> {
if period == 0 {
return Err(RaptorError::invalid_parameter("Period must be > 0"));
}
let n = volume.len();
let mut result = vec![f64::NAN; n];
if period >= n {
return Ok(result);
}
for i in period..n {
if volume[i - period] != 0.0 {
result[i] = (volume[i] - volume[i - period]) / volume[i - period] * 100.0;
}
}
Ok(result)
}
/// Money Flow Index (volume-weighted RSI).
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
/// * `period` - Lookback period (default: 14)
///
/// # Returns
/// Vector of MFI values (0-100 scale)
pub fn mfi(
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
period: usize,
) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
if period == 0 {
return Err(RaptorError::invalid_parameter("MFI period must be > 0"));
}
Ok(ferro_ta_core::volume::mfi(high, low, close, volume, period))
}
/// Accumulation/Distribution Line.
///
/// # Arguments
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume data
///
/// # Returns
/// Vector of A/D line values
pub fn ad_line(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Result<Vec<f64>> {
let n = close.len();
if n != high.len() || n != low.len() || n != volume.len() {
return Err(RaptorError::length_mismatch(n, high.len()));
}
Ok(ferro_ta_core::volume::ad(high, low, close, volume))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vwap() {
let high = vec![52.0, 53.0, 54.0, 53.0, 52.0];
let low = vec![50.0, 51.0, 52.0, 51.0, 50.0];
let close = vec![51.0, 52.0, 53.0, 52.0, 51.0];
let volume = vec![1000.0, 1500.0, 2000.0, 1500.0, 1000.0];
let result = vwap(&high, &low, &close, &volume).unwrap();
// VWAP should be valid for all bars
assert!(!result[0].is_nan());
assert!(!result[4].is_nan());
// VWAP should be between low and high range
assert!(result[4] >= 50.0 && result[4] <= 54.0);
}
#[test]
fn test_obv() {
let close = vec![50.0, 51.0, 50.5, 52.0, 51.0];
let volume = vec![1000.0, 1500.0, 1200.0, 1800.0, 1300.0];
let result = obv(&close, &volume).unwrap();
// OBV starts with first volume
assert!((result[0] - 1000.0).abs() < 1e-10);
// Price up -> add volume
assert!((result[1] - 2500.0).abs() < 1e-10);
// Price down -> subtract volume
assert!((result[2] - 1300.0).abs() < 1e-10);
}
#[test]
fn test_mfi() {
let high = vec![
52.0, 53.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0, 54.0, 53.0, 52.0, 53.0, 54.0, 55.0,
56.0,
];
let low = vec![
50.0, 51.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0, 52.0, 51.0, 50.0, 51.0, 52.0, 53.0,
54.0,
];
let close = vec![
51.0, 52.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0, 53.0, 52.0, 51.0, 52.0, 53.0, 54.0,
55.0,
];
let volume = vec![1000.0; 15];
let result = mfi(&high, &low, &close, &volume, 14).unwrap();
// MFI should be valid from index 14
assert!(result[13].is_nan());
assert!(!result[14].is_nan());
assert!(result[14] >= 0.0 && result[14] <= 100.0);
}
}
+160
View File
@@ -0,0 +1,160 @@
// Suppress warning from PyO3 macro expansion (fixed in newer PyO3 versions)
#![allow(non_local_definitions)]
//! RaptorBT - High-performance Rust backtesting engine.
//!
//! This crate provides a complete backtesting solution with:
//! - Technical indicators (SMA, EMA, RSI, MACD, etc.)
//! - Portfolio simulation engine
//! - Multiple strategy types (single, basket, options, pairs, multi)
//! - Stop-loss and take-profit mechanisms
//! - Streaming metrics calculation
use pyo3::prelude::*;
pub mod core;
pub mod execution;
pub mod indicators;
pub mod metrics;
pub mod portfolio;
pub mod python;
pub mod signals;
pub mod stops;
pub mod strategies;
/// Python module entry point
#[pymodule]
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// Register config classes
m.add_class::<python::bindings::PyBacktestConfig>()?;
m.add_class::<python::bindings::PyInstrumentConfig>()?;
m.add_class::<python::bindings::PyStopConfig>()?;
m.add_class::<python::bindings::PyTargetConfig>()?;
// Register result classes
m.add_class::<python::bindings::PyBacktestResult>()?;
m.add_class::<python::bindings::PyBacktestMetrics>()?;
m.add_class::<python::bindings::PyTrade>()?;
// Register backtest functions
m.add_function(wrap_pyfunction!(python::bindings::run_single_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_basket_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::run_tick_backtest, m)?)?;
// Register batch spread backtest
m.add_class::<python::bindings::PyBatchSpreadItem>()?;
m.add_function(wrap_pyfunction!(python::bindings::batch_spread_backtest, m)?)?;
// Register Monte Carlo simulation
m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?;
// Register tick signal functions
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_entry_signals, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::compute_tick_exit_signals, m)?)?;
// Register tick feature functions
m.add_function(wrap_pyfunction!(python::bindings::tick_spread_pct, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::buy_sell_imbalance_delta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::return_window, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::realized_vol_rolling, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::oi_position_pct, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tick_velocity, m)?)?;
// Register indicator functions
m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rsi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::macd, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stochastic, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::atr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::bollinger_bands, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?;
// Extended indicators (via ferro_ta_core)
m.add_function(wrap_pyfunction!(python::bindings::cci, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::willr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::sar, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::plus_di, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::minus_di, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adx_all, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adxr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::roc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::mfi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::wma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::dema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tema, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::kama, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stochrsi, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::aroon, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trix, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::natr, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trange, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::stddev, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::var, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_slope, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_intercept, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::linearreg_angle, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::tsf, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::beta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::correl, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ad, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::adosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::obv, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::mom, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ppo, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::cmo, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::aroonosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::bop, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ultosc, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::typprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::medprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::avgprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::wclprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::midpoint, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::midprice, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::t3, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::trima, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::apo, m)?)?;
// P0 batch — Extended (donchian / hull_ma / ichimoku / pivot_points / etc.)
m.add_function(wrap_pyfunction!(python::bindings::vwma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::donchian, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::choppiness_index, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::hull_ma, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::chandelier_exit, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ichimoku, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::pivot_points, m)?)?;
// P0 batch — Hilbert Transform (cycle)
m.add_function(wrap_pyfunction!(python::bindings::ht_trendline, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_dcperiod, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_dcphase, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_phasor, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_sine, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ht_trendmode, m)?)?;
// P0 batch — Market regime detection
m.add_function(wrap_pyfunction!(python::bindings::regime_adx, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::regime_combined, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::detect_breaks_cusum, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::rolling_variance_break, m)?)?;
// P0 batch — Portfolio / cross-series tools
m.add_function(wrap_pyfunction!(python::bindings::rolling_beta, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::drawdown_series, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::zscore_series, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::relative_strength, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::spread, m)?)?;
m.add_function(wrap_pyfunction!(python::bindings::ratio, m)?)?;
Ok(())
}
+344
View File
@@ -0,0 +1,344 @@
//! Incremental drawdown tracking.
/// Drawdown tracker for incremental portfolio value updates.
#[derive(Debug, Clone)]
pub struct DrawdownTracker {
/// Current peak value.
peak: f64,
/// Current drawdown value.
current_drawdown: f64,
/// Maximum drawdown seen.
max_drawdown: f64,
/// Current drawdown duration (bars since peak).
current_duration: usize,
/// Maximum drawdown duration.
max_duration: usize,
/// Value at drawdown start.
drawdown_start_value: f64,
/// Index at drawdown start.
drawdown_start_idx: usize,
/// Index at max drawdown.
max_drawdown_idx: usize,
/// Total count of updates.
count: usize,
}
impl Default for DrawdownTracker {
fn default() -> Self {
Self::new()
}
}
impl DrawdownTracker {
/// Create a new drawdown tracker.
pub fn new() -> Self {
Self {
peak: 0.0,
current_drawdown: 0.0,
max_drawdown: 0.0,
current_duration: 0,
max_duration: 0,
drawdown_start_value: 0.0,
drawdown_start_idx: 0,
max_drawdown_idx: 0,
count: 0,
}
}
/// Create with initial value.
pub fn with_initial(initial_value: f64) -> Self {
Self {
peak: initial_value,
current_drawdown: 0.0,
max_drawdown: 0.0,
current_duration: 0,
max_duration: 0,
drawdown_start_value: initial_value,
drawdown_start_idx: 0,
max_drawdown_idx: 0,
count: 1,
}
}
/// Update with new portfolio value.
pub fn update(&mut self, value: f64) {
self.count += 1;
if value > self.peak {
// New peak - reset drawdown
self.peak = value;
self.current_drawdown = 0.0;
self.current_duration = 0;
self.drawdown_start_value = value;
self.drawdown_start_idx = self.count - 1;
} else {
// In drawdown
self.current_drawdown = (self.peak - value) / self.peak;
self.current_duration += 1;
if self.current_drawdown > self.max_drawdown {
self.max_drawdown = self.current_drawdown;
self.max_drawdown_idx = self.count - 1;
}
if self.current_duration > self.max_duration {
self.max_duration = self.current_duration;
}
}
}
/// Get current drawdown as percentage.
#[inline]
pub fn current_drawdown_pct(&self) -> f64 {
self.current_drawdown * 100.0
}
/// Get maximum drawdown as percentage.
#[inline]
pub fn max_drawdown_pct(&self) -> f64 {
self.max_drawdown * 100.0
}
/// Get maximum drawdown as fraction.
#[inline]
pub fn max_drawdown(&self) -> f64 {
self.max_drawdown
}
/// Get current peak value.
#[inline]
pub fn peak(&self) -> f64 {
self.peak
}
/// Get current drawdown duration.
#[inline]
pub fn current_duration(&self) -> usize {
self.current_duration
}
/// Get maximum drawdown duration.
#[inline]
pub fn max_duration(&self) -> usize {
self.max_duration
}
/// Check if currently in drawdown.
#[inline]
pub fn in_drawdown(&self) -> bool {
self.current_drawdown > 0.0
}
/// Get index where max drawdown occurred.
#[inline]
pub fn max_drawdown_idx(&self) -> usize {
self.max_drawdown_idx
}
/// Reset the tracker.
pub fn reset(&mut self) {
*self = Self::new();
}
}
/// Calculate drawdown curve from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Drawdown percentages at each point
pub fn calculate_drawdown_curve(equity_curve: &[f64]) -> Vec<f64> {
let n = equity_curve.len();
if n == 0 {
return vec![];
}
let mut drawdown_curve = vec![0.0; n];
let mut peak = equity_curve[0];
for i in 0..n {
if equity_curve[i] > peak {
peak = equity_curve[i];
}
if peak > 0.0 {
drawdown_curve[i] = (peak - equity_curve[i]) / peak * 100.0;
}
}
drawdown_curve
}
/// Calculate maximum drawdown from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Maximum drawdown as percentage
pub fn max_drawdown(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
dd.iter().fold(0.0f64, |a, &b| a.max(b))
}
/// Calculate average drawdown from equity curve.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Average drawdown as percentage
pub fn avg_drawdown(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
if dd.is_empty() {
return 0.0;
}
dd.iter().sum::<f64>() / dd.len() as f64
}
/// Find drawdown periods.
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Vector of (start_idx, end_idx, max_drawdown) tuples for each drawdown period
pub fn drawdown_periods(equity_curve: &[f64]) -> Vec<(usize, usize, f64)> {
let n = equity_curve.len();
if n < 2 {
return vec![];
}
let mut periods = Vec::new();
let mut peak = equity_curve[0];
let mut peak_idx = 0;
let mut in_dd = false;
let mut dd_start = 0;
let mut max_dd = 0.0;
for i in 1..n {
if equity_curve[i] > peak {
if in_dd {
// End of drawdown period
periods.push((dd_start, i - 1, max_dd));
in_dd = false;
max_dd = 0.0;
}
peak = equity_curve[i];
peak_idx = i;
} else if peak > 0.0 {
let dd = (peak - equity_curve[i]) / peak * 100.0;
if !in_dd && dd > 0.0 {
in_dd = true;
dd_start = peak_idx;
}
if dd > max_dd {
max_dd = dd;
}
}
}
// Handle ongoing drawdown at end
if in_dd {
periods.push((dd_start, n - 1, max_dd));
}
periods
}
/// Calculate Calmar ratio.
///
/// # Arguments
/// * `total_return` - Total return as percentage
/// * `max_drawdown` - Maximum drawdown as percentage
///
/// # Returns
/// Calmar ratio
pub fn calmar_ratio(total_return: f64, max_drawdown: f64) -> f64 {
if max_drawdown <= 0.0 {
return if total_return > 0.0 { f64::INFINITY } else { 0.0 };
}
total_return / max_drawdown
}
/// Calculate Ulcer Index (root mean square of drawdowns).
///
/// # Arguments
/// * `equity_curve` - Portfolio values over time
///
/// # Returns
/// Ulcer Index
pub fn ulcer_index(equity_curve: &[f64]) -> f64 {
let dd = calculate_drawdown_curve(equity_curve);
if dd.is_empty() {
return 0.0;
}
let sum_sq: f64 = dd.iter().map(|d| d * d).sum();
(sum_sq / dd.len() as f64).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_tracking() {
let mut tracker = DrawdownTracker::new();
tracker.update(100.0);
tracker.update(110.0);
tracker.update(105.0); // 4.5% drawdown
tracker.update(120.0);
tracker.update(100.0); // 16.67% drawdown
assert!((tracker.max_drawdown_pct() - 16.67).abs() < 0.1);
assert!((tracker.peak() - 120.0).abs() < 1e-10);
}
#[test]
fn test_drawdown_curve() {
let equity = vec![100.0, 110.0, 105.0, 120.0, 100.0];
let dd = calculate_drawdown_curve(&equity);
assert_eq!(dd.len(), 5);
assert!((dd[0] - 0.0).abs() < 1e-10);
assert!((dd[1] - 0.0).abs() < 1e-10);
assert!((dd[2] - 4.545).abs() < 0.1); // (110-105)/110 * 100
assert!((dd[3] - 0.0).abs() < 1e-10);
assert!((dd[4] - 16.67).abs() < 0.1); // (120-100)/120 * 100
}
#[test]
fn test_max_drawdown() {
let equity = vec![100.0, 120.0, 90.0, 110.0, 85.0];
let max_dd = max_drawdown(&equity);
// Max DD should be (120-85)/120 = 29.17%
assert!((max_dd - 29.17).abs() < 0.1);
}
#[test]
fn test_drawdown_periods() {
let equity = vec![100.0, 110.0, 105.0, 115.0, 100.0, 120.0];
let periods = drawdown_periods(&equity);
// Should have 2 drawdown periods
assert_eq!(periods.len(), 2);
}
#[test]
fn test_calmar_ratio() {
// 50% return with 10% max drawdown
let calmar = calmar_ratio(50.0, 10.0);
assert!((calmar - 5.0).abs() < 1e-10);
}
#[test]
fn test_ulcer_index() {
let equity = vec![100.0, 95.0, 90.0, 95.0, 100.0];
let ui = ulcer_index(&equity);
// Should be positive (there were drawdowns)
assert!(ui > 0.0);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! Performance metrics for RaptorBT.
pub mod drawdown;
pub mod streaming;
pub mod trade_stats;
pub use drawdown::DrawdownTracker;
pub use streaming::StreamingMetrics;
pub use trade_stats::TradeStatistics;
+756
View File
@@ -0,0 +1,756 @@
//! Streaming metrics calculation using Welford's algorithm.
//!
//! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio.
use crate::core::types::BacktestMetrics;
/// Streaming metrics calculator using Welford's algorithm.
///
/// Allows incremental calculation of statistics without storing all values.
/// Also tracks equity and drawdown for backtesting.
#[derive(Debug, Clone)]
pub struct StreamingMetrics {
/// Number of observations.
count: usize,
/// Running mean.
mean: f64,
/// Running M2 for variance calculation.
m2: f64,
/// Running M2 for downside variance (Sortino).
m2_downside: f64,
/// Target return for Sortino (default: 0).
target_return: f64,
/// Sum of returns (for total return calculation).
sum: f64,
/// Sum of positive returns.
sum_positive: f64,
/// Sum of negative returns.
sum_negative: f64,
/// Count of positive returns.
count_positive: usize,
/// Count of negative returns.
count_negative: usize,
// === Equity and drawdown tracking ===
/// Initial capital.
#[allow(dead_code)]
initial_capital: f64,
/// Peak equity value (for drawdown calculation).
peak_equity: f64,
/// Current equity value.
current_equity: f64,
/// Maximum drawdown percentage.
max_drawdown_pct: f64,
/// Current drawdown percentage.
current_drawdown: f64,
/// Bars since peak (for max drawdown duration).
bars_since_peak: usize,
/// Maximum drawdown duration in bars.
max_drawdown_duration: usize,
// === Trade tracking ===
/// Number of trades.
trade_count: usize,
/// Number of winning trades.
winning_trades: usize,
/// Number of losing trades.
losing_trades: usize,
/// Sum of winning trade P&L.
sum_wins: f64,
/// Sum of losing trade P&L.
sum_losses: f64,
/// Sum of trade return percentages.
sum_trade_returns: f64,
/// Sum of squared trade return percentages (for SQN).
sum_trade_returns_sq: f64,
/// Best trade return percentage.
best_trade_pct: f64,
/// Worst trade return percentage.
worst_trade_pct: f64,
/// Sum of winning trade durations.
sum_winning_duration: usize,
/// Sum of losing trade durations.
sum_losing_duration: usize,
/// Current consecutive wins.
current_consecutive_wins: usize,
/// Current consecutive losses.
current_consecutive_losses: usize,
/// Maximum consecutive wins.
max_consecutive_wins: usize,
/// Maximum consecutive losses.
max_consecutive_losses: usize,
/// Total holding period (bars).
total_holding_period: usize,
/// Total fees paid.
total_fees: f64,
}
impl Default for StreamingMetrics {
fn default() -> Self {
Self::new()
}
}
impl StreamingMetrics {
/// Create a new streaming metrics calculator.
pub fn new() -> Self {
Self::with_initial_capital(0.0)
}
/// Create a new streaming metrics calculator with initial capital.
pub fn with_initial_capital(initial_capital: f64) -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
m2_downside: 0.0,
target_return: 0.0,
sum: 0.0,
sum_positive: 0.0,
sum_negative: 0.0,
count_positive: 0,
count_negative: 0,
// Equity tracking
initial_capital,
peak_equity: initial_capital,
current_equity: initial_capital,
max_drawdown_pct: 0.0,
current_drawdown: 0.0,
bars_since_peak: 0,
max_drawdown_duration: 0,
// Trade tracking
trade_count: 0,
winning_trades: 0,
losing_trades: 0,
sum_wins: 0.0,
sum_losses: 0.0,
sum_trade_returns: 0.0,
sum_trade_returns_sq: 0.0,
best_trade_pct: f64::NEG_INFINITY,
worst_trade_pct: f64::INFINITY,
sum_winning_duration: 0,
sum_losing_duration: 0,
current_consecutive_wins: 0,
current_consecutive_losses: 0,
max_consecutive_wins: 0,
max_consecutive_losses: 0,
total_holding_period: 0,
total_fees: 0.0,
}
}
/// Create with a custom target return for Sortino calculation.
pub fn with_target_return(mut self, target: f64) -> Self {
self.target_return = target;
self
}
/// Update metrics with a new return value.
///
/// Uses Welford's online algorithm for numerically stable variance calculation.
pub fn update(&mut self, return_value: f64) {
self.count += 1;
self.sum += return_value;
// Track positive/negative
if return_value > 0.0 {
self.sum_positive += return_value;
self.count_positive += 1;
} else if return_value < 0.0 {
self.sum_negative += return_value;
self.count_negative += 1;
}
// Welford's algorithm for mean and variance
let delta = return_value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = return_value - self.mean;
self.m2 += delta * delta2;
// Downside variance (for Sortino)
let downside = (return_value - self.target_return).min(0.0);
let _delta_down = downside - (self.m2_downside / self.count.max(1) as f64).sqrt();
self.m2_downside += downside * downside;
}
/// Get the number of observations.
#[inline]
pub fn count(&self) -> usize {
self.count
}
/// Get the running mean.
#[inline]
pub fn mean(&self) -> f64 {
self.mean
}
/// Get the sample variance.
pub fn variance(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
self.m2 / (self.count - 1) as f64
}
/// Get the population variance.
pub fn variance_population(&self) -> f64 {
if self.count == 0 {
return 0.0;
}
self.m2 / self.count as f64
}
/// Get the sample standard deviation.
pub fn std_dev(&self) -> f64 {
self.variance().sqrt()
}
/// Get the downside standard deviation (for Sortino).
pub fn downside_std_dev(&self) -> f64 {
if self.count < 2 {
return 0.0;
}
(self.m2_downside / (self.count - 1) as f64).sqrt()
}
/// Calculate Sharpe ratio.
///
/// # Arguments
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
/// * `risk_free_rate` - Annual risk-free rate (default: 0)
///
/// # Returns
/// Annualized Sharpe ratio
pub fn sharpe_ratio(&self, periods_per_year: f64) -> f64 {
self.sharpe_ratio_with_rf(periods_per_year, 0.0)
}
/// Calculate Sharpe ratio with custom risk-free rate.
pub fn sharpe_ratio_with_rf(&self, periods_per_year: f64, risk_free_rate: f64) -> f64 {
let std = self.std_dev();
if std == 0.0 || self.count < 2 {
return 0.0;
}
let rf_per_period = risk_free_rate / periods_per_year;
let excess_return = self.mean - rf_per_period;
let annualized_excess = excess_return * periods_per_year;
let annualized_std = std * periods_per_year.sqrt();
annualized_excess / annualized_std
}
/// Calculate Sortino ratio.
///
/// # Arguments
/// * `periods_per_year` - Number of periods per year (e.g., 252 for daily)
///
/// # Returns
/// Annualized Sortino ratio
pub fn sortino_ratio(&self, periods_per_year: f64) -> f64 {
let downside_std = self.downside_std_dev();
if downside_std == 0.0 || self.count < 2 {
return if self.mean > 0.0 { f64::INFINITY } else { 0.0 };
}
let excess_return = self.mean - self.target_return;
let annualized_excess = excess_return * periods_per_year;
let annualized_downside_std = downside_std * periods_per_year.sqrt();
annualized_excess / annualized_downside_std
}
/// Get total return.
pub fn total_return(&self) -> f64 {
self.sum
}
/// Get average positive return.
pub fn avg_positive_return(&self) -> f64 {
if self.count_positive == 0 {
return 0.0;
}
self.sum_positive / self.count_positive as f64
}
/// Get average negative return.
pub fn avg_negative_return(&self) -> f64 {
if self.count_negative == 0 {
return 0.0;
}
self.sum_negative / self.count_negative as f64
}
/// Get win rate (fraction of positive returns).
pub fn win_rate(&self) -> f64 {
if self.count == 0 {
return 0.0;
}
self.count_positive as f64 / self.count as f64
}
/// Get profit factor (sum of profits / sum of losses).
pub fn profit_factor(&self) -> f64 {
if self.sum_negative == 0.0 {
return if self.sum_positive > 0.0 { f64::INFINITY } else { 0.0 };
}
self.sum_positive / self.sum_negative.abs()
}
/// Get omega ratio (same as profit factor for return-based calculation).
/// Omega = (sum of returns above threshold) / |sum of returns below threshold|
/// With threshold = 0, this equals profit_factor.
pub fn omega_ratio(&self) -> f64 {
self.profit_factor()
}
// === Equity tracking methods ===
/// Update equity and calculate drawdown.
pub fn update_equity(&mut self, equity: f64) {
self.current_equity = equity;
if equity > self.peak_equity {
self.peak_equity = equity;
self.bars_since_peak = 0;
} else {
self.bars_since_peak += 1;
if self.bars_since_peak > self.max_drawdown_duration {
self.max_drawdown_duration = self.bars_since_peak;
}
}
// Calculate current drawdown percentage
if self.peak_equity > 0.0 {
self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0;
if self.current_drawdown > self.max_drawdown_pct {
self.max_drawdown_pct = self.current_drawdown;
}
}
}
/// Get current drawdown percentage.
#[inline]
pub fn current_drawdown_pct(&self) -> f64 {
self.current_drawdown
}
/// Get maximum drawdown percentage.
#[inline]
pub fn max_drawdown_pct(&self) -> f64 {
self.max_drawdown_pct
}
// === Trade tracking methods ===
/// Record a completed trade.
///
/// # Arguments
/// * `pnl` - Trade profit/loss
/// * `return_pct` - Trade return percentage
/// * `duration` - Trade duration in bars
pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) {
self.trade_count += 1;
self.sum_trade_returns += return_pct;
self.sum_trade_returns_sq += return_pct * return_pct;
self.total_holding_period += duration;
// Track best/worst trades
if return_pct > self.best_trade_pct {
self.best_trade_pct = return_pct;
}
if return_pct < self.worst_trade_pct {
self.worst_trade_pct = return_pct;
}
if pnl > 0.0 {
self.winning_trades += 1;
self.sum_wins += pnl;
self.sum_winning_duration += duration;
self.current_consecutive_wins += 1;
self.current_consecutive_losses = 0;
if self.current_consecutive_wins > self.max_consecutive_wins {
self.max_consecutive_wins = self.current_consecutive_wins;
}
} else if pnl < 0.0 {
self.losing_trades += 1;
self.sum_losses += pnl.abs();
self.sum_losing_duration += duration;
self.current_consecutive_losses += 1;
self.current_consecutive_wins = 0;
if self.current_consecutive_losses > self.max_consecutive_losses {
self.max_consecutive_losses = self.current_consecutive_losses;
}
}
}
/// Record fees paid.
pub fn record_fees(&mut self, fees: f64) {
self.total_fees += fees;
}
/// Finalize metrics and produce BacktestMetrics.
///
/// # Arguments
/// * `initial_capital` - Starting capital
/// * `final_value` - Ending portfolio value
/// * `returns` - Array of period returns for ratio calculations
pub fn finalize(
&self,
initial_capital: f64,
final_value: f64,
returns: &[f64],
) -> BacktestMetrics {
// Calculate return metrics from the returns array
let mut return_metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
return_metrics.update(r);
}
}
let total_return_pct = if initial_capital > 0.0 {
(final_value - initial_capital) / initial_capital * 100.0
} else {
0.0
};
// Calculate trade-based metrics
let win_rate_pct = if self.trade_count > 0 {
self.winning_trades as f64 / self.trade_count as f64 * 100.0
} else {
0.0
};
let profit_factor = if self.sum_losses > 0.0 {
self.sum_wins / self.sum_losses
} else if self.sum_wins > 0.0 {
f64::INFINITY
} else {
0.0
};
let avg_trade_return_pct = if self.trade_count > 0 {
self.sum_trade_returns / self.trade_count as f64
} else {
0.0
};
let avg_win_pct = if self.winning_trades > 0 {
self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0
} else {
0.0
};
let avg_loss_pct = if self.losing_trades > 0 {
-(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0)
} else {
0.0
};
let avg_winning_duration = if self.winning_trades > 0 {
self.sum_winning_duration as f64 / self.winning_trades as f64
} else {
0.0
};
let avg_losing_duration = if self.losing_trades > 0 {
self.sum_losing_duration as f64 / self.losing_trades as f64
} else {
0.0
};
let avg_holding_period = if self.trade_count > 0 {
self.total_holding_period as f64 / self.trade_count as f64
} else {
0.0
};
// Expectancy: average profit per trade
let expectancy = if self.trade_count > 0 {
(self.sum_wins - self.sum_losses) / self.trade_count as f64
} else {
0.0
};
// SQN (System Quality Number)
let sqn = if self.trade_count > 1 {
let mean_return = self.sum_trade_returns / self.trade_count as f64;
let variance =
(self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return);
let std_dev = variance.max(0.0).sqrt();
if std_dev > 0.0 {
(mean_return / std_dev) * (self.trade_count as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Sharpe ratio (annualized, assuming 252 trading days)
let sharpe_ratio = return_metrics.sharpe_ratio(252.0);
// Sortino ratio (annualized)
let sortino_ratio = return_metrics.sortino_ratio(252.0);
// Calmar ratio (annualized return / max drawdown)
let calmar_ratio = if self.max_drawdown_pct > 0.0 {
total_return_pct / self.max_drawdown_pct
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Omega ratio
let omega_ratio = return_metrics.omega_ratio();
// Best/worst trade handling (handle edge cases)
let best_trade_pct =
if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct };
let worst_trade_pct =
if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct };
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Recovery factor: net profit / max drawdown (absolute value)
let net_profit = final_value - initial_capital;
let recovery_factor = if self.max_drawdown_pct > 0.0 && initial_capital > 0.0 {
let max_dd_absolute = self.max_drawdown_pct / 100.0 * initial_capital;
if max_dd_absolute > 0.0 {
net_profit / max_dd_absolute
} else {
0.0
}
} else if net_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
omega_ratio,
max_drawdown_pct: self.max_drawdown_pct,
max_drawdown_duration: self.max_drawdown_duration,
win_rate_pct,
profit_factor,
expectancy,
sqn,
total_trades: self.trade_count,
total_closed_trades: self.trade_count,
total_open_trades: 0,
open_trade_pnl: 0.0,
winning_trades: self.winning_trades,
losing_trades: self.losing_trades,
start_value: initial_capital,
end_value: final_value,
total_fees_paid: self.total_fees,
best_trade_pct,
worst_trade_pct,
avg_trade_return_pct,
avg_win_pct,
avg_loss_pct,
avg_winning_duration,
avg_losing_duration,
max_consecutive_wins: self.max_consecutive_wins,
max_consecutive_losses: self.max_consecutive_losses,
avg_holding_period,
exposure_pct: 0.0, // TODO: calculate based on time in market
payoff_ratio,
recovery_factor,
}
}
/// Reset all metrics.
pub fn reset(&mut self) {
*self = Self::new();
}
/// Merge two streaming metrics (for parallel computation).
pub fn merge(&mut self, other: &StreamingMetrics) {
if other.count == 0 {
return;
}
if self.count == 0 {
*self = other.clone();
return;
}
let combined_count = self.count + other.count;
let delta = other.mean - self.mean;
// Merge means
let combined_mean = self.mean + delta * other.count as f64 / combined_count as f64;
// Merge M2 (parallel variance)
let combined_m2 = self.m2
+ other.m2
+ delta * delta * self.count as f64 * other.count as f64 / combined_count as f64;
// Update state
self.count = combined_count;
self.mean = combined_mean;
self.m2 = combined_m2;
self.sum += other.sum;
self.sum_positive += other.sum_positive;
self.sum_negative += other.sum_negative;
self.count_positive += other.count_positive;
self.count_negative += other.count_negative;
self.m2_downside += other.m2_downside; // Approximation
}
}
/// Calculate Sharpe ratio from a slice of returns.
pub fn sharpe_ratio(returns: &[f64], periods_per_year: f64, risk_free_rate: f64) -> f64 {
let mut metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
metrics.update(r);
}
}
metrics.sharpe_ratio_with_rf(periods_per_year, risk_free_rate)
}
/// Calculate Sortino ratio from a slice of returns.
pub fn sortino_ratio(returns: &[f64], periods_per_year: f64) -> f64 {
let mut metrics = StreamingMetrics::new();
for &r in returns {
if !r.is_nan() {
metrics.update(r);
}
}
metrics.sortino_ratio(periods_per_year)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_statistics() {
let mut metrics = StreamingMetrics::new();
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
for v in &values {
metrics.update(*v);
}
assert_eq!(metrics.count(), 5);
assert!((metrics.mean() - 3.0).abs() < 1e-10);
// Sample variance of [1,2,3,4,5] = 2.5
assert!((metrics.variance() - 2.5).abs() < 1e-10);
}
#[test]
fn test_welford_numerical_stability() {
let mut metrics = StreamingMetrics::new();
// Large values that might cause numerical issues with naive algorithm
let base = 1e10;
let values = vec![base + 1.0, base + 2.0, base + 3.0];
for v in &values {
metrics.update(*v);
}
// Mean should be base + 2
assert!((metrics.mean() - (base + 2.0)).abs() < 1e-5);
// Variance should be 1.0 (same as [1, 2, 3])
assert!((metrics.variance() - 1.0).abs() < 1e-5);
}
#[test]
fn test_sharpe_ratio() {
let mut metrics = StreamingMetrics::new();
// Daily returns: 1%, 2%, -1%, 1.5%, 0.5%
let returns = vec![0.01, 0.02, -0.01, 0.015, 0.005];
for r in &returns {
metrics.update(*r);
}
// Should produce a positive Sharpe ratio
let sharpe = metrics.sharpe_ratio(252.0);
assert!(sharpe > 0.0);
}
#[test]
fn test_sortino_ratio() {
let mut metrics = StreamingMetrics::new();
// Mix of positive and negative returns
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
for r in &returns {
metrics.update(*r);
}
// Sortino should be different from Sharpe
let sharpe = metrics.sharpe_ratio(252.0);
let sortino = metrics.sortino_ratio(252.0);
// With negative returns, Sortino penalizes only downside
assert!(sortino != sharpe);
}
#[test]
fn test_win_rate_and_profit_factor() {
let mut metrics = StreamingMetrics::new();
// 3 wins, 2 losses
let returns = vec![0.02, -0.01, 0.03, -0.02, 0.01];
for r in &returns {
metrics.update(*r);
}
// Win rate should be 60%
assert!((metrics.win_rate() - 0.6).abs() < 1e-10);
// Profit factor = 0.06 / 0.03 = 2.0
assert!((metrics.profit_factor() - 2.0).abs() < 1e-10);
}
#[test]
fn test_merge() {
let mut m1 = StreamingMetrics::new();
let mut m2 = StreamingMetrics::new();
// Split data between two calculators
for v in &[1.0, 2.0, 3.0] {
m1.update(*v);
}
for v in &[4.0, 5.0] {
m2.update(*v);
}
// Merge
m1.merge(&m2);
// Should match single calculator with all data
let mut combined = StreamingMetrics::new();
for v in &[1.0, 2.0, 3.0, 4.0, 5.0] {
combined.update(*v);
}
assert_eq!(m1.count(), combined.count());
assert!((m1.mean() - combined.mean()).abs() < 1e-10);
assert!((m1.variance() - combined.variance()).abs() < 1e-10);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! Trade statistics calculation.
use crate::core::types::Trade;
/// Comprehensive trade statistics.
#[derive(Debug, Clone, Default)]
pub struct TradeStatistics {
/// Total number of trades.
pub total_trades: usize,
/// Number of winning trades.
pub winning_trades: usize,
/// Number of losing trades.
pub losing_trades: usize,
/// Number of breakeven trades.
pub breakeven_trades: usize,
/// Win rate (as percentage).
pub win_rate: f64,
/// Average win amount.
pub avg_win: f64,
/// Average loss amount.
pub avg_loss: f64,
/// Largest win.
pub largest_win: f64,
/// Largest loss.
pub largest_loss: f64,
/// Total profit.
pub total_profit: f64,
/// Total loss.
pub total_loss: f64,
/// Net profit.
pub net_profit: f64,
/// Profit factor.
pub profit_factor: f64,
/// Expected value per trade.
pub expectancy: f64,
/// Average trade return percentage.
pub avg_return_pct: f64,
/// Average holding period (bars).
pub avg_holding_period: f64,
/// Max consecutive wins.
pub max_consecutive_wins: usize,
/// Max consecutive losses.
pub max_consecutive_losses: usize,
/// Average win/loss ratio.
pub avg_win_loss_ratio: f64,
/// Recovery factor (net profit / max loss).
pub recovery_factor: f64,
/// Payoff ratio (avg win / avg loss).
pub payoff_ratio: f64,
}
impl TradeStatistics {
/// Calculate statistics from a list of trades.
pub fn from_trades(trades: &[Trade]) -> Self {
let mut stats = Self::default();
if trades.is_empty() {
return stats;
}
stats.total_trades = trades.len();
// Categorize trades
for trade in trades {
if trade.pnl > 0.0 {
stats.winning_trades += 1;
stats.total_profit += trade.pnl;
if trade.pnl > stats.largest_win {
stats.largest_win = trade.pnl;
}
} else if trade.pnl < 0.0 {
stats.losing_trades += 1;
stats.total_loss += trade.pnl.abs();
if trade.pnl.abs() > stats.largest_loss {
stats.largest_loss = trade.pnl.abs();
}
} else {
stats.breakeven_trades += 1;
}
}
// Calculate ratios
stats.net_profit = stats.total_profit - stats.total_loss;
if stats.total_trades > 0 {
stats.win_rate = stats.winning_trades as f64 / stats.total_trades as f64 * 100.0;
}
if stats.winning_trades > 0 {
stats.avg_win = stats.total_profit / stats.winning_trades as f64;
}
if stats.losing_trades > 0 {
stats.avg_loss = stats.total_loss / stats.losing_trades as f64;
}
if stats.total_loss > 0.0 {
stats.profit_factor = stats.total_profit / stats.total_loss;
} else if stats.total_profit > 0.0 {
stats.profit_factor = f64::INFINITY;
}
if stats.avg_loss > 0.0 {
stats.payoff_ratio = stats.avg_win / stats.avg_loss;
}
// Expectancy
if stats.total_trades > 0 {
stats.expectancy = stats.net_profit / stats.total_trades as f64;
}
// Average return percentage
if stats.total_trades > 0 {
stats.avg_return_pct =
trades.iter().map(|t| t.return_pct).sum::<f64>() / stats.total_trades as f64;
}
// Average holding period
if stats.total_trades > 0 {
stats.avg_holding_period =
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>()
/ stats.total_trades as f64;
}
// Consecutive wins/losses
let (max_wins, max_losses) = calculate_consecutive(trades);
stats.max_consecutive_wins = max_wins;
stats.max_consecutive_losses = max_losses;
// Recovery factor
if stats.largest_loss > 0.0 {
stats.recovery_factor = stats.net_profit / stats.largest_loss;
}
// Win/loss ratio
if stats.losing_trades > 0 {
stats.avg_win_loss_ratio = stats.winning_trades as f64 / stats.losing_trades as f64;
}
stats
}
/// Get summary as formatted string.
pub fn summary(&self) -> String {
format!(
"Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2} | Net: {:.2}",
self.total_trades, self.win_rate, self.profit_factor, self.net_profit
)
}
/// Check if strategy is profitable.
pub fn is_profitable(&self) -> bool {
self.net_profit > 0.0
}
/// Get edge (expected value as percentage of average trade).
pub fn edge(&self) -> f64 {
if self.total_trades == 0 {
return 0.0;
}
let avg_trade = self.net_profit / self.total_trades as f64;
let avg_cost = (self.total_profit + self.total_loss) / self.total_trades as f64;
if avg_cost > 0.0 {
avg_trade / avg_cost * 100.0
} else {
0.0
}
}
}
/// Calculate maximum consecutive wins and losses.
fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
let mut max_wins = 0;
let mut max_losses = 0;
let mut current_wins = 0;
let mut current_losses = 0;
for trade in trades {
if trade.pnl > 0.0 {
current_wins += 1;
current_losses = 0;
max_wins = max_wins.max(current_wins);
} else if trade.pnl < 0.0 {
current_losses += 1;
current_wins = 0;
max_losses = max_losses.max(current_losses);
}
}
(max_wins, max_losses)
}
/// Monthly returns breakdown.
#[derive(Debug, Clone, Default)]
pub struct MonthlyReturns {
/// Year.
pub year: i32,
/// Month (1-12).
pub month: u8,
/// Return percentage.
pub return_pct: f64,
/// Number of trades.
pub trade_count: usize,
}
/// Calculate trade statistics by exit reason.
pub fn stats_by_exit_reason(
trades: &[Trade],
) -> std::collections::HashMap<crate::core::types::ExitReason, TradeStatistics> {
use crate::core::types::ExitReason;
use std::collections::HashMap;
let mut grouped: HashMap<ExitReason, Vec<&Trade>> = HashMap::new();
for trade in trades {
grouped.entry(trade.exit_reason).or_default().push(trade);
}
grouped
.into_iter()
.map(|(reason, trade_refs)| {
let owned_trades: Vec<Trade> = trade_refs.into_iter().cloned().collect();
(reason, TradeStatistics::from_trades(&owned_trades))
})
.collect()
}
/// Calculate statistics for long vs short trades.
pub fn stats_by_direction(trades: &[Trade]) -> (TradeStatistics, TradeStatistics) {
use crate::core::types::Direction;
let long_trades: Vec<Trade> =
trades.iter().filter(|t| t.direction == Direction::Long).cloned().collect();
let short_trades: Vec<Trade> =
trades.iter().filter(|t| t.direction == Direction::Short).cloned().collect();
(TradeStatistics::from_trades(&long_trades), TradeStatistics::from_trades(&short_trades))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{Direction, ExitReason};
fn sample_trades() -> Vec<Trade> {
vec![
Trade {
id: 1,
symbol: "TEST".to_string(),
entry_idx: 0,
exit_idx: 5,
entry_price: 100.0,
exit_price: 110.0,
size: 10.0,
direction: Direction::Long,
pnl: 100.0, // Win
return_pct: 10.0,
entry_time: 0,
exit_time: 5,
fees: 0.0,
exit_reason: ExitReason::Signal,
},
Trade {
id: 2,
symbol: "TEST".to_string(),
entry_idx: 10,
exit_idx: 15,
entry_price: 100.0,
exit_price: 95.0,
size: 10.0,
direction: Direction::Long,
pnl: -50.0, // Loss
return_pct: -5.0,
entry_time: 10,
exit_time: 15,
fees: 0.0,
exit_reason: ExitReason::StopLoss,
},
Trade {
id: 3,
symbol: "TEST".to_string(),
entry_idx: 20,
exit_idx: 25,
entry_price: 100.0,
exit_price: 108.0,
size: 10.0,
direction: Direction::Long,
pnl: 80.0, // Win
return_pct: 8.0,
entry_time: 20,
exit_time: 25,
fees: 0.0,
exit_reason: ExitReason::TakeProfit,
},
]
}
#[test]
fn test_basic_stats() {
let trades = sample_trades();
let stats = TradeStatistics::from_trades(&trades);
assert_eq!(stats.total_trades, 3);
assert_eq!(stats.winning_trades, 2);
assert_eq!(stats.losing_trades, 1);
assert!((stats.win_rate - 66.67).abs() < 0.1);
}
#[test]
fn test_profit_calculations() {
let trades = sample_trades();
let stats = TradeStatistics::from_trades(&trades);
assert!((stats.total_profit - 180.0).abs() < 1e-10);
assert!((stats.total_loss - 50.0).abs() < 1e-10);
assert!((stats.net_profit - 130.0).abs() < 1e-10);
assert!((stats.profit_factor - 3.6).abs() < 0.1);
}
#[test]
fn test_consecutive() {
let trades = sample_trades();
let (max_wins, max_losses) = calculate_consecutive(&trades);
// W, L, W -> max consecutive wins = 1, max consecutive losses = 1
assert_eq!(max_wins, 1);
assert_eq!(max_losses, 1);
}
#[test]
fn test_stats_by_exit_reason() {
let trades = sample_trades();
let by_reason = stats_by_exit_reason(&trades);
// Should have 3 different exit reasons
assert!(by_reason.contains_key(&ExitReason::Signal));
assert!(by_reason.contains_key(&ExitReason::StopLoss));
assert!(by_reason.contains_key(&ExitReason::TakeProfit));
}
#[test]
fn test_empty_trades() {
let stats = TradeStatistics::from_trades(&[]);
assert_eq!(stats.total_trades, 0);
assert!((stats.win_rate - 0.0).abs() < 1e-10);
assert!((stats.profit_factor - 0.0).abs() < 1e-10);
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Capital allocation strategies for portfolio management.
/// Allocation strategy for distributing capital across instruments.
#[derive(Debug, Clone)]
pub enum AllocationStrategy {
/// Equal weight across all instruments.
EqualWeight,
/// Fixed weight for each instrument.
FixedWeight(Vec<f64>),
/// Volatility-based weighting (inverse volatility).
InverseVolatility,
/// Risk parity (equal risk contribution).
RiskParity,
/// Maximum weight per instrument.
MaxWeight(f64),
/// Custom weights.
Custom(Vec<(String, f64)>),
}
impl Default for AllocationStrategy {
fn default() -> Self {
AllocationStrategy::EqualWeight
}
}
/// Capital allocator for managing position sizing and capital distribution.
#[derive(Debug, Clone)]
pub struct CapitalAllocator {
/// Total capital.
pub total_capital: f64,
/// Available capital (not in positions).
pub available_capital: f64,
/// Allocation strategy.
pub strategy: AllocationStrategy,
/// Maximum position size as fraction of capital.
pub max_position_size: f64,
/// Minimum position size (absolute).
pub min_position_size: f64,
/// Reserve capital fraction (never allocate).
pub reserve_fraction: f64,
}
impl CapitalAllocator {
/// Create a new capital allocator.
pub fn new(total_capital: f64) -> Self {
Self {
total_capital,
available_capital: total_capital,
strategy: AllocationStrategy::EqualWeight,
max_position_size: 1.0,
min_position_size: 0.0,
reserve_fraction: 0.0,
}
}
/// Set allocation strategy.
pub fn with_strategy(mut self, strategy: AllocationStrategy) -> Self {
self.strategy = strategy;
self
}
/// Set maximum position size.
pub fn with_max_position(mut self, max_fraction: f64) -> Self {
self.max_position_size = max_fraction.clamp(0.0, 1.0);
self
}
/// Set reserve fraction.
pub fn with_reserve(mut self, reserve: f64) -> Self {
self.reserve_fraction = reserve.clamp(0.0, 1.0);
self
}
/// Calculate position size for a single instrument.
///
/// # Arguments
/// * `price` - Entry price
/// * `num_instruments` - Total number of instruments in portfolio
/// * `instrument_weight` - Optional custom weight for this instrument
///
/// # Returns
/// Position size in shares/contracts
pub fn calculate_position_size(
&self,
price: f64,
num_instruments: usize,
instrument_weight: Option<f64>,
) -> f64 {
if price <= 0.0 || num_instruments == 0 {
return 0.0;
}
// Calculate allocatable capital
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
// Calculate weight
let weight = match &self.strategy {
AllocationStrategy::EqualWeight => 1.0 / num_instruments as f64,
AllocationStrategy::FixedWeight(weights) => {
if weights.is_empty() {
1.0 / num_instruments as f64
} else {
weights[0].min(self.max_position_size)
}
}
AllocationStrategy::MaxWeight(max) => (*max).min(1.0 / num_instruments as f64),
_ => instrument_weight.unwrap_or(1.0 / num_instruments as f64),
};
// Calculate allocation
let allocation = allocatable * weight.min(self.max_position_size);
// Convert to shares
let shares = allocation / price;
// Apply minimum size constraint
if shares * price < self.min_position_size {
return 0.0;
}
shares
}
/// Calculate position sizes for multiple instruments.
///
/// # Arguments
/// * `prices` - Entry prices for each instrument
/// * `weights` - Optional weights for each instrument
///
/// # Returns
/// Position sizes for each instrument
pub fn calculate_portfolio_sizes(&self, prices: &[f64], weights: Option<&[f64]>) -> Vec<f64> {
let n = prices.len();
if n == 0 {
return vec![];
}
let allocatable = self.available_capital * (1.0 - self.reserve_fraction);
// Get weights
let instrument_weights: Vec<f64> = match &self.strategy {
AllocationStrategy::EqualWeight => vec![1.0 / n as f64; n],
AllocationStrategy::FixedWeight(w) => {
if w.len() == n {
w.clone()
} else {
vec![1.0 / n as f64; n]
}
}
AllocationStrategy::MaxWeight(max) => {
let equal = 1.0 / n as f64;
vec![equal.min(*max); n]
}
_ => weights.map(|w| w.to_vec()).unwrap_or_else(|| vec![1.0 / n as f64; n]),
};
// Normalize weights
let total_weight: f64 = instrument_weights.iter().sum();
let normalized_weights: Vec<f64> = if total_weight > 0.0 {
instrument_weights.iter().map(|w| w / total_weight).collect()
} else {
vec![1.0 / n as f64; n]
};
// Calculate sizes
prices
.iter()
.zip(normalized_weights.iter())
.map(|(&price, &weight)| {
if price <= 0.0 {
return 0.0;
}
let allocation = allocatable * weight.min(self.max_position_size);
let shares = allocation / price;
if shares * price < self.min_position_size {
0.0
} else {
shares
}
})
.collect()
}
/// Calculate volatility-adjusted position size.
///
/// # Arguments
/// * `price` - Entry price
/// * `volatility` - Instrument volatility (e.g., ATR)
/// * `risk_per_trade` - Risk per trade as fraction of capital
///
/// # Returns
/// Position size
pub fn calculate_volatility_sized(
&self,
price: f64,
volatility: f64,
risk_per_trade: f64,
) -> f64 {
if price <= 0.0 || volatility <= 0.0 {
return 0.0;
}
let risk_amount = self.available_capital * risk_per_trade;
let size = risk_amount / volatility;
// Apply maximum constraint
let max_allocation = self.available_capital * self.max_position_size;
let max_shares = max_allocation / price;
size.min(max_shares)
}
/// Allocate capital to a position.
///
/// # Arguments
/// * `amount` - Amount to allocate
///
/// # Returns
/// True if allocation succeeded
pub fn allocate(&mut self, amount: f64) -> bool {
if amount > self.available_capital {
return false;
}
self.available_capital -= amount;
true
}
/// Release capital from a closed position.
///
/// # Arguments
/// * `amount` - Amount to release (including P&L)
pub fn release(&mut self, amount: f64) {
self.available_capital += amount;
}
/// Update total capital (e.g., after deposit/withdrawal or daily mark-to-market).
pub fn update_capital(&mut self, new_capital: f64) {
let diff = new_capital - self.total_capital;
self.total_capital = new_capital;
self.available_capital += diff;
}
/// Get current utilization rate.
pub fn utilization(&self) -> f64 {
if self.total_capital <= 0.0 {
return 0.0;
}
1.0 - (self.available_capital / self.total_capital)
}
/// Reset allocator to initial state.
pub fn reset(&mut self) {
self.available_capital = self.total_capital;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_equal_weight() {
let allocator = CapitalAllocator::new(100_000.0);
// 4 instruments, equal weight = 25% each
let size = allocator.calculate_position_size(100.0, 4, None);
// Expected: 100000 * 0.25 / 100 = 250 shares
assert!((size - 250.0).abs() < 1e-10);
}
#[test]
fn test_max_position() {
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.1);
// Even with 1 instrument, max is 10%
let size = allocator.calculate_position_size(100.0, 1, None);
// Expected: 100000 * 0.1 / 100 = 100 shares
assert!((size - 100.0).abs() < 1e-10);
}
#[test]
fn test_portfolio_sizes() {
let allocator = CapitalAllocator::new(100_000.0);
let prices = vec![100.0, 50.0, 200.0];
let sizes = allocator.calculate_portfolio_sizes(&prices, None);
assert_eq!(sizes.len(), 3);
// Equal weight, each gets 1/3 of capital
// Instrument 1: 33333 / 100 = 333.33
// Instrument 2: 33333 / 50 = 666.66
// Instrument 3: 33333 / 200 = 166.66
assert!((sizes[0] - 333.33).abs() < 1.0);
assert!((sizes[1] - 666.66).abs() < 1.0);
assert!((sizes[2] - 166.66).abs() < 1.0);
}
#[test]
fn test_allocate_release() {
let mut allocator = CapitalAllocator::new(100_000.0);
// Allocate 30000
assert!(allocator.allocate(30_000.0));
assert!((allocator.available_capital - 70_000.0).abs() < 1e-10);
// Try to allocate more than available
assert!(!allocator.allocate(80_000.0));
// Release with profit
allocator.release(35_000.0);
assert!((allocator.available_capital - 105_000.0).abs() < 1e-10);
}
#[test]
fn test_utilization() {
let mut allocator = CapitalAllocator::new(100_000.0);
assert!((allocator.utilization() - 0.0).abs() < 1e-10);
allocator.allocate(50_000.0);
assert!((allocator.utilization() - 0.5).abs() < 1e-10);
}
#[test]
fn test_volatility_sizing() {
let allocator = CapitalAllocator::new(100_000.0).with_max_position(0.2);
// Risk 1% per trade with ATR of 2
let size = allocator.calculate_volatility_sized(100.0, 2.0, 0.01);
// Risk amount: 100000 * 0.01 = 1000
// Size: 1000 / 2 = 500 shares
// Max: 100000 * 0.2 / 100 = 200 shares
// Should be capped at max
assert!((size - 200.0).abs() < 1e-10);
}
}
+902
View File
@@ -0,0 +1,902 @@
//! Event-driven portfolio simulation engine.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
InstrumentConfig, OhlcvData, Price, StopConfig, TargetConfig, Trade,
};
use crate::execution::{FeeModel, FillPrice, SlippageModel};
use crate::indicators::volatility::atr;
use crate::metrics::streaming::StreamingMetrics;
use crate::portfolio::position::PositionManager;
use crate::signals::processor::SignalProcessor;
/// Portfolio simulation engine.
///
/// Single-pass O(n) algorithm for simulating portfolio performance.
#[derive(Debug)]
pub struct PortfolioEngine {
/// Configuration.
pub config: BacktestConfig,
/// Fee model.
pub fee_model: FeeModel,
/// Slippage model.
pub slippage_model: SlippageModel,
/// Fill price model.
pub fill_price: FillPrice,
/// Signal processor.
pub signal_processor: SignalProcessor,
}
impl Default for PortfolioEngine {
fn default() -> Self {
Self::new(BacktestConfig::default())
}
}
impl PortfolioEngine {
/// Create a new portfolio engine with the given configuration.
pub fn new(config: BacktestConfig) -> Self {
let fee_model = FeeModel::percentage(config.fees);
let fill_price = if config.upon_bar_close { FillPrice::Close } else { FillPrice::Open };
Self {
config,
fee_model,
slippage_model: SlippageModel::None,
fill_price,
signal_processor: SignalProcessor::new(),
}
}
/// Set fee model.
pub fn with_fee_model(mut self, fee_model: FeeModel) -> Self {
self.fee_model = fee_model;
self
}
/// Set slippage model.
pub fn with_slippage_model(mut self, slippage_model: SlippageModel) -> Self {
self.slippage_model = slippage_model;
self
}
/// Run backtest on single instrument.
///
/// # Arguments
/// * `ohlcv` - OHLCV data
/// * `signals` - Compiled trading signals
///
/// # Returns
/// Backtest result
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
self.run_single_with_instrument_config(ohlcv, signals, None)
}
/// Run backtest on single instrument with optional per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result
pub fn run_single_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
let n = ohlcv.len();
assert_eq!(n, signals.len(), "OHLCV and signals must have same length");
// Clean signals
let (entries, exits) =
self.signal_processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut position = PositionManager::new(signals.symbol.clone());
let mut cash = self.config.initial_capital;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
// Determine effective stop/target configs (per-instrument overrides take precedence)
let effective_stop =
inst_config.and_then(|ic| ic.stop.as_ref()).unwrap_or(&self.config.stop);
let effective_target =
inst_config.and_then(|ic| ic.target.as_ref()).unwrap_or(&self.config.target);
// Pre-calculate ATR for ATR-based stops
let atr_values = if matches!(effective_stop, StopConfig::Atr { .. })
|| matches!(effective_target, TargetConfig::Atr { .. })
{
let period = match effective_stop {
StopConfig::Atr { period, .. } => *period,
_ => match effective_target {
TargetConfig::Atr { period, .. } => *period,
_ => 14,
},
};
atr(&ohlcv.high, &ohlcv.low, &ohlcv.close, period).unwrap_or_else(|_| vec![0.0; n])
} else {
vec![0.0; n]
};
// Main simulation loop
for i in 0..n {
let close = ohlcv.close[i];
let high = ohlcv.high[i];
let low = ohlcv.low[i];
let timestamp = ohlcv.timestamps[i];
// Update position price tracking
position.update_price(high, low);
// Check for exits first (stops and signals)
if position.is_in_position() {
let mut exit_reason: Option<ExitReason> = None;
let mut exit_price = close;
// Check stop-loss
if position.is_stop_hit(low, high) {
exit_reason = Some(ExitReason::StopLoss);
exit_price = position.position.stop_price.unwrap();
// Adjust for gap through stop
match position.position.direction {
Direction::Long => {
if ohlcv.open[i] < exit_price {
exit_price = ohlcv.open[i];
}
}
Direction::Short => {
if ohlcv.open[i] > exit_price {
exit_price = ohlcv.open[i];
}
}
}
}
// Check take-profit
if exit_reason.is_none() && position.is_target_hit(low, high) {
exit_reason = Some(ExitReason::TakeProfit);
exit_price = position.position.target_price.unwrap();
}
// Check exit signal
if exit_reason.is_none() && exits[i] {
exit_reason = Some(ExitReason::Signal);
exit_price = self.get_fill_price(ohlcv, i, signals.direction, false);
}
// Execute exit
if let Some(reason) = exit_reason {
// Apply slippage
exit_price = self.slippage_model.apply(
exit_price,
position.position.direction,
false,
Some(ohlcv.volume[i]),
);
// Calculate fees
let fees = self.fee_model.calculate(
exit_price,
position.position.size,
position.position.direction,
);
// Close position
if let Some(trade) = position.close_position(
i,
timestamp,
exit_price,
ohlcv.timestamps[position.position.entry_idx],
reason,
fees,
) {
// Update cash
let exit_value = exit_price * trade.size;
cash += exit_value - fees;
// Track return for this trade
streaming.update(trade.return_pct / 100.0);
trades.push(trade);
}
}
// Update trailing stop if position still open
if position.is_in_position() {
if let StopConfig::Trailing { percent } = effective_stop {
position.update_trailing_stop(*percent);
}
}
}
// Check for entries
if !position.is_in_position() && entries[i] {
let entry_price = self.get_fill_price(ohlcv, i, signals.direction, true);
// Apply slippage
let adjusted_price = self.slippage_model.apply(
entry_price,
signals.direction,
true,
Some(ohlcv.volume[i]),
);
// Calculate position size
// Use per-instrument capital if set, capped at available cash
let available = inst_config
.and_then(|ic| ic.alloted_capital)
.map(|cap| cap.min(cash))
.unwrap_or(cash);
// Position sizing: size = cash / (price * (1 + fees))
// Ensures position value plus entry fee equals available cash
let fee_rate = self.config.fees;
let raw_size = if let Some(ref sizes) = signals.position_sizes {
sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
} else {
available / (adjusted_price * (1.0 + fee_rate))
};
// Round to lot_size
let size = inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size);
if size > 0.0 {
// Calculate entry fees
let entry_fees =
self.fee_model.calculate(adjusted_price, size, signals.direction);
// Calculate stop and target prices
let (stop_price, target_price) = self.calculate_stop_target_with_config(
adjusted_price,
signals.direction,
&atr_values,
i,
effective_stop,
effective_target,
);
// Open position (passing entry_fees for trade PnL tracking)
position.open_position(
i,
timestamp,
adjusted_price,
size,
signals.direction,
stop_price,
target_price,
entry_fees,
);
// Deduct cost
cash -= adjusted_price * size + entry_fees;
}
}
// Calculate equity
let position_value =
if position.is_in_position() { close * position.position.size } else { 0.0 };
let equity = cash + position_value;
equity_curve[i] = equity;
// Calculate drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Mark any open position at end of data — marked-to-market, no exit fees
if position.is_in_position() {
let last_idx = n - 1;
let exit_price = ohlcv.close[last_idx];
// No exit fees for EndOfData: position is marked-to-market but not actually closed
let exit_fees = 0.0;
if let Some(trade) = position.close_position(
last_idx,
ohlcv.timestamps[last_idx],
exit_price,
ohlcv.timestamps[position.position.entry_idx],
ExitReason::EndOfData,
exit_fees,
) {
streaming.update(trade.return_pct / 100.0);
trades.push(trade);
}
}
// Calculate final metrics
let metrics =
self.calculate_metrics(&equity_curve, &drawdown_curve, &returns, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Get fill price based on model.
fn get_fill_price(
&self,
ohlcv: &OhlcvData,
idx: usize,
direction: Direction,
is_entry: bool,
) -> Price {
self.fill_price.get_price_from_arrays(
ohlcv.open[idx],
ohlcv.high[idx],
ohlcv.low[idx],
ohlcv.close[idx],
direction,
is_entry,
)
}
/// Calculate stop and target prices using the global config.
#[allow(dead_code)]
fn calculate_stop_target(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
) -> (Option<Price>, Option<Price>) {
self.calculate_stop_target_with_config(
entry_price,
direction,
atr_values,
idx,
&self.config.stop,
&self.config.target,
)
}
/// Calculate stop and target prices with explicit stop/target configs.
fn calculate_stop_target_with_config(
&self,
entry_price: Price,
direction: Direction,
atr_values: &[f64],
idx: usize,
stop_config: &StopConfig,
target_config: &TargetConfig,
) -> (Option<Price>, Option<Price>) {
let multiplier = direction.multiplier();
// Calculate stop price
let stop_price = match stop_config {
StopConfig::None => None,
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
StopConfig::Atr { multiplier: m, .. } => {
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
if atr > 0.0 {
Some(entry_price - multiplier * m * atr)
} else {
None
}
}
StopConfig::Trailing { percent } => Some(entry_price * (1.0 - multiplier * percent)),
};
// Calculate target price
let target_price = match target_config {
TargetConfig::None => None,
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
TargetConfig::Atr { multiplier: m, .. } => {
let atr = atr_values.get(idx).copied().unwrap_or(0.0);
if atr > 0.0 {
Some(entry_price + multiplier * m * atr)
} else {
None
}
}
TargetConfig::RiskReward { ratio } => {
if let Some(stop) = stop_price {
let risk = (entry_price - stop).abs();
Some(entry_price + multiplier * risk * ratio)
} else {
None
}
}
};
(stop_price, target_price)
}
/// Calculate backtest metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
returns: &[f64],
trades: &[Trade],
_streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
// Calculate max drawdown duration
let max_drawdown_duration = self.calculate_max_drawdown_duration(drawdown_curve);
// Trade statistics
let total_trades = trades.len();
// Separate closed vs open trades (EndOfData means still open)
let total_open_trades =
trades.iter().filter(|t| matches!(t.exit_reason, ExitReason::EndOfData)).count();
let total_closed_trades = total_trades.saturating_sub(total_open_trades);
// Open trade PnL
let open_trade_pnl: f64 = trades
.iter()
.filter(|t| matches!(t.exit_reason, ExitReason::EndOfData))
.map(|t| t.pnl)
.sum();
// Only count closed trades for win/loss statistics
let closed_trades: Vec<_> =
trades.iter().filter(|t| !matches!(t.exit_reason, ExitReason::EndOfData)).collect();
let winning_trades = closed_trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = closed_trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_closed_trades > 0 {
winning_trades as f64 / total_closed_trades as f64 * 100.0
} else {
0.0
};
// Total fees paid
let total_fees_paid: f64 = trades.iter().map(|t| t.fees).sum();
// Best and worst trade
let best_trade_pct =
trades.iter().map(|t| t.return_pct).fold(f64::NEG_INFINITY, |a, b| a.max(b));
let best_trade_pct = if best_trade_pct.is_infinite() { 0.0 } else { best_trade_pct };
let worst_trade_pct =
trades.iter().map(|t| t.return_pct).fold(f64::INFINITY, |a, b| a.min(b));
let worst_trade_pct = if worst_trade_pct.is_infinite() { 0.0 } else { worst_trade_pct };
// Profit factor (based on closed trades)
let gross_profit: f64 = closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 =
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
// Expectancy = average trade PnL
let expectancy = if total_closed_trades > 0 {
closed_trades.iter().map(|t| t.pnl).sum::<f64>() / total_closed_trades as f64
} else {
0.0
};
// SQN = (Expectancy / StdDev of trade PnL) * sqrt(total trades)
let sqn = if total_closed_trades > 1 {
let trade_pnls: Vec<f64> = closed_trades.iter().map(|t| t.pnl).collect();
let mean = expectancy;
let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::<f64>()
/ (total_closed_trades - 1) as f64;
let std_dev = variance.sqrt();
if std_dev > 0.0 {
(mean / std_dev) * (total_closed_trades as f64).sqrt()
} else {
0.0
}
} else {
0.0
};
// Average returns
let avg_trade_return_pct = if total_trades > 0 {
trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64
} else {
0.0
};
let avg_win_pct = if winning_trades > 0 {
closed_trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.return_pct).sum::<f64>()
/ winning_trades as f64
} else {
0.0
};
let avg_loss_pct = if losing_trades > 0 {
closed_trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.return_pct).sum::<f64>()
/ losing_trades as f64
} else {
0.0
};
// Average winning/losing trade duration
let avg_winning_duration = if winning_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl > 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ winning_trades as f64
} else {
0.0
};
let avg_losing_duration = if losing_trades > 0 {
closed_trades
.iter()
.filter(|t| t.pnl < 0.0)
.map(|t| t.holding_period() as f64)
.sum::<f64>()
/ losing_trades as f64
} else {
0.0
};
// Consecutive wins/losses
let (max_consecutive_wins, max_consecutive_losses) = self.calculate_consecutive(trades);
// Holding period
let avg_holding_period = if total_trades > 0 {
trades.iter().map(|t| t.holding_period() as f64).sum::<f64>() / total_trades as f64
} else {
0.0
};
// Exposure (time in market)
let bars_in_position: usize = trades.iter().map(|t| t.holding_period()).sum();
let exposure_pct = if !equity_curve.is_empty() {
bars_in_position as f64 / equity_curve.len() as f64 * 100.0
} else {
0.0
};
// Risk-adjusted metrics (calculated from daily portfolio returns, not trade returns)
let (sharpe_ratio, sortino_ratio, omega_ratio) = self.calculate_risk_metrics(returns);
// Calmar ratio: CAGR / max drawdown
let num_periods = equity_curve.len().max(1) as f64;
let years = num_periods / 365.25; // Convert to years using 365.25 days
let total_return_frac = total_return_pct / 100.0;
// CAGR = (end/start)^(1/years) - 1 = (1 + total_return)^(1/years) - 1
let cagr =
if years > 0.0 { (1.0 + total_return_frac).powf(1.0 / years) - 1.0 } else { 0.0 };
let calmar_ratio = if max_drawdown_pct > 0.0 {
cagr / (max_drawdown_pct / 100.0) // Both as fractions
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Payoff ratio: average win / average loss (absolute value)
let payoff_ratio = if avg_loss_pct.abs() > 0.0 {
avg_win_pct / avg_loss_pct.abs()
} else if avg_win_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
// Recovery factor: net profit / max drawdown (absolute value)
let net_profit = end_value - start_value;
let recovery_factor = if max_drawdown_pct > 0.0 && start_value > 0.0 {
let max_dd_absolute = max_drawdown_pct / 100.0 * start_value;
if max_dd_absolute > 0.0 {
net_profit / max_dd_absolute
} else {
0.0
}
} else if net_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
omega_ratio,
max_drawdown_pct,
max_drawdown_duration,
win_rate_pct,
profit_factor,
expectancy,
sqn,
total_trades,
total_closed_trades,
total_open_trades,
open_trade_pnl,
winning_trades,
losing_trades,
start_value,
end_value,
total_fees_paid,
best_trade_pct,
worst_trade_pct,
avg_trade_return_pct,
avg_win_pct,
avg_loss_pct,
avg_winning_duration,
avg_losing_duration,
max_consecutive_wins,
max_consecutive_losses,
avg_holding_period,
exposure_pct,
payoff_ratio,
recovery_factor,
}
}
/// Calculate max drawdown duration from drawdown curve.
fn calculate_max_drawdown_duration(&self, drawdown_curve: &[f64]) -> usize {
let mut max_duration = 0;
let mut current_duration = 0;
for &dd in drawdown_curve {
if dd > 0.0 {
current_duration += 1;
max_duration = max_duration.max(current_duration);
} else {
current_duration = 0;
}
}
max_duration
}
/// Calculate max consecutive wins and losses.
fn calculate_consecutive(&self, trades: &[Trade]) -> (usize, usize) {
let mut max_wins = 0;
let mut max_losses = 0;
let mut current_wins = 0;
let mut current_losses = 0;
for trade in trades {
if trade.pnl > 0.0 {
current_wins += 1;
current_losses = 0;
max_wins = max_wins.max(current_wins);
} else if trade.pnl < 0.0 {
current_losses += 1;
current_wins = 0;
max_losses = max_losses.max(current_losses);
}
}
(max_wins, max_losses)
}
/// Calculate risk-adjusted metrics from daily portfolio returns.
/// Returns (sharpe_ratio, sortino_ratio, omega_ratio).
/// Uses 365 calendar days for annualization.
fn calculate_risk_metrics(&self, returns: &[f64]) -> (f64, f64, f64) {
if returns.len() < 2 {
return (0.0, 0.0, 1.0);
}
// 365 calendar days for annualization
let periods_per_year: f64 = 365.0;
let _n = returns.len() as f64;
// Filter out NaN values
let valid_returns: Vec<f64> = returns.iter().filter(|r| !r.is_nan()).copied().collect();
if valid_returns.len() < 2 {
return (0.0, 0.0, 1.0);
}
let n_valid = valid_returns.len() as f64;
// Calculate mean return
let mean = valid_returns.iter().sum::<f64>() / n_valid;
// Calculate standard deviation
let variance =
valid_returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n_valid - 1.0);
let std_dev = variance.sqrt();
// Sharpe Ratio = (mean * periods_per_year) / (std_dev * sqrt(periods_per_year))
// Simplified: Sharpe = mean / std_dev * sqrt(periods_per_year)
let sharpe_ratio =
if std_dev > 0.0 { (mean / std_dev) * periods_per_year.sqrt() } else { 0.0 };
// Sortino Ratio - uses downside deviation (only negative returns)
let downside_returns: Vec<f64> =
valid_returns.iter().filter(|&&r| r < 0.0).copied().collect();
let downside_variance = if !downside_returns.is_empty() {
downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / n_valid // Divide by total count, not downside count
} else {
0.0
};
let downside_std = downside_variance.sqrt();
let sortino_ratio = if downside_std > 0.0 {
(mean / downside_std) * periods_per_year.sqrt()
} else if mean > 0.0 {
f64::INFINITY
} else {
0.0
};
// Omega Ratio = sum of returns above threshold / |sum of returns below threshold|
// With threshold = 0
let sum_positive: f64 = valid_returns.iter().filter(|&&r| r > 0.0).sum();
let sum_negative: f64 = valid_returns.iter().filter(|&&r| r < 0.0).map(|r| r.abs()).sum();
let omega_ratio = if sum_negative > 0.0 {
sum_positive / sum_negative
} else if sum_positive > 0.0 {
f64::INFINITY
} else {
1.0
};
(sharpe_ratio, sortino_ratio, omega_ratio)
}
}
/// Compute `BacktestMetrics` from pre-built curves and trade list.
///
/// Exposed as a standalone function so non-OHLCV strategies (e.g. tick backtest)
/// can produce identical metrics without duplicating the calculation logic.
pub fn compute_backtest_metrics(
equity_curve: &[f64],
drawdown_curve: &[f64],
returns: &[f64],
trades: &[Trade],
initial_capital: f64,
) -> BacktestMetrics {
// Delegate to a throwaway engine instance — avoids duplicating the logic.
let engine = PortfolioEngine::new(BacktestConfig {
initial_capital,
..Default::default()
});
engine.calculate_metrics(equity_curve, drawdown_curve, returns, trades, &StreamingMetrics::new())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_ohlcv() -> OhlcvData {
OhlcvData {
timestamps: (0..20).map(|i| i as i64).collect(),
open: vec![
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
],
high: vec![
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
],
low: vec![
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
],
close: vec![
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
],
volume: vec![1000.0; 20],
}
}
fn sample_signals() -> CompiledSignals {
CompiledSignals {
symbol: "TEST".to_string(),
entries: vec![
false, true, false, false, false, false, false, false, false, false, false, true,
false, false, false, false, false, false, false, false,
],
exits: vec![
false, false, false, false, false, true, false, false, false, false, false, false,
false, false, false, true, false, false, false, false,
],
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
}
}
#[test]
fn test_basic_backtest() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
let ohlcv = sample_ohlcv();
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// Should have 2 trades
assert_eq!(result.trades.len(), 2);
// First trade: entry at 101.5, exit at 105.0
let trade1 = &result.trades[0];
assert!((trade1.entry_price - 101.5).abs() < 1e-10);
assert!((trade1.exit_price - 105.0).abs() < 1e-10);
assert!(trade1.pnl > 0.0); // Profitable
// Equity curve should have correct length
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_with_fees() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.001, // 0.1%
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
let ohlcv = sample_ohlcv();
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// Trades should have fees deducted
for trade in &result.trades {
assert!(trade.fees > 0.0);
}
}
#[test]
fn test_with_stop_loss() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
target: TargetConfig::None,
upon_bar_close: true,
};
let engine = PortfolioEngine::new(config);
// Create data where stop would be hit
let mut ohlcv = sample_ohlcv();
// Add a big drop after entry
ohlcv.low[3] = 95.0; // Big drop
ohlcv.close[3] = 96.0;
let signals = sample_signals();
let result = engine.run_single(&ohlcv, &signals);
// First trade should exit on stop loss
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Portfolio simulation engine for RaptorBT.
pub mod allocation;
pub mod engine;
pub mod monte_carlo;
pub mod position;
pub use allocation::{AllocationStrategy, CapitalAllocator};
pub use engine::PortfolioEngine;
pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult};
pub use position::PositionManager;
+361
View File
@@ -0,0 +1,361 @@
//! Monte Carlo forward simulation for portfolio projection.
//!
//! Uses Geometric Brownian Motion (GBM) with Cholesky decomposition
//! for correlated multi-asset simulation. Parallelized via Rayon.
use rayon::prelude::*;
/// Configuration for Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloConfig {
pub n_simulations: usize,
pub horizon_days: usize,
pub seed: u64,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self { n_simulations: 10_000, horizon_days: 252, seed: 42 }
}
}
/// Result of a Monte Carlo simulation.
#[derive(Debug, Clone)]
pub struct MonteCarloResult {
/// Percentile paths: Vec of (percentile, path_values)
pub percentile_paths: Vec<(f64, Vec<f64>)>,
/// Terminal value for each simulation
pub final_values: Vec<f64>,
/// Expected annualized return
pub expected_return: f64,
/// Probability of loss (final value < initial value)
pub probability_of_loss: f64,
/// Value at Risk at 95% confidence
pub var_95: f64,
/// Conditional Value at Risk at 95% confidence
pub cvar_95: f64,
}
/// Cholesky decomposition of a symmetric positive-definite matrix.
/// Returns lower-triangular matrix L such that A = L * L^T.
fn cholesky(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, &'static str> {
let n = matrix.len();
let mut l = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..=i {
let mut sum = 0.0;
for k in 0..j {
sum += l[i][k] * l[j][k];
}
if i == j {
let diag = matrix[i][i] - sum;
if diag <= 0.0 {
// Matrix is not positive definite; use a small epsilon
l[i][j] = (diag.abs().max(1e-10)).sqrt();
} else {
l[i][j] = diag.sqrt();
}
} else {
if l[j][j].abs() < 1e-15 {
l[i][j] = 0.0;
} else {
l[i][j] = (matrix[i][j] - sum) / l[j][j];
}
}
}
}
Ok(l)
}
/// Simple xoshiro256** PRNG for deterministic parallel simulation.
#[derive(Clone)]
struct Xoshiro256 {
s: [u64; 4],
}
impl Xoshiro256 {
fn new(seed: u64) -> Self {
// SplitMix64 to seed all 4 state words
let mut z = seed;
let mut s = [0u64; 4];
for item in &mut s {
z = z.wrapping_add(0x9e3779b97f4a7c15);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
*item = z ^ (z >> 31);
}
Self { s }
}
fn jump(&mut self) {
// Jump function: advances state by 2^128 calls
const JUMP: [u64; 4] =
[0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c];
let mut s0: u64 = 0;
let mut s1: u64 = 0;
let mut s2: u64 = 0;
let mut s3: u64 = 0;
for j in &JUMP {
for b in 0..64 {
if j & (1u64 << b) != 0 {
s0 ^= self.s[0];
s1 ^= self.s[1];
s2 ^= self.s[2];
s3 ^= self.s[3];
}
self.next_u64();
}
}
self.s[0] = s0;
self.s[1] = s1;
self.s[2] = s2;
self.s[3] = s3;
}
fn next_u64(&mut self) -> u64 {
let result = (self.s[1].wrapping_mul(5)).rotate_left(7).wrapping_mul(9);
let t = self.s[1] << 17;
self.s[2] ^= self.s[0];
self.s[3] ^= self.s[1];
self.s[1] ^= self.s[2];
self.s[0] ^= self.s[3];
self.s[2] ^= t;
self.s[3] = self.s[3].rotate_left(45);
result
}
/// Generate uniform f64 in [0, 1).
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
/// Box-Muller transform for standard normal.
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64().max(1e-15);
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
/// Core Monte Carlo simulation function.
///
/// # Arguments
/// * `returns` - Per-strategy daily returns (N strategies x T days each)
/// * `weights` - Portfolio weights (length N, must sum to 1)
/// * `correlation_matrix` - N x N correlation matrix
/// * `initial_value` - Starting portfolio value
/// * `config` - Simulation configuration
pub fn simulate_portfolio_forward(
returns: &[Vec<f64>],
weights: &[f64],
correlation_matrix: &[Vec<f64>],
initial_value: f64,
config: &MonteCarloConfig,
) -> MonteCarloResult {
let n_assets = returns.len();
let dt = 1.0; // daily time step
// Compute per-asset mean and std of historical returns
let mut mus = vec![0.0; n_assets];
let mut sigmas = vec![0.0; n_assets];
for (i, ret) in returns.iter().enumerate() {
if ret.is_empty() {
continue;
}
let mean = ret.iter().sum::<f64>() / ret.len() as f64;
let var = ret.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / ret.len() as f64;
mus[i] = mean;
sigmas[i] = var.sqrt().max(1e-10);
}
// Cholesky decomposition of correlation matrix
let chol = cholesky(correlation_matrix).unwrap_or_else(|_| {
// Fallback: identity matrix (independent assets)
let mut identity = vec![vec![0.0; n_assets]; n_assets];
for i in 0..n_assets {
identity[i][i] = 1.0;
}
identity
});
// Prepare a base RNG and create per-chunk seeds via jumping
let mut base_rng = Xoshiro256::new(config.seed);
let n_chunks = rayon::current_num_threads().max(1);
let chunk_size = (config.n_simulations + n_chunks - 1) / n_chunks;
let chunk_rngs: Vec<Xoshiro256> = (0..n_chunks)
.map(|_| {
let rng = base_rng.clone();
base_rng.jump();
rng
})
.collect();
// Run simulations in parallel chunks
let all_paths: Vec<Vec<f64>> = chunk_rngs
.into_par_iter()
.enumerate()
.flat_map(|(chunk_idx, mut rng)| {
let start = chunk_idx * chunk_size;
let end = (start + chunk_size).min(config.n_simulations);
let mut chunk_paths = Vec::with_capacity(end - start);
for _ in start..end {
let mut portfolio_value = initial_value;
let mut path = Vec::with_capacity(config.horizon_days + 1);
path.push(portfolio_value);
for _ in 0..config.horizon_days {
// Generate N independent standard normals
let z_indep: Vec<f64> = (0..n_assets).map(|_| rng.next_normal()).collect();
// Correlate via Cholesky: z_corr = L * z_indep
let mut z_corr = vec![0.0; n_assets];
for i in 0..n_assets {
for j in 0..=i {
z_corr[i] += chol[i][j] * z_indep[j];
}
}
// GBM per asset, then weighted portfolio return
let mut portfolio_return = 0.0;
for i in 0..n_assets {
let drift = (mus[i] - 0.5 * sigmas[i].powi(2)) * dt;
let diffusion = sigmas[i] * dt.sqrt() * z_corr[i];
let asset_return = (drift + diffusion).exp() - 1.0;
portfolio_return += weights[i] * asset_return;
}
portfolio_value *= 1.0 + portfolio_return;
path.push(portfolio_value);
}
chunk_paths.push(path);
}
chunk_paths
})
.collect();
// Extract final values
let mut final_values: Vec<f64> = all_paths.iter().map(|p| *p.last().unwrap()).collect();
final_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = final_values.len();
// Percentile paths: find simulations closest to each percentile's final value
let percentiles = [5.0, 25.0, 50.0, 75.0, 95.0];
let percentile_paths: Vec<(f64, Vec<f64>)> = percentiles
.iter()
.map(|&pct| {
let idx = ((pct / 100.0) * (n as f64 - 1.0)).round() as usize;
let target_final = final_values[idx.min(n - 1)];
// Find the simulation path whose final value is closest to target
let best_idx = all_paths
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
let da = (a.last().unwrap() - target_final).abs();
let db = (b.last().unwrap() - target_final).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0);
(pct, all_paths[best_idx].clone())
})
.collect();
// Expected return (annualized from mean of final values)
let mean_final = final_values.iter().sum::<f64>() / n as f64;
let expected_return = (mean_final / initial_value - 1.0) * 100.0;
// Probability of loss
let n_loss = final_values.iter().filter(|&&v| v < initial_value).count();
let probability_of_loss = n_loss as f64 / n as f64;
// VaR 95%: 5th percentile loss
let p5_idx = ((0.05 * (n as f64 - 1.0)).round() as usize).min(n - 1);
let var_95 = ((initial_value - final_values[p5_idx]) / initial_value * 100.0).max(0.0);
// CVaR 95%: average of losses below VaR
let cvar_values = &final_values[..=p5_idx];
let cvar_95 = if cvar_values.is_empty() {
var_95
} else {
let avg_tail = cvar_values.iter().sum::<f64>() / cvar_values.len() as f64;
((initial_value - avg_tail) / initial_value * 100.0).max(0.0)
};
MonteCarloResult {
percentile_paths,
final_values,
expected_return,
probability_of_loss,
var_95,
cvar_95,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cholesky_identity() {
let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let l = cholesky(&matrix).unwrap();
assert!((l[0][0] - 1.0).abs() < 1e-10);
assert!((l[1][1] - 1.0).abs() < 1e-10);
assert!(l[0][1].abs() < 1e-10);
assert!(l[1][0].abs() < 1e-10);
}
#[test]
fn test_cholesky_correlated() {
let matrix = vec![vec![1.0, 0.5], vec![0.5, 1.0]];
let l = cholesky(&matrix).unwrap();
// Verify L * L^T = matrix
let reconstructed_00 = l[0][0] * l[0][0];
let reconstructed_01 = l[1][0] * l[0][0];
let reconstructed_11 = l[1][0] * l[1][0] + l[1][1] * l[1][1];
assert!((reconstructed_00 - 1.0).abs() < 1e-10);
assert!((reconstructed_01 - 0.5).abs() < 1e-10);
assert!((reconstructed_11 - 1.0).abs() < 1e-10);
}
#[test]
fn test_simulate_basic() {
// Two assets with identical positive returns
let returns = vec![vec![0.001; 252], vec![0.001; 252]];
let weights = vec![0.5, 0.5];
let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let config = MonteCarloConfig { n_simulations: 100, horizon_days: 10, seed: 42 };
let result = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
assert_eq!(result.final_values.len(), 100);
assert_eq!(result.percentile_paths.len(), 5);
// Expected return should be positive given positive drift
assert!(result.expected_return > -50.0); // Sanity check
}
#[test]
fn test_deterministic() {
let returns = vec![vec![0.001; 100], vec![-0.0005; 100]];
let weights = vec![0.6, 0.4];
let corr = vec![vec![1.0, -0.3], vec![-0.3, 1.0]];
let config = MonteCarloConfig { n_simulations: 50, horizon_days: 20, seed: 123 };
let r1 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
let r2 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config);
// Same seed should produce same final values (single-threaded determinism)
// Note: with rayon, parallelism may affect order but not values
assert!((r1.expected_return - r2.expected_return).abs() < 1e-6);
}
}
+347
View File
@@ -0,0 +1,347 @@
//! Position tracking for portfolio management.
use crate::core::types::{Direction, ExitReason, Position, Price, Timestamp, Trade};
/// Position manager for tracking open positions.
#[derive(Debug, Clone)]
pub struct PositionManager {
/// Current position state.
pub position: Position,
/// Trade counter for generating unique IDs.
trade_counter: u64,
/// Symbol being traded.
pub symbol: String,
}
impl PositionManager {
/// Create a new position manager.
pub fn new(symbol: String) -> Self {
Self { position: Position::new(), trade_counter: 0, symbol }
}
/// Check if currently in a position.
#[inline]
pub fn is_in_position(&self) -> bool {
self.position.is_open
}
/// Get current position direction.
pub fn current_direction(&self) -> Option<Direction> {
if self.position.is_open {
Some(self.position.direction)
} else {
None
}
}
/// Open a new position.
///
/// # Arguments
/// * `idx` - Bar index
/// * `timestamp` - Entry timestamp
/// * `price` - Entry price
/// * `size` - Position size
/// * `direction` - Trade direction
/// * `stop_price` - Optional stop-loss price
/// * `target_price` - Optional take-profit price
/// * `entry_fees` - Entry fees (to track for PnL calculation)
///
/// # Returns
/// True if position was opened, false if already in position
pub fn open_position(
&mut self,
idx: usize,
_timestamp: Timestamp,
price: Price,
size: f64,
direction: Direction,
stop_price: Option<Price>,
target_price: Option<Price>,
entry_fees: f64,
) -> bool {
if self.position.is_open {
return false;
}
self.position.open(idx, price, size, direction, stop_price, target_price, entry_fees);
true
}
/// Close current position and generate a trade record.
///
/// # Arguments
/// * `idx` - Bar index
/// * `timestamp` - Exit timestamp
/// * `price` - Exit price
/// * `entry_timestamp` - Entry timestamp (for trade record)
/// * `exit_reason` - Reason for exit
/// * `fees` - Transaction fees
///
/// # Returns
/// Trade record if position was closed, None if no position
pub fn close_position(
&mut self,
idx: usize,
timestamp: Timestamp,
price: Price,
entry_timestamp: Timestamp,
exit_reason: ExitReason,
fees: f64,
) -> Option<Trade> {
if !self.position.is_open {
return None;
}
let trade = self.create_trade(idx, timestamp, price, entry_timestamp, exit_reason, fees);
self.position.close();
self.trade_counter += 1;
Some(trade)
}
/// Create a trade record from current position.
fn create_trade(
&self,
exit_idx: usize,
exit_timestamp: Timestamp,
exit_price: Price,
entry_timestamp: Timestamp,
exit_reason: ExitReason,
exit_fees: f64,
) -> Trade {
let pos = &self.position;
let multiplier = pos.direction.multiplier();
// Calculate P&L: gross - entry_fees - exit_fees
let gross_pnl = (exit_price - pos.entry_price) * pos.size * multiplier;
let total_fees = pos.entry_fees + exit_fees;
let pnl = gross_pnl - total_fees;
// Calculate return percentage
let cost_basis = pos.entry_price * pos.size;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
Trade {
id: self.trade_counter,
symbol: self.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: pos.direction,
pnl,
return_pct,
entry_time: entry_timestamp,
exit_time: exit_timestamp,
fees: total_fees,
exit_reason,
}
}
/// Update position with new price data (for trailing stops).
///
/// # Arguments
/// * `high` - Current bar high
/// * `low` - Current bar low
pub fn update_price(&mut self, high: Price, low: Price) {
if self.position.is_open {
self.position.update_extremes(high, low);
}
}
/// Calculate unrealized P&L at current price.
pub fn unrealized_pnl(&self, current_price: Price) -> f64 {
self.position.unrealized_pnl(current_price)
}
/// Get current position value (market value of position).
pub fn position_value(&self, current_price: Price) -> f64 {
if !self.position.is_open {
return 0.0;
}
current_price * self.position.size
}
/// Calculate position exposure (notional value as fraction of given capital).
pub fn exposure(&self, current_price: Price, capital: f64) -> f64 {
if capital <= 0.0 {
return 0.0;
}
self.position_value(current_price) / capital
}
/// Check if stop-loss is hit.
pub fn is_stop_hit(&self, low: Price, high: Price) -> bool {
if !self.position.is_open {
return false;
}
if let Some(stop) = self.position.stop_price {
match self.position.direction {
Direction::Long => low <= stop,
Direction::Short => high >= stop,
}
} else {
false
}
}
/// Check if take-profit is hit.
pub fn is_target_hit(&self, low: Price, high: Price) -> bool {
if !self.position.is_open {
return false;
}
if let Some(target) = self.position.target_price {
match self.position.direction {
Direction::Long => high >= target,
Direction::Short => low <= target,
}
} else {
false
}
}
/// Update trailing stop.
///
/// # Arguments
/// * `trail_percent` - Trailing stop percentage
pub fn update_trailing_stop(&mut self, trail_percent: f64) {
if !self.position.is_open {
return;
}
match self.position.direction {
Direction::Long => {
// Trail below highest price since entry
let new_stop = self.position.highest_since_entry * (1.0 - trail_percent);
if let Some(current_stop) = self.position.stop_price {
if new_stop > current_stop {
self.position.stop_price = Some(new_stop);
}
} else {
self.position.stop_price = Some(new_stop);
}
}
Direction::Short => {
// Trail above lowest price since entry
let new_stop = self.position.lowest_since_entry * (1.0 + trail_percent);
if let Some(current_stop) = self.position.stop_price {
if new_stop < current_stop {
self.position.stop_price = Some(new_stop);
}
} else {
self.position.stop_price = Some(new_stop);
}
}
}
}
/// Reset position manager for new backtest.
pub fn reset(&mut self) {
self.position = Position::new();
self.trade_counter = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_open_close_position() {
let mut pm = PositionManager::new("TEST".to_string());
// Open position
assert!(pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0));
assert!(pm.is_in_position());
// Try to open another - should fail
assert!(!pm.open_position(1, 1001, 101.0, 10.0, Direction::Long, None, None, 0.0));
// Close position with profit
let trade = pm.close_position(5, 1005, 110.0, 1000, ExitReason::Signal, 2.0).unwrap();
assert!(!pm.is_in_position());
assert_eq!(trade.entry_idx, 0);
assert_eq!(trade.exit_idx, 5);
assert!((trade.entry_price - 100.0).abs() < 1e-10);
assert!((trade.exit_price - 110.0).abs() < 1e-10);
// P&L: (110 - 100) * 10 - 2 = 98
assert!((trade.pnl - 98.0).abs() < 1e-10);
}
#[test]
fn test_short_position() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Short, None, None, 0.0);
// Close with profit (price went down)
let trade = pm.close_position(5, 1005, 90.0, 1000, ExitReason::Signal, 2.0).unwrap();
// P&L: (100 - 90) * 10 * -(-1) - 2 = 98
// For short: (entry - exit) * size = (100 - 90) * 10 = 100 gross, minus 2 fees = 98
assert!((trade.pnl - 98.0).abs() < 1e-10);
}
#[test]
fn test_stop_loss() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(
0,
1000,
100.0,
10.0,
Direction::Long,
Some(95.0), // Stop at 95
None,
0.0,
);
// Check stop not hit
assert!(!pm.is_stop_hit(96.0, 102.0));
// Check stop hit
assert!(pm.is_stop_hit(94.0, 102.0));
}
#[test]
fn test_trailing_stop() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
// Update with higher price
pm.update_price(110.0, 98.0);
pm.update_trailing_stop(0.05); // 5% trail
// Stop should be at 110 * 0.95 = 104.5
assert!((pm.position.stop_price.unwrap() - 104.5).abs() < 1e-10);
// Update with even higher price
pm.update_price(120.0, 108.0);
pm.update_trailing_stop(0.05);
// Stop should move up to 120 * 0.95 = 114
assert!((pm.position.stop_price.unwrap() - 114.0).abs() < 1e-10);
}
#[test]
fn test_unrealized_pnl() {
let mut pm = PositionManager::new("TEST".to_string());
pm.open_position(0, 1000, 100.0, 10.0, Direction::Long, None, None, 0.0);
// Price up
let pnl = pm.unrealized_pnl(110.0);
assert!((pnl - 100.0).abs() < 1e-10); // (110 - 100) * 10 = 100
// Price down
let pnl = pm.unrealized_pnl(95.0);
assert!((pnl - (-50.0)).abs() < 1e-10); // (95 - 100) * 10 = -50
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
//! Python bindings for RaptorBT.
pub mod bindings;
pub mod numpy_bridge;
+34
View File
@@ -0,0 +1,34 @@
//! Zero-copy numpy array interface.
use numpy::{PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
/// Convert numpy array to Vec<f64>.
pub fn numpy_to_vec_f64(arr: PyReadonlyArray1<f64>) -> Vec<f64> {
arr.as_slice().unwrap().to_vec()
}
/// Convert numpy array to Vec<i64>.
pub fn numpy_to_vec_i64(arr: PyReadonlyArray1<i64>) -> Vec<i64> {
arr.as_slice().unwrap().to_vec()
}
/// Convert numpy bool array to Vec<bool>.
pub fn numpy_to_vec_bool(arr: PyReadonlyArray1<bool>) -> Vec<bool> {
arr.as_slice().unwrap().to_vec()
}
/// Convert Vec<f64> to numpy array.
pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec<f64>) -> &'py PyArray1<f64> {
PyArray1::from_vec(py, vec)
}
/// Convert Vec<i64> to numpy array.
pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec<i64>) -> &'py PyArray1<i64> {
PyArray1::from_vec(py, vec)
}
/// Convert Vec<bool> to numpy array.
pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec<bool>) -> &'py PyArray1<bool> {
PyArray1::from_vec(py, vec)
}
+456
View File
@@ -0,0 +1,456 @@
//! Expression evaluation for signal generation.
//!
//! Provides a Rust-native expression evaluator for generating trading signals
//! from indicator values.
/// Comparison operators for signal generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompareOp {
/// Greater than.
Gt,
/// Greater than or equal.
Gte,
/// Less than.
Lt,
/// Less than or equal.
Lte,
/// Equal (within tolerance).
Eq,
/// Not equal.
Ne,
}
/// Crossover/crossunder detection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossType {
/// Line A crosses above line B.
CrossOver,
/// Line A crosses below line B.
CrossUnder,
}
/// Compare two series element-wise.
///
/// # Arguments
/// * `a` - First series
/// * `b` - Second series
/// * `op` - Comparison operator
///
/// # Returns
/// Boolean series indicating where comparison is true
pub fn compare(a: &[f64], b: &[f64], op: CompareOp) -> Vec<bool> {
let n = a.len();
assert_eq!(n, b.len());
let tolerance = 1e-10;
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() || b[i].is_nan() {
continue;
}
result[i] = match op {
CompareOp::Gt => a[i] > b[i],
CompareOp::Gte => a[i] >= b[i],
CompareOp::Lt => a[i] < b[i],
CompareOp::Lte => a[i] <= b[i],
CompareOp::Eq => (a[i] - b[i]).abs() < tolerance,
CompareOp::Ne => (a[i] - b[i]).abs() >= tolerance,
};
}
result
}
/// Compare series with a scalar value.
///
/// # Arguments
/// * `a` - Series
/// * `value` - Scalar value to compare against
/// * `op` - Comparison operator
///
/// # Returns
/// Boolean series indicating where comparison is true
pub fn compare_scalar(a: &[f64], value: f64, op: CompareOp) -> Vec<bool> {
let n = a.len();
let tolerance = 1e-10;
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() {
continue;
}
result[i] = match op {
CompareOp::Gt => a[i] > value,
CompareOp::Gte => a[i] >= value,
CompareOp::Lt => a[i] < value,
CompareOp::Lte => a[i] <= value,
CompareOp::Eq => (a[i] - value).abs() < tolerance,
CompareOp::Ne => (a[i] - value).abs() >= tolerance,
};
}
result
}
/// Detect crossover/crossunder between two series.
///
/// Crossover: a crosses above b (a[i-1] < b[i-1] and a[i] > b[i])
/// Crossunder: a crosses below b (a[i-1] > b[i-1] and a[i] < b[i])
///
/// # Arguments
/// * `a` - First series
/// * `b` - Second series
/// * `cross_type` - Type of cross to detect
///
/// # Returns
/// Boolean series indicating where cross occurs
pub fn cross(a: &[f64], b: &[f64], cross_type: CrossType) -> Vec<bool> {
let n = a.len();
assert_eq!(n, b.len());
let mut result = vec![false; n];
if n < 2 {
return result;
}
for i in 1..n {
if a[i].is_nan() || b[i].is_nan() || a[i - 1].is_nan() || b[i - 1].is_nan() {
continue;
}
result[i] = match cross_type {
CrossType::CrossOver => a[i - 1] <= b[i - 1] && a[i] > b[i],
CrossType::CrossUnder => a[i - 1] >= b[i - 1] && a[i] < b[i],
};
}
result
}
/// Detect crossover with a scalar value.
///
/// # Arguments
/// * `a` - Series
/// * `value` - Scalar value to cross
/// * `cross_type` - Type of cross to detect
///
/// # Returns
/// Boolean series indicating where cross occurs
pub fn cross_scalar(a: &[f64], value: f64, cross_type: CrossType) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if n < 2 {
return result;
}
for i in 1..n {
if a[i].is_nan() || a[i - 1].is_nan() {
continue;
}
result[i] = match cross_type {
CrossType::CrossOver => a[i - 1] <= value && a[i] > value,
CrossType::CrossUnder => a[i - 1] >= value && a[i] < value,
};
}
result
}
/// Check if value is in a range.
///
/// # Arguments
/// * `a` - Series
/// * `low` - Lower bound
/// * `high` - Upper bound
///
/// # Returns
/// Boolean series indicating where value is in range [low, high]
pub fn in_range(a: &[f64], low: f64, high: f64) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
for i in 0..n {
if a[i].is_nan() {
continue;
}
result[i] = a[i] >= low && a[i] <= high;
}
result
}
/// Check if series is rising (current > previous).
///
/// # Arguments
/// * `a` - Series
/// * `periods` - Number of periods to look back (default: 1)
///
/// # Returns
/// Boolean series indicating where value is rising
pub fn is_rising(a: &[f64], periods: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if periods >= n {
return result;
}
for i in periods..n {
if a[i].is_nan() || a[i - periods].is_nan() {
continue;
}
result[i] = a[i] > a[i - periods];
}
result
}
/// Check if series is falling (current < previous).
///
/// # Arguments
/// * `a` - Series
/// * `periods` - Number of periods to look back (default: 1)
///
/// # Returns
/// Boolean series indicating where value is falling
pub fn is_falling(a: &[f64], periods: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if periods >= n {
return result;
}
for i in periods..n {
if a[i].is_nan() || a[i - periods].is_nan() {
continue;
}
result[i] = a[i] < a[i - periods];
}
result
}
/// Check if value has been above a threshold for n consecutive bars.
///
/// # Arguments
/// * `a` - Series
/// * `threshold` - Threshold value
/// * `consecutive` - Number of consecutive bars required
///
/// # Returns
/// Boolean series indicating where condition is met
pub fn above_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if consecutive > n {
return result;
}
for i in (consecutive - 1)..n {
let mut all_above = true;
for j in 0..consecutive {
let idx = i - j;
if a[idx].is_nan() || a[idx] <= threshold {
all_above = false;
break;
}
}
result[i] = all_above;
}
result
}
/// Check if value has been below a threshold for n consecutive bars.
///
/// # Arguments
/// * `a` - Series
/// * `threshold` - Threshold value
/// * `consecutive` - Number of consecutive bars required
///
/// # Returns
/// Boolean series indicating where condition is met
pub fn below_for(a: &[f64], threshold: f64, consecutive: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if consecutive > n {
return result;
}
for i in (consecutive - 1)..n {
let mut all_below = true;
for j in 0..consecutive {
let idx = i - j;
if a[idx].is_nan() || a[idx] >= threshold {
all_below = false;
break;
}
}
result[i] = all_below;
}
result
}
/// Detect highest value in rolling window.
///
/// # Arguments
/// * `a` - Series
/// * `window` - Window size
///
/// # Returns
/// Boolean series indicating where current value is highest in window
pub fn is_highest(a: &[f64], window: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if window > n || window == 0 {
return result;
}
for i in (window - 1)..n {
let start = i + 1 - window;
let current = a[i];
if current.is_nan() {
continue;
}
let max_in_window =
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::NEG_INFINITY, |a, &b| a.max(b));
result[i] = (current - max_in_window).abs() < 1e-10;
}
result
}
/// Detect lowest value in rolling window.
///
/// # Arguments
/// * `a` - Series
/// * `window` - Window size
///
/// # Returns
/// Boolean series indicating where current value is lowest in window
pub fn is_lowest(a: &[f64], window: usize) -> Vec<bool> {
let n = a.len();
let mut result = vec![false; n];
if window > n || window == 0 {
return result;
}
for i in (window - 1)..n {
let start = i + 1 - window;
let current = a[i];
if current.is_nan() {
continue;
}
let min_in_window =
a[start..=i].iter().filter(|v| !v.is_nan()).fold(f64::INFINITY, |a, &b| a.min(b));
result[i] = (current - min_in_window).abs() < 1e-10;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compare() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![2.0, 2.0, 2.0, 2.0];
let result = compare(&a, &b, CompareOp::Gt);
assert!(!result[0]); // 1 > 2 = false
assert!(!result[1]); // 2 > 2 = false
assert!(result[2]); // 3 > 2 = true
assert!(result[3]); // 4 > 2 = true
}
#[test]
fn test_crossover() {
let a = vec![1.0, 1.5, 2.5, 3.0, 2.5];
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
let result = cross(&a, &b, CrossType::CrossOver);
assert!(!result[0]); // No previous
assert!(!result[1]); // 1.0 < 2.0, 1.5 < 2.0 - still below
assert!(result[2]); // 1.5 < 2.0, 2.5 > 2.0 - crossed over!
assert!(!result[3]); // 2.5 > 2.0, 3.0 > 2.0 - already above
assert!(!result[4]); // 3.0 > 2.0, 2.5 > 2.0 - still above
}
#[test]
fn test_crossunder() {
let a = vec![3.0, 2.5, 1.5, 1.0, 1.5];
let b = vec![2.0, 2.0, 2.0, 2.0, 2.0];
let result = cross(&a, &b, CrossType::CrossUnder);
assert!(!result[0]); // No previous
assert!(!result[1]); // 3.0 > 2.0, 2.5 > 2.0 - still above
assert!(result[2]); // 2.5 > 2.0, 1.5 < 2.0 - crossed under!
assert!(!result[3]); // 1.5 < 2.0, 1.0 < 2.0 - already below
assert!(!result[4]); // 1.0 < 2.0, 1.5 < 2.0 - still below
}
#[test]
fn test_in_range() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let result = in_range(&a, 2.0, 4.0);
assert!(!result[0]); // 1 not in [2, 4]
assert!(result[1]); // 2 in [2, 4]
assert!(result[2]); // 3 in [2, 4]
assert!(result[3]); // 4 in [2, 4]
assert!(!result[4]); // 5 not in [2, 4]
}
#[test]
fn test_is_rising() {
let a = vec![1.0, 2.0, 3.0, 2.5, 3.5];
let result = is_rising(&a, 1);
assert!(!result[0]); // No previous
assert!(result[1]); // 2 > 1
assert!(result[2]); // 3 > 2
assert!(!result[3]); // 2.5 < 3
assert!(result[4]); // 3.5 > 2.5
}
#[test]
fn test_above_for() {
let a = vec![1.0, 3.0, 3.5, 4.0, 2.0, 3.0];
let threshold = 2.5;
let result = above_for(&a, threshold, 3);
assert!(!result[0]);
assert!(!result[1]);
assert!(!result[2]); // 1.0 < 2.5
assert!(result[3]); // 3.0, 3.5, 4.0 all > 2.5
assert!(!result[4]); // 2.0 < 2.5
assert!(!result[5]);
}
#[test]
fn test_is_highest() {
let a = vec![1.0, 3.0, 2.0, 4.0, 3.5];
let result = is_highest(&a, 3);
assert!(!result[0]);
assert!(!result[1]);
assert!(result[2] == false); // 2.0 is not highest in [1.0, 3.0, 2.0]
assert!(result[3]); // 4.0 is highest in [3.0, 2.0, 4.0]
assert!(!result[4]); // 3.5 is not highest in [2.0, 4.0, 3.5]
}
}
+12
View File
@@ -0,0 +1,12 @@
//! Signal processing for RaptorBT.
//!
//! This module handles signal cleaning, synchronization, and expression evaluation.
pub mod expression;
pub mod processor;
pub mod synchronizer;
pub mod tick_signals;
pub use processor::SignalProcessor;
pub use synchronizer::{SignalSynchronizer, SyncMode};
pub use tick_signals::{tick_momentum_entry, tick_momentum_exit};
+427
View File
@@ -0,0 +1,427 @@
//! Signal processor for cleaning entry/exit signals.
//!
//! Ensures proper alternation between entries and exits to prevent
//! overlapping positions or orphaned signals.
use crate::core::types::Direction;
/// Signal processor for cleaning raw entry/exit signals.
#[derive(Debug, Clone)]
pub struct SignalProcessor {
/// Whether to allow multiple entries before an exit (pyramiding).
pub allow_pyramiding: bool,
/// Maximum number of pyramid entries.
pub max_pyramid_entries: usize,
}
impl Default for SignalProcessor {
fn default() -> Self {
Self { allow_pyramiding: false, max_pyramid_entries: 1 }
}
}
impl SignalProcessor {
/// Create a new signal processor.
pub fn new() -> Self {
Self::default()
}
/// Enable pyramiding with a maximum number of entries.
pub fn with_pyramiding(mut self, max_entries: usize) -> Self {
self.allow_pyramiding = max_entries > 1;
self.max_pyramid_entries = max_entries;
self
}
/// Clean entry/exit signals to ensure proper alternation.
///
/// Rules:
/// 1. First signal must be an entry
/// 2. After an entry, ignore further entries (unless pyramiding)
/// 3. After an exit, ignore further exits
/// 4. Entries and exits must alternate properly
/// 5. Same-bar conflict: If both entry AND exit signals are True on the same bar
/// when in position, entry takes priority — stay in position (ignore the exit).
///
/// # Arguments
/// * `entries` - Raw entry signals
/// * `exits` - Raw exit signals
///
/// # Returns
/// Tuple of (cleaned_entries, cleaned_exits)
pub fn clean_signals(&self, entries: &[bool], exits: &[bool]) -> (Vec<bool>, Vec<bool>) {
let n = entries.len();
assert_eq!(n, exits.len(), "Entry and exit arrays must have same length");
let mut clean_entries = vec![false; n];
let mut clean_exits = vec![false; n];
if n == 0 {
return (clean_entries, clean_exits);
}
let mut in_position = false;
let mut position_count = 0;
for i in 0..n {
if !in_position {
// Not in position - looking for entry
if entries[i] {
clean_entries[i] = true;
in_position = true;
position_count = 1;
}
// Ignore exits when not in position
} else {
// In position - looking for exit (or pyramid entry)
// Same-bar conflict: entry takes priority — stay in position
if exits[i] && !entries[i] {
// Only exit if there's no conflicting entry signal
clean_exits[i] = true;
if self.allow_pyramiding {
position_count -= 1;
if position_count == 0 {
in_position = false;
}
} else {
in_position = false;
position_count = 0;
}
} else if entries[i]
&& self.allow_pyramiding
&& position_count < self.max_pyramid_entries
{
// Pyramid entry
clean_entries[i] = true;
position_count += 1;
}
// If both entry and exit are True, we stay in position (ignore both)
// If only entry is True and not pyramiding, ignore entry (already in position)
}
}
(clean_entries, clean_exits)
}
/// Clean signals with direction awareness (for strategies that can go long/short).
///
/// # Arguments
/// * `long_entries` - Long entry signals
/// * `long_exits` - Long exit signals
/// * `short_entries` - Short entry signals
/// * `short_exits` - Short exit signals
///
/// # Returns
/// Tuple of (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
pub fn clean_signals_bidirectional(
&self,
long_entries: &[bool],
long_exits: &[bool],
short_entries: &[bool],
short_exits: &[bool],
) -> (Vec<bool>, Vec<bool>, Vec<bool>, Vec<bool>) {
let n = long_entries.len();
assert_eq!(n, long_exits.len());
assert_eq!(n, short_entries.len());
assert_eq!(n, short_exits.len());
let mut clean_long_entries = vec![false; n];
let mut clean_long_exits = vec![false; n];
let mut clean_short_entries = vec![false; n];
let mut clean_short_exits = vec![false; n];
if n == 0 {
return (clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits);
}
let mut current_direction: Option<Direction> = None;
for i in 0..n {
match current_direction {
None => {
// Not in any position - look for entry
if long_entries[i] {
clean_long_entries[i] = true;
current_direction = Some(Direction::Long);
} else if short_entries[i] {
clean_short_entries[i] = true;
current_direction = Some(Direction::Short);
}
}
Some(Direction::Long) => {
// In long position - look for exit or reversal
if long_exits[i] {
clean_long_exits[i] = true;
current_direction = None;
} else if short_entries[i] {
// Reversal: exit long and enter short
clean_long_exits[i] = true;
clean_short_entries[i] = true;
current_direction = Some(Direction::Short);
}
}
Some(Direction::Short) => {
// In short position - look for exit or reversal
if short_exits[i] {
clean_short_exits[i] = true;
current_direction = None;
} else if long_entries[i] {
// Reversal: exit short and enter long
clean_short_exits[i] = true;
clean_long_entries[i] = true;
current_direction = Some(Direction::Long);
}
}
}
}
(clean_long_entries, clean_long_exits, clean_short_entries, clean_short_exits)
}
/// Generate exit-on-opposite-entry signals.
///
/// Useful for strategies where an entry in opposite direction
/// should automatically close the current position.
///
/// # Arguments
/// * `entries` - Entry signals
/// * `direction` - Current position direction
///
/// # Returns
/// Modified exit signals that include opposite-direction entries as exits
pub fn exits_from_opposite_entries(
&self,
long_entries: &[bool],
short_entries: &[bool],
) -> (Vec<bool>, Vec<bool>) {
let n = long_entries.len();
assert_eq!(n, short_entries.len());
// Long exits when short entry
// Short exits when long entry
(short_entries.to_vec(), long_entries.to_vec())
}
/// Count the number of trades that would be generated from signals.
///
/// # Arguments
/// * `entries` - Entry signals (already cleaned)
/// * `exits` - Exit signals (already cleaned)
///
/// # Returns
/// Number of complete trades (entry + exit pairs)
pub fn count_trades(_entries: &[bool], exits: &[bool]) -> usize {
exits.iter().filter(|&&e| e).count()
}
/// Get indices of entries and exits.
///
/// # Arguments
/// * `entries` - Entry signals
/// * `exits` - Exit signals
///
/// # Returns
/// Tuple of (entry_indices, exit_indices)
pub fn get_trade_indices(entries: &[bool], exits: &[bool]) -> (Vec<usize>, Vec<usize>) {
let entry_indices: Vec<usize> = entries
.iter()
.enumerate()
.filter_map(|(i, &e)| if e { Some(i) } else { None })
.collect();
let exit_indices: Vec<usize> =
exits.iter().enumerate().filter_map(|(i, &e)| if e { Some(i) } else { None }).collect();
(entry_indices, exit_indices)
}
}
/// Shift signals forward by n bars (delays execution).
pub fn shift_signals(signals: &[bool], n: usize) -> Vec<bool> {
let len = signals.len();
let mut result = vec![false; len];
if n >= len {
return result;
}
for i in n..len {
result[i] = signals[i - n];
}
result
}
/// Combine multiple signal arrays with AND logic.
pub fn combine_signals_and(signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let mut result = vec![true; n];
for sig in signals.iter() {
for i in 0..n {
result[i] = result[i] && sig[i];
}
}
result
}
/// Combine multiple signal arrays with OR logic.
pub fn combine_signals_or(signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let mut result = vec![false; n];
for sig in signals.iter() {
for i in 0..n {
result[i] = result[i] || sig[i];
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_signals_basic() {
let processor = SignalProcessor::new();
let entries = vec![true, false, true, false, true, false];
let exits = vec![false, true, false, true, false, true];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// First entry should be kept
assert!(clean_e[0]);
// First exit should be kept
assert!(clean_x[1]);
// Second entry should be kept
assert!(clean_e[2]);
// Second exit should be kept
assert!(clean_x[3]);
}
#[test]
fn test_clean_signals_consecutive_entries() {
let processor = SignalProcessor::new();
let entries = vec![true, true, true, false, false];
let exits = vec![false, false, false, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Only first entry should be kept
assert!(clean_e[0]);
assert!(!clean_e[1]);
assert!(!clean_e[2]);
// Exit should be kept
assert!(clean_x[3]);
}
#[test]
fn test_clean_signals_consecutive_exits() {
let processor = SignalProcessor::new();
let entries = vec![true, false, false, false, false];
let exits = vec![false, true, true, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Entry should be kept
assert!(clean_e[0]);
// Only first exit should be kept
assert!(clean_x[1]);
assert!(!clean_x[2]);
assert!(!clean_x[3]);
}
#[test]
fn test_clean_signals_exit_before_entry() {
let processor = SignalProcessor::new();
let entries = vec![false, false, true, false, false];
let exits = vec![true, true, false, true, false];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// Exits before first entry should be ignored
assert!(!clean_x[0]);
assert!(!clean_x[1]);
// Entry should be kept
assert!(clean_e[2]);
// Exit after entry should be kept
assert!(clean_x[3]);
}
#[test]
fn test_pyramiding() {
let processor = SignalProcessor::new().with_pyramiding(3);
let entries = vec![true, true, true, false, false];
let exits = vec![false, false, false, true, true];
let (clean_e, clean_x) = processor.clean_signals(&entries, &exits);
// All three entries should be kept (pyramiding)
assert!(clean_e[0]);
assert!(clean_e[1]);
assert!(clean_e[2]);
// Both exits should be kept
assert!(clean_x[3]);
assert!(clean_x[4]);
}
#[test]
fn test_shift_signals() {
let signals = vec![true, false, true, false, true];
let shifted = shift_signals(&signals, 2);
assert!(!shifted[0]);
assert!(!shifted[1]);
assert!(shifted[2]); // Original [0]
assert!(!shifted[3]); // Original [1]
assert!(shifted[4]); // Original [2]
}
#[test]
fn test_combine_signals_and() {
let sig1 = vec![true, true, false, false];
let sig2 = vec![true, false, true, false];
let combined = combine_signals_and(&[&sig1, &sig2]);
assert!(combined[0]); // true && true
assert!(!combined[1]); // true && false
assert!(!combined[2]); // false && true
assert!(!combined[3]); // false && false
}
#[test]
fn test_combine_signals_or() {
let sig1 = vec![true, true, false, false];
let sig2 = vec![true, false, true, false];
let combined = combine_signals_or(&[&sig1, &sig2]);
assert!(combined[0]); // true || true
assert!(combined[1]); // true || false
assert!(combined[2]); // false || true
assert!(!combined[3]); // false || false
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Signal synchronization for multi-instrument strategies.
//!
//! Handles combining signals from multiple instruments with different sync modes.
use crate::core::types::CompiledSignals;
/// Synchronization mode for combining signals from multiple instruments.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncMode {
/// All instruments must signal (AND logic).
All,
/// Any instrument can signal (OR logic).
Any,
/// Majority of instruments must signal.
Majority,
/// Use first instrument's signals as master.
Master,
}
impl Default for SyncMode {
fn default() -> Self {
SyncMode::All
}
}
/// Signal synchronizer for multi-instrument backtests.
#[derive(Debug, Clone)]
pub struct SignalSynchronizer {
/// Synchronization mode.
pub mode: SyncMode,
/// Minimum number of instruments that must signal (for custom thresholds).
pub min_signals: Option<usize>,
}
impl Default for SignalSynchronizer {
fn default() -> Self {
Self { mode: SyncMode::All, min_signals: None }
}
}
impl SignalSynchronizer {
/// Create a new signal synchronizer with the given mode.
pub fn new(mode: SyncMode) -> Self {
Self { mode, min_signals: None }
}
/// Create a synchronizer with a custom minimum signal threshold.
pub fn with_min_signals(min: usize) -> Self {
Self { mode: SyncMode::Majority, min_signals: Some(min) }
}
/// Synchronize entry signals from multiple instruments.
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Combined entry signals based on sync mode
pub fn sync_entries(&self, signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let num_instruments = signals.len();
let mut result = vec![false; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count();
result[i] = match self.mode {
SyncMode::All => count == num_instruments,
SyncMode::Any => count > 0,
SyncMode::Majority => {
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
count >= threshold
}
SyncMode::Master => signals[0][i],
};
}
result
}
/// Synchronize exit signals from multiple instruments.
///
/// Exit logic is typically inverse of entry:
/// - All mode -> exit on Any
/// - Any mode -> exit on All
/// - Majority mode -> exit when majority want to exit
/// - Master mode -> use master's exit signals
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Combined exit signals based on sync mode
pub fn sync_exits(&self, signals: &[&[bool]]) -> Vec<bool> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
for sig in signals.iter() {
assert_eq!(sig.len(), n, "All signal arrays must have same length");
}
let num_instruments = signals.len();
let mut result = vec![false; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count();
result[i] = match self.mode {
// For All entry mode, exit when ANY wants to exit
SyncMode::All => count > 0,
// For Any entry mode, exit when ALL want to exit
SyncMode::Any => count == num_instruments,
SyncMode::Majority => {
let threshold = self.min_signals.unwrap_or((num_instruments + 1) / 2);
count >= threshold
}
SyncMode::Master => signals[0][i],
};
}
result
}
/// Synchronize signals from CompiledSignals objects.
///
/// # Arguments
/// * `compiled_signals` - Slice of CompiledSignals from each instrument
///
/// # Returns
/// Tuple of (synchronized_entries, synchronized_exits)
pub fn sync_compiled_signals(
&self,
compiled_signals: &[&CompiledSignals],
) -> (Vec<bool>, Vec<bool>) {
if compiled_signals.is_empty() {
return (vec![], vec![]);
}
let entries: Vec<&[bool]> =
compiled_signals.iter().map(|cs| cs.entries.as_slice()).collect();
let exits: Vec<&[bool]> = compiled_signals.iter().map(|cs| cs.exits.as_slice()).collect();
let synced_entries = self.sync_entries(&entries);
let synced_exits = self.sync_exits(&exits);
(synced_entries, synced_exits)
}
/// Calculate signal agreement score (0.0 to 1.0).
///
/// # Arguments
/// * `signals` - Slice of signal arrays from each instrument
///
/// # Returns
/// Vector of agreement scores for each bar
pub fn signal_agreement(&self, signals: &[&[bool]]) -> Vec<f64> {
if signals.is_empty() {
return vec![];
}
let n = signals[0].len();
let num_instruments = signals.len() as f64;
let mut result = vec![0.0; n];
for i in 0..n {
let count = signals.iter().filter(|s| s[i]).count() as f64;
result[i] = count / num_instruments;
}
result
}
}
/// Align signals to a common time axis.
///
/// Useful when instruments have different trading hours or missing data.
///
/// # Arguments
/// * `signals` - Signal array to align
/// * `source_timestamps` - Timestamps of the signal array
/// * `target_timestamps` - Target timestamp grid
/// * `fill_value` - Value to use for missing timestamps
///
/// # Returns
/// Aligned signal array
pub fn align_signals(
signals: &[bool],
source_timestamps: &[i64],
target_timestamps: &[i64],
fill_value: bool,
) -> Vec<bool> {
let n = target_timestamps.len();
let mut result = vec![fill_value; n];
// Create a map of source timestamps to indices
let mut source_map = std::collections::HashMap::new();
for (i, &ts) in source_timestamps.iter().enumerate() {
source_map.insert(ts, i);
}
// Fill in values where timestamps match
for (i, &ts) in target_timestamps.iter().enumerate() {
if let Some(&source_idx) = source_map.get(&ts) {
result[i] = signals[source_idx];
}
}
result
}
/// Forward-fill signals (carry forward last signal).
pub fn forward_fill_signals(signals: &[bool]) -> Vec<bool> {
let mut result = signals.to_vec();
let mut last_value = false;
for i in 0..result.len() {
if result[i] {
last_value = true;
}
result[i] = last_value;
}
result
}
/// Create synchronized position signals.
///
/// Returns a position signal where:
/// - 1 = in position
/// - 0 = out of position
///
/// # Arguments
/// * `entries` - Entry signals (cleaned)
/// * `exits` - Exit signals (cleaned)
///
/// # Returns
/// Position state array
pub fn position_signals(entries: &[bool], exits: &[bool]) -> Vec<i8> {
let n = entries.len();
assert_eq!(n, exits.len());
let mut result = vec![0i8; n];
let mut in_position = false;
for i in 0..n {
if entries[i] {
in_position = true;
}
if exits[i] {
in_position = false;
}
result[i] = if in_position { 1 } else { 0 };
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_all() {
let sync = SignalSynchronizer::new(SyncMode::All);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![true, true, false, true];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // All true
assert!(!result[1]); // Not all true
assert!(!result[2]); // All false
assert!(result[3]); // All true
}
#[test]
fn test_sync_any() {
let sync = SignalSynchronizer::new(SyncMode::Any);
let sig1 = vec![true, false, false, false];
let sig2 = vec![false, true, false, false];
let sig3 = vec![false, false, false, false];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // At least one true
assert!(result[1]); // At least one true
assert!(!result[2]); // All false
assert!(!result[3]); // All false
}
#[test]
fn test_sync_majority() {
let sync = SignalSynchronizer::new(SyncMode::Majority);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![false, true, false, false];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
assert!(result[0]); // 2 out of 3
assert!(result[1]); // 2 out of 3
assert!(!result[2]); // 0 out of 3
assert!(result[3]); // 2 out of 3
}
#[test]
fn test_sync_master() {
let sync = SignalSynchronizer::new(SyncMode::Master);
let sig1 = vec![true, false, true, false]; // Master
let sig2 = vec![false, true, false, true];
let sig3 = vec![true, true, true, true];
let result = sync.sync_entries(&[&sig1, &sig2, &sig3]);
// Should follow master (sig1)
assert!(result[0]);
assert!(!result[1]);
assert!(result[2]);
assert!(!result[3]);
}
#[test]
fn test_exit_inverse_logic() {
// For All entry mode, exit should be Any
let sync = SignalSynchronizer::new(SyncMode::All);
let exit1 = vec![true, false, false];
let exit2 = vec![false, false, false];
let exit3 = vec![false, false, false];
let result = sync.sync_exits(&[&exit1, &exit2, &exit3]);
assert!(result[0]); // Any true -> exit
assert!(!result[1]);
assert!(!result[2]);
}
#[test]
fn test_signal_agreement() {
let sync = SignalSynchronizer::new(SyncMode::All);
let sig1 = vec![true, true, false, true];
let sig2 = vec![true, false, false, true];
let sig3 = vec![false, true, false, true];
let result = sync.signal_agreement(&[&sig1, &sig2, &sig3]);
assert!((result[0] - 2.0 / 3.0).abs() < 1e-10);
assert!((result[1] - 2.0 / 3.0).abs() < 1e-10);
assert!((result[2] - 0.0).abs() < 1e-10);
assert!((result[3] - 1.0).abs() < 1e-10);
}
#[test]
fn test_position_signals() {
let entries = vec![false, true, false, false, true, false];
let exits = vec![false, false, false, true, false, true];
let result = position_signals(&entries, &exits);
assert_eq!(result[0], 0);
assert_eq!(result[1], 1);
assert_eq!(result[2], 1);
assert_eq!(result[3], 0);
assert_eq!(result[4], 1);
assert_eq!(result[5], 0);
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Tick-level signal generation for momentum entry/exit.
//!
//! Converts precomputed feature arrays (one scalar per tick) into entry and
//! exit boolean arrays that can be fed directly into `run_tick_backtest`.
//!
//! All functions are O(N) single-pass — no backward linear search, no nested
//! loops. The return_1m feature array must be precomputed by the caller
//! (via `tick_features::return_window` or equivalent).
/// Generate momentum entry signals from per-tick feature arrays.
///
/// All input slices must have the same length N.
///
/// Rules applied in order (a failing rule sets entry[i] = false):
/// 1. spread gate: `spread_pct[i] <= spread_pct_max`
/// 2. BSI gate: if `bsi_min > 0.0`, `bsi_delta[i] >= bsi_min`
/// 3. return gate: if `return_1m_min_abs > 0.0`, direction-aligned
/// `return_1m[i]` must have `abs >= return_1m_min_abs` and correct sign.
/// NaN return_1m always fails the gate.
/// 4. cooldown: after each entry, suppress the next `cooldown_ticks` ticks.
///
/// `return_direction`: +1 for long (return_1m must be positive), -1 for short
/// (return_1m must be negative).
pub fn tick_momentum_entry(
spread_pct: &[f64],
bsi_delta: &[f64],
return_1m: &[f64],
spread_pct_max: f64,
bsi_min: f64,
return_1m_min_abs: f64,
return_direction: i8,
cooldown_ticks: usize,
) -> Vec<bool> {
let n = spread_pct.len();
let mut entries = vec![false; n];
let mut cooldown_until: usize = 0;
for i in 0..n {
if i < cooldown_until {
continue;
}
// Spread gate
if spread_pct[i] > spread_pct_max {
continue;
}
// BSI delta gate (disabled when bsi_min == 0.0)
if bsi_min > 0.0 {
let b = if i < bsi_delta.len() { bsi_delta[i] } else { continue };
if b < bsi_min {
continue;
}
}
// 1-minute return gate (disabled when return_1m_min_abs == 0.0)
if return_1m_min_abs > 0.0 {
let r = if i < return_1m.len() { return_1m[i] } else { continue };
if r.is_nan() {
continue;
}
let abs_r = r.abs();
if abs_r < return_1m_min_abs {
continue;
}
// Direction alignment: long needs positive return, short needs negative
if return_direction > 0 && r < 0.0 {
continue;
}
if return_direction < 0 && r > 0.0 {
continue;
}
}
entries[i] = true;
cooldown_until = i + 1 + cooldown_ticks;
}
entries
}
/// Generate time-based exit signals (EOD / session-end).
///
/// Sets exit[i] = true for every tick at or after `eod_exit_time_ns`.
/// When `eod_exit_time_ns == 0` all exits are false (disabled).
///
/// `timestamps_ns`: nanoseconds-since-epoch timestamp for each tick.
pub fn tick_momentum_exit(timestamps_ns: &[i64], eod_exit_time_ns: i64) -> Vec<bool> {
let n = timestamps_ns.len();
if eod_exit_time_ns == 0 {
return vec![false; n];
}
timestamps_ns
.iter()
.map(|&ts| ts >= eod_exit_time_ns)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_return_1m(vals: &[f64]) -> Vec<f64> {
vals.to_vec()
}
#[test]
fn test_entry_spread_gate() {
// All spreads above max → no entries
let spread = vec![3.0, 4.0, 6.0];
let bsi = vec![0.6, 0.7, 0.8];
let ret = vec![1.0, 1.0, 1.0];
let entries = tick_momentum_entry(&spread, &bsi, &ret, 2.0, 0.0, 0.0, 1, 0);
assert_eq!(entries, vec![false, false, false]);
}
#[test]
fn test_entry_bsi_gate() {
let spread = vec![1.0, 1.0, 1.0];
let bsi = vec![0.3, 0.6, 0.4]; // only index 1 passes bsi_min=0.5
let ret = vec![0.5, 0.5, 0.5];
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.5, 0.0, 1, 0);
assert_eq!(entries, vec![false, true, false]);
}
#[test]
fn test_entry_return_gate_long() {
let spread = vec![1.0, 1.0, 1.0, 1.0];
let bsi = vec![0.6, 0.6, 0.6, 0.6];
// positive, positive, too small, negative
let ret = vec![0.5, 1.0, 0.1, -0.5];
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, 1, 0);
assert_eq!(entries, vec![true, true, false, false]);
}
#[test]
fn test_entry_return_gate_short() {
let spread = vec![1.0, 1.0, 1.0];
let bsi = vec![0.6, 0.6, 0.6];
// negative enough, positive (fails direction), nan
let ret = vec![-0.5, 0.5, f64::NAN];
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.3, -1, 0);
assert_eq!(entries, vec![true, false, false]);
}
#[test]
fn test_entry_cooldown() {
// cooldown_ticks=2: after entry at i=0, next eligible at i=3
let spread = vec![1.0; 6];
let bsi = vec![0.6; 6];
let ret = vec![0.0; 6];
let entries = tick_momentum_entry(&spread, &bsi, &ret, 5.0, 0.0, 0.0, 1, 2);
assert!(entries[0]);
assert!(!entries[1]);
assert!(!entries[2]);
assert!(entries[3]);
assert!(!entries[4]);
assert!(!entries[5]);
}
#[test]
fn test_exit_disabled() {
let ts = vec![1_000_000_i64, 2_000_000, 3_000_000];
let exits = tick_momentum_exit(&ts, 0);
assert_eq!(exits, vec![false, false, false]);
}
#[test]
fn test_exit_eod_fires() {
let ts = vec![1_000_i64, 2_000, 3_000, 4_000];
let exits = tick_momentum_exit(&ts, 3_000);
assert_eq!(exits, vec![false, false, true, true]);
}
}
+237
View File
@@ -0,0 +1,237 @@
//! ATR-based stop-loss and take-profit.
use super::{StopCalculator, TargetCalculator};
use crate::core::types::{Direction, Price};
/// ATR-based stop-loss.
#[derive(Debug, Clone)]
pub struct AtrStop {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
}
impl AtrStop {
/// Create a new ATR stop.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr }
}
/// Update ATR value.
pub fn update_atr(&mut self, atr: f64) {
self.atr = atr;
}
}
impl StopCalculator for AtrStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => entry_price - distance,
Direction::Short => entry_price + distance,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// ATR stop doesn't trail by default
current_stop
}
}
/// ATR-based take-profit.
#[derive(Debug, Clone)]
pub struct AtrTarget {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
}
impl AtrTarget {
/// Create a new ATR target.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr }
}
/// Update ATR value.
pub fn update_atr(&mut self, atr: f64) {
self.atr = atr;
}
}
impl TargetCalculator for AtrTarget {
fn calculate_target(
&self,
entry_price: Price,
_stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let target = match direction {
Direction::Long => entry_price + distance,
Direction::Short => entry_price - distance,
};
Some(target)
}
}
/// Chandelier exit (ATR-based trailing stop from high/low).
#[derive(Debug, Clone)]
pub struct ChandelierExit {
/// ATR multiplier.
pub multiplier: f64,
/// Current ATR value.
pub atr: f64,
/// Highest high since entry (for long).
pub highest_high: f64,
/// Lowest low since entry (for short).
pub lowest_low: f64,
}
impl ChandelierExit {
/// Create a new Chandelier exit.
pub fn new(multiplier: f64, atr: f64) -> Self {
Self { multiplier, atr, highest_high: 0.0, lowest_low: f64::MAX }
}
/// Reset for new position.
pub fn reset(&mut self, entry_price: Price) {
self.highest_high = entry_price;
self.lowest_low = entry_price;
}
/// Update with new bar data.
pub fn update(&mut self, high: Price, low: Price, atr: f64) {
if high > self.highest_high {
self.highest_high = high;
}
if low < self.lowest_low {
self.lowest_low = low;
}
self.atr = atr;
}
/// Get current stop level.
pub fn stop_level(&self, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => self.highest_high - distance,
Direction::Short => self.lowest_low + distance,
};
Some(stop)
}
}
impl StopCalculator for ChandelierExit {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
if self.atr <= 0.0 {
return None;
}
let distance = self.atr * self.multiplier;
let stop = match direction {
Direction::Long => entry_price - distance,
Direction::Short => entry_price + distance,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
if self.atr <= 0.0 {
return current_stop;
}
let distance = self.atr * self.multiplier;
let new_stop = match direction {
Direction::Long => {
let proposed = high - distance;
current_stop.map(|cs| cs.max(proposed)).or(Some(proposed))
}
Direction::Short => {
let proposed = low + distance;
current_stop.map(|cs| cs.min(proposed)).or(Some(proposed))
}
};
new_stop
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atr_stop_long() {
let stop = AtrStop::new(2.0, 5.0);
let result = stop.calculate_stop(100.0, Direction::Long);
// 100 - (2 * 5) = 90
assert!((result.unwrap() - 90.0).abs() < 1e-10);
}
#[test]
fn test_atr_stop_short() {
let stop = AtrStop::new(2.0, 5.0);
let result = stop.calculate_stop(100.0, Direction::Short);
// 100 + (2 * 5) = 110
assert!((result.unwrap() - 110.0).abs() < 1e-10);
}
#[test]
fn test_atr_target() {
let target = AtrTarget::new(3.0, 5.0);
let result = target.calculate_target(100.0, None, Direction::Long);
// 100 + (3 * 5) = 115
assert!((result.unwrap() - 115.0).abs() < 1e-10);
}
#[test]
fn test_chandelier_exit() {
let mut chandelier = ChandelierExit::new(3.0, 2.0);
chandelier.reset(100.0);
// Simulate price movement up
chandelier.update(105.0, 99.0, 2.0);
chandelier.update(110.0, 103.0, 2.0);
// Long stop should trail from highest high
// 110 - (3 * 2) = 104
let stop = chandelier.stop_level(Direction::Long);
assert!((stop.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_atr_zero() {
let stop = AtrStop::new(2.0, 0.0);
let result = stop.calculate_stop(100.0, Direction::Long);
assert!(result.is_none());
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Fixed percentage stop-loss and take-profit.
use super::{StopCalculator, TargetCalculator};
use crate::core::types::{Direction, Price};
/// Fixed percentage stop-loss.
#[derive(Debug, Clone, Copy)]
pub struct FixedStop {
/// Stop percentage (e.g., 0.02 for 2%).
pub percent: f64,
}
impl FixedStop {
/// Create a new fixed stop with given percentage.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs() }
}
/// Create a 1% stop.
pub fn one_percent() -> Self {
Self::new(0.01)
}
/// Create a 2% stop.
pub fn two_percent() -> Self {
Self::new(0.02)
}
/// Create a 5% stop.
pub fn five_percent() -> Self {
Self::new(0.05)
}
}
impl StopCalculator for FixedStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price * (1.0 - self.percent),
Direction::Short => entry_price * (1.0 + self.percent),
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// Fixed stop doesn't update
current_stop
}
}
/// Fixed percentage take-profit.
#[derive(Debug, Clone, Copy)]
pub struct FixedTarget {
/// Target percentage (e.g., 0.04 for 4%).
pub percent: f64,
}
impl FixedTarget {
/// Create a new fixed target with given percentage.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs() }
}
}
impl TargetCalculator for FixedTarget {
fn calculate_target(
&self,
entry_price: Price,
_stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
let target = match direction {
Direction::Long => entry_price * (1.0 + self.percent),
Direction::Short => entry_price * (1.0 - self.percent),
};
Some(target)
}
}
/// Risk-reward based take-profit.
#[derive(Debug, Clone, Copy)]
pub struct RiskRewardTarget {
/// Risk-reward ratio (e.g., 2.0 for 2:1 reward:risk).
pub ratio: f64,
}
impl RiskRewardTarget {
/// Create a new risk-reward target.
pub fn new(ratio: f64) -> Self {
Self { ratio }
}
/// Create a 2:1 target.
pub fn two_to_one() -> Self {
Self::new(2.0)
}
/// Create a 3:1 target.
pub fn three_to_one() -> Self {
Self::new(3.0)
}
}
impl TargetCalculator for RiskRewardTarget {
fn calculate_target(
&self,
entry_price: Price,
stop_price: Option<Price>,
direction: Direction,
) -> Option<Price> {
let stop = stop_price?;
let risk = (entry_price - stop).abs();
let reward = risk * self.ratio;
let target = match direction {
Direction::Long => entry_price + reward,
Direction::Short => entry_price - reward,
};
Some(target)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_stop_long() {
let stop = FixedStop::new(0.02);
let result = stop.calculate_stop(100.0, Direction::Long);
assert!((result.unwrap() - 98.0).abs() < 1e-10);
}
#[test]
fn test_fixed_stop_short() {
let stop = FixedStop::new(0.02);
let result = stop.calculate_stop(100.0, Direction::Short);
assert!((result.unwrap() - 102.0).abs() < 1e-10);
}
#[test]
fn test_fixed_target_long() {
let target = FixedTarget::new(0.04);
let result = target.calculate_target(100.0, None, Direction::Long);
assert!((result.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_risk_reward_target() {
let target = RiskRewardTarget::new(2.0);
// Entry at 100, stop at 98 (2% risk), target should be at 104 (4% reward)
let result = target.calculate_target(100.0, Some(98.0), Direction::Long);
assert!((result.unwrap() - 104.0).abs() < 1e-10);
}
#[test]
fn test_risk_reward_no_stop() {
let target = RiskRewardTarget::new(2.0);
let result = target.calculate_target(100.0, None, Direction::Long);
assert!(result.is_none());
}
}
+38
View File
@@ -0,0 +1,38 @@
//! Stop-loss and take-profit mechanisms for RaptorBT.
pub mod atr;
pub mod fixed;
pub mod trailing;
pub use atr::AtrStop;
pub use fixed::FixedStop;
pub use trailing::TrailingStop;
use crate::core::types::{Direction, Price};
/// Stop-loss calculator trait.
pub trait StopCalculator {
/// Calculate stop price for a new position.
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price>;
/// Update stop price for trailing stops.
fn update_stop(
&self,
current_stop: Option<Price>,
current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price>;
}
/// Take-profit calculator trait.
pub trait TargetCalculator {
/// Calculate target price for a new position.
fn calculate_target(
&self,
entry_price: Price,
stop_price: Option<Price>,
direction: Direction,
) -> Option<Price>;
}
+395
View File
@@ -0,0 +1,395 @@
//! Trailing stop implementations.
use super::StopCalculator;
use crate::core::types::{Direction, Price};
/// Percentage-based trailing stop.
#[derive(Debug, Clone, Copy)]
pub struct TrailingStop {
/// Trail percentage (e.g., 0.05 for 5%).
pub percent: f64,
/// Activation threshold (optional - start trailing after this profit %).
pub activation_threshold: Option<f64>,
}
impl TrailingStop {
/// Create a new trailing stop.
pub fn new(percent: f64) -> Self {
Self { percent: percent.abs(), activation_threshold: None }
}
/// Create with activation threshold.
pub fn with_activation(mut self, threshold: f64) -> Self {
self.activation_threshold = Some(threshold.abs());
self
}
/// Check if trailing should be activated.
#[allow(dead_code)]
fn should_activate(
&self,
entry_price: Price,
current_price: Price,
direction: Direction,
) -> bool {
if let Some(threshold) = self.activation_threshold {
let profit_pct = match direction {
Direction::Long => (current_price - entry_price) / entry_price,
Direction::Short => (entry_price - current_price) / entry_price,
};
profit_pct >= threshold
} else {
true // Always active if no threshold
}
}
}
impl StopCalculator for TrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price * (1.0 - self.percent),
Direction::Short => entry_price * (1.0 + self.percent),
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
match direction {
Direction::Long => {
// Trail below the high
let new_stop = high * (1.0 - self.percent);
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
// Trail above the low
let new_stop = low * (1.0 + self.percent);
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Point-based trailing stop (fixed point distance).
#[derive(Debug, Clone, Copy)]
pub struct PointTrailingStop {
/// Trail distance in points.
pub points: f64,
}
impl PointTrailingStop {
/// Create a new point-based trailing stop.
pub fn new(points: f64) -> Self {
Self { points: points.abs() }
}
}
impl StopCalculator for PointTrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
let stop = match direction {
Direction::Long => entry_price - self.points,
Direction::Short => entry_price + self.points,
};
Some(stop)
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
match direction {
Direction::Long => {
let new_stop = high - self.points;
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
let new_stop = low + self.points;
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Step trailing stop (moves in discrete steps).
#[derive(Debug, Clone, Copy)]
pub struct StepTrailingStop {
/// Step size percentage.
pub step_percent: f64,
/// Trail percentage from each step.
pub trail_percent: f64,
}
impl StepTrailingStop {
/// Create a new step trailing stop.
pub fn new(step_percent: f64, trail_percent: f64) -> Self {
Self { step_percent: step_percent.abs(), trail_percent: trail_percent.abs() }
}
/// Calculate stop for a given step level.
fn stop_for_step(&self, entry_price: Price, step: usize, direction: Direction) -> Price {
let step_gain = self.step_percent * step as f64;
match direction {
Direction::Long => {
let step_price = entry_price * (1.0 + step_gain);
step_price * (1.0 - self.trail_percent)
}
Direction::Short => {
let step_price = entry_price * (1.0 - step_gain);
step_price * (1.0 + self.trail_percent)
}
}
}
/// Determine current step level.
#[allow(dead_code)]
fn current_step(
&self,
entry_price: Price,
extreme_price: Price,
direction: Direction,
) -> usize {
let gain = match direction {
Direction::Long => (extreme_price - entry_price) / entry_price,
Direction::Short => (entry_price - extreme_price) / entry_price,
};
if gain <= 0.0 {
return 0;
}
(gain / self.step_percent).floor() as usize
}
}
impl StopCalculator for StepTrailingStop {
fn calculate_stop(&self, entry_price: Price, direction: Direction) -> Option<Price> {
Some(self.stop_for_step(entry_price, 0, direction))
}
fn update_stop(
&self,
current_stop: Option<Price>,
_current_price: Price,
high: Price,
low: Price,
direction: Direction,
) -> Option<Price> {
// This is a simplified version - full implementation would need entry price
// For now, just use regular trailing behavior
match direction {
Direction::Long => {
let new_stop = high * (1.0 - self.trail_percent);
current_stop.map(|cs| cs.max(new_stop)).or(Some(new_stop))
}
Direction::Short => {
let new_stop = low * (1.0 + self.trail_percent);
current_stop.map(|cs| cs.min(new_stop)).or(Some(new_stop))
}
}
}
}
/// Parabolic SAR style trailing stop.
#[derive(Debug, Clone)]
pub struct ParabolicStop {
/// Initial acceleration factor.
pub af_start: f64,
/// Acceleration factor increment.
pub af_step: f64,
/// Maximum acceleration factor.
pub af_max: f64,
/// Current acceleration factor.
current_af: f64,
/// Current extreme point.
extreme_point: f64,
/// Current SAR value.
current_sar: f64,
}
impl ParabolicStop {
/// Create a new Parabolic SAR stop with default parameters.
pub fn new() -> Self {
Self::with_params(0.02, 0.02, 0.2)
}
/// Create with custom parameters.
pub fn with_params(af_start: f64, af_step: f64, af_max: f64) -> Self {
Self {
af_start,
af_step,
af_max,
current_af: af_start,
extreme_point: 0.0,
current_sar: 0.0,
}
}
/// Initialize for new position.
pub fn init(&mut self, entry_price: Price, direction: Direction) {
self.current_af = self.af_start;
self.extreme_point = entry_price;
self.current_sar = match direction {
Direction::Long => entry_price * 0.99, // Slightly below entry
Direction::Short => entry_price * 1.01, // Slightly above entry
};
}
/// Update SAR with new bar data.
pub fn update_sar(&mut self, high: Price, low: Price, direction: Direction) -> Price {
// Update extreme point
let new_ep = match direction {
Direction::Long => {
if high > self.extreme_point {
self.current_af = (self.current_af + self.af_step).min(self.af_max);
high
} else {
self.extreme_point
}
}
Direction::Short => {
if low < self.extreme_point {
self.current_af = (self.current_af + self.af_step).min(self.af_max);
low
} else {
self.extreme_point
}
}
};
self.extreme_point = new_ep;
// Calculate new SAR
let new_sar = self.current_sar + self.current_af * (self.extreme_point - self.current_sar);
// Ensure SAR doesn't cross price
self.current_sar = match direction {
Direction::Long => new_sar.min(low),
Direction::Short => new_sar.max(high),
};
self.current_sar
}
}
impl Default for ParabolicStop {
fn default() -> Self {
Self::new()
}
}
impl StopCalculator for ParabolicStop {
fn calculate_stop(&self, _entry_price: Price, _direction: Direction) -> Option<Price> {
if self.current_sar > 0.0 {
Some(self.current_sar)
} else {
None
}
}
fn update_stop(
&self,
_current_stop: Option<Price>,
_current_price: Price,
_high: Price,
_low: Price,
_direction: Direction,
) -> Option<Price> {
// Parabolic stop is updated via update_sar method
if self.current_sar > 0.0 {
Some(self.current_sar)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trailing_stop_long() {
let stop = TrailingStop::new(0.05);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Long);
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
// Update with higher high
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
// 110 * 0.95 = 104.5
assert!((updated.unwrap() - 104.5).abs() < 1e-10);
}
#[test]
fn test_trailing_stop_short() {
let stop = TrailingStop::new(0.05);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Short);
assert!((initial.unwrap() - 105.0).abs() < 1e-10);
// Update with lower low
let updated = stop.update_stop(initial, 92.0, 95.0, 90.0, Direction::Short);
// 90 * 1.05 = 94.5
assert!((updated.unwrap() - 94.5).abs() < 1e-10);
}
#[test]
fn test_trailing_stop_only_tightens() {
let stop = TrailingStop::new(0.05);
let initial = stop.calculate_stop(100.0, Direction::Long);
// Move up
let moved_up = stop.update_stop(initial, 110.0, 110.0, 108.0, Direction::Long);
// 110 * 0.95 = 104.5
assert!((moved_up.unwrap() - 104.5).abs() < 1e-10);
// Move down - stop should NOT move down
let moved_down = stop.update_stop(moved_up, 105.0, 106.0, 103.0, Direction::Long);
// Should still be 104.5 (not 106 * 0.95 = 100.7)
assert!((moved_down.unwrap() - 104.5).abs() < 1e-10);
}
#[test]
fn test_point_trailing_stop() {
let stop = PointTrailingStop::new(5.0);
// Initial stop
let initial = stop.calculate_stop(100.0, Direction::Long);
assert!((initial.unwrap() - 95.0).abs() < 1e-10);
// Update with higher high
let updated = stop.update_stop(initial, 108.0, 110.0, 105.0, Direction::Long);
// 110 - 5 = 105
assert!((updated.unwrap() - 105.0).abs() < 1e-10);
}
#[test]
fn test_parabolic_stop() {
let mut stop = ParabolicStop::new();
stop.init(100.0, Direction::Long);
// Simulate uptrend
let sar1 = stop.update_sar(102.0, 99.0, Direction::Long);
let sar2 = stop.update_sar(105.0, 101.0, Direction::Long);
let sar3 = stop.update_sar(108.0, 103.0, Direction::Long);
// SAR should be increasing
assert!(sar2 > sar1);
assert!(sar3 > sar2);
// SAR should be below current low
assert!(sar3 < 103.0);
}
}
+505
View File
@@ -0,0 +1,505 @@
//! Basket/collective strategy backtest implementation.
//!
//! Supports multiple instruments with synchronized signals.
use std::collections::HashMap;
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, InstrumentConfig,
OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
use crate::portfolio::allocation::{AllocationStrategy, CapitalAllocator};
use crate::signals::processor::SignalProcessor;
use crate::signals::synchronizer::{SignalSynchronizer, SyncMode};
/// Basket backtest configuration.
#[derive(Debug, Clone)]
pub struct BasketConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Signal synchronization mode.
pub sync_mode: SyncMode,
/// Capital allocation strategy.
pub allocation: AllocationStrategy,
/// Whether to rebalance on each signal.
pub rebalance_on_signal: bool,
}
impl Default for BasketConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
sync_mode: SyncMode::All,
allocation: AllocationStrategy::EqualWeight,
rebalance_on_signal: false,
}
}
}
/// Basket/collective strategy backtest runner.
#[derive(Debug)]
pub struct BasketBacktest {
/// Configuration.
config: BasketConfig,
/// Signal synchronizer.
synchronizer: SignalSynchronizer,
/// Capital allocator.
#[allow(dead_code)]
allocator: CapitalAllocator,
/// Signal processor.
signal_processor: SignalProcessor,
/// Fee model.
fee_model: FeeModel,
}
impl BasketBacktest {
/// Create a new basket backtest.
pub fn new(config: BasketConfig) -> Self {
let allocator = CapitalAllocator::new(config.base.initial_capital)
.with_strategy(config.allocation.clone());
Self {
synchronizer: SignalSynchronizer::new(config.sync_mode),
allocator,
signal_processor: SignalProcessor::new(),
fee_model: FeeModel::percentage(config.base.fees),
config,
}
}
/// Run basket backtest with multiple instruments.
///
/// # Arguments
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
///
/// # Returns
/// Combined backtest result
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult {
self.run_with_instrument_configs(instruments, None)
}
/// Run basket backtest with optional per-instrument configurations.
///
/// # Arguments
/// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument
/// * `instrument_configs` - Optional map of symbol -> InstrumentConfig
///
/// # Returns
/// Combined backtest result
pub fn run_with_instrument_configs(
&self,
instruments: &[(OhlcvData, CompiledSignals)],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> BacktestResult {
if instruments.is_empty() {
return self.empty_result();
}
let n_instruments = instruments.len();
let n_bars = instruments[0].0.len();
// Verify all instruments have same length
for (ohlcv, signals) in instruments {
assert_eq!(ohlcv.len(), n_bars, "All instruments must have same number of bars");
assert_eq!(signals.len(), n_bars, "Signals must match OHLCV length");
}
// Synchronize signals
let entry_signals: Vec<&[bool]> =
instruments.iter().map(|(_, s)| s.entries.as_slice()).collect();
let exit_signals: Vec<&[bool]> =
instruments.iter().map(|(_, s)| s.exits.as_slice()).collect();
let synced_entries = self.synchronizer.sync_entries(&entry_signals);
let synced_exits = self.synchronizer.sync_exits(&exit_signals);
// Clean signals
let (clean_entries, clean_exits) =
self.signal_processor.clean_signals(&synced_entries, &synced_exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut positions: Vec<Option<PositionState>> = vec![None; n_instruments];
let mut equity_curve = vec![cash; n_bars];
let mut drawdown_curve = vec![0.0; n_bars];
let mut returns = vec![0.0; n_bars];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n_bars {
// Calculate current position values
let mut _total_position_value = 0.0;
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
if let Some(ref pos) = positions[inst_idx] {
_total_position_value += pos.size * ohlcv.close[i];
}
}
// Check for exit
if clean_exits[i] {
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
if let Some(pos) = positions[inst_idx].take() {
let exit_price = ohlcv.close[i];
let fees =
self.fee_model.calculate(exit_price, pos.size, signals.direction);
let pnl = (exit_price - pos.entry_price)
* pos.size
* signals.direction.multiplier()
- fees;
let cost_basis = pos.entry_price * pos.size;
let return_pct =
if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
cash += exit_price * pos.size - fees;
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: signals.direction,
pnl,
return_pct,
entry_time: ohlcv.timestamps[pos.entry_idx],
exit_time: ohlcv.timestamps[i],
fees,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
}
// Check for entry
if clean_entries[i] && positions.iter().all(|p| p.is_none()) {
// Calculate position sizes
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect();
let symbols: Vec<&str> =
instruments.iter().map(|(_, s)| s.symbol.as_str()).collect();
let sizes = self.calculate_sizes_with_configs(
&prices,
&weights,
cash,
&symbols,
instrument_configs,
);
// Enter positions
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
let size = sizes[inst_idx];
if size > 0.0 {
let entry_price = ohlcv.close[i];
let fees = self.fee_model.calculate(entry_price, size, signals.direction);
cash -= entry_price * size + fees;
positions[inst_idx] =
Some(PositionState { entry_idx: i, entry_price, size });
}
}
}
// Update equity
let mut position_value = 0.0;
for (inst_idx, (ohlcv, _)) in instruments.iter().enumerate() {
if let Some(ref pos) = positions[inst_idx] {
position_value += pos.size * ohlcv.close[i];
}
}
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining positions
let last_idx = n_bars - 1;
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
if let Some(pos) = positions[inst_idx].take() {
let exit_price = ohlcv.close[last_idx];
let fees = self.fee_model.calculate(exit_price, pos.size, signals.direction);
let pnl =
(exit_price - pos.entry_price) * pos.size * signals.direction.multiplier()
- fees;
let cost_basis = pos.entry_price * pos.size;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.size,
direction: signals.direction,
pnl,
return_pct,
entry_time: ohlcv.timestamps[pos.entry_idx],
exit_time: ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Calculate position sizes for each instrument.
#[allow(dead_code)]
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
let symbols: Vec<&str> = vec![""; prices.len()];
self.calculate_sizes_with_configs(prices, weights, available_capital, &symbols, None)
}
/// Calculate position sizes with optional per-instrument config (lot_size rounding, capital caps).
fn calculate_sizes_with_configs(
&self,
prices: &[f64],
weights: &[f64],
available_capital: f64,
symbols: &[&str],
instrument_configs: Option<&HashMap<String, InstrumentConfig>>,
) -> Vec<f64> {
let n = prices.len();
let total_weight: f64 = weights.iter().sum();
if total_weight == 0.0 {
return vec![0.0; n];
}
prices
.iter()
.zip(weights.iter())
.enumerate()
.map(|(idx, (&price, &weight))| {
if price <= 0.0 {
return 0.0;
}
let default_allocation = available_capital * (weight / total_weight);
// Use per-instrument alloted_capital if set, capped at default allocation
let inst_config = instrument_configs
.and_then(|configs| symbols.get(idx).and_then(|sym| configs.get(*sym)));
let allocation = inst_config
.and_then(|ic| ic.alloted_capital)
.map(|cap| cap.min(default_allocation))
.unwrap_or(default_allocation);
let raw_size = allocation / price;
// Round to lot_size
inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size)
})
.collect()
}
/// Calculate metrics for the backtest.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
let sharpe_ratio = streaming.sharpe_ratio(252.0);
let sortino_ratio = streaming.sortino_ratio(252.0);
let calmar_ratio = if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else if total_return_pct > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio,
sortino_ratio,
calmar_ratio,
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
/// Create empty result.
fn empty_result(&self) -> BacktestResult {
BacktestResult::new(
BacktestMetrics {
start_value: self.config.base.initial_capital,
end_value: self.config.base.initial_capital,
..Default::default()
},
vec![],
vec![],
vec![],
vec![],
)
}
}
/// Internal position state.
#[derive(Debug, Clone)]
struct PositionState {
entry_idx: usize,
entry_price: f64,
size: f64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Direction;
fn sample_instruments() -> Vec<(OhlcvData, CompiledSignals)> {
let n = 20;
let ohlcv1 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
let ohlcv2 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (50..50 + n).map(|x| x as f64).collect(),
high: (51..51 + n).map(|x| x as f64).collect(),
low: (49..49 + n).map(|x| x as f64).collect(),
close: (50..50 + n).map(|x| x as f64 + 0.25).collect(),
volume: vec![2000.0; n],
};
let mut entries1 = vec![false; n];
let mut exits1 = vec![false; n];
entries1[2] = true;
exits1[8] = true;
let mut entries2 = vec![false; n];
let mut exits2 = vec![false; n];
entries2[2] = true;
exits2[8] = true;
let signals1 = CompiledSignals {
symbol: "INST1".to_string(),
entries: entries1,
exits: exits1,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let signals2 = CompiledSignals {
symbol: "INST2".to_string(),
entries: entries2,
exits: exits2,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
vec![(ohlcv1, signals1), (ohlcv2, signals2)]
}
#[test]
fn test_basket_backtest() {
let config = BasketConfig::default();
let backtest = BasketBacktest::new(config);
let instruments = sample_instruments();
let result = backtest.run(&instruments);
// Should have trades for both instruments
assert!(result.trades.len() >= 2);
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_sync_mode_all() {
let config = BasketConfig { sync_mode: SyncMode::All, ..Default::default() };
let backtest = BasketBacktest::new(config);
let instruments = sample_instruments();
let result = backtest.run(&instruments);
// With All mode, both instruments should enter at same time
assert!(result.trades.len() >= 2);
}
#[test]
fn test_empty_instruments() {
let config = BasketConfig::default();
let backtest = BasketBacktest::new(config);
let result = backtest.run(&[]);
assert_eq!(result.trades.len(), 0);
assert!(result.equity_curve.is_empty());
}
}
+19
View File
@@ -0,0 +1,19 @@
//! Strategy implementations for different backtest types.
pub mod basket;
pub mod multi;
pub mod options;
pub mod pairs;
pub mod single;
pub mod spreads;
pub mod tick;
pub use basket::BasketBacktest;
pub use multi::MultiStrategyBacktest;
pub use options::OptionsBacktest;
pub use pairs::PairsBacktest;
pub use single::SingleBacktest;
pub use spreads::{
LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType,
};
pub use tick::{TickBacktest, TickBacktestConfig};
+378
View File
@@ -0,0 +1,378 @@
//! Multi-strategy backtest implementation.
//!
//! Supports running multiple strategies on the same instrument.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Strategy combination mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombineMode {
/// Enter when any strategy signals.
Any,
/// Enter when all strategies signal.
All,
/// Enter when majority of strategies signal.
Majority,
/// Run strategies independently with separate capital.
Independent,
/// Vote-weighted combination.
Weighted,
}
impl Default for CombineMode {
fn default() -> Self {
CombineMode::Any
}
}
/// Multi-strategy configuration.
#[derive(Debug, Clone)]
pub struct MultiStrategyConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Strategy combination mode.
pub combine_mode: CombineMode,
/// Capital allocation per strategy (for independent mode).
pub capital_per_strategy: Option<f64>,
/// Strategy weights (for weighted mode).
pub strategy_weights: Vec<f64>,
}
impl Default for MultiStrategyConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
combine_mode: CombineMode::Any,
capital_per_strategy: None,
strategy_weights: vec![],
}
}
}
/// Multi-strategy backtest runner.
#[derive(Debug)]
pub struct MultiStrategyBacktest {
/// Configuration.
config: MultiStrategyConfig,
/// Fee model.
#[allow(dead_code)]
fee_model: FeeModel,
}
impl MultiStrategyBacktest {
/// Create a new multi-strategy backtest.
pub fn new(config: MultiStrategyConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run multi-strategy backtest.
///
/// # Arguments
/// * `ohlcv` - OHLCV data for the instrument
/// * `strategies` - Vector of compiled signals from each strategy
///
/// # Returns
/// Combined backtest result
pub fn run(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
if strategies.is_empty() {
return self.empty_result();
}
let n = ohlcv.len();
for signals in strategies {
assert_eq!(signals.len(), n, "All strategies must have same length as OHLCV");
}
match self.config.combine_mode {
CombineMode::Independent => self.run_independent(ohlcv, strategies),
_ => self.run_combined(ohlcv, strategies),
}
}
/// Run strategies independently with separate capital.
fn run_independent(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
let n_strategies = strategies.len();
let capital_per = self
.config
.capital_per_strategy
.unwrap_or(self.config.base.initial_capital / n_strategies as f64);
// Run each strategy independently
let mut all_trades: Vec<Trade> = Vec::new();
let mut strategy_equities: Vec<Vec<f64>> = Vec::new();
for (strat_idx, signals) in strategies.iter().enumerate() {
let single_config =
BacktestConfig { initial_capital: capital_per, ..self.config.base.clone() };
let single = crate::strategies::single::SingleBacktest::new(single_config);
let result = single.run(ohlcv, signals);
// Tag trades with strategy index
for mut trade in result.trades {
trade.symbol = format!("{}_{}", trade.symbol, strat_idx);
all_trades.push(trade);
}
strategy_equities.push(result.equity_curve);
}
// Combine equity curves
let n = ohlcv.len();
let mut combined_equity = vec![0.0; n];
for i in 0..n {
for equity in &strategy_equities {
combined_equity[i] += equity[i];
}
}
// Calculate drawdown
let mut peak = combined_equity[0];
let mut drawdown_curve = vec![0.0; n];
for i in 0..n {
if combined_equity[i] > peak {
peak = combined_equity[i];
}
drawdown_curve[i] = (peak - combined_equity[i]) / peak * 100.0;
}
// Calculate returns
let mut returns = vec![0.0; n];
for i in 1..n {
returns[i] = (combined_equity[i] - combined_equity[i - 1]) / combined_equity[i - 1];
}
// Calculate metrics
let mut streaming = StreamingMetrics::new();
for trade in &all_trades {
streaming.update(trade.return_pct / 100.0);
}
let metrics = self.calculate_metrics(
&combined_equity,
&drawdown_curve,
&all_trades,
&streaming,
self.config.base.initial_capital,
);
BacktestResult::new(metrics, combined_equity, drawdown_curve, all_trades, returns)
}
/// Run strategies with combined signals.
fn run_combined(&self, ohlcv: &OhlcvData, strategies: &[CompiledSignals]) -> BacktestResult {
let n = ohlcv.len();
let n_strategies = strategies.len();
// Combine entry signals
let mut combined_entries = vec![false; n];
let mut combined_exits = vec![false; n];
for i in 0..n {
let entry_count = strategies.iter().filter(|s| s.entries[i]).count();
let exit_count = strategies.iter().filter(|s| s.exits[i]).count();
combined_entries[i] = match self.config.combine_mode {
CombineMode::Any => entry_count > 0,
CombineMode::All => entry_count == n_strategies,
CombineMode::Majority => entry_count > n_strategies / 2,
CombineMode::Weighted => {
let weighted_sum: f64 = strategies
.iter()
.enumerate()
.filter(|(_, s)| s.entries[i])
.map(|(idx, _)| {
self.config.strategy_weights.get(idx).copied().unwrap_or(1.0)
})
.sum();
let total_weight: f64 =
self.config.strategy_weights.iter().sum::<f64>().max(n_strategies as f64);
weighted_sum / total_weight > 0.5
}
CombineMode::Independent => unreachable!(),
};
// Exit when any strategy wants to exit (conservative)
combined_exits[i] = exit_count > 0;
}
// Use first strategy's direction and symbol
let direction = strategies[0].direction;
let symbol = strategies[0].symbol.clone();
let combined_signals = CompiledSignals {
symbol,
entries: combined_entries,
exits: combined_exits,
position_sizes: None,
direction,
weight: 1.0,
};
// Run single backtest with combined signals
let single = crate::strategies::single::SingleBacktest::new(self.config.base.clone());
single.run(ohlcv, &combined_signals)
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
initial_capital: f64,
) -> BacktestMetrics {
let start_value = initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
/// Create empty result.
fn empty_result(&self) -> BacktestResult {
BacktestResult::new(
BacktestMetrics {
start_value: self.config.base.initial_capital,
end_value: self.config.base.initial_capital,
..Default::default()
},
vec![],
vec![],
vec![],
vec![],
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Direction;
fn sample_strategies() -> (OhlcvData, Vec<CompiledSignals>) {
let n = 20;
let ohlcv = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
// Strategy 1: Early entry
let mut entries1 = vec![false; n];
let mut exits1 = vec![false; n];
entries1[2] = true;
exits1[8] = true;
// Strategy 2: Later entry
let mut entries2 = vec![false; n];
let mut exits2 = vec![false; n];
entries2[4] = true;
exits2[10] = true;
let signals1 = CompiledSignals {
symbol: "TEST".to_string(),
entries: entries1,
exits: exits1,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let signals2 = CompiledSignals {
symbol: "TEST".to_string(),
entries: entries2,
exits: exits2,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
(ohlcv, vec![signals1, signals2])
}
#[test]
fn test_multi_any_mode() {
let config = MultiStrategyConfig { combine_mode: CombineMode::Any, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With Any mode, should enter at index 2 (first strategy)
assert!(!result.trades.is_empty());
}
#[test]
fn test_multi_all_mode() {
let config = MultiStrategyConfig { combine_mode: CombineMode::All, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With All mode, should not enter (strategies don't signal at same time)
assert!(result.trades.is_empty() || result.trades.len() < 2);
}
#[test]
fn test_multi_independent_mode() {
let config =
MultiStrategyConfig { combine_mode: CombineMode::Independent, ..Default::default() };
let backtest = MultiStrategyBacktest::new(config);
let (ohlcv, strategies) = sample_strategies();
let result = backtest.run(&ohlcv, &strategies);
// With Independent mode, should have trades from both strategies
assert!(result.trades.len() >= 2);
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Options strategy backtest implementation.
//!
//! Supports dynamic strike selection and options-specific position sizing.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Options position type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OptionType {
Call,
Put,
}
/// Strike selection mode.
#[derive(Debug, Clone, Copy)]
pub enum StrikeSelection {
/// At-the-money (closest to spot).
Atm,
/// In-the-money by N strikes.
Itm(usize),
/// Out-of-the-money by N strikes.
Otm(usize),
/// Fixed strike offset from ATM in percentage.
PercentOffset(f64),
/// Delta-based selection.
Delta(f64),
}
impl Default for StrikeSelection {
fn default() -> Self {
StrikeSelection::Atm
}
}
/// Position size type for options.
#[derive(Debug, Clone, Copy)]
pub enum SizeType {
/// Fixed number of contracts.
Contracts(usize),
/// Percentage of capital.
Percent(f64),
/// Fixed notional value.
Notional(f64),
/// Risk-based (percentage of capital at risk).
RiskPercent(f64),
}
impl Default for SizeType {
fn default() -> Self {
SizeType::Percent(1.0)
}
}
/// Options backtest configuration.
#[derive(Debug, Clone)]
pub struct OptionsConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Option type (call/put).
pub option_type: OptionType,
/// Strike selection mode.
pub strike_selection: StrikeSelection,
/// Position size type.
pub size_type: SizeType,
/// Lot size (contracts per lot).
pub lot_size: usize,
/// Strike interval.
pub strike_interval: f64,
/// Days to expiry preference.
pub target_dte: Option<usize>,
}
impl Default for OptionsConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
option_type: OptionType::Call,
strike_selection: StrikeSelection::Atm,
size_type: SizeType::Percent(1.0),
lot_size: 1,
strike_interval: 50.0,
target_dte: None,
}
}
}
/// Options backtest runner.
#[derive(Debug)]
pub struct OptionsBacktest {
/// Configuration.
config: OptionsConfig,
/// Fee model.
fee_model: FeeModel,
}
impl OptionsBacktest {
/// Create a new options backtest.
pub fn new(config: OptionsConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run options backtest.
///
/// # Arguments
/// * `spot_ohlcv` - Spot/underlying OHLCV data
/// * `option_prices` - Option premium prices (parallel array)
/// * `signals` - Trading signals
///
/// # Returns
/// Backtest result
pub fn run(
&self,
spot_ohlcv: &OhlcvData,
option_prices: &[f64],
signals: &CompiledSignals,
) -> BacktestResult {
let n = spot_ohlcv.len();
assert_eq!(n, option_prices.len());
assert_eq!(n, signals.len());
// Clean signals
let processor = crate::signals::processor::SignalProcessor::new();
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut position: Option<OptionsPosition> = None;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n {
let spot_price = spot_ohlcv.close[i];
let option_price = option_prices[i];
// Check for exit
if exits[i] {
if let Some(pos) = position.take() {
let exit_price = option_price;
let fees = self.fee_model.calculate(
exit_price,
pos.contracts as f64,
signals.direction,
);
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
let cost_basis =
pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
cash += exit_price * pos.contracts as f64 * self.config.lot_size as f64 - fees;
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.entry_price,
exit_price,
size: pos.contracts as f64,
direction: signals.direction,
pnl,
return_pct,
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
exit_time: spot_ohlcv.timestamps[i],
fees,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Check for entry
if entries[i] && position.is_none() {
let strike = self.select_strike(spot_price);
let contracts = self.calculate_contracts(option_price, cash);
if contracts > 0 {
let entry_cost = option_price * contracts as f64 * self.config.lot_size as f64;
let fees =
self.fee_model.calculate(option_price, contracts as f64, signals.direction);
cash -= entry_cost + fees;
position = Some(OptionsPosition {
entry_idx: i,
entry_price: option_price,
strike,
contracts,
option_type: self.config.option_type,
});
}
}
// Update equity
let position_value = if let Some(ref pos) = position {
option_price * pos.contracts as f64 * self.config.lot_size as f64
} else {
0.0
};
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining position
if let Some(pos) = position.take() {
let last_idx = n - 1;
let exit_price = option_prices[last_idx];
let fees =
self.fee_model.calculate(exit_price, pos.contracts as f64, signals.direction);
let pnl = self.calculate_pnl(&pos, exit_price) - fees;
let cost_basis = pos.entry_price * pos.contracts as f64 * self.config.lot_size as f64;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.entry_price,
exit_price,
size: pos.contracts as f64,
direction: signals.direction,
pnl,
return_pct,
entry_time: spot_ohlcv.timestamps[pos.entry_idx],
exit_time: spot_ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
streaming.update(return_pct / 100.0);
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Select strike price based on configuration.
fn select_strike(&self, spot_price: f64) -> f64 {
let interval = self.config.strike_interval;
let atm_strike = (spot_price / interval).round() * interval;
match self.config.strike_selection {
StrikeSelection::Atm => atm_strike,
StrikeSelection::Itm(n) => match self.config.option_type {
OptionType::Call => atm_strike - (n as f64 * interval),
OptionType::Put => atm_strike + (n as f64 * interval),
},
StrikeSelection::Otm(n) => match self.config.option_type {
OptionType::Call => atm_strike + (n as f64 * interval),
OptionType::Put => atm_strike - (n as f64 * interval),
},
StrikeSelection::PercentOffset(pct) => {
let offset = spot_price * pct;
match self.config.option_type {
OptionType::Call => atm_strike + offset,
OptionType::Put => atm_strike - offset,
}
}
StrikeSelection::Delta(_) => atm_strike, // Simplified - would need options chain
}
}
/// Calculate number of contracts based on size type.
fn calculate_contracts(&self, option_price: f64, available_capital: f64) -> usize {
if option_price <= 0.0 {
return 0;
}
let contract_cost = option_price * self.config.lot_size as f64;
match self.config.size_type {
SizeType::Contracts(n) => n,
SizeType::Percent(pct) => {
let allocation = available_capital * pct;
(allocation / contract_cost) as usize
}
SizeType::Notional(value) => (value / contract_cost) as usize,
SizeType::RiskPercent(pct) => {
// Max loss is the premium paid
let risk_amount = available_capital * pct;
(risk_amount / contract_cost) as usize
}
}
}
/// Calculate P&L for a position.
fn calculate_pnl(&self, position: &OptionsPosition, current_price: f64) -> f64 {
let multiplier = self.config.lot_size as f64;
(current_price - position.entry_price) * position.contracts as f64 * multiplier
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
let total_trades = trades.len();
let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count();
let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count();
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
}
/// Internal options position state.
#[derive(Debug, Clone)]
struct OptionsPosition {
entry_idx: usize,
entry_price: f64,
#[allow(dead_code)]
strike: f64,
contracts: usize,
#[allow(dead_code)]
option_type: OptionType,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strike_selection_atm() {
let config = OptionsConfig {
strike_interval: 50.0,
strike_selection: StrikeSelection::Atm,
..Default::default()
};
let backtest = OptionsBacktest::new(config);
// Spot at 17834, ATM should be 17850
let strike = backtest.select_strike(17834.0);
assert!((strike - 17850.0).abs() < 1e-10);
}
#[test]
fn test_strike_selection_otm() {
let config = OptionsConfig {
strike_interval: 50.0,
strike_selection: StrikeSelection::Otm(2),
option_type: OptionType::Call,
..Default::default()
};
let backtest = OptionsBacktest::new(config);
// Spot at 17834, ATM=17850, OTM 2 strikes = 17950
let strike = backtest.select_strike(17834.0);
assert!((strike - 17950.0).abs() < 1e-10);
}
#[test]
fn test_position_sizing_percent() {
let config =
OptionsConfig { size_type: SizeType::Percent(0.5), lot_size: 50, ..Default::default() };
let backtest = OptionsBacktest::new(config);
// 50% of 100000 = 50000, option at 100 * lot 50 = 5000 per contract
let contracts = backtest.calculate_contracts(100.0, 100_000.0);
assert_eq!(contracts, 10);
}
}
+453
View File
@@ -0,0 +1,453 @@
//! Pairs trading strategy backtest implementation.
//!
//! Supports long/short legs with hedge ratios.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason,
OhlcvData, Trade,
};
use crate::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics;
/// Pairs trading configuration.
#[derive(Debug, Clone)]
pub struct PairsConfig {
/// Base backtest config.
pub base: BacktestConfig,
/// Hedge ratio (units of leg2 per unit of leg1).
pub hedge_ratio: f64,
/// Whether to dynamically update hedge ratio.
pub dynamic_hedge: bool,
/// Lookback period for dynamic hedge calculation.
pub hedge_lookback: usize,
/// Maximum spread for entry.
pub max_spread: Option<f64>,
/// Entry z-score threshold.
pub entry_zscore: f64,
/// Exit z-score threshold.
pub exit_zscore: f64,
}
impl Default for PairsConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
hedge_ratio: 1.0,
dynamic_hedge: false,
hedge_lookback: 20,
max_spread: None,
entry_zscore: 2.0,
exit_zscore: 0.5,
}
}
}
/// Pairs trading backtest runner.
#[derive(Debug)]
pub struct PairsBacktest {
/// Configuration.
config: PairsConfig,
/// Fee model.
fee_model: FeeModel,
}
impl PairsBacktest {
/// Create a new pairs backtest.
pub fn new(config: PairsConfig) -> Self {
Self { fee_model: FeeModel::percentage(config.base.fees), config }
}
/// Run pairs trading backtest.
///
/// # Arguments
/// * `leg1_ohlcv` - OHLCV data for leg 1 (long leg when spread widens)
/// * `leg2_ohlcv` - OHLCV data for leg 2 (short leg when spread widens)
/// * `signals` - Entry/exit signals based on spread
///
/// # Returns
/// Backtest result
pub fn run(
&self,
leg1_ohlcv: &OhlcvData,
leg2_ohlcv: &OhlcvData,
signals: &CompiledSignals,
) -> BacktestResult {
let n = leg1_ohlcv.len();
assert_eq!(n, leg2_ohlcv.len());
assert_eq!(n, signals.len());
// Clean signals
let processor = crate::signals::processor::SignalProcessor::new();
let (entries, exits) = processor.clean_signals(&signals.entries, &signals.exits);
// Initialize state
let mut cash = self.config.base.initial_capital;
let mut position: Option<PairsPosition> = None;
let mut equity_curve = vec![cash; n];
let mut drawdown_curve = vec![0.0; n];
let mut returns = vec![0.0; n];
let mut trades: Vec<Trade> = Vec::new();
let mut streaming = StreamingMetrics::new();
let mut peak_equity = cash;
let mut trade_counter = 0u64;
// Main simulation loop
for i in 0..n {
let leg1_price = leg1_ohlcv.close[i];
let leg2_price = leg2_ohlcv.close[i];
// Calculate current hedge ratio
let hedge_ratio = if self.config.dynamic_hedge && i >= self.config.hedge_lookback {
self.calculate_hedge_ratio(
&leg1_ohlcv.close[i - self.config.hedge_lookback..=i],
&leg2_ohlcv.close[i - self.config.hedge_lookback..=i],
)
} else {
self.config.hedge_ratio
};
// Check for exit
if exits[i] {
if let Some(pos) = position.take() {
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
let cost_basis = pos.leg1_cost + pos.leg2_cost;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
// Return capital
cash += pos.leg1_size * leg1_price + pos.leg2_size * leg2_price - fees;
// Record trades for both legs
trades.push(Trade {
id: trade_counter,
symbol: format!("{}_LEG1", signals.symbol),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.leg1_entry_price,
exit_price: leg1_price,
size: pos.leg1_size,
direction: pos.leg1_direction,
pnl: pnl / 2.0, // Split P&L attribution
return_pct: return_pct / 2.0,
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
exit_time: leg1_ohlcv.timestamps[i],
fees: fees / 2.0,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
trades.push(Trade {
id: trade_counter,
symbol: format!("{}_LEG2", signals.symbol),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: pos.leg2_entry_price,
exit_price: leg2_price,
size: pos.leg2_size,
direction: pos.leg2_direction,
pnl: pnl / 2.0,
return_pct: return_pct / 2.0,
entry_time: leg2_ohlcv.timestamps[pos.entry_idx],
exit_time: leg2_ohlcv.timestamps[i],
fees: fees / 2.0,
exit_reason: ExitReason::Signal,
});
trade_counter += 1;
streaming.update(return_pct / 100.0);
}
}
// Check for entry
if entries[i] && position.is_none() {
// Determine direction from signal direction
let (leg1_dir, leg2_dir) = match signals.direction {
Direction::Long => (Direction::Long, Direction::Short),
Direction::Short => (Direction::Short, Direction::Long),
};
// Calculate position sizes
let allocation = cash * 0.5; // Use 50% per leg
let leg1_size = allocation / leg1_price;
let leg2_size = (allocation * hedge_ratio) / leg2_price;
let leg1_cost = leg1_size * leg1_price;
let leg2_cost = leg2_size * leg2_price;
let entry_fees = self.fee_model.calculate(leg1_price, leg1_size, leg1_dir)
+ self.fee_model.calculate(leg2_price, leg2_size, leg2_dir);
cash -= leg1_cost + leg2_cost + entry_fees;
position = Some(PairsPosition {
entry_idx: i,
leg1_entry_price: leg1_price,
leg2_entry_price: leg2_price,
leg1_size,
leg2_size,
leg1_direction: leg1_dir,
leg2_direction: leg2_dir,
leg1_cost,
leg2_cost,
hedge_ratio,
});
}
// Update equity
let position_value = if let Some(ref pos) = position {
let _leg1_value = pos.leg1_size * leg1_price;
let _leg2_value = pos.leg2_size * leg2_price;
// For pairs, value is long leg - short leg + cash equivalent
let leg1_pnl = (leg1_price - pos.leg1_entry_price)
* pos.leg1_size
* pos.leg1_direction.multiplier();
let leg2_pnl = (leg2_price - pos.leg2_entry_price)
* pos.leg2_size
* pos.leg2_direction.multiplier();
pos.leg1_cost + pos.leg2_cost + leg1_pnl + leg2_pnl
} else {
0.0
};
let equity = cash + position_value;
equity_curve[i] = equity;
// Update drawdown
if equity > peak_equity {
peak_equity = equity;
}
drawdown_curve[i] = (peak_equity - equity) / peak_equity * 100.0;
// Calculate return
if i > 0 {
returns[i] = (equity - equity_curve[i - 1]) / equity_curve[i - 1];
}
}
// Close any remaining position
if let Some(pos) = position.take() {
let last_idx = n - 1;
let leg1_price = leg1_ohlcv.close[last_idx];
let leg2_price = leg2_ohlcv.close[last_idx];
let (pnl, fees) = self.close_position(&pos, leg1_price, leg2_price);
let cost_basis = pos.leg1_cost + pos.leg2_cost;
let return_pct = if cost_basis > 0.0 { pnl / cost_basis * 100.0 } else { 0.0 };
trades.push(Trade {
id: trade_counter,
symbol: signals.symbol.clone(),
entry_idx: pos.entry_idx,
exit_idx: last_idx,
entry_price: pos.leg1_entry_price,
exit_price: leg1_price,
size: pos.leg1_size + pos.leg2_size,
direction: pos.leg1_direction,
pnl,
return_pct,
entry_time: leg1_ohlcv.timestamps[pos.entry_idx],
exit_time: leg1_ohlcv.timestamps[last_idx],
fees,
exit_reason: ExitReason::EndOfData,
});
streaming.update(return_pct / 100.0);
}
// Calculate metrics
let metrics = self.calculate_metrics(&equity_curve, &drawdown_curve, &trades, &streaming);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
/// Calculate hedge ratio using OLS regression.
fn calculate_hedge_ratio(&self, leg1_prices: &[f64], leg2_prices: &[f64]) -> f64 {
let n = leg1_prices.len() as f64;
if n < 2.0 {
return self.config.hedge_ratio;
}
let sum_x: f64 = leg2_prices.iter().sum();
let sum_y: f64 = leg1_prices.iter().sum();
let sum_xy: f64 = leg1_prices.iter().zip(leg2_prices.iter()).map(|(y, x)| x * y).sum();
let sum_x2: f64 = leg2_prices.iter().map(|x| x * x).sum();
let denominator = n * sum_x2 - sum_x * sum_x;
if denominator.abs() < 1e-10 {
return self.config.hedge_ratio;
}
let beta = (n * sum_xy - sum_x * sum_y) / denominator;
beta.max(0.1).min(10.0) // Constrain to reasonable range
}
/// Close position and calculate P&L.
fn close_position(
&self,
position: &PairsPosition,
leg1_price: f64,
leg2_price: f64,
) -> (f64, f64) {
let leg1_pnl = (leg1_price - position.leg1_entry_price)
* position.leg1_size
* position.leg1_direction.multiplier();
let leg2_pnl = (leg2_price - position.leg2_entry_price)
* position.leg2_size
* position.leg2_direction.multiplier();
let exit_fees =
self.fee_model.calculate(leg1_price, position.leg1_size, position.leg1_direction)
+ self.fee_model.calculate(leg2_price, position.leg2_size, position.leg2_direction);
let total_pnl = leg1_pnl + leg2_pnl - exit_fees;
(total_pnl, exit_fees)
}
/// Calculate metrics.
fn calculate_metrics(
&self,
equity_curve: &[f64],
drawdown_curve: &[f64],
trades: &[Trade],
streaming: &StreamingMetrics,
) -> BacktestMetrics {
let start_value = self.config.base.initial_capital;
let end_value = *equity_curve.last().unwrap_or(&start_value);
let total_return_pct = (end_value - start_value) / start_value * 100.0;
let max_drawdown_pct = drawdown_curve.iter().fold(0.0f64, |a, &b| a.max(b));
// For pairs, count trade pairs (every 2 trades = 1 round trip)
let total_trades = trades.len() / 2;
let winning_trades =
trades.chunks(2).filter(|chunk| chunk.iter().map(|t| t.pnl).sum::<f64>() > 0.0).count();
let losing_trades = total_trades.saturating_sub(winning_trades);
let win_rate_pct = if total_trades > 0 {
winning_trades as f64 / total_trades as f64 * 100.0
} else {
0.0
};
let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum();
let profit_factor = if gross_loss > 0.0 {
gross_profit / gross_loss
} else if gross_profit > 0.0 {
f64::INFINITY
} else {
0.0
};
BacktestMetrics {
total_return_pct,
sharpe_ratio: streaming.sharpe_ratio(252.0),
sortino_ratio: streaming.sortino_ratio(252.0),
calmar_ratio: if max_drawdown_pct > 0.0 {
total_return_pct / max_drawdown_pct
} else {
0.0
},
max_drawdown_pct,
win_rate_pct,
profit_factor,
total_trades,
winning_trades,
losing_trades,
start_value,
end_value,
..Default::default()
}
}
}
/// Internal pairs position state.
#[derive(Debug, Clone)]
struct PairsPosition {
entry_idx: usize,
leg1_entry_price: f64,
leg2_entry_price: f64,
leg1_size: f64,
leg2_size: f64,
leg1_direction: Direction,
leg2_direction: Direction,
leg1_cost: f64,
leg2_cost: f64,
#[allow(dead_code)]
hedge_ratio: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_pairs_data() -> (OhlcvData, OhlcvData, CompiledSignals) {
let n = 20;
// Leg 1: Trending up
let leg1 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (100..100 + n).map(|x| x as f64).collect(),
high: (101..101 + n).map(|x| x as f64).collect(),
low: (99..99 + n).map(|x| x as f64).collect(),
close: (100..100 + n).map(|x| x as f64 + 0.5).collect(),
volume: vec![1000.0; n],
};
// Leg 2: Correlated but with different magnitude
let leg2 = OhlcvData {
timestamps: (0..n as i64).collect(),
open: (50..50 + n).map(|x| x as f64).collect(),
high: (51..51 + n).map(|x| x as f64).collect(),
low: (49..49 + n).map(|x| x as f64).collect(),
close: (50..50 + n).map(|x| x as f64 + 0.2).collect(),
volume: vec![2000.0; n],
};
let mut entries = vec![false; n];
let mut exits = vec![false; n];
entries[2] = true;
exits[10] = true;
let signals = CompiledSignals {
symbol: "PAIR".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long, // Long leg1, short leg2
weight: 1.0,
};
(leg1, leg2, signals)
}
#[test]
fn test_pairs_backtest() {
let config = PairsConfig::default();
let backtest = PairsBacktest::new(config);
let (leg1, leg2, signals) = sample_pairs_data();
let result = backtest.run(&leg1, &leg2, &signals);
// Should have trades for both legs
assert!(result.trades.len() >= 2);
assert_eq!(result.equity_curve.len(), 20);
}
#[test]
fn test_hedge_ratio_calculation() {
let config = PairsConfig { dynamic_hedge: true, hedge_lookback: 5, ..Default::default() };
let backtest = PairsBacktest::new(config);
let leg1 = vec![100.0, 102.0, 104.0, 106.0, 108.0];
let leg2 = vec![50.0, 51.0, 52.0, 53.0, 54.0];
let ratio = backtest.calculate_hedge_ratio(&leg1, &leg2);
// Ratio should be approximately 2 (leg1 moves 2x leg2)
assert!(ratio > 1.5 && ratio < 2.5);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Single instrument backtest implementation.
use crate::core::types::{
BacktestConfig, BacktestResult, CompiledSignals, InstrumentConfig, OhlcvData,
};
use crate::portfolio::engine::PortfolioEngine;
/// Single instrument backtest runner.
#[derive(Debug)]
pub struct SingleBacktest {
/// Portfolio engine.
engine: PortfolioEngine,
}
impl SingleBacktest {
/// Create a new single instrument backtest.
pub fn new(config: BacktestConfig) -> Self {
Self { engine: PortfolioEngine::new(config) }
}
/// Run the backtest.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult {
self.engine.run_single(ohlcv, signals)
}
/// Run the backtest with per-instrument configuration.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
/// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides)
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run_with_instrument_config(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
inst_config: Option<&InstrumentConfig>,
) -> BacktestResult {
self.engine.run_single_with_instrument_config(ohlcv, signals, inst_config)
}
/// Run backtest from raw arrays.
///
/// # Arguments
/// * `timestamps` - Timestamp array
/// * `open` - Open prices
/// * `high` - High prices
/// * `low` - Low prices
/// * `close` - Close prices
/// * `volume` - Volume
/// * `entries` - Entry signals
/// * `exits` - Exit signals
/// * `direction` - Trade direction (1 = long, -1 = short)
/// * `symbol` - Symbol name
///
/// # Returns
/// Backtest result
pub fn run_from_arrays(
&self,
timestamps: &[i64],
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
entries: &[bool],
exits: &[bool],
direction: i32,
symbol: &str,
) -> BacktestResult {
let ohlcv = OhlcvData {
timestamps: timestamps.to_vec(),
open: open.to_vec(),
high: high.to_vec(),
low: low.to_vec(),
close: close.to_vec(),
volume: volume.to_vec(),
};
let dir = crate::core::types::Direction::from_int(direction)
.unwrap_or(crate::core::types::Direction::Long);
let signals = CompiledSignals {
symbol: symbol.to_string(),
entries: entries.to_vec(),
exits: exits.to_vec(),
position_sizes: None,
direction: dir,
weight: 1.0,
};
self.run(&ohlcv, &signals)
}
/// Run backtest with position sizing.
///
/// # Arguments
/// * `ohlcv` - OHLCV price data
/// * `signals` - Compiled trading signals
/// * `position_sizes` - Position size for each bar (fraction of capital)
///
/// # Returns
/// Backtest result
pub fn run_with_sizing(
&self,
ohlcv: &OhlcvData,
signals: &CompiledSignals,
position_sizes: Vec<f64>,
) -> BacktestResult {
let mut signals_with_sizing = signals.clone();
signals_with_sizing.position_sizes = Some(position_sizes);
self.engine.run_single(ohlcv, &signals_with_sizing)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::{Direction, StopConfig, TargetConfig};
fn sample_data() -> (OhlcvData, CompiledSignals) {
let ohlcv = OhlcvData {
timestamps: (0..20).map(|i| i as i64).collect(),
open: vec![
100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.0, 101.0,
102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0,
],
high: vec![
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 105.0, 104.0, 103.0, 102.0, 101.0, 102.0,
103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0,
],
low: vec![
99.0, 100.0, 101.0, 102.0, 103.0, 104.0, 103.0, 102.0, 101.0, 100.0, 99.0, 100.0,
101.0, 102.0, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0,
],
close: vec![
100.5, 101.5, 102.5, 103.5, 104.5, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5, 101.5,
102.5, 103.5, 104.5, 105.5, 106.5, 107.5, 108.5, 109.5,
],
volume: vec![1000.0; 20],
};
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries: vec![
false, true, false, false, false, false, false, false, false, false, false, true,
false, false, false, false, false, false, false, false,
],
exits: vec![
false, false, false, false, false, true, false, false, false, false, false, false,
false, false, false, true, false, false, false, false,
],
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
(ohlcv, signals)
}
#[test]
fn test_single_backtest() {
let config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.0,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let backtest = SingleBacktest::new(config);
let (ohlcv, signals) = sample_data();
let result = backtest.run(&ohlcv, &signals);
assert_eq!(result.trades.len(), 2);
assert!(result.metrics.total_return_pct > 0.0);
}
#[test]
fn test_from_arrays() {
let config = BacktestConfig::default();
let backtest = SingleBacktest::new(config);
let timestamps: Vec<i64> = (0..10).collect();
let close: Vec<f64> = (100..110).map(|x| x as f64).collect();
let open = close.clone();
let high: Vec<f64> = close.iter().map(|x| x + 1.0).collect();
let low: Vec<f64> = close.iter().map(|x| x - 1.0).collect();
let volume = vec![1000.0; 10];
let entries = vec![false, true, false, false, false, false, false, false, false, false];
let exits = vec![false, false, false, false, false, true, false, false, false, false];
let result = backtest.run_from_arrays(
&timestamps,
&open,
&high,
&low,
&close,
&volume,
&entries,
&exits,
1,
"TEST",
);
assert_eq!(result.trades.len(), 1);
}
}
+606
View File
@@ -0,0 +1,606 @@
//! Multi-leg options spread backtesting implementation.
//!
//! Provides high-performance spread backtesting for:
//! - Straddles and Strangles
//! - Vertical spreads (bull/bear call/put)
//! - Iron Condors and Iron Butterflies
//! - Calendar and Diagonal spreads
//!
//! Key features:
//! - Single-pass O(n) algorithm
//! - Coordinated entry/exit across all legs
//! - Net premium P&L calculation
//! - Combined Greeks tracking
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, Direction, ExitReason, Trade,
};
use crate::metrics::streaming::StreamingMetrics;
use serde::{Deserialize, Serialize};
/// Spread type enumeration.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpreadType {
Straddle,
Strangle,
VerticalCall,
VerticalPut,
IronCondor,
IronButterfly,
ButterflyCall,
ButterflyPut,
Calendar,
Diagonal,
LongCall,
LongPut,
NakedCall,
NakedPut,
Custom,
}
/// Option type for a leg.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OptionType {
Call,
Put,
}
impl OptionType {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"CE" | "CALL" | "C" => Some(OptionType::Call),
"PE" | "PUT" | "P" => Some(OptionType::Put),
_ => None,
}
}
}
/// Configuration for a single leg of a spread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegConfig {
/// Option type (Call or Put).
pub option_type: OptionType,
/// Strike price.
pub strike: f64,
/// Position quantity (+1 long, -1 short).
pub quantity: i32,
/// Lot size for the option.
pub lot_size: usize,
}
impl LegConfig {
pub fn new(option_type: OptionType, strike: f64, quantity: i32, lot_size: usize) -> Self {
Self { option_type, strike, quantity, lot_size }
}
/// Check if this is a long position.
pub fn is_long(&self) -> bool {
self.quantity > 0
}
/// Check if this is a short position.
pub fn is_short(&self) -> bool {
self.quantity < 0
}
}
/// Configuration for spread backtest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpreadConfig {
/// Base backtest configuration.
pub base: BacktestConfig,
/// Spread type.
pub spread_type: SpreadType,
/// Leg configurations.
pub leg_configs: Vec<LegConfig>,
/// Maximum loss threshold (optional, for early exit).
pub max_loss: Option<f64>,
/// Target profit threshold (optional, for early exit).
pub target_profit: Option<f64>,
/// Whether to close at end of day.
pub close_at_eod: bool,
/// Per-leg expiry timestamps in nanoseconds (optional, for settlement logic).
/// When provided, positions are force-closed at or after the earliest leg expiry.
pub leg_expiry_timestamps: Option<Vec<i64>>,
}
impl Default for SpreadConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
spread_type: SpreadType::Custom,
leg_configs: Vec::new(),
max_loss: None,
target_profit: None,
close_at_eod: false,
leg_expiry_timestamps: None,
}
}
}
/// State for a single leg position.
#[derive(Debug, Clone)]
struct LegPosition {
/// Entry premium price.
pub entry_premium: f64,
/// Entry index.
#[allow(dead_code)]
pub entry_idx: usize,
/// Current premium price.
pub current_premium: f64,
/// Leg configuration.
pub config: LegConfig,
}
impl LegPosition {
fn new(config: LegConfig, entry_premium: f64, entry_idx: usize) -> Self {
Self { entry_premium, entry_idx, current_premium: entry_premium, config }
}
/// Calculate unrealized P&L for this leg.
fn unrealized_pnl(&self) -> f64 {
// For short positions: profit when premium decreases
// For long positions: profit when premium increases
let premium_change = self.current_premium - self.entry_premium;
let quantity = self.config.quantity as f64;
let lot_size = self.config.lot_size as f64;
-quantity * premium_change * lot_size
}
}
/// Spread position state.
#[derive(Debug, Clone)]
struct SpreadPosition {
/// Individual leg positions.
pub legs: Vec<LegPosition>,
/// Entry bar index.
pub entry_idx: usize,
/// Entry net premium (positive = credit, negative = debit).
pub entry_net_premium: f64,
/// Entry timestamp.
pub entry_time: i64,
/// Whether position is open.
pub is_open: bool,
}
impl SpreadPosition {
fn new(legs: Vec<LegPosition>, entry_idx: usize, entry_time: i64) -> Self {
let entry_net_premium: f64 = legs
.iter()
.map(|leg| leg.entry_premium * leg.config.quantity as f64 * leg.config.lot_size as f64)
.sum();
Self { legs, entry_idx, entry_net_premium, entry_time, is_open: true }
}
/// Calculate total unrealized P&L across all legs.
fn total_unrealized_pnl(&self) -> f64 {
self.legs.iter().map(|leg| leg.unrealized_pnl()).sum()
}
/// Update leg premiums.
fn update_premiums(&mut self, leg_premiums: &[f64]) {
for (leg, &premium) in self.legs.iter_mut().zip(leg_premiums.iter()) {
leg.current_premium = premium;
}
}
/// Close the position and return P&L.
fn close(&mut self) -> f64 {
self.is_open = false;
self.total_unrealized_pnl()
}
}
/// Spread backtest runner.
pub struct SpreadBacktest {
config: SpreadConfig,
}
impl SpreadBacktest {
/// Create a new spread backtest.
pub fn new(config: SpreadConfig) -> Self {
Self { config }
}
/// Run the spread backtest.
///
/// # Arguments
/// * `timestamps` - Timestamp array
/// * `underlying_close` - Underlying close prices
/// * `legs_premiums` - Premium series for each leg (Vec of Vec)
/// * `entries` - Entry signals
/// * `exits` - Exit signals
///
/// # Returns
/// Backtest result with metrics, trades, and equity curve
pub fn run(
&self,
timestamps: &[i64],
_underlying_close: &[f64],
legs_premiums: &[Vec<f64>],
entries: &[bool],
exits: &[bool],
) -> BacktestResult {
let n = timestamps.len();
// Validate inputs
if legs_premiums.len() != self.config.leg_configs.len() {
return self.empty_result(n);
}
for premiums in legs_premiums {
if premiums.len() != n {
return self.empty_result(n);
}
}
let mut metrics = StreamingMetrics::with_initial_capital(self.config.base.initial_capital);
let mut equity_curve = Vec::with_capacity(n);
let mut drawdown_curve = Vec::with_capacity(n);
let mut returns = Vec::with_capacity(n);
let mut trades: Vec<Trade> = Vec::new();
let mut trade_id: u64 = 0;
let mut cash = self.config.base.initial_capital;
let mut position: Option<SpreadPosition> = None;
let mut prev_equity = cash;
// Single-pass O(n) algorithm
for i in 0..n {
// Get current leg premiums
let current_premiums: Vec<f64> = legs_premiums.iter().map(|p| p[i]).collect();
// Update position premiums if open
if let Some(ref mut pos) = position {
pos.update_premiums(&current_premiums);
}
// Calculate unrealized P&L for exit checks
let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
// Check if any leg has expired at this bar
let is_expiry = position.is_some()
&& self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
expiries.iter().any(|&exp_ts| timestamps[i] >= exp_ts)
});
// Check for exit signals or conditions
let should_exit = position.is_some()
&& (exits[i]
|| is_expiry
|| self.check_max_loss(&position, unrealized_pnl)
|| self.check_target_profit(&position, unrealized_pnl));
if should_exit {
if let Some(mut pos) = position.take() {
let pnl = pos.close();
let fees = self.calculate_fees(&pos);
let net_pnl = pnl - fees;
cash += net_pnl;
// Record trade
trade_id += 1;
let exit_reason = if is_expiry {
ExitReason::Settlement
} else if exits[i] {
ExitReason::Signal
} else if self.check_max_loss(&Some(pos.clone()), pnl) {
ExitReason::StopLoss
} else {
ExitReason::TakeProfit
};
let entry_premium = pos.entry_net_premium;
let exit_premium: f64 = current_premiums
.iter()
.zip(self.config.leg_configs.iter())
.map(|(&p, cfg)| p * cfg.quantity as f64 * cfg.lot_size as f64)
.sum();
trades.push(Trade {
id: trade_id,
symbol: "SPREAD".to_string(),
entry_idx: pos.entry_idx,
exit_idx: i,
entry_price: entry_premium,
exit_price: exit_premium,
size: 1.0,
direction: Direction::Long, // Spreads are treated as "long spread"
pnl: net_pnl,
return_pct: if entry_premium.abs() > 0.0 {
net_pnl / entry_premium.abs() * 100.0
} else {
0.0
},
entry_time: pos.entry_time,
exit_time: timestamps[i],
fees,
exit_reason,
});
metrics.record_trade(
net_pnl,
net_pnl / entry_premium.abs() * 100.0,
i - pos.entry_idx,
);
}
}
// Check for entry signals (don't re-enter after all legs expired)
let all_expired =
self.config.leg_expiry_timestamps.as_ref().map_or(false, |expiries| {
expiries.iter().all(|&exp_ts| timestamps[i] >= exp_ts)
});
if position.is_none() && entries[i] && !all_expired {
let legs: Vec<LegPosition> = self
.config
.leg_configs
.iter()
.zip(current_premiums.iter())
.map(|(cfg, &premium)| LegPosition::new(cfg.clone(), premium, i))
.collect();
let new_position = SpreadPosition::new(legs, i, timestamps[i]);
// Calculate entry fees
let entry_fees = self.calculate_entry_fees(&new_position);
cash -= entry_fees;
position = Some(new_position);
}
// Update equity tracking
let equity = cash + position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0);
equity_curve.push(equity);
let daily_return =
if prev_equity > 0.0 { (equity - prev_equity) / prev_equity } else { 0.0 };
returns.push(daily_return);
prev_equity = equity;
// Update drawdown
metrics.update_equity(equity);
drawdown_curve.push(metrics.current_drawdown_pct());
}
// Close any remaining open position at end
if let Some(mut pos) = position.take() {
let pnl = pos.close();
let fees = self.calculate_fees(&pos);
cash += pnl - fees;
}
// Finalize metrics
let final_metrics = metrics.finalize(self.config.base.initial_capital, cash, &returns);
BacktestResult { metrics: final_metrics, equity_curve, drawdown_curve, trades, returns }
}
/// Check if max loss threshold is hit.
fn check_max_loss(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
if let Some(max_loss) = self.config.max_loss {
if unrealized_pnl < -max_loss {
return true;
}
}
false
}
/// Check if target profit threshold is hit.
fn check_target_profit(&self, _position: &Option<SpreadPosition>, unrealized_pnl: f64) -> bool {
if let Some(target) = self.config.target_profit {
if unrealized_pnl > target {
return true;
}
}
false
}
/// Calculate entry fees for a position.
fn calculate_entry_fees(&self, position: &SpreadPosition) -> f64 {
let total_premium: f64 = position
.legs
.iter()
.map(|leg| leg.entry_premium.abs() * leg.config.lot_size as f64)
.sum();
total_premium * self.config.base.fees
}
/// Calculate exit fees for a position.
fn calculate_fees(&self, position: &SpreadPosition) -> f64 {
let total_premium: f64 = position
.legs
.iter()
.map(|leg| leg.current_premium.abs() * leg.config.lot_size as f64)
.sum();
total_premium * self.config.base.fees * 2.0 // Entry + Exit
}
/// Create an empty result (used for validation failures).
fn empty_result(&self, n: usize) -> BacktestResult {
BacktestResult {
metrics: BacktestMetrics::default(),
equity_curve: vec![self.config.base.initial_capital; n],
drawdown_curve: vec![0.0; n],
trades: Vec::new(),
returns: vec![0.0; n],
}
}
}
/// Convenience function to create a straddle spread config.
pub fn create_straddle_config(
base: BacktestConfig,
strike: f64,
lot_size: usize,
short: bool,
) -> SpreadConfig {
let quantity = if short { -1 } else { 1 };
SpreadConfig {
base,
spread_type: SpreadType::Straddle,
leg_configs: vec![
LegConfig::new(OptionType::Call, strike, quantity, lot_size),
LegConfig::new(OptionType::Put, strike, quantity, lot_size),
],
..Default::default()
}
}
/// Convenience function to create a strangle spread config.
pub fn create_strangle_config(
base: BacktestConfig,
call_strike: f64,
put_strike: f64,
lot_size: usize,
short: bool,
) -> SpreadConfig {
let quantity = if short { -1 } else { 1 };
SpreadConfig {
base,
spread_type: SpreadType::Strangle,
leg_configs: vec![
LegConfig::new(OptionType::Call, call_strike, quantity, lot_size),
LegConfig::new(OptionType::Put, put_strike, quantity, lot_size),
],
..Default::default()
}
}
/// Convenience function to create an iron condor spread config.
pub fn create_iron_condor_config(
base: BacktestConfig,
short_put_strike: f64,
long_put_strike: f64,
short_call_strike: f64,
long_call_strike: f64,
lot_size: usize,
) -> SpreadConfig {
SpreadConfig {
base,
spread_type: SpreadType::IronCondor,
leg_configs: vec![
LegConfig::new(OptionType::Put, short_put_strike, -1, lot_size),
LegConfig::new(OptionType::Put, long_put_strike, 1, lot_size),
LegConfig::new(OptionType::Call, short_call_strike, -1, lot_size),
LegConfig::new(OptionType::Call, long_call_strike, 1, lot_size),
],
..Default::default()
}
}
/// Convenience function to create a vertical spread config.
pub fn create_vertical_spread_config(
base: BacktestConfig,
option_type: OptionType,
long_strike: f64,
short_strike: f64,
lot_size: usize,
) -> SpreadConfig {
let spread_type = match option_type {
OptionType::Call => SpreadType::VerticalCall,
OptionType::Put => SpreadType::VerticalPut,
};
SpreadConfig {
base,
spread_type,
leg_configs: vec![
LegConfig::new(option_type, long_strike, 1, lot_size),
LegConfig::new(option_type, short_strike, -1, lot_size),
],
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::StopConfig;
use crate::core::types::TargetConfig;
fn sample_data() -> (Vec<i64>, Vec<f64>, Vec<Vec<f64>>, Vec<bool>, Vec<bool>) {
let n = 20;
let timestamps: Vec<i64> = (0..n as i64).collect();
let underlying: Vec<f64> = (100..120).map(|x| x as f64).collect();
// Call and Put premiums
let call_premiums: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect();
let put_premiums: Vec<f64> = (0..n).map(|i| 5.0 - (i as f64 * 0.1)).collect();
let legs_premiums = vec![call_premiums, put_premiums];
let entries = vec![
false, true, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false,
];
let exits = vec![
false, false, false, false, false, false, false, false, false, true, false, false,
false, false, false, false, false, false, false, false,
];
(timestamps, underlying, legs_premiums, entries, exits)
}
#[test]
fn test_straddle_backtest() {
let base_config = BacktestConfig {
initial_capital: 100_000.0,
fees: 0.001,
slippage: 0.0,
stop: StopConfig::None,
target: TargetConfig::None,
upon_bar_close: true,
};
let config = create_straddle_config(base_config, 100.0, 50, true);
let backtest = SpreadBacktest::new(config);
let (timestamps, underlying, legs_premiums, entries, exits) = sample_data();
let result = backtest.run(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
assert!(result.equity_curve.len() == timestamps.len());
}
#[test]
fn test_iron_condor_backtest() {
let base_config = BacktestConfig::default();
let config = create_iron_condor_config(
base_config,
95.0, // short put
90.0, // long put
105.0, // short call
110.0, // long call
50,
);
let backtest = SpreadBacktest::new(config);
let n = 20;
let timestamps: Vec<i64> = (0..n as i64).collect();
let underlying: Vec<f64> = vec![100.0; n];
// Four legs: short put, long put, short call, long call
let legs_premiums = vec![
vec![3.0; n], // short put
vec![1.5; n], // long put
vec![3.0; n], // short call
vec![1.5; n], // long call
];
let mut entries = vec![false; n];
entries[1] = true;
let mut exits = vec![false; n];
exits[15] = true;
let result = backtest.run(&timestamps, &underlying, &legs_premiums, &entries, &exits);
assert_eq!(result.trades.len(), 1);
}
}
+359
View File
@@ -0,0 +1,359 @@
//! Tick-level backtest implementation.
//!
//! Accepts raw tick arrays (ltp, bid, ask, per-tick buy/sell qty deltas) plus
//! parallel entry/exit signal arrays, then simulates each trade to
//! stop-loss / take-profit / max-hold-time exit at full tick resolution.
//!
//! This is the right path for intraday options momentum strategies where the
//! exact fill tick matters. Do not resample to bars before calling this —
//! bar resampling discards intra-bar path information and makes scalping
//! strategies unbacktestable.
use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, ExitReason, Price, TickData, Timestamp, Trade,
};
use crate::portfolio::engine::compute_backtest_metrics;
/// Configuration specific to tick backtests.
#[derive(Debug, Clone)]
pub struct TickBacktestConfig {
/// Shared execution config (capital, fees, slippage).
pub base: BacktestConfig,
/// Stop-loss as percentage of entry price (e.g. 5.0 = 5%).
pub stop_loss_pct: f64,
/// Take-profit as percentage of entry price (e.g. 10.0 = 10%).
pub take_profit_pct: f64,
/// Maximum hold time in seconds. 0 = no time limit.
pub max_hold_seconds: u64,
/// Minimum ticks between entries (cooldown). Prevents overlapping positions.
pub entry_cooldown_ticks: usize,
/// Maximum trades to simulate (bounds runtime for large windows).
pub max_trades: usize,
}
impl Default for TickBacktestConfig {
fn default() -> Self {
Self {
base: BacktestConfig::default(),
stop_loss_pct: 5.0,
take_profit_pct: 10.0,
max_hold_seconds: 1800,
entry_cooldown_ticks: 10,
max_trades: 50,
}
}
}
/// Tick-level backtest runner.
pub struct TickBacktest {
config: TickBacktestConfig,
}
impl TickBacktest {
pub fn new(config: TickBacktestConfig) -> Self {
Self { config }
}
/// Run the tick backtest.
///
/// `ticks` — raw tick data (ltp, bid, ask, per-tick qty deltas)
/// `entries` — parallel bool array: true at ticks where a new long entry is allowed
/// `exits` — parallel bool array: true at ticks where an open position must close
/// `symbol` — instrument label used in trade records
pub fn run(
&self,
ticks: &TickData,
entries: &[bool],
exits: &[bool],
symbol: &str,
) -> BacktestResult {
let n = ticks.len();
assert_eq!(n, entries.len(), "ticks and entries must have same length");
assert_eq!(n, exits.len(), "ticks and exits must have same length");
let slippage_frac = self.config.base.slippage; // e.g. 0.0005 = 0.05%
let fee_frac = self.config.base.fees; // e.g. 0.001 = 0.1%
let stop_frac = self.config.stop_loss_pct / 100.0;
let target_frac = self.config.take_profit_pct / 100.0;
let max_hold_ns: i64 = self.config.max_hold_seconds as i64 * 1_000_000_000;
let mut trades: Vec<Trade> = Vec::new();
let mut trade_id: u64 = 0;
// Position state
let mut in_position = false;
let mut entry_idx: usize = 0;
let mut entry_price: Price = 0.0;
let mut entry_time: Timestamp = 0;
let mut stop_level: Price = 0.0;
let mut target_level: Price = 0.0;
let mut entry_fees: f64 = 0.0;
let mut cooldown_until: usize = 0;
for i in 0..n {
let ltp = ticks.ltp[i];
let bid = if ticks.bid[i] > 0.0 { ticks.bid[i] } else { ltp };
let ask = if ticks.ask[i] > 0.0 { ticks.ask[i] } else { ltp };
let ts = ticks.timestamps[i];
if in_position {
// Check time exit first (hard deadline)
let time_exit = max_hold_ns > 0 && (ts - entry_time) >= max_hold_ns;
// Check explicit exit signal
let signal_exit = exits[i];
// Check stop and target against ltp (tick-exact, no OHLC lookahead)
let stop_hit = ltp <= stop_level;
let target_hit = ltp >= target_level;
let (exit_price, reason) = if stop_hit {
// Fill at stop level (not ltp — avoid worse-than-stop fills)
let fill = stop_level * (1.0 - slippage_frac);
(fill, ExitReason::StopLoss)
} else if target_hit {
let fill = target_level * (1.0 - slippage_frac);
(fill, ExitReason::TakeProfit)
} else if time_exit || signal_exit {
let fill = bid * (1.0 - slippage_frac);
let reason = if time_exit { ExitReason::TimeExit } else { ExitReason::Signal };
(fill, reason)
} else if i == n - 1 {
// End of data — force close at bid
let fill = bid * (1.0 - slippage_frac);
(fill, ExitReason::EndOfData)
} else {
continue;
};
let exit_fees = exit_price * fee_frac;
let gross_pnl = (exit_price - entry_price) * 1.0; // qty=1; caller scales by lot_size
let net_pnl = gross_pnl - entry_fees - exit_fees;
let return_pct = net_pnl / entry_price * 100.0;
trades.push(Trade {
id: trade_id,
symbol: symbol.to_string(),
entry_idx,
exit_idx: i,
entry_price,
exit_price,
size: 1.0,
direction: crate::core::types::Direction::Long,
pnl: net_pnl,
return_pct,
entry_time,
exit_time: ts,
fees: entry_fees + exit_fees,
exit_reason: reason,
});
trade_id += 1;
in_position = false;
cooldown_until = i + self.config.entry_cooldown_ticks;
if trades.len() >= self.config.max_trades {
break;
}
} else {
// Not in position — check for entry
if i < cooldown_until {
continue;
}
if !entries[i] {
continue;
}
if ask <= 0.0 {
continue;
}
entry_price = ask * (1.0 + slippage_frac);
entry_fees = entry_price * fee_frac;
entry_idx = i;
entry_time = ts;
stop_level = entry_price * (1.0 - stop_frac);
target_level = entry_price * (1.0 + target_frac);
in_position = true;
}
}
Self::build_result(trades, self.config.base.initial_capital, symbol)
}
fn build_result(trades: Vec<Trade>, initial_capital: f64, _symbol: &str) -> BacktestResult {
if trades.is_empty() {
let metrics = BacktestMetrics {
start_value: initial_capital,
end_value: initial_capital,
..Default::default()
};
return BacktestResult::new(metrics, vec![initial_capital], vec![0.0], vec![], vec![]);
}
// Build per-trade equity and return curves (one point per trade close).
let mut equity = initial_capital;
let mut equity_curve = vec![initial_capital];
let mut returns = Vec::with_capacity(trades.len());
for t in &trades {
let prev = *equity_curve.last().unwrap();
equity += t.pnl;
equity_curve.push(equity);
let ret = if prev > 0.0 { (equity - prev) / prev } else { 0.0 };
returns.push(ret);
}
// Drawdown curve over equity points (percentage, positive = drawdown).
let mut peak = initial_capital;
let drawdown_curve: Vec<f64> = equity_curve
.iter()
.map(|&e| {
if e > peak {
peak = e;
}
if peak > 0.0 { (peak - e) / peak * 100.0 } else { 0.0 }
})
.collect();
let metrics =
compute_backtest_metrics(&equity_curve, &drawdown_curve, &returns, &trades, initial_capital);
BacktestResult::new(metrics, equity_curve, drawdown_curve, trades, returns)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::BacktestConfig;
fn make_ticks(n: usize, base_price: f64, trend: f64) -> TickData {
let ltp: Vec<f64> = (0..n).map(|i| base_price + i as f64 * trend).collect();
let bid: Vec<f64> = ltp.iter().map(|p| p - 0.5).collect();
let ask: Vec<f64> = ltp.iter().map(|p| p + 0.5).collect();
TickData {
timestamps: (0..n as i64).map(|i| i * 1_000_000_000).collect(), // 1s apart
ltp,
bid,
ask,
buy_qty_delta: vec![100.0; n],
sell_qty_delta: vec![80.0; n],
oi: vec![0.0; n],
}
}
#[test]
fn test_target_hit() {
// 100 ticks trending up — entry at tick 0, target should be hit
let ticks = make_ticks(100, 100.0, 0.5); // price goes 100 → 149.5
let mut entries = vec![false; 100];
entries[0] = true;
let exits = vec![false; 100];
let config = TickBacktestConfig {
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
stop_loss_pct: 5.0,
take_profit_pct: 10.0,
max_hold_seconds: 0, // no time limit
entry_cooldown_ticks: 5,
max_trades: 10,
};
let bt = TickBacktest::new(config);
let result = bt.run(&ticks, &entries, &exits, "TEST");
assert_eq!(result.trades.len(), 1);
assert_eq!(result.trades[0].exit_reason, ExitReason::TakeProfit);
assert!(result.trades[0].pnl > 0.0);
}
#[test]
fn test_stop_hit() {
// 100 ticks trending down — entry at tick 0, stop should be hit
let ticks = make_ticks(100, 100.0, -0.5); // price goes 100 → 50.5
let mut entries = vec![false; 100];
entries[0] = true;
let exits = vec![false; 100];
let config = TickBacktestConfig {
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
stop_loss_pct: 5.0,
take_profit_pct: 20.0,
max_hold_seconds: 0,
entry_cooldown_ticks: 5,
max_trades: 10,
};
let bt = TickBacktest::new(config);
let result = bt.run(&ticks, &entries, &exits, "TEST");
assert_eq!(result.trades.len(), 1);
assert_eq!(result.trades[0].exit_reason, ExitReason::StopLoss);
assert!(result.trades[0].pnl < 0.0);
}
#[test]
fn test_time_exit() {
// Flat price — neither stop nor target hit, time exit should fire
let ticks = make_ticks(200, 100.0, 0.0);
let mut entries = vec![false; 200];
entries[0] = true;
let exits = vec![false; 200];
let config = TickBacktestConfig {
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
stop_loss_pct: 50.0, // very wide, won't hit
take_profit_pct: 50.0,
max_hold_seconds: 10, // 10 ticks at 1s each
entry_cooldown_ticks: 5,
max_trades: 10,
};
let bt = TickBacktest::new(config);
let result = bt.run(&ticks, &entries, &exits, "TEST");
assert_eq!(result.trades.len(), 1);
assert_eq!(result.trades[0].exit_reason, ExitReason::TimeExit);
}
#[test]
fn test_multiple_trades_with_cooldown() {
let ticks = make_ticks(200, 100.0, 0.2);
// Entry every 20 ticks
let entries: Vec<bool> = (0..200).map(|i| i % 20 == 0).collect();
let exits = vec![false; 200];
let config = TickBacktestConfig {
base: BacktestConfig { initial_capital: 10_000.0, fees: 0.0, slippage: 0.0, ..Default::default() },
stop_loss_pct: 5.0,
take_profit_pct: 10.0,
max_hold_seconds: 0,
entry_cooldown_ticks: 5,
max_trades: 20,
};
let bt = TickBacktest::new(config);
let result = bt.run(&ticks, &entries, &exits, "TEST");
assert!(result.trades.len() > 1);
assert!(result.metrics.total_trades > 1);
}
#[test]
fn test_empty_ticks_returns_empty_result() {
let ticks = TickData {
timestamps: vec![],
ltp: vec![],
bid: vec![],
ask: vec![],
buy_qty_delta: vec![],
sell_qty_delta: vec![],
oi: vec![],
};
let config = TickBacktestConfig::default();
let bt = TickBacktest::new(config);
let result = bt.run(&ticks, &[], &[], "TEST");
assert_eq!(result.trades.len(), 0);
assert_eq!(result.metrics.total_trades, 0);
}
}
+64
View File
@@ -0,0 +1,64 @@
"""
策略注册表 自动发现本目录下所有策略模块
新增策略只需:
1. 在本目录创建 xxx.py, 定义一个继承 strategies.base.Strategy 的类
2. 该模块顶层需有 `STRATEGY_CLASS = XxxStrategy` (大写常量名固定)
3. 重启后自动出现在 `python main.py list`
无需手动修改本文件
"""
from __future__ import annotations
import importlib
import pkgutil
from typing import Dict, Type
from .base import Strategy, SignalResult
__all__ = ["Strategy", "SignalResult", "get_strategies", "get_strategy"]
# 策略模块约定: 每个模块需暴露 STRATEGY_CLASS 常量
_STRATEGY_ATTR = "STRATEGY_CLASS"
def _discover() -> Dict[str, Type[Strategy]]:
"""扫描本包下所有模块,收集 STRATEGY_CLASS 常量"""
registry: Dict[str, Type[Strategy]] = {}
for _finder, mod_name, _is_pkg in pkgutil.iter_modules(__path__):
if mod_name.startswith("_") or mod_name == "base":
continue
try:
mod = importlib.import_module(f"{__name__}.{mod_name}")
except Exception as e:
print(f" ⚠️ 加载策略模块 {mod_name} 失败: {e}")
continue
cls = getattr(mod, _STRATEGY_ATTR, None)
if cls is None:
continue
if not isinstance(cls, type) or not issubclass(cls, Strategy):
print(f" ⚠️ {mod_name}.STRATEGY_CLASS 不是 Strategy 子类, 跳过")
continue
registry[cls.name] = cls
return registry
_CACHE: Dict[str, Type[Strategy]] | None = None
def get_strategies() -> Dict[str, Type[Strategy]]:
"""返回 {策略名: 策略类} 字典,首次调用时懒加载"""
global _CACHE
if _CACHE is None:
_CACHE = _discover()
return _CACHE
def get_strategy(name: str) -> Strategy:
"""按名称实例化策略"""
strategies = get_strategies()
if name not in strategies:
available = ", ".join(sorted(strategies.keys())) or "(无可用策略)"
raise KeyError(f"未知策略 '{name}'。可用: {available}")
return strategies[name]()
+67
View File
@@ -0,0 +1,67 @@
"""SMA 交叉 + ATR 动态止损 + 风险回报比止盈"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class AtrStopRrStrategy(Strategy):
"""入场用 SMA 交叉, 出场依赖 ATR 止损 + 风险回报比止盈"""
name = "atr_stop_rr"
def __init__(
self,
fast: int = 10,
slow: int = 20,
atr_multiplier: float = 2.0,
atr_period: int = 14,
rr_ratio: float = 2.0,
):
self.fast = fast
self.slow = slow
self.atr_multiplier = atr_multiplier
self.atr_period = atr_period
self.rr_ratio = rr_ratio
def warmup_bars(self) -> int:
return self.slow + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
sma_fast = raptorbt.sma(close, period=self.fast)
sma_slow = raptorbt.sma(close, period=self.slow)
entries = self.cross_above(sma_fast, sma_slow).astype(bool)
exits = self.cross_below(sma_fast, sma_slow).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries,
exits=exits,
direction=1,
extra={"sma_fast": sma_fast, "sma_slow": sma_slow},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0,
fees=0.001,
slippage=0.0005,
)
config.set_atr_stop(multiplier=self.atr_multiplier, period=self.atr_period)
config.set_risk_reward_target(ratio=self.rr_ratio)
return config
def description(self) -> str:
return (
f"SMA({self.fast}/{self.slow}) 交叉 + "
f"{self.atr_multiplier}×ATR({self.atr_period}) 止损 + "
f"{self.rr_ratio}:1 风险回报止盈"
)
STRATEGY_CLASS = AtrStopRrStrategy
+105
View File
@@ -0,0 +1,105 @@
"""
策略基类 所有策略遵循统一接口协议
实现一个新策略只需继承 Strategy 并实现 generate_signals / build_config / warmup_bars
策略文件放入本目录后会被 strategies/__init__.py 自动发现注册
约定:
- df pandas.DataFrame, : time, open, high, low, close, tick_volume
- entries / exits np.ndarray[bool], 长度 = len(df)
- warmup 期内的信号位必须置 False (防前视偏差)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pandas as pd
import raptorbt
@dataclass
class SignalResult:
"""策略生成的信号集合"""
entries: np.ndarray # bool, 入场信号
exits: np.ndarray # bool, 出场信号
direction: int = 1 # 1=做多, -1=做空
extra: dict = field(default_factory=dict) # 策略附加信息(如中间指标值)
class Strategy:
"""
策略抽象基类
子类必须设置类属性 `name` 并实现以下方法:
- generate_signals(df) -> SignalResult
- build_config() -> raptorbt.PyBacktestConfig
- warmup_bars() -> int
可选覆盖:
- description() -> str
"""
name: str = "base"
# ── 必须实现 ──
def generate_signals(self, df: pd.DataFrame) -> SignalResult:
raise NotImplementedError
def build_config(self) -> "raptorbt.PyBacktestConfig":
raise NotImplementedError
def warmup_bars(self) -> int:
"""返回指标预热所需的最小 K 线数"""
return 0
# ── 可选覆盖 ──
def description(self) -> str:
return self.name
# ── 通用辅助 ──
@staticmethod
def to_arrays(df: pd.DataFrame) -> dict:
"""从 DataFrame 提取 raptorbt 所需的 ndarray, float64/int64"""
return {
"timestamps": df["time"].values.astype("int64"),
"open": df["open"].values.astype(np.float64),
"high": df["high"].values.astype(np.float64),
"low": df["low"].values.astype(np.float64),
"close": df["close"].values.astype(np.float64),
"volume": df.get("tick_volume", df.get("volume", pd.Series(np.ones(len(df))))).values.astype(np.float64),
}
@staticmethod
def cross_above(fast: np.ndarray, slow: np.ndarray) -> np.ndarray:
"""fast 向上穿越 slow 的信号 (bar i 收盘后判定, 无前视)"""
if len(fast) == 0:
return np.array([], dtype=bool)
# 前一根 fast <= slow, 当前根 fast > slow
prev_le = np.empty_like(fast, dtype=bool)
prev_le[0] = False # 第一根无前值, 不触发交叉
prev_le[1:] = fast[:-1] <= slow[:-1]
return (fast > slow) & prev_le
@staticmethod
def cross_below(fast: np.ndarray, slow: np.ndarray) -> np.ndarray:
"""fast 向下穿越 slow 的信号 (bar i 收盘后判定, 无前视)"""
if len(fast) == 0:
return np.array([], dtype=bool)
prev_ge = np.empty_like(fast, dtype=bool)
prev_ge[0] = False
prev_ge[1:] = fast[:-1] >= slow[:-1]
return (fast < slow) & prev_ge
def apply_warmup(self, entries: np.ndarray, exits: np.ndarray) -> tuple:
"""将 warmup 期内的信号置 False"""
w = self.warmup_bars()
if w > 0:
entries = entries.copy()
exits = exits.copy()
entries[:w] = False
exits[:w] = False
return entries, exits
+52
View File
@@ -0,0 +1,52 @@
"""RSI 均值回归策略 — 超卖买入、超买卖出"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class RsiMeanReversionStrategy(Strategy):
"""RSI < oversold 买入, RSI > overbought 卖出"""
name = "rsi_mean_reversion"
def __init__(self, period: int = 14, oversold: float = 30.0, overbought: float = 70.0):
self.period = period
self.oversold = oversold
self.overbought = overbought
def warmup_bars(self) -> int:
return self.period + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
rsi = raptorbt.rsi(close, period=self.period)
entries = (rsi < self.oversold).astype(bool)
exits = (rsi > self.overbought).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries,
exits=exits,
direction=1,
extra={"rsi": rsi},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0,
fees=0.001,
slippage=0.0005,
)
config.set_trailing_stop(0.03)
return config
def description(self) -> str:
return f"RSI({self.period}) 均值回归, 买入<{self.oversold}/卖出>{self.overbought}, 3% 追踪止损"
STRATEGY_CLASS = RsiMeanReversionStrategy
+92
View File
@@ -0,0 +1,92 @@
"""SAR + ADX + CCI 组合策略 — 趋势过滤 + 动量确认"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class SarAdxCciStrategy(Strategy):
"""
Parabolic SAR + ADX + CCI 三重过滤:
- ADX > threshold: 趋势足够强
- SAR 在价格另一侧: 方向确认
- CCI 超买/超卖: 动量入场
"""
name = "sar_adx_cci"
def __init__(
self,
adx_period: int = 14,
cci_period: int = 20,
sar_af: float = 0.02,
sar_max: float = 0.2,
adx_threshold: float = 25.0,
cci_threshold: float = 100.0,
):
self.adx_period = adx_period
self.cci_period = cci_period
self.sar_af = sar_af
self.sar_max = sar_max
self.adx_threshold = adx_threshold
self.cci_threshold = cci_threshold
def warmup_bars(self) -> int:
# ADX 需要 2*period, CCI/SAR 也需要预热,取保守值
return 2 * self.adx_period + self.cci_period
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
sar = raptorbt.sar(high, low, acceleration=self.sar_af, maximum=self.sar_max)
adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=self.adx_period)
cci = raptorbt.cci(high, low, close, period=self.cci_period)
sar_below = sar < close
sar_above = sar > close
adx_strong = adx > self.adx_threshold
bullish_di = plus_di > minus_di
bearish_di = minus_di > plus_di
cci_oversold = cci < -self.cci_threshold
cci_overbought = cci > self.cci_threshold
entries_long = sar_below & adx_strong & bullish_di & cci_oversold
entries_short = sar_above & adx_strong & bearish_di & cci_overbought
entries = (entries_long | entries_short).astype(bool)
exits = (
(sar_below & bearish_di & adx_strong)
| (sar_above & bullish_di & adx_strong)
).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries,
exits=exits,
direction=1,
extra={"sar": sar, "adx": adx, "cci": cci, "plus_di": plus_di, "minus_di": minus_di},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0,
fees=0.001,
slippage=0.0005,
)
config.set_atr_stop(multiplier=2.5, period=14)
config.set_fixed_target(0.04)
return config
def description(self) -> str:
return (
f"SAR({self.sar_af}/{self.sar_max}) + ADX({self.adx_period})>{self.adx_threshold} "
f"+ CCI({self.cci_period}{self.cci_threshold}, 2.5×ATR 止损/4% 止盈"
)
STRATEGY_CLASS = SarAdxCciStrategy
+53
View File
@@ -0,0 +1,53 @@
"""SMA 双均线交叉策略"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class SmaCrossStrategy(Strategy):
"""快慢 SMA 金叉买入、死叉卖出"""
name = "sma_cross"
def __init__(self, fast: int = 10, slow: int = 20):
self.fast = fast
self.slow = slow
def warmup_bars(self) -> int:
return self.slow + 1
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
sma_fast = raptorbt.sma(close, period=self.fast)
sma_slow = raptorbt.sma(close, period=self.slow)
entries = self.cross_above(sma_fast, sma_slow).astype(bool)
exits = self.cross_below(sma_fast, sma_slow).astype(bool)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries,
exits=exits,
direction=1,
extra={"sma_fast": sma_fast, "sma_slow": sma_slow},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0,
fees=0.001,
slippage=0.0005,
)
config.set_fixed_stop(0.02)
config.set_fixed_target(0.04)
return config
def description(self) -> str:
return f"SMA({self.fast})/SMA({self.slow}) 双均线交叉, 2% 止损/4% 止盈"
STRATEGY_CLASS = SmaCrossStrategy
+123
View File
@@ -0,0 +1,123 @@
"""xau_m5_supertrend — XAUUSD M5 Supertrend+ADX+RSI 趋势跟踪策略
前视偏差 (Look-Ahead Bias) 注意事项:
信号生成时只能用当前 bar 及之前的数据, 严禁使用未来 bar
以下模式会引入前视偏差, 必须避免:
- .shift 负参数 # 访问未来 bar
- 负索引访问数组 # 从末尾取, 等价于未来
- df.iloc 切片到未来 # 如 i+N 之后
- 滚动统计后 shift 负值
正确做法:
- cross_above / cross_below (基类已内置前视安全)
- 信号在 bar 收盘后生成, close 成交 (引擎默认 upon_bar_close=True)
- 检测: python -m app.main check xau_m5_supertrend
"""
from __future__ import annotations
import numpy as np
import raptorbt
from .base import Strategy, SignalResult
class XauM5SupertrendStrategy(Strategy):
"""XAUUSD M5 Supertrend+ADX+RSI 趋势跟踪策略"""
name = "xau_m5_supertrend"
def __init__(
self,
st_period: int = 10,
st_multiplier: float = 3.0,
adx_period: int = 14,
adx_threshold: float = 25.0,
rsi_period: int = 14,
rsi_lower: float = 30.0,
rsi_upper: float = 70.0,
atr_period: int = 14,
atr_stop_mult: float = 2.0,
rr_ratio: float = 2.0,
):
self.st_period = st_period
self.st_multiplier = st_multiplier
self.adx_period = adx_period
self.adx_threshold = adx_threshold
self.rsi_period = rsi_period
self.rsi_lower = rsi_lower
self.rsi_upper = rsi_upper
self.atr_period = atr_period
self.atr_stop_mult = atr_stop_mult
self.rr_ratio = rr_ratio
def warmup_bars(self) -> int:
# 三指标中预热最大的是 ADX 和 Supertrend, 都依赖 st_period/adx_period
return max(self.st_period, self.adx_period, self.rsi_period, self.atr_period) + 5
def generate_signals(self, df) -> SignalResult:
close = df["close"].values.astype(np.float64)
high = df["high"].values.astype(np.float64)
low = df["low"].values.astype(np.float64)
# ── 1. 指标计算 (全部用 raptorbt 原生) ──────────────────
# Supertrend: 返回趋势线和方向 (1=涨, -1=跌)
st_line, st_dir = raptorbt.supertrend(
high, low, close,
period=self.st_period, multiplier=self.st_multiplier,
)
# ADX: 趋势强度过滤
adx = raptorbt.adx(high, low, close, period=self.adx_period)
# RSI: 极端区域过滤
rsi = raptorbt.rsi(close, period=self.rsi_period)
# EMA 长期趋势过滤 (避免逆大趋势入场)
ema_long = raptorbt.ema(close, period=50)
# ── 2. 信号生成 (只用当前 bar 数据) ─────────────────────
# 用 cross_above / cross_below 生成突破信号 (基类已内置前视安全)
# 入场: close 上穿 Supertrend 趋势线 (突破确认)
# 出场: close 下穿 Supertrend 趋势线 (趋势结束)
st_line_safe = np.nan_to_num(st_line, nan=close[0])
adx_safe = np.nan_to_num(adx, nan=0.0)
rsi_safe = np.nan_to_num(rsi, nan=50.0)
ema_safe = np.nan_to_num(ema_long, nan=close[0])
cross_up = self.cross_above(close, st_line_safe) # close 上穿 supertrend
cross_dn = self.cross_below(close, st_line_safe) # close 下穿 supertrend
# 多重过滤: 趋势强度 + 非超买 + 顺 EMA 长期趋势
strong = adx_safe > self.adx_threshold
not_overbought = rsi_safe < self.rsi_upper
above_ema = close > ema_safe # 只在 EMA50 上方做多
entries = cross_up & strong & not_overbought & above_ema
exits = cross_dn # 跌破 supertrend 即出场
# 预热期处理 (前 warmup_bars 根 bar 不产生信号)
entries, exits = self.apply_warmup(entries, exits)
return SignalResult(
entries=entries, exits=exits, direction=1,
extra={"supertrend": st_line, "adx": adx, "rsi": rsi, "ema50": ema_long},
)
def build_config(self) -> raptorbt.PyBacktestConfig:
config = raptorbt.PyBacktestConfig(
initial_capital=100000.0, fees=0.001, slippage=0.0005,
)
# ATR 动态止损 + 风险回报比止盈 + 追踪止损 (避免 Supertrend 频繁翻转)
config.set_atr_stop(multiplier=self.atr_stop_mult, period=self.atr_period)
config.set_risk_reward_target(ratio=self.rr_ratio)
config.set_trailing_stop(0.02) # 2% 追踪止损
return config
def description(self) -> str:
return (
f"XAUUSD M5 Supertrend({self.st_period},{self.st_multiplier}) + "
f"ADX({self.adx_period})>{self.adx_threshold} + "
f"RSI({self.rsi_period})<{self.rsi_upper}, "
f"{self.atr_stop_mult}×ATR 止损, {self.rr_ratio}:1 风险回报"
)
STRATEGY_CLASS = XauM5SupertrendStrategy
+484
View File
@@ -0,0 +1,484 @@
//! Integration tests for RaptorBT indicators.
use raptorbt::indicators::ferro_bridge::{
chandelier_exit, choppiness_index, detect_breaks_cusum, donchian, drawdown_series,
ht_dcperiod, ht_dcphase, ht_trendline, hull_ma, ichimoku, pivot_points, regime_adx,
relative_strength, rolling_beta, rolling_variance_break, ratio, spread, vwma, zscore_series,
};
use raptorbt::indicators::momentum::{macd, rsi, stochastic};
use raptorbt::indicators::strength::adx;
use raptorbt::indicators::trend::{ema, sma, supertrend};
use raptorbt::indicators::volatility::{atr, bollinger_bands};
use raptorbt::indicators::volume::vwap;
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
// Create sample OHLCV data with 50 bars
let n = 50;
let mut close: Vec<f64> = vec![100.0];
let mut high: Vec<f64> = vec![101.0];
let mut low: Vec<f64> = vec![99.0];
let mut open: Vec<f64> = vec![100.0];
let volume: Vec<f64> = vec![1000.0; n];
// Generate trending data
for i in 1..n {
let prev_close = close[i - 1];
let change = ((i as f64 * 0.2).sin() * 2.0) + 0.5; // Slight uptrend with oscillation
let new_close = prev_close + change;
close.push(new_close);
open.push(prev_close);
high.push(new_close.max(prev_close) + 0.5);
low.push(new_close.min(prev_close) - 0.5);
}
(open, high, low, close, volume)
}
#[test]
fn test_sma_correctness() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let result = sma(&data, 3).unwrap();
// First 2 values should be NaN
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// SMA(3) for [1,2,3] = 2.0
assert!((result[2] - 2.0).abs() < 1e-10);
// SMA(3) for [2,3,4] = 3.0
assert!((result[3] - 3.0).abs() < 1e-10);
// SMA(3) for [8,9,10] = 9.0
assert!((result[9] - 9.0).abs() < 1e-10);
}
#[test]
fn test_ema_correctness() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
let result = ema(&data, 3).unwrap();
// First 2 values should be NaN
assert!(result[0].is_nan());
assert!(result[1].is_nan());
// EMA should be valid from index 2
assert!(!result[2].is_nan());
assert!(!result[9].is_nan());
// EMA should be between min and max
assert!(result[9] >= 1.0 && result[9] <= 10.0);
}
#[test]
fn test_rsi_range() {
let (_, _, _, close, _) = sample_ohlcv();
let result = rsi(&close, 14).unwrap();
// Check RSI is in valid range [0, 100]
for (i, &value) in result.iter().enumerate() {
if !value.is_nan() {
assert!(
value >= 0.0 && value <= 100.0,
"RSI at index {} is out of range: {}",
i,
value
);
}
}
}
#[test]
fn test_macd_structure() {
let (_, _, _, close, _) = sample_ohlcv();
let result = macd(&close, 12, 26, 9).unwrap();
assert_eq!(result.macd_line.len(), close.len());
assert_eq!(result.signal_line.len(), close.len());
assert_eq!(result.histogram.len(), close.len());
// MACD line should be valid from index 25 (slow_period - 1)
assert!(result.macd_line[24].is_nan());
assert!(!result.macd_line[25].is_nan());
}
#[test]
fn test_stochastic_range() {
let (_, high, low, close, _) = sample_ohlcv();
let result = stochastic(&high, &low, &close, 14, 3).unwrap();
// %K and %D should be in [0, 100]
for (i, &k) in result.k.iter().enumerate() {
if !k.is_nan() {
assert!(k >= 0.0 && k <= 100.0, "%K at index {} is out of range: {}", i, k);
}
}
for (i, &d) in result.d.iter().enumerate() {
if !d.is_nan() {
assert!(d >= 0.0 && d <= 100.0, "%D at index {} is out of range: {}", i, d);
}
}
}
#[test]
fn test_atr_positive() {
let (_, high, low, close, _) = sample_ohlcv();
let result = atr(&high, &low, &close, 14).unwrap();
// ATR should always be non-negative
for (i, &value) in result.iter().enumerate() {
if !value.is_nan() {
assert!(value >= 0.0, "ATR at index {} is negative: {}", i, value);
}
}
}
#[test]
fn test_bollinger_bands_ordering() {
let (_, _, _, close, _) = sample_ohlcv();
let result = bollinger_bands(&close, 20, 2.0).unwrap();
// Upper > Middle > Lower
for i in 19..close.len() {
if !result.upper[i].is_nan() {
assert!(
result.upper[i] >= result.middle[i],
"Upper band should be >= middle at index {}",
i
);
assert!(
result.middle[i] >= result.lower[i],
"Middle band should be >= lower at index {}",
i
);
}
}
}
#[test]
fn test_adx_range() {
let (_, high, low, close, _) = sample_ohlcv();
let result = adx(&high, &low, &close, 14).unwrap();
// ADX should be in [0, 100]
for (i, &value) in result.iter().enumerate() {
if !value.is_nan() {
assert!(
value >= 0.0 && value <= 100.0,
"ADX at index {} is out of range: {}",
i,
value
);
}
}
}
#[test]
fn test_vwap_bounds() {
let (_, high, low, close, volume) = sample_ohlcv();
let result = vwap(&high, &low, &close, &volume).unwrap();
// VWAP should be between the overall min low and max high
let min_low = low.iter().cloned().fold(f64::INFINITY, f64::min);
let max_high = high.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
for (i, &value) in result.iter().enumerate() {
if !value.is_nan() {
assert!(
value >= min_low && value <= max_high,
"VWAP at index {} is out of bounds: {} (should be between {} and {})",
i,
value,
min_low,
max_high
);
}
}
}
#[test]
fn test_supertrend_direction() {
let (_, high, low, close, _) = sample_ohlcv();
let result = supertrend(&high, &low, &close, 10, 3.0).unwrap();
// Direction should be either 1 or -1
for (i, &dir) in result.direction.iter().enumerate() {
if dir != 0 {
assert!(
dir == 1 || dir == -1,
"Supertrend direction at index {} is invalid: {}",
i,
dir
);
}
}
}
#[test]
fn test_invalid_period() {
let data = vec![1.0, 2.0, 3.0];
// Period of 0 should error
assert!(sma(&data, 0).is_err());
assert!(ema(&data, 0).is_err());
assert!(rsi(&data, 0).is_err());
}
#[test]
fn test_empty_data() {
let empty: Vec<f64> = vec![];
let result = sma(&empty, 10).unwrap();
assert!(result.is_empty());
let result = ema(&empty, 10).unwrap();
assert!(result.is_empty());
}
// =========================================================================
// P0 batch — Extended / Cycle / Regime / Portfolio
// =========================================================================
#[test]
fn test_vwma_basic() {
let (open, high, low, close, volume) = sample_ohlcv();
let _ = open;
let _ = high;
let _ = low;
let r = vwma(&close, &volume, 5).unwrap();
assert_eq!(r.len(), close.len());
assert!(r[0].is_nan());
assert!(r[3].is_nan());
assert!(!r[4].is_nan());
}
#[test]
fn test_vwma_invalid_period() {
let (_, _, _, close, volume) = sample_ohlcv();
assert!(vwma(&close, &volume, 0).is_err());
}
#[test]
fn test_donchian_basic() {
let (_, high, low, _, _) = sample_ohlcv();
let r = donchian(&high, &low, 10).unwrap();
assert_eq!(r.upper.len(), high.len());
assert_eq!(r.middle.len(), high.len());
assert_eq!(r.lower.len(), high.len());
for i in 9..high.len() {
assert!(r.upper[i] >= r.middle[i]);
assert!(r.middle[i] >= r.lower[i]);
}
}
#[test]
fn test_donchian_invalid_period() {
let (_, high, low, _, _) = sample_ohlcv();
assert!(donchian(&high, &low, 0).is_err());
}
#[test]
fn test_choppiness_range() {
let (_, high, low, close, _) = sample_ohlcv();
let r = choppiness_index(&high, &low, &close, 14).unwrap();
for (i, &v) in r.iter().enumerate() {
if !v.is_nan() {
assert!(v >= 0.0 && v <= 100.0, "CI at {} out of range: {}", i, v);
}
}
}
#[test]
fn test_hull_ma_basic() {
let (_, _, _, close, _) = sample_ohlcv();
let r = hull_ma(&close, 20).unwrap();
assert_eq!(r.len(), close.len());
let valid = r.iter().filter(|v| !v.is_nan()).count();
assert!(valid > 0);
}
#[test]
fn test_chandelier_exit_ordering() {
let (_, high, low, close, _) = sample_ohlcv();
let r = chandelier_exit(&high, &low, &close, 10, 2.0).unwrap();
assert_eq!(r.long_exit.len(), high.len());
assert_eq!(r.short_exit.len(), high.len());
}
#[test]
fn test_chandelier_invalid() {
let (_, high, low, close, _) = sample_ohlcv();
assert!(chandelier_exit(&high, &low, &close, 0, 2.0).is_err());
assert!(chandelier_exit(&high, &low, &close, 10, -1.0).is_err());
}
#[test]
fn test_ichimoku_structure() {
let (_, high, low, close, _) = sample_ohlcv();
let r = ichimoku(&high, &low, &close, 9, 26, 52, 26).unwrap();
assert_eq!(r.tenkan.len(), close.len());
assert_eq!(r.kijun.len(), close.len());
assert_eq!(r.senkou_a.len(), close.len());
assert_eq!(r.senkou_b.len(), close.len());
assert_eq!(r.chikou.len(), close.len());
// tenkan period 9 -> first valid at index 8
assert!(r.tenkan[7].is_nan());
assert!(!r.tenkan[8].is_nan());
}
#[test]
fn test_pivot_points_classic() {
let (_, high, low, close, _) = sample_ohlcv();
let r = pivot_points(&high, &low, &close, "classic").unwrap();
assert_eq!(r.pivot.len(), close.len());
assert!(r.pivot[0].is_nan());
assert!(!r.pivot[1].is_nan());
}
#[test]
fn test_pivot_points_unknown_method() {
let (_, high, low, close, _) = sample_ohlcv();
assert!(pivot_points(&high, &low, &close, "bogus").is_err());
}
#[test]
fn test_ht_trendline_min_length() {
let (_, _, _, close, _) = sample_ohlcv();
assert!(ht_trendline(&close).is_ok());
let short = vec![1.0; 10];
assert!(ht_trendline(&short).is_err());
}
#[test]
fn test_ht_dcperiod_dcphase_basic() {
let (_, _, _, close, _) = sample_ohlcv();
let p = ht_dcperiod(&close).unwrap();
let ph = ht_dcphase(&close).unwrap();
assert_eq!(p.len(), close.len());
assert_eq!(ph.len(), close.len());
}
#[test]
fn test_regime_adx_labels() {
// Synthesize ADX series
let adx_input: Vec<f64> = (0..50).map(|i| if i < 25 { 10.0 } else { 30.0 }).collect();
let r = regime_adx(&adx_input, 20.0).unwrap();
// First 25 bars -> range (0), last 25 -> trend (1)
for i in 0..25 {
assert_eq!(r[i], 0, "idx {} expected 0 got {}", i, r[i]);
}
for i in 25..50 {
assert_eq!(r[i], 1, "idx {} expected 1 got {}", i, r[i]);
}
}
#[test]
fn test_detect_breaks_cusum_basic() {
let series: Vec<f64> = (0..100)
.map(|i| if i < 50 { 0.0 } else { 5.0 })
.collect();
let r = detect_breaks_cusum(&series, 10, 5.0, 0.5).unwrap();
assert_eq!(r.len(), series.len());
assert_eq!(r[0], 0);
}
#[test]
fn test_detect_breaks_cusum_invalid_window() {
let v = vec![1.0; 10];
assert!(detect_breaks_cusum(&v, 1, 1.0, 0.5).is_err());
}
#[test]
fn test_rolling_variance_break_basic() {
let series: Vec<f64> = (0..60)
.map(|i| if i < 40 { 0.01 } else { 1.0 })
.collect();
let r = rolling_variance_break(&series, 5, 20, 2.0).unwrap();
assert_eq!(r.len(), series.len());
}
#[test]
fn test_rolling_variance_break_invalid() {
let v = vec![1.0; 30];
assert!(rolling_variance_break(&v, 1, 10, 1.0).is_err());
assert!(rolling_variance_break(&v, 5, 5, 1.0).is_err());
}
#[test]
fn test_rolling_beta_basic() {
let (_, _, _, close, _) = sample_ohlcv();
let bench: Vec<f64> = close.iter().map(|x| x * 0.5 + 1.0).collect();
let r = rolling_beta(&close, &bench, 14).unwrap();
assert_eq!(r.len(), close.len());
}
#[test]
fn test_rolling_beta_invalid_window() {
let v = vec![1.0; 10];
assert!(rolling_beta(&v, &v, 1).is_err());
}
#[test]
fn test_drawdown_series_basic() {
let equity = vec![100.0, 110.0, 105.0, 120.0, 90.0, 95.0, 130.0];
let r = drawdown_series(&equity).unwrap();
assert_eq!(r.series.len(), equity.len());
assert!(r.max_drawdown <= 0.0);
// Max drawdown: 90 / 120 - 1 = -0.25
assert!((r.max_drawdown - (-0.25)).abs() < 1e-9);
// Per-bar dd is non-positive at all valid points
for &v in &r.series {
assert!(v <= 0.0);
}
}
#[test]
fn test_zscore_series_basic() {
let x: Vec<f64> = (0..30).map(|i| i as f64).collect();
let r = zscore_series(&x, 10).unwrap();
assert_eq!(r.len(), x.len());
}
#[test]
fn test_zscore_invalid_window() {
let v = vec![1.0; 10];
assert!(zscore_series(&v, 1).is_err());
}
#[test]
fn test_relative_strength_basic() {
let a: Vec<f64> = (0..50).map(|i| (i as f64) * 0.01).collect();
let b: Vec<f64> = (0..50).map(|i| (i as f64) * 0.005).collect();
let r = relative_strength(&a, &b).unwrap();
assert_eq!(r.len(), a.len());
// RS should be positive: a - beta*b > 0 since a > b
for i in 10..r.len() {
assert!(r[i] > 0.0);
}
}
#[test]
fn test_relative_strength_length_mismatch() {
let a = vec![1.0; 10];
let b = vec![2.0; 5];
assert!(relative_strength(&a, &b).is_err());
}
#[test]
fn test_spread_and_ratio() {
let a = vec![10.0, 20.0, 30.0];
let b = vec![1.0, 2.0, 3.0];
let s = spread(&a, &b, 2.0).unwrap();
let r = ratio(&a, &b).unwrap();
assert_eq!(s.len(), a.len());
assert_eq!(r.len(), a.len());
assert!((s[0] - 8.0).abs() < 1e-9); // 10 - 2*1
assert!((r[0] - 10.0).abs() < 1e-9); // 10/1
}
#[test]
fn test_spread_length_mismatch() {
let a = vec![1.0; 5];
let b = vec![2.0; 3];
assert!(spread(&a, &b, 1.0).is_err());
assert!(ratio(&a, &b).is_err());
}
+309
View File
@@ -0,0 +1,309 @@
//! Integration tests for RaptorBT portfolio engine.
use raptorbt::core::types::{
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig,
};
use raptorbt::portfolio::engine::PortfolioEngine;
fn sample_ohlcv() -> OhlcvData {
// Create trending sample data
let n = 100;
let mut close = vec![100.0];
let mut open = vec![100.0];
let mut high = vec![101.0];
let mut low = vec![99.0];
for i in 1..n {
let trend = (i as f64) * 0.5; // Upward trend
let noise = ((i as f64) * 0.3).sin() * 2.0;
let new_close = 100.0 + trend + noise;
close.push(new_close);
open.push(close[i - 1]);
high.push(new_close + 1.0);
low.push(new_close - 1.0);
}
OhlcvData {
timestamps: (0..n as i64).collect(),
open,
high,
low,
close,
volume: vec![1000.0; n],
}
}
fn simple_signals(n: usize) -> CompiledSignals {
// Entry at bar 10, exit at bar 50
let mut entries = vec![false; n];
let mut exits = vec![false; n];
entries[10] = true;
exits[50] = true;
CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
}
}
#[test]
fn test_basic_backtest() {
let ohlcv = sample_ohlcv();
let signals = simple_signals(ohlcv.len());
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Should have 1 complete trade
assert_eq!(result.trades.len(), 1);
// Equity curve should have same length as data
assert_eq!(result.equity_curve.len(), ohlcv.len());
// In an uptrend, should have positive return
assert!(result.metrics.total_return_pct > 0.0);
}
#[test]
fn test_multiple_trades() {
let ohlcv = sample_ohlcv();
let n = ohlcv.len();
// Multiple trades
let mut entries = vec![false; n];
let mut exits = vec![false; n];
entries[10] = true;
exits[20] = true;
entries[30] = true;
exits[40] = true;
entries[50] = true;
exits[60] = true;
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Should have 3 trades
assert_eq!(result.trades.len(), 3);
}
#[test]
fn test_with_fees() {
let ohlcv = sample_ohlcv();
let signals = simple_signals(ohlcv.len());
let config = BacktestConfig {
fees: 0.01, // 1% fee
..Default::default()
};
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Trade should have fees deducted
assert!(result.trades[0].fees > 0.0);
// Return should be lower due to fees
let config_no_fees = BacktestConfig::default();
let engine_no_fees = PortfolioEngine::new(config_no_fees);
let result_no_fees = engine_no_fees.run_single(&ohlcv, &signals);
assert!(result.metrics.end_value < result_no_fees.metrics.end_value);
}
#[test]
fn test_fixed_stop_loss() {
let ohlcv = sample_ohlcv();
let n = ohlcv.len();
// Entry at bar 10
let mut entries = vec![false; n];
entries[10] = true;
let exits = vec![false; n]; // No exit signal
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let config = BacktestConfig {
stop: StopConfig::Fixed { percent: 0.02 }, // 2% stop
..Default::default()
};
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Should have at least one trade (may exit on stop or end of data)
assert!(!result.trades.is_empty());
}
#[test]
fn test_fixed_take_profit() {
let ohlcv = sample_ohlcv();
let n = ohlcv.len();
// Entry at bar 10
let mut entries = vec![false; n];
entries[10] = true;
let exits = vec![false; n]; // No exit signal
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let config = BacktestConfig {
target: TargetConfig::Fixed { percent: 0.10 }, // 10% target
..Default::default()
};
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Should have at least one trade
assert!(!result.trades.is_empty());
}
#[test]
fn test_no_trades() {
let ohlcv = sample_ohlcv();
let n = ohlcv.len();
// No entry signals
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries: vec![false; n],
exits: vec![false; n],
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Should have no trades
assert_eq!(result.trades.len(), 0);
assert_eq!(result.metrics.total_trades, 0);
// Equity should remain at initial capital
assert!((result.metrics.end_value - result.metrics.start_value).abs() < 1e-10);
}
#[test]
fn test_drawdown_positive() {
let ohlcv = sample_ohlcv();
let signals = simple_signals(ohlcv.len());
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// All drawdown values should be non-negative
for dd in &result.drawdown_curve {
assert!(*dd >= 0.0, "Drawdown should be non-negative");
}
}
#[test]
fn test_short_direction() {
// Create downtrend data
let n = 100;
let mut close = vec![100.0];
for i in 1..n {
close.push(100.0 - (i as f64) * 0.3); // Downward trend
}
let ohlcv = OhlcvData {
timestamps: (0..n as i64).collect(),
open: close.iter().skip(1).chain(std::iter::once(&close[n - 1])).cloned().collect(),
high: close.iter().map(|c| c + 1.0).collect(),
low: close.iter().map(|c| c - 1.0).collect(),
close: close.clone(),
volume: vec![1000.0; n],
};
// Entry at bar 10, exit at bar 50
let mut entries = vec![false; n];
let mut exits = vec![false; n];
entries[10] = true;
exits[50] = true;
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Short, // Short direction
weight: 1.0,
};
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Short in a downtrend should be profitable
assert!(result.trades[0].pnl > 0.0);
}
#[test]
fn test_metrics_consistency() {
let ohlcv = sample_ohlcv();
let n = ohlcv.len();
// Multiple trades for statistics
let mut entries = vec![false; n];
let mut exits = vec![false; n];
for i in (10..90).step_by(20) {
entries[i] = true;
exits[i + 10] = true;
}
let signals = CompiledSignals {
symbol: "TEST".to_string(),
entries,
exits,
position_sizes: None,
direction: Direction::Long,
weight: 1.0,
};
let config = BacktestConfig::default();
let engine = PortfolioEngine::new(config);
let result = engine.run_single(&ohlcv, &signals);
// Total trades should equal winning + losing
assert_eq!(
result.metrics.total_trades,
result.metrics.winning_trades + result.metrics.losing_trades
);
// Win rate should be in [0, 100]
assert!(result.metrics.win_rate_pct >= 0.0);
assert!(result.metrics.win_rate_pct <= 100.0);
// Exposure should be in [0, 100]
assert!(result.metrics.exposure_pct >= 0.0);
assert!(result.metrics.exposure_pct <= 100.0);
}
Generated
+8
View File
@@ -0,0 +1,8 @@
version = 1
revision = 1
requires-python = ">=3.10"
[[package]]
name = "raptorbt"
version = "0.2.0"
source = { editable = "." }
+23
View File
@@ -0,0 +1,23 @@
# vendor/ — 第三方源码依赖
本目录存放项目编译所需的第三方 Rust crate 源码,确保项目在任意机器上无需外部网络即可编译。
## 内容
### ferro-ta-main/
- **来源**: https://github.com/pratikbhadane24/ferro-ta (v1.2.0)
- **用途**: 提供 `ferro_ta_core` crate — 80+ 技术指标的 Rust 原生实现
- **使用方式**: 主项目 [Cargo.toml](../Cargo.toml) 通过 path 依赖引用:
```toml
ferro_ta_core = { path = "vendor/ferro-ta-main/crates/ferro_ta_core", default-features = false }
```
- **我们只用**: `crates/ferro_ta_core/` 子 crate (纯 Rust, 无 PyO3 依赖)
- **未使用部分**: ferro-ta-main 根目录的 Python 包、benchmark、fuzz、docs 等保留但未集成
## 维护规则
1. **必须随项目提交**: 换电脑/部署新环境时,这是编译依赖的唯一来源
2. **不要修改 vendor/ 下的源码**: 如需修改,在主项目代码中通过 wrapper 适配
3. **更新依赖时**: 替换整个 ferro-ta-main/ 目录,并同步更新本 README 的版本号
4. **编译产物自动忽略**: ferro-ta-main/.gitignore 已排除 target/、*.pyd、*.so 等
+17
View File
@@ -0,0 +1,17 @@
# Local development build configuration for ferro-ta.
#
# Enables target-cpu=native so the compiler can emit instructions for the
# host machine (AVX2, NEON, etc.). This primarily benefits release builds
# where LTO and codegen-units=1 are active (see Cargo.toml [profile.release]).
#
# Cargo config.toml does not support per-profile rustflags, so this applies
# to both debug and release profiles. The impact on debug builds is negligible.
#
# WASM targets are excluded so wasm-pack / wasm32-unknown-unknown builds
# are unaffected.
#
# CI may override RUSTFLAGS or use a separate .cargo/config.toml to produce
# portable binaries for distribution.
[target.'cfg(not(target_arch = "wasm32"))']
rustflags = ["-C", "target-cpu=native"]
+35
View File
@@ -0,0 +1,35 @@
{
"name": "ferro-ta dev",
"image": "mcr.microsoft.com/devcontainers/rust:1-bookworm",
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.12"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
}
},
"postCreateCommand": "pip install uv && uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml sphinx sphinx-rtd-theme ruff mypy pyright && rustup component add rustfmt clippy",
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"ms-python.python",
"ms-python.mypy-type-checker",
"tamasfe.even-better-toml",
"charliermarsh.ruff"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"editor.formatOnSave": true,
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
},
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff"
}
}
}
},
"remoteUser": "vscode"
}
+55
View File
@@ -0,0 +1,55 @@
# Rust build artifacts
/target/
wasm/target/
# Compiled Python extension
*.so
*.pyd
*.dll
# macOS dSYM debug symbols (generated by maturin develop)
*.dSYM/
#
/plans/
# Maturin / wheel build outputs
dist/
*.egg-info/
__pycache__/
*.pyc
*.pyo
# Virtual environments
.venv/
venv/
env/
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Issue tracker
/myissues/
# WASM build output
wasm/pkg/
wasm/pkg-web/
wasm/node/
wasm/web/
benchmark_vs_talib.json
wasm_benchmark.json
.wasm_benchmark.prepush.json
.coverage
.coverage.*
coverage.xml
.hypothesis/
/docs/_build/
# DS Store in all directories
.DS_Store
*.DS_Store
+44
View File
@@ -0,0 +1,44 @@
# Pre-commit hooks for ferro-ta
# Install: pre-commit install --hook-type pre-commit --hook-type pre-push
# Run: pre-commit run --all-files
default_language_version:
python: python3
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
exclude: ^conda/meta\.yaml$
- id: check-added-large-files
args: [--maxkb=1000]
- id: check-merge-conflict
- id: debug-statements
# Optional: uncomment to run mypy on commit (after type errors are fixed)
# - repo: local
# hooks:
# - id: mypy
# name: mypy
# entry: mypy python/ferro_ta --ignore-missing-imports
# language: system
# pass_filenames: false
- repo: local
hooks:
- id: ci-basic-pre-push
name: ci basic pre-push
entry: scripts/pre_push_checks.sh
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
+553
View File
@@ -0,0 +1,553 @@
# Changelog
All notable changes to **ferro-ta** are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and the project uses [Semantic Versioning](https://semver.org/).
---
## [Unreleased]
## [1.2.0] — 2026-06-29
### Added
- **Runtime CPU-feature dispatch** for SIMD hot paths via the `multiversion`
crate (`ferro_ta_core/src/simd.rs`). One binary now selects baseline /
AVX2-FMA / AVX-512 / NEON kernels at load time via CPUID instead of being
pinned at build time, so it runs on any CPU of the target architecture with
no illegal-instruction crashes on older chips. The `simd` feature is on by
default; `--no-default-features` gives a pure-scalar build.
- **Broader wheel coverage**: release builds now also produce Linux `aarch64`
(manylinux) and `musllinux` (x86_64 + aarch64) wheels and a Windows `arm64`
wheel, alongside the existing Linux x86_64, macOS universal2, and Windows
x64 wheels.
- **abi3 wheels** (`cp310-abi3`): a single stable-ABI wheel per platform now
covers CPython 3.10+ (including future 3.14+), replacing the per-version
wheel matrix.
- **Dynamic Time Warping** (`DTW`, `DTW_DISTANCE`, `BATCH_DTW`): Euclidean-cost
DTW with optional Sakoe-Chiba band (`window=` parameter). `DTW()` returns
`(distance, path)` with the optimal warping path as an `(N, 2)` index array;
`DTW_DISTANCE()` is the faster distance-only variant; `BATCH_DTW()` computes
distances from each row of a 2-D matrix to a reference series in parallel
via rayon. Distance convention matches `dtaidistance.dtw.distance()`.
- **Indicator-specific exception types** (`FerroTaError`, `InvalidPeriodError`,
`InsufficientDataError`, `LengthMismatchError`, `NumericConvergenceError`,
`InvalidInputError`): finer-grained errors for catching specific failure
modes. All subclass `ValueError` so existing `except ValueError` code keeps
working (backward compatible).
### Changed
- **SIMD is now enabled by default and runtime-dispatched.** Replaced the
compile-time `wide` crate (which was never actually enabled in published
wheels) with `multiversion`. The `wide` Cargo feature is removed; use the
default `simd` feature instead.
- **Dependency bumps.** Rust: `log` 0.4.32, `serde_json` 1.0.150,
`rayon` 1.12.0. API service (`api/requirements.txt`): `uvicorn>=0.49.0`,
`pydantic>=2.13.4`, `ferro-ta>=1.1.4`. CI actions: `actions/deploy-pages` v5,
`actions/upload-pages-artifact` v5, `softprops/action-gh-release` v3.
- Python coverage threshold raised from 65% to 80% and enforced in CI.
### Fixed
- **aarch64 Linux containers can now install ferro-ta.** Previously no Linux
`aarch64` wheel was published, so arm64 images (e.g. AWS Graviton) fell back
to an sdist build that failed without a Rust toolchain. A manylinux/musllinux
aarch64 wheel is now published.
- Published wheels now actually ship SIMD-accelerated kernels; the prior build
enabled no SIMD feature at all.
### Security
- **SLSA build provenance** attestations are now generated for every PyPI
wheel and sdist via `actions/attest-build-provenance`. Verify with
`gh attestation verify <wheel>`.
- **Sigstore keyless signatures** are now published alongside both
CycloneDX/SPDX SBOMs on every GitHub Release (`.sig` + `.pem` files).
- `ferro_ta_core` crate now declares `#![forbid(unsafe_code)]` to prevent
regression — the pure-logic layer has no unsafe code and never will.
- Dependabot now covers the WASM npm package in addition to pip, cargo,
and GitHub Actions.
- **pip-audit fixes**: bumped dev lockfile deps `idna` 3.18, `pytest` 9.1.1,
and `urllib3` 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, and
PYSEC-2026-141/142.
- **pyo3 advisories triaged**: RUSTSEC-2026-0176 and RUSTSEC-2026-0177 are
ignored in `deny.toml` with rationale — ferro-ta uses neither affected code
path (PyList/PyTuple `nth` iterators; `PyCFunction::new_closure`). The
upstream fix requires pyo3 >=0.29 (a large API migration), tracked for a
follow-up.
## [1.1.3] — 2026-04-02
### Added
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
Exposed in all three layers: Rust core, Python, and WASM.
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
Batch vectorisation supported.
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
numerical delta / gamma / vega. Scalar and batch variants.
- **American options** (`american_option_price`, `early_exercise_premium`):
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
Scalar and batch variants.
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
~14× more efficient than close-to-close and handles overnight gaps.
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
of realised vol across user-specified window lengths — contextualises current
IV against historical norms.
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
over a spot grid (time value included), complementing `strategy_payoff`
(expiry intrinsic).
- **`expected_move`**: log-normal ±1σ expected price range over N days.
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
computing C P (S·e^{qT} K·e^{rT}).
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
`aggregate_greeks_dense`, `strategy_value_grid`.
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
Rust, no PyO3/numpy dependency) enabling WASM reuse.
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
Examples) on all new Python functions.
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
digital options, extended Greeks, American options, and vol estimators
against scipy and analytical reference formulas.
- scipy added to `dev` optional dependencies for reference testing.
### Changed
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
Existing option legs are unaffected.
- `docs/derivatives-analytics.md` rewritten to cover all new features with
runnable examples and an efficiency comparison table for vol estimators.
## [1.1.2] — 2026-04-01
### Changed
- WASM npm package now ships both Node.js (CommonJS) and browser/web worker
(ESM) builds via conditional exports in package.json.
- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and
pre-publish test gate.
- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish.
- Aligned Node.js version to 20 across all CI workflows.
- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature
parity (200+ WASM exports, 22 core modules).
## [1.1.1] — 2026-04-01
### Added
- Full feature parity across Rust core, Python, and WASM targets.
- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100,
WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC,
DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE,
MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants,
TSF, BETA, CORREL, NATR, and 19 math operators/transforms.
- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming
API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/
synthetic, backtest engine (close-only + OHLCV), walk-forward analysis,
Monte Carlo bootstrap, performance metrics, batch operations, portfolio
analytics, and signal utilities.
- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm
publishing.
## [1.0.6] — 2026-03-24
### Added
- Added a repo-managed pre-push gate that mirrors the core local CI and
release checks, including version and changelog validation, Rust formatting
and clippy, Python linting and type checks, tests, docs, and the WASM smoke
suite.
- Added generated API manifest tooling and CI coverage so Python and WASM
export drift is detected before release candidates are pushed.
- Expanded the Rust-backed implementation surface for analysis and data-heavy
workflows, including backtest signal generation, portfolio loops, payoff and
Greeks aggregation, chunked indicator execution, and related helper paths.
- Expanded the WASM package surface with additional indicator exports such as
`WMA`, `ADX`, and `MFI`, along with refreshed Node examples and conformance
coverage against the Python package.
### Changed
- Refreshed benchmark wrappers, perf-contract artifacts, and benchmark
comparison helpers so the checked-in performance evidence stays aligned with
the current feature set.
- Hardened Python CI and local tooling so they run the same typecheck and test
entrypoints, including installing the optional MCP dependency needed by the
MCP server tests.
- Updated local pre-commit integration to match the current Ruff
configuration and refreshed locked dependencies to pick up the audited
PyJWT security fix.
### Fixed
- One-off benchmark output files produced in the repository root are now
ignored so local benchmarking no longer dirties the repo by default.
- Tightened API typing and MCP helper behavior so the stricter lint and
typecheck pipeline passes consistently before release.
## [1.0.4] — 2026-03-24
### Added
- The optional MCP server now exposes the broad public ferro-ta callable
surface, including exact top-level exports, non-top-level public analysis and
tooling functions, and generic stored-instance tools for stateful classes and
returned callables.
- Added a dedicated `TA_LIB_COMPATIBILITY.md` document so the full TA-Lib
coverage matrix remains available without bloating the project homepage.
### Changed
- Reworked the root README into a shorter product-first landing page with a
compatibility summary and docs map, and refreshed MCP documentation to match
the expanded server behavior.
- Updated the MCP implementation to use generated tool registration over the
public API while keeping the legacy lowercase aliases (`sma`, `ema`, `rsi`,
`macd`, `backtest`) available for existing clients.
- Refreshed locked Python dependency resolutions for the latest low-risk direct
updates in this release cycle.
### Fixed
- The repository no longer tracks the stray `.coverage` artifact, and coverage
outputs are now ignored consistently.
- MCP tests now cover generated tool discovery, stored-instance workflows, and
callable-reference execution paths so the broader server surface does not
regress silently.
## [1.0.3] — 2026-03-24
### Added
- Public package metadata helpers: `ferro_ta.__version__`, `ferro_ta.about()`,
and `ferro_ta.methods()` for quick API discovery across the top-level,
indicators, data, and analysis surfaces.
- A standalone derivatives benchmark runner covering selected
Black-Scholes-Merton pricing, implied-volatility recovery, Greeks, and
Black-76 pricing paths with reproducible machine/runtime metadata, per-run
timing samples, variance stats, and Python-tracked allocation snapshots.
- A one-command version bump helper, `scripts/bump_version.py`, plus `make version
VERSION=X.Y.Z` for aligned Cargo, Python, WASM, Conda, and docs release
surfaces.
### Changed
- Tightened the homepage and docs product narrative so the core Rust-backed
Python TA library leads, while adjacent tooling is called out separately.
- Strengthened benchmark evidence and support documentation with clearer
benchmark caveats, support-matrix pages, and more explicit release/version
consistency guidance.
### Fixed
- Python CI now recognizes the top-level metadata API in type stubs, and the
derivatives benchmark smoke test no longer depends on importing the
`benchmarks` package from an installed wheel layout.
- The tag-driven GitHub Release workflow now uses a valid glob trigger and an
explicit semantic-version validation step, so pushing `v1.0.3`-style tags
correctly creates the release that fans out into the publish jobs.
## [1.0.2] — 2026-03-24
### Performance
- Optimized rolling statistical kernels (`CORREL`, `BETA`, `LINEARREG*`, `TSF`)
with incremental window math and matching warmup semantics.
- Vectorized Python analysis hotspots in options, backtesting, features, and
rank-composition paths, reducing Python-loop overhead on common workflows.
- Added grouped multi-indicator execution for shared-input workloads and
refactored batch execution around explicit series-major workspaces.
### Added
- Reproducible perf-contract artifacts for single-series, batch, streaming,
SIMD, TA-Lib comparison, and WASM benchmark runs.
- Hotspot and TA-Lib regression gates suitable for CI perf smoke coverage.
- Streaming, SIMD, and WASM benchmark scripts plus updated performance docs and
benchmark playbook.
## [1.0.1] — 2026-03-24
### Added
- `crates/ferro_ta_core/README.md` is now shipped with the published Rust crate, and
`ferro_ta_core` metadata now points documentation to docs.rs.
### Fixed
- CI has been modularized into focused workflow files (`ci-rust.yml`,
`ci-python.yml`, `ci-wasm.yml`, `ci-docs.yml`) while keeping the release
publishing jobs in `CI.yml` for PyPI and crates.io trusted-publisher
compatibility.
- The `ci-complete` gate no longer fails successful runs because of an escaped
shell variable, and the release SBOM job now uses a valid `anchore/sbom-action`
version.
- The npm publish workflow now uses GitHub OIDC trusted publishing, installs
the `wasm32-unknown-unknown` target, and no longer depends on an `NPM_TOKEN`
secret.
- The WASM npm package now removes the generated `pkg/.gitignore` during
`prepack`, so the published tarball includes the built `pkg/` artifacts.
## [1.0.0] — 2026-03-23 *(initial stable release)*
### Performance
- **SMA/EMA** (`src/overlap/sma.rs`, `src/overlap/ema.rs`): Replaced per-bar `ta::SimpleMovingAverage` / `ta::ExponentialMovingAverage` state-machine objects with `ferro_ta_core::overlap::sma` (O(n) sliding-window sum) and `ferro_ta_core::overlap::ema` (O(n) recurrence). SMA/EMA now run at **200600 M bars/s** on 1 M input.
- **WMA** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/wma.rs`): Replaced O(n × period) double-loop with an **O(n) incremental algorithm** using running weighted sum `T[i] = T[i-1] + n·close[i] - S[i-1]` and sliding sum `S`. ~10× speedup vs previous implementation for large periods.
- **BBANDS** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/bbands.rs`): Replaced O(n × period) per-window variance with **O(n) sliding `sum` and `sum_sq`** accumulators (`var = sum_sq/n - mean²`). ~10× speedup.
- **MACD** (`crates/ferro_ta_core/src/overlap.rs`, `src/overlap/macd.rs`): Replaced `ta::MovingAverageConvergenceDivergence` per-bar object with a pure-Rust implementation. Fast and slow EMAs now advance **in a single combined loop** to minimise allocation and memory round-trips.
- **MFI** (`src/momentum/mfi.rs`): Removed per-bar `ta::DataItem::builder().build()` allocation. Replaced `ta::MoneyFlowIndex` with `ferro_ta_core::volume::mfi` — a direct O(n) sliding-window implementation on raw high/low/close/volume slices. ~5× speedup.
- **batch_sma / batch_ema** (`src/batch/mod.rs`): Batch functions now delegate to `ferro_ta_core` O(n) implementations instead of constructing per-bar `ta` indicator objects.
### Fixed
- **Rust clippy**: Removed dead code `compute_ema` function from `src/extended/mod.rs`.
- **fuzz/Cargo.toml**: Added `[workspace]` table to prevent cargo workspace detection error (same fix as `wasm/Cargo.toml`).
- **Python lint**: Replaced deprecated `typing.Dict/List/Tuple/Type` with built-in equivalents across 21 Python files (ruff UP035).
- **Type checking (mypy)**: Fixed `_normalize_rust_error` return type to `NoReturn`; fixed type errors in `_utils.py`, `crypto.py`, `chunked.py`, `regime.py`, `features.py`, `dsl.py`, `mcp/__init__.py`.
- **Type checking (pyright)**: Set `reportMissingImports = false` to handle Rust extension and optional deps; fixed `gpu.py` cupy handling with `Any` type annotation.
- **Sphinx docs**: Fixed RST title underline lengths; fixed unexpected indentation in `plugins.rst`; fixed invalid `:doc:` references in `index.rst` and `contributing.rst`.
- **Sphinx autodoc**: Fixed `conf.py` to not override `sys.path` when the wheel is installed; added `suppress_warnings` for autodoc import failures.
- **CI test coverage**: Added `pandas`, `polars`, `hypothesis`, `pyyaml` to CI test dependencies; coverage threshold adjusted from 80% to 65% (up from failing 59%).
- **Exception hierarchy**: All `FerroTAError` subclasses now accept `code` and `suggestion` keyword arguments; validation helpers (`check_timeperiod`, `check_equal_length`, `check_finite`, `check_min_length`) populate error codes and actionable suggestion hints.
### Added
- **Dependabot**: Added `.github/dependabot.yml` for weekly automated dependency updates (Python, Rust, GitHub Actions).
- **Error codes**: Every `FerroTAError` exception now carries a short code (e.g. `FTERR001``FTERR006`) for programmatic handling; see `ferro_ta.exceptions.ERROR_CODES`.
- **Observability / Logging** (`ferro_ta.logging_utils`): New module with `enable_debug()`, `disable_debug()`, `debug_mode()` context manager, `log_call()`, `benchmark()`, and `traced()` decorator. Re-exported from the `ferro_ta` namespace.
- **API discovery** (`ferro_ta.api_info`): New `ferro_ta.indicators(category=None)` function listing all 160+ indicators with metadata; `ferro_ta.info(func)` returning signature, docstring and parameter info. Re-exported from the `ferro_ta` namespace.
- **Developer experience**: Added `Makefile` with `make dev/build/test/lint/fmt/typecheck/docs/bench/audit/clean` targets; added `.devcontainer/devcontainer.json` for zero-friction VS Code/Codespaces onboarding; added `TROUBLESHOOTING.md` for common build issues.
- **Security**: Added `deny.toml` for `cargo-deny` license and advisory checking.
- **Test fixtures**: Added `tests/fixtures/ohlcv_daily.csv` (252-bar synthetic OHLCV dataset); added `tests/test_integration.py` with end-to-end indicator tests on the fixture.
### Changed
- **Python 3.10 minimum:** Dropped support for Python 3.8 and 3.9. `requires-python` is now
`>=3.10` so optional dependencies (e.g. `mcp`) resolve correctly with uv/pip. CI, docs,
PLATFORMS.md, VERSIONING.md, CONTRIBUTING.md, and conda recipe updated accordingly.
### Added — Rust-first migration: streaming, extended indicators, math operators
- **Rust streaming classes** (`src/streaming/mod.rs`): All 9 streaming classes
(`StreamingSMA`, `StreamingEMA`, `StreamingRSI`, `StreamingATR`,
`StreamingBBands`, `StreamingMACD`, `StreamingStoch`, `StreamingVWAP`,
`StreamingSupertrend`) are now PyO3 `#[pyclass]` types compiled into
`_ferro_ta`. Zero Python overhead for bar-by-bar updates in live-trading use.
Python `streaming.py` re-exports the Rust classes from the ``_ferro_ta``
extension; there is no Python fallback (the extension must be built).
- **Rust extended indicators** (`src/extended/mod.rs`): All 10 extended
indicators (VWAP, SUPERTREND, DONCHIAN, ICHIMOKU, PIVOT_POINTS,
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) now
compute entirely in Rust. The SUPERTREND sequential band-adjustment loop,
DONCHIAN/CHANDELIER rolling max/min, and CHOPPINESS_INDEX rolling window are
now O(n) monotonic deque operations in Rust — no Python loops remain.
- **Rust rolling math operators** (`src/math_ops/mod.rs`): `rolling_sum`,
`rolling_max`, `rolling_min`, `rolling_maxindex`, `rolling_minindex` — all
using O(n) prefix-sum or monotonic deque algorithms. Python `SUM`, `MAX`,
`MIN`, `MAXINDEX`, `MININDEX` in `math_ops.py` now delegate to Rust.
- **`docs/rust_first.md`**: New Rust-first architecture policy document.
Defines the Python/Rust boundary, porting rules, forbidden patterns, a
checklist for new indicator PRs, and a status table of all modules.
- **`ferro_ta.raw` expanded**: Added streaming classes (`StreamingSMA`, …),
extended indicator functions (`supertrend`, `donchian`, `vwap`, …), and
rolling math operators (`rolling_sum`, `rolling_max`, …) to `raw.py`.
- **`docs/index.rst`**: Added link to `docs/rust_first.md`.
### Added — Rust batch API, raw submodule, stability docs, and production polish
- **Rust batch API:** Added `src/batch/mod.rs` with `batch_sma`, `batch_ema`,
`batch_rsi` Rust functions that accept 2-D numpy arrays and process all columns
in a single Rust call (one GIL release for all columns). Eliminates the
per-column Python round-trip in the previous implementation.
- **Python batch fast path:** `ferro_ta.batch.batch_sma/ema/rsi` call the Rust
batch functions for 2-D input (no Python fallback; extension required).
The generic `batch_apply` remains for arbitrary indicators that do not have
a Rust batch implementation.
- **`ferro_ta.raw` submodule:** New `python/ferro_ta/raw.py` that re-exports all
compiled Rust functions without pandas/polars wrapping, validation, or `_to_f64`
conversion. Use when you have pre-converted float64 arrays and need minimal
overhead. Includes the new `batch_sma/ema/rsi` Rust functions.
- **`docs/stability.md`:** New API stability policy document: stable vs experimental
tiers, versioning table, deprecation policy (keep deprecated name for ≥1 minor
release with `DeprecationWarning`).
- **`docs/plans/2026-03-08-production-grade.md`:** Implementation plan tracking
all parts of the production-grade plan with status and commit references.
- **`ndarray` dependency:** Added `ndarray = "0.16"` to `Cargo.toml` to support
2-D array operations in the batch Rust module.
- **`docs/index.rst`:** Added link to `docs/stability.md`.
- **CONTRIBUTING.md:** Added uv-based development workflow as the recommended
setup path; pip-based alternative preserved for users who prefer it.
- **RELEASE.md:** Added security audit step (`cargo audit` + `pip-audit`) to
pre-release checklist; added CHANGELOG completeness requirement.
### Added — Performance, uv, CI improvements, and architecture docs
- **`_to_f64` fast path:** 1-D C-contiguous `float64` NumPy arrays are returned
as-is (zero copy/allocation) instead of always calling `np.ascontiguousarray`.
- **polars zero-copy result:** `polars_wrap` now builds `pl.Series` from the
NumPy buffer via `pl.Series(name, np.asarray(result))` instead of the O(n)
`.tolist()` path, improving polars throughput for all indicators.
- **uv project manager support:** Added `[tool.uv]` section to `pyproject.toml`
with `dev-dependencies`; added a `dev` extra to `[project.optional-dependencies]`.
Development workflow: `uv sync --extra dev` then `uv run pytest tests/`.
- **CI — separate optional jobs:** Rust tarpaulin coverage moved to a dedicated
`rust-coverage` job (marked `continue-on-error: true` at job level, not step
level); fuzz job similarly isolated. All required CI steps are in blocking
jobs. The `continue-on-error` flag is no longer scattered across individual
steps, making failures visible in the CI summary.
- **CI — uv in lint/typecheck/audit:** `lint`, `typecheck`, and `audit` jobs
install uv and run tools via `uv run --with <tool>`.
- **Docs — `docs/architecture.md`:** New document describing the two-crate
Rust layout, Python binding flow, module table, packaging details, and where
validation lives.
- **Docs — `docs/performance.md`:** New guide covering the fast path for
contiguous arrays, raw `_ferro_ta` API, pandas/polars overhead, batch
limitations, streaming characteristics, and practical tips.
- **Docs — `docs/index.rst`:** Added links to architecture and performance docs.
### Added — Production-grade hardening (validation, CI, docs)
- **Validation:** All Python indicator wrappers now call `check_timeperiod()` and `check_equal_length()` where applicable and re-raise Rust `ValueError` as `FerroTAValueError`/`FerroTAInputError` via `_normalize_rust_error()`. New helper `check_min_length()` in `ferro_ta.exceptions`.
- **CI:** Coverage gate (pytest `--cov-fail-under=80`), lint job (ruff check + format), pyright in typecheck job, CHANGELOG check for PRs, audit and fuzz no longer use `continue-on-error`.
- **Docs:** `docs/error_handling.rst`, `docs/api/exceptions.rst`, CONTRIBUTING updated for modular Rust layout (`src/pattern/mod.rs` + per-pattern files), Sphinx `release` from `FERRO_TA_VERSION` env.
- **Tests:** `tests/test_validation.py` (invalid timeperiod, mismatched lengths, empty/short arrays, exception inheritance), `tests/test_property_based.py` (Hypothesis), hypothesis optional dependency.
- **Tooling:** Ruff and pre-commit config (`.pre-commit-config.yaml`), mypy `warn_return_any = true`, pyright in CI, RELEASE.md and SECURITY.md updated.
### Added — TA-Lib numerical parity documentation
- Added MAMA, SAR/SAREXT, and all HT_* tests to `tests/test_vs_talib.py` with
documented justification for each remaining "Corr/Shape" difference.
- `issues/Stages1-10.md` created with known-difference table for MAMA, SAR,
SAREXT, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDLINE, HT_TRENDMODE.
### Added — Pure Rust core library
- New Cargo workspace: root `Cargo.toml` declares workspace members `[".","crates/ferro_ta_core"]`.
- `crates/ferro_ta_core` — pure Rust library crate with no PyO3/numpy dependency.
- Core modules: `overlap` (SMA/EMA/WMA/BBANDS), `momentum` (RSI/MOM), `volatility` (ATR/TRANGE), `volume` (OBV), `statistic` (STDDEV), `math` (SUM/MAX/MIN).
- `cargo test -p ferro_ta_core` passes (12 tests).
- CI `rust-core` job: `cargo build -p ferro_ta_core && cargo test -p ferro_ta_core`.
- README and CONTRIBUTING describe the two-layer architecture.
### Added — Batch execution API
- New `ferro_ta.batch` module: `batch_sma`, `batch_ema`, `batch_rsi`, `batch_apply`.
- Accepts 2-D `(n_samples × n_series)` arrays; returns same shape.
- 1-D input falls back to single-series behaviour (backward compatible).
- Exported from `ferro_ta.__init__`; documented in `docs/batch.rst`.
### Added — Documentation CI
- New CI job `docs`: installs Sphinx + ferro_ta, runs `sphinx-build -b html docs docs/_build -W`.
- `docs/batch.rst` and `docs/api/batch.rst` added; linked from `docs/index.rst`.
- Feature list in `docs/index.rst` updated to mention batch API and Rust core.
### Added — Rust coverage
- CI `rust` job installs `cargo-tarpaulin` and collects XML coverage for `ferro_ta_core`.
- Coverage artifact `rust-coverage` uploaded per-run.
- CONTRIBUTING updated with `cargo tarpaulin` instructions.
### Added — Community governance (issues/ directory)
- `issues/Stages1-10.md` — full issue text for stages 110 (linked from ROADMAP.md).
- `issues/Stages11-20.md` — stage overview for stages 1120.
### Added — Release and versioning playbook
- `RELEASE.md` — step-by-step release playbook (version bump → CHANGELOG → tag → PyPI verify).
- CI `version-check` job: fails if `Cargo.toml` and `pyproject.toml` versions diverge.
- `CONTRIBUTING.md` updated with release process, changelog policy, and fuzzing instructions.
### Added — Optional GPU backend (PyTorch)
- `ferro_ta.gpu` module: `sma`, `ema`, `rsi` — GPU-accelerated when PyTorch is available.
- `ferro_ta[gpu]` optional extra in `pyproject.toml`.
- `docs/gpu-backend.md` — design doc with scope, limitations, and benchmark table.
- `benchmarks/bench_gpu.py` — CPU vs GPU benchmark script.
### Added — WASM binding expansion
- WASM `macd()` added to `wasm/src/lib.rs` (7 indicators total).
- CI WASM job builds package and uploads `wasm-pkg` artifact.
- `wasm/README.md` updated with Node.js + browser examples and CI artifact docs.
### Added — Fuzzing and robustness
- `fuzz/` directory with cargo-fuzz targets for SMA and RSI.
- CI `fuzz` job: nightly Rust, 1000 iterations per target, uploads crash artifacts.
- Fuzzing instructions added to `CONTRIBUTING.md`.
### Added — Indicator pipeline / composition API
- `ferro_ta.pipeline` module: `Pipeline` class, `make_pipeline` factory.
- Chain multiple indicators; results returned as a named dictionary.
- Supports multi-output indicators (BBANDS, MACD) via `output_keys`.
### Added — Polars integration
- Transparent `polars.Series` support via `polars_wrap` decorator in `_utils.py`.
- `ferro_ta[polars]` optional extra in `pyproject.toml`.
- Polars Series in → Polars Series out; NumPy path unchanged.
### Added — Configuration and defaults management
- `ferro_ta.config` module: `set_default`, `get_default`, `get_defaults_for`, `reset`, `list_defaults`.
- `Config` context manager for temporary parameter overrides.
- Thread-local storage — safe for concurrent tests.
### Added — Additional WASM indicators
- WASM `mom()` (Momentum) and `stochf()` (Fast Stochastic) added (9 indicators total).
- Tests for both new indicators in `wasm/src/lib.rs`.
- `wasm/README.md` updated with expanded indicator table.
### Added — Jupyter notebook examples
- `examples/quickstart.ipynb` — core API tour (SMA, RSI, MACD, BBANDS, batch, pipeline, pandas).
- `examples/streaming.ipynb` — streaming bar-by-bar API demonstration.
- `examples/backtesting.ipynb` — backtesting harness, pipeline feature engineering, config defaults.
- `examples/README.md` — index of all notebooks with run instructions.
### Added — v1.0 preparation and API stability
- `VERSIONING.md` updated with API stability guarantees and compatibility matrix.
- `ROADMAP.md` updated: stages 1520 marked Done.
- README updated with Pipeline, Polars, and Config API sections.
### Added — Alternative language bindings (WASM)
- New `wasm/` directory: WebAssembly bindings via `wasm-bindgen` / `wasm-pack`.
- Exposes `sma`, `ema`, `bbands`, `rsi`, `atr`, `obv` for Node.js and browsers.
- `wasm/README.md` — build & usage instructions; `wasm/package.json`.
- CI job `wasm` builds and tests the WASM crate with `wasm-pack test --node`.
### Added — Distribution & packaging maturity
- Python 3.13 added to CI test matrix.
- `conda/meta.yaml` — Conda recipe for conda-forge / local channel builds.
- Supported platforms documented in `PLATFORMS.md`.
### Added — Type stubs & typing
- `python/ferro_ta/py.typed` marker added (PEP 561 compliance).
- `pyproject.toml` `[tool.mypy]` section added for IDE / CI use.
- `Typing :: Typed` PyPI classifier present in `pyproject.toml`.
### Added — Error model & validation
- `ferro_ta.exceptions` module: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`.
- Validation helpers: `check_timeperiod`, `check_equal_length`, `check_finite`.
- All three exception classes exported from `ferro_ta` top-level namespace.
### Added — Backtesting utilities
- `ferro_ta.backtest` module: `backtest()` entry point, `BacktestResult` container.
- Built-in strategies: `rsi_strategy` (RSI 30/70) and `sma_crossover_strategy`.
- Clear scope note: "minimal harness for testing strategies."
### Added — CI/CD & quality expansion
- `pytest-cov` coverage reporting added to CI (`tests` job); coverage XML uploaded.
- `CHANGELOG.md` (this file).
- `VERSIONING.md` — semantic versioning policy and release playbook.
### Added — Plugin / extension system
- `ferro_ta.registry` module: `register`, `unregister`, `get`, `run`, `list_indicators`.
- All built-in indicators auto-registered at import time.
- `FerroTARegistryError` raised for unknown indicator names.
---
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.6...HEAD
[1.0.6]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...v1.0.6
[1.0.4]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.3...v1.0.4
[1.0.3]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...v1.0.3
[1.0.2]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v1.0.0
+131
View File
@@ -0,0 +1,131 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainer at **pratikbhadane24@gmail.com**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+492
View File
@@ -0,0 +1,492 @@
# Contributing to ferro-ta
Thank you for your interest in contributing to **ferro-ta**! This guide explains how to add new candlestick patterns and other indicators.
## Prerequisites
- Rust toolchain (stable, ≥ 1.70)
- **Python 3.103.13** (PyO3 supports up to 3.13; for 3.14+ use a separate venv with an older interpreter)
- [maturin](https://www.maturin.rs/) (`pip install maturin`)
- numpy (`pip install numpy`)
- pytest (`pip install pytest`)
## Recommended: set up with uv (fast, reproducible)
[uv](https://docs.astral.sh/uv/) is the recommended development tool for ferro-ta.
It handles virtual environments, dependency locking, and running commands:
```bash
# Install uv (once)
pip install uv # or: curl -Lsf https://astral.sh/uv/install.sh | sh
# Sync dev environment (creates .venv and installs all dev deps)
uv sync --extra dev
# Build the Rust extension and install in the current env
uv run maturin build --release --out dist
pip install dist/*.whl
# Run tests
uv run pytest tests/unit/ tests/integration/
# Run linter
uv run ruff check python/ tests/
# Run type checker
uv run mypy python/ferro_ta --ignore-missing-imports
```
## Git hooks and pre-push checks
Install the repo-managed git hooks after syncing your environment:
```bash
make hooks
```
That installs both the existing `pre-commit` hook and a `pre-push` hook that
runs the local CI gate before anything is pushed.
To run the same gate manually:
```bash
make prepush
```
To run only part of it while iterating:
```bash
make prepush CHECKS="version changelog python_lint"
```
The pre-push runner covers the basic required CI categories we can execute
locally: version/changelog checks, Rust fmt/clippy/core checks, Python
lint/typecheck/tests, docs, and WASM. It intentionally skips the multi-version
matrix, audit, and benchmark-regression jobs.
## Alternative: set up with plain pip
```bash
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install maturin numpy pytest
maturin develop --release # Build and install in editable mode
```
---
## Adding a New Candlestick Pattern
All candlestick patterns live in **`src/pattern/`** (Rust: `mod.rs` plus one `.rs` file per pattern) and **`python/ferro_ta/indicators/pattern.py`** (Python wrapper).
### Step 1 — Implement the Rust function
Add a new file `src/pattern/cdl_mypattern.rs` with your `#[pyfunction]`, or add the function to an existing pattern file. Register it in **`src/pattern/mod.rs`**. Open `src/pattern/mod.rs` to see how other patterns are declared and registered (e.g. `mod cdl_doji;` and `self::cdl_doji::cdl_doji` in `register()`). Then implement the logic in your new file (e.g. open `src/pattern/cdl_doji.rs` as a template) and add a new `#[pyfunction]` using the shared helper functions already available at the top of the file:
| Helper | Description |
|---|---|
| `body_size(open, close)` | Absolute body size |
| `upper_shadow(open, high, close)` | Upper shadow length |
| `lower_shadow(open, low, close)` | Lower shadow length |
| `candle_range(high, low)` | Full candle range (high low) |
| `is_bullish(open, close)` | `true` when close ≥ open |
| `is_bearish(open, close)` | `true` when close < open |
**Template for a single-candle pattern** (save as `src/pattern/cdl_mypattern.rs` and add `mod cdl_mypattern;` plus the register call in `src/pattern/mod.rs`):
```rust
#[pyfunction]
pub fn cdl_mypattern<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
if n != highs.len() || n != lows.len() || n != closes.len() {
return Err(PyValueError::new_err("arrays must have the same length"));
}
let mut result = vec![0i32; n];
for i in 0..n {
let body = body_size(opens[i], closes[i]);
let range = candle_range(highs[i], lows[i]);
let lower = lower_shadow(opens[i], lows[i], closes[i]);
let upper = upper_shadow(opens[i], highs[i], closes[i]);
// TODO: replace with your pattern conditions
if range > 0.0 && /* pattern conditions */ {
result[i] = 100; // bullish (use -100 for bearish)
}
}
Ok(result.into_pyarray(py))
}
```
**Template for a multi-candle pattern** (adjust `i in K..n` for K-candle lookback):
```rust
#[pyfunction]
pub fn cdl_mypattern<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<i32>>> {
let opens = open.as_slice()?;
let highs = high.as_slice()?;
let lows = low.as_slice()?;
let closes = close.as_slice()?;
let n = opens.len();
if n != highs.len() || n != lows.len() || n != closes.len() {
return Err(PyValueError::new_err("arrays must have the same length"));
}
let mut result = vec![0i32; n];
for i in 2..n { // 3-candle: use 2..n; 2-candle: use 1..n
let (o1, h1, l1, c1) = (opens[i-2], highs[i-2], lows[i-2], closes[i-2]);
let (o2, h2, l2, c2) = (opens[i-1], highs[i-1], lows[i-1], closes[i-1]);
let (o3, h3, l3, c3) = (opens[i], highs[i], lows[i], closes[i] );
// TODO: add your multi-candle conditions here
if /* conditions */ {
result[i] = 100; // or -100
}
}
Ok(result.into_pyarray(py))
}
```
### Step 2 — Register the function
In **`src/pattern/mod.rs`**, add `mod cdl_mypattern;` at the top with the other pattern modules, and in the `register()` function add `self::cdl_mypattern::cdl_mypattern` to the list of registered functions.
### Step 3 — Add the Python wrapper
Open `python/ferro_ta/indicators/pattern.py` and:
1. Import the Rust function at the top:
```python
from ferro_ta._ferro_ta import cdl_mypattern as _cdl_mypattern
```
2. Add a typed Python wrapper:
```python
def CDL_MYPATTERN(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
) -> np.ndarray:
"""One-line summary.
Parameters
----------
open, high, low, close : array-like
OHLC price arrays.
Returns
-------
numpy.ndarray[int32]
100 (bullish), -100 (bearish), or 0.
"""
return _cdl_mypattern(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close))
```
3. Add `"CDL_MYPATTERN"` to the `__all__` list.
### Step 4 — Export from the top-level package
Open `python/ferro_ta/__init__.py` and add an import (the canonical source is
`ferro_ta.indicators.pattern`; old flat path `ferro_ta.pattern` still works via
backward-compat stub):
```python
from ferro_ta.indicators.pattern import ( # noqa: F401
# ... existing imports ...
CDL_MYPATTERN,
)
```
Also add `"CDL_MYPATTERN"` to `__all__`.
### Step 5 — Write a test
Add a test class to `tests/unit/test_ferro_ta.py`:
```python
class TestCDLMyPattern:
def test_output_values(self):
result = CDL_MYPATTERN(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)
assert len(result) == len(OHLCV_PRICES)
assert all(v in (-100, 0, 100) for v in result)
def test_detects_pattern(self):
"""Craft minimal OHLC data that must match the pattern."""
o = np.array([...])
h = np.array([...])
l = np.array([...])
c = np.array([...])
result = CDL_MYPATTERN(o, h, l, c)
assert result[-1] in (100, -100)
```
### Step 6 — Build and verify
```bash
maturin develop --release
pytest tests/unit/test_ferro_ta.py -v -k mypattern
```
---
## Adding Other Indicators
- **Overlap Studies** (MAs, bands): `src/overlap/` (e.g. `mod.rs`, `sma.rs`) + `python/ferro_ta/indicators/overlap.py`
- **Momentum Indicators**: `src/momentum/` + `python/ferro_ta/indicators/momentum.py`
- **Cycle Indicators**: `src/cycle/` + `python/ferro_ta/indicators/cycle.py`
- **Volatility / Volume / Statistics**: corresponding `src/*/` directories + `python/ferro_ta/indicators/*.py` files
Each module has a `pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()>` at the bottom — add your `wrap_pyfunction!` call there.
---
## Code Style
- Rust: follow `rustfmt` defaults (`cargo fmt`).
- Python: follow PEP 8; use Ruff for lint and format (`ruff check`, `ruff format`).
- Every public Rust function needs a docstring above the `#[pyfunction]` attribute.
- Every Python wrapper must have a NumPy-style docstring with `Parameters` and `Returns` sections.
## Validation and tests for new indicators
All new indicators **must**:
- Use the validation helpers in `ferro_ta.exceptions`: call `check_timeperiod()` for every period parameter and `check_equal_length()` for multi-array inputs (OHLCV) before calling the Rust extension. Wrap the Rust call in `try/except ValueError` and re-raise with `_normalize_rust_error(e)`.
- Have tests in `tests/unit/` (including at least one test for invalid parameters or edge cases where applicable).
- Update docstrings and type stubs (`python/ferro_ta/__init__.pyi`) when adding or changing the public API.
## Running the Full Test Suite
```bash
pytest tests/unit/ tests/integration/ -v
```
CI runs on Python 3.103.13 across Linux, macOS, and Windows. Please make sure your change passes on all targets locally before opening a pull request.
## Pull Request Checklist
- [ ] Rust code compiles without warnings (`cargo build --release`)
- [ ] All existing tests still pass
- [ ] New test(s) cover the added function(s)
- [ ] Python wrapper and `__all__` updated
- [ ] `__init__.py` re-exports updated
- [ ] Docstrings present in both Rust and Python
- [ ] No vulnerable dependencies introduced (CI runs `cargo audit` and `pip-audit`; critical/high should be addressed)
---
## Architecture: Two-Layer Rust/Python Design
ferro-ta uses a **workspace** with two Rust crates:
| Crate | Path | Purpose |
|-------|------|---------|
| `ferro_ta` | `.` (root) | PyO3 `#[pyfunction]` wrappers — converts numpy ↔ `&[f64]`; builds the Python `.whl` |
| `ferro_ta_core` | `crates/ferro_ta_core/` | Pure Rust indicators — no PyO3 / numpy dependency |
When adding a new indicator:
1. Implement the algorithm in `crates/ferro_ta_core/src/<module>.rs` with a unit test.
2. Add a thin `#[pyfunction]` wrapper in `src/<module>.rs` (or the appropriate submodule under `src/<module>/`) that calls into the core.
3. Add the Python wrapper in `python/ferro_ta/indicators/<module>.py` (or the appropriate
sub-package: `python/ferro_ta/data/`, `python/ferro_ta/analysis/`, `python/ferro_ta/tools/`).
```bash
# Build and test only the core (no Python required)
cargo build -p ferro_ta_core
cargo test -p ferro_ta_core
```
### Python sub-package layout
The `python/ferro_ta/` package is organized into sub-packages by concern.
Backward-compat stubs at the old flat paths (e.g. `ferro_ta.momentum`) re-export
from the new locations so existing code continues to work without changes.
```
python/ferro_ta/
├── __init__.py # top-level re-exports and public API
├── core/ # Exceptions, configuration, registry, logging, raw FFI bindings
├── indicators/ # Technical indicators (momentum, overlap, volatility, volume,
│ # statistic, cycle, pattern, price_transform, math_ops, extended)
├── data/ # Streaming, batch, chunked, resampling, aggregation, adapters
├── analysis/ # Portfolio, backtest, regime, cross_asset, attribution,
│ # signals, features, crypto, options
├── tools/ # Visualisation, alerting, DSL, pipeline, workflow,
│ # api_info, GPU support
└── mcp/ # Model Context Protocol server
```
### Test directory layout
```
tests/
├── conftest.py # shared fixtures (inherited by all sub-directories)
├── unit/ # pure unit tests and property-based tests
│ ├── test_ferro_ta.py
│ ├── test_coverage.py
│ ├── test_validation.py
│ ├── test_known_values.py
│ ├── test_property_based.py
│ ├── test_stages_*.py
│ └── test_math_ops_vs_numpy.py
├── integration/ # integration and comparison tests (vs TA-Lib, pandas-ta, ta)
│ ├── test_integration.py
│ ├── test_streaming_accuracy.py
│ ├── test_vs_talib.py
│ ├── test_vs_pandas_ta.py
│ └── test_vs_ta.py
└── benchmarks/ # benchmark tests are in top-level benchmarks/
```
The root crate (`src/`) is organized by TA-Lib category:
| Module | Path | Contents |
|--------|------|----------|
| `overlap` | `src/overlap/` | Overlap studies: SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, SAR, MAMA, SAREXT, MACDEXT, MIDPOINT, MIDPRICE, MA, MAVP |
| `momentum` | `src/momentum/` | Momentum: RSI, MOM, ROC, WILLR, AROON, CCI, MFI, STOCH, ADX, TRIX, etc. |
| `pattern` | `src/pattern/` | Candlestick patterns: CDLDOJI, CDLENGULFING, CDLHAMMER, … |
| `cycle` | `src/cycle/` | Cycle: HT_TRENDLINE, HT_DCPERIOD, HT_PHASOR, HT_SINE, HT_TRENDMODE |
| `volatility` | `src/volatility/` | ATR, NATR, TRANGE |
| `volume` | `src/volume/` | AD, ADOSC, OBV |
| `statistic` | `src/statistic/` | STDDEV, VAR, LINEARREG, BETA, CORREL, … |
| `price_transform` | `src/price_transform/` | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE |
Each module is a directory with `mod.rs` and one or more `.rs` files (e.g. `src/overlap/mod.rs`, `src/overlap/sma.rs`).
**Modular layout:** Pattern recognition is split into `src/pattern/mod.rs` plus one file per pattern (e.g. `src/pattern/cdl_doji.rs`, `src/pattern/cdl_engulfing.rs`). Overlap and momentum use a similar directory layout. To add a new pattern: add a new `src/pattern/cdl_*.rs` file with your `#[pyfunction]` and register it in `src/pattern/mod.rs`.
---
## Batch API
The `ferro_ta.batch` module provides `batch_sma`, `batch_ema`, and `batch_rsi`
(Rust 2-D implementations) plus the generic `batch_apply(data, fn, **kwargs)`.
For a new indicator that does not have a dedicated Rust batch function, use
`batch_apply(data, YOUR_INDICATOR)`.
---
## Running Rust Benchmarks
```bash
# Compile benchmarks only (fast, used in CI)
cargo bench --no-run
# Run benchmarks and get timings
cargo bench
```
Benchmarks are in `benches/indicators.rs` using [Criterion](https://github.com/bheisler/criterion.rs).
---
## Rust Coverage
```bash
# Install cargo-tarpaulin (one-time)
cargo install cargo-tarpaulin
# Collect coverage for the core crate
cargo tarpaulin -p ferro_ta_core --out Html
# Open htmlcov/index.html
```
---
## Type Checking (mypy)
```bash
# Install mypy (one-time)
pip install mypy numpy
# Run type checking
mypy python/ferro_ta --ignore-missing-imports
# No errors should be reported.
```
Type stubs live in `python/ferro_ta/__init__.pyi`. Update them whenever you add a new
public function.
---
## Release Process
See [RELEASE.md](RELEASE.md) for the full step-by-step release playbook and
[PACKAGING.md](PACKAGING.md) for conda-forge submission and feedstock maintenance. (version bump →
changelog → tag → CI builds wheels → publish to PyPI).
See [VERSIONING.md](VERSIONING.md) for the versioning policy (MAJOR/MINOR/PATCH rules,
supported Python version policy, and changelog maintenance requirements).
### Changelog requirement
Every PR that touches `src/`, `python/`, or `wasm/` **must** add an entry to the
`[Unreleased]` section of [CHANGELOG.md](CHANGELOG.md). Use the
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
(`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`).
### Version consistency
`Cargo.toml` and `pyproject.toml` must always carry the same `version` string.
CI enforces this with the `version-check` job — a PR that changes one but not the
other will fail CI.
### Dependency audits
CI runs **cargo audit** (Rust) and **pip-audit** (Python) in the `audit` job. PRs must
not introduce critical or high-severity vulnerabilities. If a dependency cannot be
updated immediately, document the accepted risk in the PR or in SECURITY.md. See
[SECURITY.md](SECURITY.md) for the full policy.
### Fuzzing (robustness)
Fuzz targets live in `fuzz/` (cargo-fuzz). To run fuzzing locally:
```bash
# Install cargo-fuzz (one-time)
cargo install cargo-fuzz
# Run the SMA fuzz target for 60 seconds
cargo fuzz run fuzz_sma -- -max_total_time=60
# Run the RSI fuzz target
cargo fuzz run fuzz_rsi -- -max_total_time=60
```
Any crash found by the fuzzer is saved to `fuzz/artifacts/<target>/`. Open a bug report
with the reproducing input and the panic message.
---
## Getting Help
If you have a question, found a bug, or want to suggest a new indicator:
- **GitHub Discussions** — For questions, ideas, and general discussion, use our [Discussions](https://github.com/pratikbhadane24/ferro-ta/discussions) space:
- **Q&A** — Ask usage or API questions
- **Ideas** — Propose new features or indicators
- **Show & Tell** — Share strategies and projects built with ferro-ta
- **Announcements** — Follow for release notes and important updates
- **GitHub Issues** — For confirmed bugs and actionable feature requests, open an [issue](https://github.com/pratikbhadane24/ferro-ta/issues).
- **Security issues** — See [SECURITY.md](SECURITY.md) for responsible disclosure instructions.
+868
View File
@@ -0,0 +1,868 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[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 = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "criterion"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
dependencies = [
"alloca",
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"itertools",
"num-traits",
"oorandom",
"page_size",
"plotters",
"rayon",
"regex",
"serde",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta"
version = "1.2.0"
dependencies = [
"criterion",
"ferro_ta_core",
"log",
"ndarray",
"numpy",
"pyo3",
"pyo3-log",
"rayon",
"ta",
]
[[package]]
name = "ferro_ta_core"
version = "1.2.0"
dependencies = [
"criterion",
"multiversion",
"serde",
"serde_json",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[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.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "multiversion"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"target-features",
]
[[package]]
name = "ndarray"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "numpy"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29f1dee9aa8d3f6f8e8b9af3803006101bb3653866ef056d530d53ae68587191"
dependencies = [
"libc",
"ndarray",
"num-complex",
"num-integer",
"num-traits",
"pyo3",
"pyo3-build-config",
"rustc-hash",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[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 = "pyo3"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a"
dependencies = [
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-log"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45192e5e4a4d2505587e27806c7b710c231c40c56f3bfc19535d0bb25df52264"
dependencies = [
"arc-swap",
"log",
"pyo3",
]
[[package]]
name = "pyo3-macros"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[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 = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[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 = "ta"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[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 = "web-sys"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+52
View File
@@ -0,0 +1,52 @@
[workspace]
members = [".", "crates/ferro_ta_core"]
exclude = ["fuzz"]
resolver = "2"
[package]
name = "ferro_ta"
version = "1.2.0"
edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT"
readme = "README.md"
repository = "https://github.com/pratikbhadane24/ferro-ta"
homepage = "https://github.com/pratikbhadane24/ferro-ta#readme"
documentation = "https://pratikbhadane24.github.io/ferro-ta/"
keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"]
categories = ["finance", "mathematics"]
publish = false
[lib]
name = "ferro_ta"
crate-type = ["cdylib"]
[dependencies]
# abi3-py310: build a single stable-ABI wheel that runs on CPython 3.10+
# (including future 3.14+), instead of one wheel per minor version.
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py310"] }
ta = "0.5.0"
numpy = "0.25"
# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only).
ndarray = "0.16"
rayon = "1.10"
log = "0.4"
pyo3-log = "0.12"
# default-features = false so the `simd` toggle is forwarded explicitly via
# this crate's own `simd` feature (below). Without this, core's default `simd`
# would always be on and `--no-default-features` could never produce a true
# pure-scalar build (used by the SIMD benchmark baseline).
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.2.0", default-features = false, features = ["serde"] }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
[profile.release]
lto = true
codegen-units = 1
[features]
# SIMD runtime dispatch ON by default → published wheels ship the adaptive
# fast path with no extra flags. `--no-default-features` yields pure scalar.
default = ["simd"]
simd = ["ferro_ta_core/simd"]
+63
View File
@@ -0,0 +1,63 @@
# Governance
## Maintainers
ferro-ta is currently maintained by:
- **@pratikbhadane24** — project creator and lead maintainer
## Decision Making
Decisions about the project are made by the maintainers. For significant
changes (new indicator categories, API changes, roadmap priorities) we welcome
community discussion in GitHub Issues before implementation begins.
For minor bug fixes, documentation improvements, and dependency updates, pull
requests may be merged once CI passes and at least one maintainer approves.
For major features (new roadmap stages), an issue or discussion should
be opened first to agree on scope and approach.
## How to Contribute
See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on setting up a
development environment, coding style, and the pull request process.
## How to Become a Maintainer
Consistent, high-quality contributions over time may lead to an invitation to
become a maintainer. If you are interested, please reach out via a GitHub Issue
or by contacting the project at **pratikbhadane24@gmail.com**.
## Call for Co-Maintainers
ferro-ta is a growing library with 160+ indicators, an active roadmap, and a
community of traders and developers who depend on it. We are actively looking
for **co-maintainers** to help with:
- Reviewing pull requests and triaging issues
- Adding new indicators and extending the Rust core
- Improving documentation and tutorials
- Managing CI, releases, and dependency updates
**If you are interested**, please open a GitHub Discussion in the
[**Announcements → Co-maintainer interest**](https://github.com/pratikbhadane24/ferro-ta/discussions)
category, or reach out at **pratikbhadane24@gmail.com**.
Ideal co-maintainers have:
- Familiarity with Rust and/or Python numerical computing
- Experience with open-source project workflows (PRs, issues, CI)
- Interest in algorithmic trading or quantitative finance
We value contributions at all experience levels — there is no minimum bar
beyond genuine interest and consistent engagement.
## Code of Conduct
All contributors and community members are expected to follow the
[Code of Conduct](CODE_OF_CONDUCT.md).
## Roadmap
The project roadmap is documented in [ROADMAP.md](ROADMAP.md). Stages 120
define the scope of planned work; the current focus is indicated there.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 ferro-ta contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+72
View File
@@ -0,0 +1,72 @@
# ferro-ta development Makefile
# Usage: make <target>
.PHONY: help dev build test lint typecheck fmt docs clean bench version audit prepush hooks
# Default target
help:
@echo "ferro-ta development targets:"
@echo ""
@echo " make dev Install dev dependencies (maturin + test extras)"
@echo " make build Build and install the Rust extension in dev mode"
@echo " make test Run the full Python test suite with coverage"
@echo " make lint Run ruff linter on python/ and tests/"
@echo " make fmt Run rustfmt + ruff formatter"
@echo " make typecheck Run mypy + pyright type checkers"
@echo " make docs Build the Sphinx documentation"
@echo " make bench Run Rust criterion benchmarks (ferro_ta_core)"
@echo " make version Bump tracked version strings (set VERSION=X.Y.Z)"
@echo " make audit Run cargo-audit + pip-audit"
@echo " make prepush Run the local pre-push CI gate (set CHECKS='version rust_fmt' to scope it)"
@echo " make hooks Install pre-commit and pre-push git hooks"
@echo " make clean Remove build artefacts"
dev:
pip install uv
uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \
sphinx sphinx-rtd-theme ruff mypy pyright pre-commit
build:
maturin develop --release
test: build
pytest tests/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
lint:
uv run --with ruff ruff check python/ tests/
uv run --with ruff ruff format --check python/ tests/
fmt:
cargo fmt --all
uv run --with ruff ruff format python/ tests/
typecheck:
uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
uv run --with pyright pyright python/ferro_ta
docs:
pip install sphinx sphinx-rtd-theme
sphinx-build -b html docs docs/_build --keep-going
bench:
cargo bench -p ferro_ta_core
version:
@test -n "$(VERSION)" || (echo "Usage: make version VERSION=X.Y.Z" && exit 1)
python3 scripts/bump_version.py "$(VERSION)"
audit:
cargo audit
uv run --with pip-audit pip-audit
prepush:
bash scripts/pre_push_checks.sh $(CHECKS)
hooks:
uv run --with pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push
clean:
cargo clean
rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
find . -name "*.so" -delete 2>/dev/null || true
+28
View File
@@ -0,0 +1,28 @@
# Packaging and distribution
This document describes how ferro-ta is packaged and published.
## PyPI (pip)
Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)).
Release publishing currently targets CPython 3.10, 3.11, 3.12, and 3.13 on:
- Linux x86_64 (`manylinux_2_17` / `manylinux2014`)
- macOS universal2 (covers Intel and Apple Silicon)
- Windows x86_64
Each release also publishes a source distribution (`sdist`) so compatible
environments outside the wheel matrix can still build from source.
Publishing uses PyPI Trusted Publishing via GitHub OIDC; no long-lived PyPI API
token is required.
Supported platforms and Python versions are documented in [PLATFORMS.md](PLATFORMS.md).
## npm (WASM)
The Node.js / browser WASM package is published to npm by the **wasm-publish** workflow on release.
## crates.io (Rust)
The pure-Rust library `ferro_ta_core` is published to crates.io by the CI job **publish-cratesio** on release.
+140
View File
@@ -0,0 +1,140 @@
# ferro-ta Performance Roadmap
## Goal: 100x Faster Than Tulipy for Every Indicator
This document tracks the path from current performance to the 100x target.
---
## Current State (10k bars, median µs)
| Indicator | ferro_ta | Tulipy | Ratio (tu/ft) | Status |
|-----------|--------:|-------:|:-------------:|--------|
| SMA | 186 | 84 | 0.45x | ❌ Tulipy faster |
| EMA | 90 | 89 | 0.99x | 🔄 Parity |
| RSI | 112 | 91 | 0.81x | 🔄 Near parity |
| MACD | 135 | 99 | 0.73x | 🔄 Near parity |
| BBANDS | 99 | 96 | 0.97x | 🔄 Parity |
| ATR | 113 | 103 | 0.91x | 🔄 Near parity |
| CCI | 147 | 126 | 0.86x | 🔄 Near parity |
| WILLR | 167 | 119 | 0.71x | 🔄 Near parity |
| OBV | 88 | 83 | 0.94x | 🔄 Parity |
| ADX | 165 | 126 | 0.76x | 🔄 Near parity |
| MFI | 111 | 122 | 1.10x | ✅ ferro_ta faster |
| STOCH | 176 | 144 | 0.82x | 🔄 Near parity |
**vs `ta` library** (Python loops): ferro_ta is already **150350x faster** for slow indicators (ATR, CCI, ADX, MFI).
---
## Why ferro_ta Doesn't Beat Tulipy Yet
Both ferro_ta and Tulipy are Rust/C extensions processing 10,000 `f64` values. The bottlenecks are:
1. **FFI overhead dominates at 10k bars** — Python→Rust call overhead is ~50µs fixed cost
2. **Array allocation**: ferro_ta pads NaN values; Tulipy truncates (saves allocation)
3. **SIMD**: Tulipy's C code uses auto-vectorization; ferro_ta Rust needs explicit SIMD
---
## Optimization Plan
### Phase 1: Eliminate FFI Overhead (Target: 2x improvement)
**Problem**: Each Python call into Rust costs ~50µs regardless of array size.
**Solutions**:
- [ ] Batch API: `compute_many([("SMA", close, 20), ("EMA", close, 14)])` — single FFI call
- [ ] Buffer reuse: accept pre-allocated output arrays to avoid allocation round-trips
- [ ] NumPy zero-copy: use `PyReadonlyArray` in pyo3 to avoid copies on input
**Expected gain**: 2x for small arrays (<1k bars), 1.3x for 10k bars.
### Phase 2: SIMD Auto-Vectorization (Target: 3x improvement)
**Problem**: Rust scalar loops vs SIMD C in Tulipy.
**Solutions**:
- [ ] Use `std::simd` (portable SIMD) for rolling sum accumulation (SMA, WMA)
- [ ] Use `packed_simd2` for element-wise operations (ADD, SQRT, LOG10, price transforms)
- [ ] Enable `target-cpu=native` in `.cargo/config.toml` for AVX2/AVX-512
```toml
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
rustflags = ["-C", "target-cpu=native"]
```
**Expected gain**: 3-5x for vectorizable indicators (SMA, WMA, price transforms, math ops).
### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement)
#### SMA — O(n) running sum
Current: recomputes each window.
Target: single-pass running sum (already done in Rust — verify SIMD path is hit).
#### BBANDS — Welford's algorithm
Current: compute mean, then variance in two passes.
Target: Welford's online algorithm — single pass, better cache utilization.
#### ATR/ADX — Avoid redundant True Range calculations
Current: ATR → ADX each compute TR independently.
Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass.
#### MACD — Reuse EMA computations
Current: Compute fast EMA and slow EMA separately.
Target: Single function computes both EMAs in one pass.
#### Candlestick Patterns — Batch lookup table
Current: Sequential condition checks per bar.
Target: Pre-compute body/shadow ratios, vectorized pattern matching.
### Phase 4: Streaming Precomputation (Target: 100x for incremental updates)
For real-time systems that update one bar at a time:
- [ ] `StreamingSMA` already O(1) per update — document and benchmark vs batch
- [ ] `StreamingEMA` α * new + (1-α) * prev — single multiply + add
- [ ] `StreamingBBands` — use Welford's online variance
- [ ] `StreamingRSI` — Wilder's smoothing: single multiply per update
**At 100k bars, streaming 1 bar at a time is O(n) vs O(n) batch, but with near-zero latency per update.**
Benchmark: batch 100k bars vs 100k × streaming 1 bar:
```
ferro_ta batch SMA(100k): ~1.8ms
ferro_ta streaming SMA(100k): ~0.5ms total (5µs per bar × 100k = too slow)
```
Streaming becomes 100x advantage when:
- You only need the latest value (no history needed)
- Input arrives one bar at a time (WebSocket price feed)
---
## Measurement Methodology
All benchmarks use:
- `pytest-benchmark` with `pedantic()` mode
- 5 iterations × 20 rounds × 2 warmup rounds
- Median timing (not mean) to exclude JIT warmup
- C-contiguous `float64` arrays
- 10,000 bars for main benchmarks, 100,000 for scaling tests
Machine: Apple M-series / Intel x86_64 (note: results vary significantly by CPU)
---
## Tracking Progress
Run `pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`
and commit `results.json` to track regression over time.
---
## References
- [Tulipy source](https://github.com/cirla/tulipy) — C with auto-vectorization
- [Rust SIMD Guide](https://doc.rust-lang.org/std/simd/index.html)
- [pyo3 zero-copy arrays](https://pyo3.rs/v0.22.0/numpy)
+76
View File
@@ -0,0 +1,76 @@
# Supported Platforms & Python Versions
## Python versions
| Python | Status |
|--------|--------|
| 3.13 | ✅ Supported (tested in CI) |
| 3.12 | ✅ Supported (tested in CI) |
| 3.11 | ✅ Supported (tested in CI) |
| 3.10 | ✅ Supported (tested in CI) |
| < 3.10 | ❌ Not supported |
We follow the [NEP 29](https://numpy.org/neps/nep-0029-deprecation-policy.html)
deprecation schedule: Python versions that have reached end-of-life are dropped
in the next minor release of ferro-ta.
## Operating systems & architectures
Pre-compiled wheels are published to PyPI for the following targets:
| OS | Architecture | Notes |
|---------|-----------------|-------|
| Linux | x86_64 (manylinux2014 / `manylinux_2_17`) | Pre-compiled wheel |
| macOS | universal2 | One wheel covers Intel + Apple Silicon |
| Windows | x86_64 | |
Wheel releases target CPython 3.10, 3.11, 3.12, and 3.13. A source
distribution is also published so other compatible environments can build from
source.
> **Note:** Python 3.14+ is not yet tested. Set
> `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` to attempt a build on a newer
> interpreter and report any issues.
## Installation
### pip (recommended)
```bash
pip install ferro-ta
```
No C-compiler required on the wheel targets listed above.
### conda / conda-forge
A Conda recipe is available in `conda/meta.yaml`. To build locally, see
[PACKAGING.md](PACKAGING.md). Quick start:
```bash
conda install conda-build
conda build conda/
conda install --use-local ferro_ta
```
Once submitted to conda-forge the package will be installable via:
```bash
conda install -c conda-forge ferro_ta
```
## Source build
If no wheel is available for your platform, pip will attempt a source build:
```bash
# Requires Rust (stable toolchain) and maturin
pip install maturin
pip install ferro-ta --no-binary ferro-ta
```
## Known limitations
- WASM binding: full feature parity with 200+ exports including all TA-Lib indicators, candlestick patterns, streaming API, options, futures, and backtesting (see `wasm/README.md`).
- Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`.
- 32-bit platforms: not officially supported; source builds may succeed.
+139
View File
@@ -0,0 +1,139 @@
<div align="center">
# ⚡ ferro-ta
### Rust-powered Python technical analysis with a TA-Lib-compatible API
**Focused on one primary job: fast, reproducible technical analysis for Python users who want TA-Lib-style ergonomics without native build friction.**
[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb)
[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb)
[![Documentation](https://img.shields.io/badge/docs-github.io-blue)](https://pratikbhadane24.github.io/ferro-ta/)
</div>
---
> `ferro-ta` is a Rust-backed Python technical analysis library for NumPy-first workloads. It keeps TA-Lib-style ergonomics, ships pre-built wheels on supported targets, and publishes reproducible benchmark artifacts instead of blanket speed claims.
## 🚀 What ferro-ta is
| | TA-Lib | ferro-ta |
|---|---|---|
| **Primary product** | C-backed Python TA library | Rust-backed Python TA library |
| **API shape** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` |
| **Installation** | Often requires native/system setup | Pre-built wheels on supported targets |
| **Scope** | Technical indicators | Technical indicators first; other tooling is optional and secondary |
## ⚡ Benchmark evidence
The latest checked-in TA-Lib comparison artifact uses contiguous `float64`
arrays at 10k and 100k bars on an `Apple M3 Max`, `CPython 3.13.5`, and `Rust
1.91.1`.
- `ferro-ta` achieves competitive parity with TA-Lib, winning on 7 of 12 tested indicators at 100k bars (5 of 12 at 10k bars).
- Strong performance wins at 100k bars include `MFI` (`3.25×`), `WMA` (`2.20×`), `BBANDS` (`1.97×`), and `SMA` (`1.93×`) vs TA-Lib.
- TA-Lib maintains performance advantages on `STOCH` and `ADX`; `EMA`, `ATR`, and `OBV` are statistical ties.
- Compared to pure-Python libraries like Tulipy, `ferro-ta` provides 150-350x speedups through Rust-optimized implementations.
See the benchmark methodology and artifacts:
- [benchmarks/README.md](benchmarks/README.md)
- [benchmarks/artifacts/latest/](benchmarks/artifacts/latest/)
- [docs/benchmarks.rst](docs/benchmarks.rst)
## 🎯 Core capabilities
- 160+ indicators with a TA-Lib-style public API.
- Batch and streaming APIs for multi-series and bar-by-bar workloads.
- NumPy-first execution with pandas and polars adapters.
- Pre-built wheels on the supported Python and OS matrix.
- Type stubs, error codes, examples, and reproducible benchmarks.
Adjacent and experimental surfaces such as derivatives analytics, MCP, GPU,
plugins, and WASM remain opt-in and secondary to the core TA library story.
## 📦 Installation
```bash
pip install ferro-ta
```
Optional extras:
```bash
pip install "ferro-ta[pandas]" # pandas.Series support
pip install "ferro-ta[polars]" # polars.Series support
pip install "ferro-ta[gpu]" # PyTorch-backed GPU helpers
pip install "ferro-ta[options]" # derivatives analytics helpers
pip install "ferro-ta[mcp]" # MCP server for agent/tool clients
pip install "ferro-ta[all]" # most optional extras (excluding gpu)
```
## ⚡ Quick start
```python
import numpy as np
from ferro_ta import SMA, EMA, RSI, MACD, BBANDS
close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15,
43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
sma = SMA(close, timeperiod=5)
ema = EMA(close, timeperiod=5)
rsi = RSI(close, timeperiod=14)
macd_line, signal, histogram = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
upper, middle, lower = BBANDS(close, timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
```
## 📊 TA-Lib compatibility
- `ferro-ta` implements 100% of TA-Lib's function set (`162+` indicators).
- Most functions are marked `Exact` or `Close`; the remaining notable non-exact categories are the Hilbert cycle indicators plus `MAMA`, `SAR`, and `SAREXT`.
- The full parity matrix and coverage summary now live in [TA_LIB_COMPATIBILITY.md](TA_LIB_COMPATIBILITY.md).
Migration and compatibility references:
- [docs/migration_talib.rst](docs/migration_talib.rst)
- [docs/compatibility/talib.md](docs/compatibility/talib.md)
- [docs/support_matrix.rst](docs/support_matrix.rst)
## 🗺️ Docs map
Core guides:
- [docs/quickstart.rst](docs/quickstart.rst)
- [docs/migration_talib.rst](docs/migration_talib.rst)
- [docs/support_matrix.rst](docs/support_matrix.rst)
- [PLATFORMS.md](PLATFORMS.md)
Evidence and APIs:
- [benchmarks/README.md](benchmarks/README.md)
- [docs/batch.rst](docs/batch.rst)
- [docs/streaming.rst](docs/streaming.rst)
- [docs/derivatives.rst](docs/derivatives.rst)
Optional and experimental surfaces:
- [docs/mcp.md](docs/mcp.md)
- [docs/adjacent_tooling.rst](docs/adjacent_tooling.rst)
- [docs/plugins.rst](docs/plugins.rst)
- [wasm/README.md](wasm/README.md)
Project and release docs:
- [CONTRIBUTING.md](CONTRIBUTING.md)
- [CHANGELOG.md](CHANGELOG.md)
- [VERSIONING.md](VERSIONING.md)
- [RELEASE.md](RELEASE.md)
## 🛠️ Development
```bash
uv sync --extra dev
uv run pytest tests/unit tests/integration
uv run maturin build --release --out dist
```
More setup details live in [CONTRIBUTING.md](CONTRIBUTING.md).

Some files were not shown because too many files have changed in this diff Show More