初步完成AI-AGENK开发框架
This commit is contained in:
+43
@@ -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/ 目录下的源码是项目编译依赖, 不可忽略
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,993 @@
|
||||
# RaptorBT
|
||||
|
||||
[](https://uppercase.org/licenses/MIT)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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]
|
||||
```
|
||||
|
||||
### PyBacktestMetrics(33 个字段)
|
||||
|
||||
**核心绩效**:`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)
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
@@ -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 策略自动化框架自动生成*
|
||||
"""
|
||||
@@ -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, ...)",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
+1238
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
numpy
|
||||
pandas
|
||||
requests
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 (60–300 s at ~80 ticks/min
|
||||
/// = 80–400 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, <p, 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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
//! Python bindings for RaptorBT.
|
||||
|
||||
pub mod bindings;
|
||||
pub mod numpy_bridge;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
×tamps,
|
||||
&open,
|
||||
&high,
|
||||
&low,
|
||||
&close,
|
||||
&volume,
|
||||
&entries,
|
||||
&exits,
|
||||
1,
|
||||
"TEST",
|
||||
);
|
||||
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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(×tamps, &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(×tamps, &underlying, &legs_premiums, &entries, &exits);
|
||||
|
||||
assert_eq!(result.trades.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
Vendored
+23
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
Vendored
+553
@@ -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 **200–600 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 1–10 (linked from ROADMAP.md).
|
||||
- `issues/Stages11-20.md` — stage overview for stages 11–20.
|
||||
|
||||
### 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 15–20 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
@@ -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
|
||||
Vendored
+492
@@ -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.10–3.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.10–3.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
@@ -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"
|
||||
Vendored
+52
@@ -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"]
|
||||
Vendored
+63
@@ -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 1–20
|
||||
define the scope of planned work; the current focus is indicated there.
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+72
@@ -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
|
||||
Vendored
+28
@@ -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
@@ -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 **150–350x 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)
|
||||
Vendored
+76
@@ -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.
|
||||
Vendored
+139
@@ -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.**
|
||||
|
||||
[](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb)
|
||||
[](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb)
|
||||
[](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
Reference in New Issue
Block a user