commit a10a4df03508d66e0f28e9664df1d9076f712a61 Author: gavindiaz Date: Thu Jul 9 21:23:10 2026 +0800 初步完成AI-AGENK开发框架 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..697c7ea --- /dev/null +++ b/.gitignore @@ -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/ 目录下的源码是项目编译依赖, 不可忽略 \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49a9ff6 --- /dev/null +++ b/AGENTS.md @@ -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 `,禁止 `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 +""" + +⚠️ 前视偏差注意事项: (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 桥接使用 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..36e7b06 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7d7fe6e --- /dev/null +++ b/Cargo.toml @@ -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 "] +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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f9e6c0 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f8b4e8 --- /dev/null +++ b/README.md @@ -0,0 +1,993 @@ +# RaptorBT + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://uppercase.org/licenses/MIT) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![Rust](https://img.shields.io/badge/rust-1.70+-red.svg)](https://www.rust-lang.org/) + +**高性能 Rust 回测引擎 + 策略自动化框架,亚毫秒级回测,80+ 技术指标,位级确定性执行。** + +RaptorBT 是一个用 Rust 编写的高性能回测引擎,通过 PyO3 提供 Python 绑定。支持单标的、篮子、配对、期权、价差、多策略、Tick 级回测,在亚毫秒级时间内返回完整的 33 项绩效指标报告。项目还内置完整的策略自动化框架(参数优化 / Walk-Forward 验证 / 验收检查 / 交付包导出),支持 AI agent 自主开发与交付策略。 + +

+ 亚毫秒级回测 · 编译后 < 1 MB · 80+ 技术指标 · 位级确定性 · 原生并行 · 策略自动化框架 +

+ +--- + +## 快速开始 + +### 安装 + +```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) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..665b487 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,20 @@ +"""RaptorBT 策略应用层 + +包含 CLI 入口、数据加载、指标库、优化器、Walk-Forward 验证、 +验收检查、模板生成、交付导出等应用层模块。 + +入口: + python -m app.main + +模块: + 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" diff --git a/app/acceptance.py b/app/acceptance.py new file mode 100644 index 0000000..b4e174e --- /dev/null +++ b/app/acceptance.py @@ -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 低的常见原因是止损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), + ) diff --git a/app/data_loader.py b/app/data_loader.py new file mode 100644 index 0000000..8879363 --- /dev/null +++ b/app/data_loader.py @@ -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 diff --git a/app/exporter.py b/app/exporter.py new file mode 100644 index 0000000..0e7733e --- /dev/null +++ b/app/exporter.py @@ -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 策略自动化框架自动生成* +""" diff --git a/app/indicator_catalog.py b/app/indicator_catalog.py new file mode 100644 index 0000000..b1ce4e0 --- /dev/null +++ b/app/indicator_catalog.py @@ -0,0 +1,479 @@ +""" +指标目录 — AI agent 可查询的指标清单 + +所有指标都通过 raptorbt.(...) 直接调用, 输入为 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.(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.(numpy_array, ...)", + } diff --git a/app/indicators.py b/app/indicators.py new file mode 100644 index 0000000..3fc231d --- /dev/null +++ b/app/indicators.py @@ -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 diff --git a/app/lookahead_check.py b/app/lookahead_check.py new file mode 100644 index 0000000..d315024 --- /dev/null +++ b/app/lookahead_check.py @@ -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 = "") -> 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, + } diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..39c8a4a --- /dev/null +++ b/app/main.py @@ -0,0 +1,1077 @@ +""" +RaptorBT 统一策略入口 + +用法: + python -m app.main list # 列出所有可用策略 + python -m app.main run --strategy sma_cross # 用默认参数跑策略 + python -m app.main run --strategy sar_adx_cci \ + --symbol XAUUSD --timeframe H1 --bars 500 # 指定数据 + python -m app.main compare # 对比所有策略表现 + +环境变量: + MT5_BRIDGE_URL Mt5Bridge 地址 (默认 http://61.164.252.86:13485) + MT5_BRIDGE_KEY API Key (必需, 未设置时用内置默认 key) +""" + +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import os +import sys +from datetime import datetime, timedelta, timezone + +import numpy as np +import pandas as pd +import requests + +import raptorbt +from strategies import get_strategies, get_strategy +from strategies.base import Strategy + + +# ============================================================================ +# 配置 +# ============================================================================ + +BRIDGE_URL = os.environ.get("MT5_BRIDGE_URL", "http://61.164.252.86:13485") +API_KEY = os.environ.get("MT5_BRIDGE_KEY", "UiHMqtaYLZzwBdcuS4RFmEGhgDO8N2eI") +OUTPUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backtest_output") + + +# ============================================================================ +# JSON 输出辅助 +# ============================================================================ + +@contextlib.contextmanager +def _suppress_stdout(enabled: bool): + """在 enabled=True 时, 临时吞掉所有 print 输出 (用于 --json 模式)""" + if not enabled: + yield + return + sink = io.StringIO() + old_stdout = sys.stdout + sys.stdout = sink + try: + yield sink + finally: + sys.stdout = old_stdout + + +def _clean_nan(obj): + """递归把 NaN/Inf 转为 None, 确保 JSON 可序列化""" + if isinstance(obj, float): + if np.isnan(obj) or np.isinf(obj): + return None + return obj + if isinstance(obj, dict): + return {k: _clean_nan(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_clean_nan(v) for v in obj] + return obj + + +def _emit_json(payload: dict): + """输出 JSON 到 stdout (UTF-8, 紧凑, 不转义中文, 自动清理 NaN/Inf)""" + payload = _clean_nan(payload) + print(json.dumps(payload, ensure_ascii=False, allow_nan=False, default=str)) + + +def _safe_metric(m) -> dict: + """把 PyBacktestMetrics 转为可 JSON 序列化的 dict (处理 NaN)""" + d = m.to_dict() + out = {} + for k, v in d.items(): + if isinstance(v, float) and np.isnan(v): + out[k] = None + else: + out[k] = v + # 补充 to_dict() 未包含的常用字段 + for extra in ["total_trades", "winning_trades", "losing_trades", + "max_consecutive_wins", "max_consecutive_losses", + "avg_holding_period", "exposure_pct", "payoff_ratio", + "recovery_factor", "omega_ratio"]: + val = getattr(m, extra, None) + if val is not None and not (isinstance(val, float) and np.isnan(val)): + out[extra] = val + return out + + +# ============================================================================ +# Mt5Bridge 数据加载 +# ============================================================================ + +def _api_get(path: str, params=None): + resp = requests.get( + f"{BRIDGE_URL}{path}", + params=params, + headers={"X-API-Key": API_KEY}, + timeout=15, + ) + resp.raise_for_status() + return resp.json() + + +def check_health() -> bool: + """健康检查,返回 MT5 是否已连接""" + data = _api_get("/health") + connected = data.get("mt5_connected", False) + status = data.get("status", "unknown") + print(f" Bridge: {status} MT5 连接: {'✓' if connected else '✗'}") + return connected + + +def fetch_klines(symbol: str, timeframe: str, bars: int, + source: str = "csv", data_file: str | None = None) -> pd.DataFrame: + """ + 拉取 K 线数据, 返回标准 DataFrame + + 参数: + symbol: 品种代码 (如 XAUUSD) + timeframe: 周期 (M1/M5/M15/M30/H1/H4/D1) + bars: 返回的 K 线数量 (CSV 模式取最后 bars 根) + source: 数据源 "csv" (默认, 离线) 或 "mt5" (Mt5Bridge) + data_file: 显式指定 CSV 文件路径 (None 时按 symbol 自动查找) + """ + if source == "csv": + return _fetch_from_csv(symbol, timeframe, bars, data_file) + elif source == "mt5": + return _fetch_from_mt5(symbol, timeframe, bars) + else: + raise ValueError(f"未知数据源: {source}, 可选: csv / mt5") + + +def _fetch_from_csv(symbol: str, timeframe: str, bars: int, + data_file: str | None) -> pd.DataFrame: + """从 CSV 加载数据 (策略研究用)""" + from .data_loader import load_csv, find_csv_for_symbol, compute_slippage + + # 1. 定位 CSV 文件 + if data_file is None: + data_file = find_csv_for_symbol(symbol) + if data_file is None or not os.path.exists(data_file): + raise FileNotFoundError( + f"未找到 {symbol} 的 CSV 文件。\n" + f"请把 CSV 放到 data/ 目录, 或用 --data-file 显式指定路径" + ) + + print(f" 数据源: CSV ({os.path.basename(data_file)})") + + # 2. 加载 + 重采样到目标周期 + df = load_csv(data_file, symbol=symbol, timeframe=timeframe) + + # 3. 取最后 bars 根 (模拟最近行情) + if bars > 0 and len(df) > bars: + df = df.iloc[-bars:].reset_index(drop=True) + + # 4. 显示数据范围 + 计算点差 + from .data_loader import get_tick_size + avg_spread = float(df["spread"].mean()) if "spread" in df.columns else 0.0 + tick_size = get_tick_size(symbol) + spread_cost = avg_spread * tick_size + slippage = compute_slippage(df["spread"], df["close"], symbol) if "spread" in df.columns else 0.0005 + + print(f" 数据范围: {df['time'].iloc[0]} ~ {df['time'].iloc[-1]}") + print(f" K 线数量: {len(df)} ({timeframe})") + if avg_spread > 0: + print(f" 平均点差: {avg_spread:.1f} 点 (≈{spread_cost:.5f}) → slippage={slippage:.5f}") + + # 5. 缓存 slippage 到全局, 供 run_strategy 注入 + global _cached_slippage + _cached_slippage = slippage + + return df + + +def _fetch_from_mt5(symbol: str, timeframe: str, bars: int) -> pd.DataFrame: + """从 Mt5Bridge 拉取数据 (最终验证用)""" + global _cached_slippage + _cached_slippage = None # MT5 模式不自动注入 slippage + + date_to = datetime.now(timezone.utc).strftime("%Y-%m-%d") + date_from = (datetime.now(timezone.utc) - timedelta(days=bars // 24 + 30)).strftime("%Y-%m-%d") + + data = _api_get("/rates/from-date", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "date_from": date_from, + "date_to": date_to, + }) + rows = data.get("data", []) + + if not rows: + # fallback: 按偏移量拉取 + data = _api_get("/rates/from-pos", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, + "count": bars, + }) + rows = data.get("data", []) + + if not rows: + raise RuntimeError(f"无法拉取 {symbol} K 线数据, 检查品种名或 MT5 连接") + + df = pd.DataFrame(rows) + df["time"] = pd.to_datetime(df["time"]) + df = df.sort_values("time").reset_index(drop=True) + print(f" 数据源: Mt5Bridge ({symbol} {timeframe})") + print(f" K 线数量: {len(df)}") + return df + + +# CSV 模式下缓存的 slippage (供 run_strategy 自动注入) +_cached_slippage: float | None = None + + +# ============================================================================ +# 回测执行 +# ============================================================================ + +def run_strategy(strategy_name: str, df: pd.DataFrame, symbol: str) -> "raptorbt.PyBacktestResult": + """实例化策略并执行回测""" + strategy = get_strategy(strategy_name) + print(f"\n{'═' * 60}") + print(f"策略: {strategy.name}") + print(f"描述: {strategy.description()}") + print(f"预热期: {strategy.warmup_bars()} bars") + print(f"{'═' * 60}") + + arr = strategy.to_arrays(df) + signals = strategy.generate_signals(df) + + n_entries = int(signals.entries.sum()) + n_exits = int(signals.exits.sum()) + print(f"入场信号: {n_entries} 出场信号: {n_exits} 方向: {'多' if signals.direction == 1 else '空'}") + + if n_entries == 0: + print(" ⚠️ 无入场信号, 跳过回测") + return None + + config = strategy.build_config() + # CSV 模式下自动注入 spread→slippage + if _cached_slippage is not None: + config.slippage = _cached_slippage + print(f" 自动注入 slippage: {_cached_slippage:.5f} (来自 CSV 平均点差)") + 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 + print(f"\n {'─' * 40}") + print(f" {'指标':<16}{'值':>20}") + print(f" {'─' * 40}") + print(f" {'总收益率':<16}{m.total_return_pct:>19.2f} %") + print(f" {'夏普比率':<16}{m.sharpe_ratio:>20.2f}") + print(f" {'索提诺比率':<16}{m.sortino_ratio:>20.2f}") + print(f" {'最大回撤':<16}{m.max_drawdown_pct:>19.2f} %") + print(f" {'总交易数':<16}{m.total_trades:>20d}") + print(f" {'胜率':<16}{m.win_rate_pct:>19.1f} %") + print(f" {'盈利因子':<16}{m.profit_factor:>20.2f}") + print(f" {'期望值':<16}{m.expectancy:>20.2f}") + print(f" {'市场暴露':<16}{m.exposure_pct:>19.1f} %") + print(f" {'─' * 40}") + + # 出场原因分布 + trades = result.trades() + if trades: + exit_reasons = {} + for t in trades: + r = t.exit_reason + exit_reasons[r] = exit_reasons.get(r, 0) + 1 + print(f" 出场原因: {exit_reasons}") + + return result + + +def export_result(result, df: pd.DataFrame, strategy_name: str): + """导出交易/曲线/指标到 CSV""" + if result is None: + return + os.makedirs(OUTPUT_DIR, exist_ok=True) + + # 交易记录 + trades = result.trades() + if trades: + rows = [{ + "trade_id": t.id, "symbol": t.symbol, + "direction": "Long" if t.direction == 1 else "Short", + "entry_idx": t.entry_idx, "exit_idx": t.exit_idx, + "entry_time": df["time"].iloc[t.entry_idx] if t.entry_idx < len(df) else "", + "exit_time": df["time"].iloc[t.exit_idx] if t.exit_idx < len(df) else "", + "entry_price": t.entry_price, "exit_price": t.exit_price, + "size": t.size, "pnl": t.pnl, "return_pct": t.return_pct, + "fees": t.fees, "exit_reason": t.exit_reason, + } for t in trades] + pd.DataFrame(rows).to_csv( + os.path.join(OUTPUT_DIR, f"{strategy_name}_trades.csv"), + index=False, encoding="utf-8-sig", + ) + + # 曲线 + equity = result.equity_curve() + pd.DataFrame({ + "time": df["time"].values[:len(equity)], + "equity": equity, + "drawdown": result.drawdown_curve(), + "returns": result.returns(), + }).to_csv( + os.path.join(OUTPUT_DIR, f"{strategy_name}_curves.csv"), + index=False, encoding="utf-8-sig", + ) + + # 指标 + m = result.metrics + d = m.to_dict() + d.update( + total_trades=m.total_trades, winning_trades=m.winning_trades, + losing_trades=m.losing_trades, max_consecutive_wins=m.max_consecutive_wins, + max_consecutive_losses=m.max_consecutive_losses, avg_holding_period=m.avg_holding_period, + exposure_pct=m.exposure_pct, payoff_ratio=m.payoff_ratio, + recovery_factor=m.recovery_factor, omega_ratio=m.omega_ratio, + ) + pd.DataFrame(list(d.items()), columns=["metric", "value"]).to_csv( + os.path.join(OUTPUT_DIR, f"{strategy_name}_metrics.csv"), + index=False, encoding="utf-8-sig", + ) + print(f" → 结果已导出至 {OUTPUT_DIR}/{strategy_name}_*.csv") + + +# ============================================================================ +# CLI 命令 +# ============================================================================ + +def cmd_list(args): + # 模式: --indicators 显示指标目录 + if args.indicators: + from .indicator_catalog import format_text, format_json + if args.json: + _emit_json(format_json()) + else: + print(format_text()) + return + + strategies = get_strategies() + print(f"\n可用策略 ({len(strategies)} 个):") + print(f"{'═' * 60}") + for name, cls in sorted(strategies.items()): + inst = cls() + print(f" {name:<22} {inst.description()}") + print(f"{'═' * 60}") + print(f"使用: python -m app.main run --strategy <名称>") + + # 显示可用 CSV 数据 + try: + from .data_loader import list_available_symbols + symbols = list_available_symbols() + if symbols: + print(f"\n可用 CSV 数据 ({len(symbols)} 个):") + print(f"{'─' * 60}") + for sym, fname in symbols: + print(f" {sym:<10} {fname}") + print(f"{'─' * 60}") + except Exception: + pass + + +def cmd_run(args): + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"RaptorBT 策略回测") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线") + print(f" 范围: {df['time'].iloc[0]} ~ {df['time'].iloc[-1]}") + print(f" Close: {df['close'].min():.2f} ~ {df['close'].max():.2f}") + + result = run_strategy(args.strategy, df, args.symbol) + if args.export and result is not None: + export_result(result, df, args.strategy) + + if args.json: + payload = { + "command": "run", + "strategy": args.strategy, + "symbol": args.symbol, + "timeframe": args.timeframe, + "bars": len(df) if df is not None else 0, + } + if result is not None: + payload["metrics"] = _safe_metric(result.metrics) + trades = result.trades() + payload["n_trades"] = len(trades) + payload["trades"] = [ + { + "id": t.id, "symbol": t.symbol, + "direction": "Long" if t.direction == 1 else "Short", + "entry_idx": t.entry_idx, "exit_idx": t.exit_idx, + "entry_price": t.entry_price, "exit_price": t.exit_price, + "size": t.size, "pnl": t.pnl, + "return_pct": t.return_pct, "fees": t.fees, + "exit_reason": t.exit_reason, + } + for t in trades[:50] # 限制前 50 条, 避免超大输出 + ] + else: + payload["metrics"] = None + payload["n_trades"] = 0 + _emit_json(payload) + + +def cmd_compare(args): + print(f"{'═' * 60}") + print(f"策略对比 (全量)") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线\n") + + strategies = get_strategies() + print(f"{'策略':<22} {'收益%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>5} {'胜率%':>7} {'PF':>6}") + print(f"{'─' * 70}") + + for name in sorted(strategies.keys()): + try: + result = run_strategy(name, df, args.symbol) + if result is None: + print(f" {name:<22} (无信号)") + continue + m = result.metrics + print( + f" {name:<22} {m.total_return_pct:>7.2f} {m.sharpe_ratio:>7.2f} " + f"{m.max_drawdown_pct:>7.2f} {m.total_trades:>5d} " + f"{m.win_rate_pct:>6.1f} {m.profit_factor:>6.2f}" + ) + if args.export: + export_result(result, df, name) + except Exception as e: + print(f" {name:<22} ❌ {e}") + + +def parse_param_grid(param_args): + """解析 --param fast=5,10,15 → {"fast": [5, 10, 15]}""" + grid = {} + for arg in param_args: + if "=" not in arg: + continue + k, v = arg.split("=", 1) + values = [] + for item in v.split(","): + item = item.strip() + try: + values.append(int(item)) + except ValueError: + try: + values.append(float(item)) + except ValueError: + values.append(item) + grid[k] = values + return grid + + +def cmd_optimize(args): + result = None + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"策略参数优化: {args.strategy}") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线") + + param_grid = parse_param_grid(args.param) + if not param_grid: + print(" ❌ 未指定参数空间, 用 --param name=v1,v2,v3") + return + print(f" 参数空间: {param_grid}") + print(f" 目标指标: {args.metric}") + + from .optimizer import StrategyOptimizer + from strategies import get_strategies + + strategies = get_strategies() + if args.strategy not in strategies: + print(f" ❌ 未知策略: {args.strategy}") + return + + opt = StrategyOptimizer(metric=args.metric) + result = opt.optimize( + strategy_class=strategies[args.strategy], + df=df, + param_grid=param_grid, + symbol=args.symbol, + ) + print(f"\n{result.summary()}") + + print(f"\nTop 10 参数组合:") + print(result.top_n(10).to_string(index=False)) + + if args.export: + out = os.path.join(OUTPUT_DIR, f"{args.strategy}_optimization.csv") + result.export(out) + print(f"\n → 结果已导出: {out}") + + if args.json: + payload = { + "command": "optimize", + "strategy": args.strategy, + "symbol": args.symbol, + "timeframe": args.timeframe, + "metric": args.metric, + } + if result is not None: + payload.update(result.to_dict()) + else: + payload["error"] = "未指定参数空间或策略未知" + _emit_json(payload) + + +def cmd_walkforward(args): + result = None + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"Walk-Forward 验证: {args.strategy}") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线") + + param_grid = parse_param_grid(args.param) + if not param_grid: + print(" ❌ 未指定参数空间") + return + + from .walk_forward import WalkForwardValidator + from strategies import get_strategies + + strategies = get_strategies() + if args.strategy not in strategies: + print(f" ❌ 未知策略: {args.strategy}") + return + + wf = WalkForwardValidator(train_size=args.train_size, test_size=args.test_size) + result = wf.validate( + strategy_class=strategies[args.strategy], + df=df, + param_grid=param_grid, + metric=args.metric, + symbol=args.symbol, + ) + + print(f"\n{result.summary()}") + + if args.export: + out = os.path.join(OUTPUT_DIR, f"{args.strategy}_walkforward.csv") + result.export(out) + print(f"\n → 结果已导出: {out}") + + if args.json: + payload = { + "command": "walkforward", + "strategy": args.strategy, + "symbol": args.symbol, + "timeframe": args.timeframe, + "train_size": args.train_size, + "test_size": args.test_size, + } + if result is not None: + payload.update(result.to_dict()) + else: + payload["error"] = "未指定参数空间或策略未知" + _emit_json(payload) + + +def cmd_validate(args): + wf_result = None + accept_report = None + lookahead_report = None + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"策略验收: {args.strategy}") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + # 前置: 前视偏差检测 (静态 + 动态) + from .lookahead_check import full_check + from strategies import get_strategies as _get_strategies + _strats = _get_strategies() + if args.strategy not in _strats: + print(f" ❌ 未知策略: {args.strategy}") + return + _strat_cls = _strats[args.strategy] + _strat_file = f"strategies/{args.strategy}.py" + print(f"\n 前视偏差检测...") + _lookahead = full_check(_strat_cls, _strat_file, df=None, run_dynamic=False) + print(f" {_lookahead.summary()}") + lookahead_report = _lookahead + if not _lookahead.passed: + print(f"\n ❌ 检测到前视偏差, 终止验收") + return + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线") + + param_grid = parse_param_grid(args.param) + if not param_grid: + print(" ❌ 未指定参数空间") + return + + from .walk_forward import WalkForwardValidator + from .acceptance import StrategyAcceptance + + strategies = get_strategies() + if args.strategy not in strategies: + print(f" ❌ 未知策略: {args.strategy}") + return + + wf = WalkForwardValidator(train_size=args.train_size, test_size=args.test_size) + wf_result = wf.validate( + strategy_class=strategies[args.strategy], + df=df, + param_grid=param_grid, + metric=args.metric, + symbol=args.symbol, + ) + + print(f"\n{wf_result.summary()}") + + checker = StrategyAcceptance() + accept_report = checker.check(wf_result) + print(f"\n{accept_report.summary()}") + + if accept_report.passed: + print("\n ✅ 策略通过验收, 可交付") + else: + print("\n ❌ 策略未通过验收, 需进一步优化") + + if args.json: + payload = { + "command": "validate", + "strategy": args.strategy, + "passed": accept_report.passed if accept_report else False, + } + if lookahead_report is not None: + payload["lookahead"] = lookahead_report.to_dict() + if not lookahead_report.passed: + payload["error"] = "前视偏差检测未通过, 验收终止" + if wf_result is not None: + payload["walk_forward"] = wf_result.to_dict() + if accept_report is not None: + payload["acceptance"] = accept_report.to_dict() + _emit_json(payload) + + +def cmd_scaffold(args): + from .scaffold import scaffold_strategy + + try: + path = scaffold_strategy( + name=args.name, + template=args.template, + description=args.description or "", + overwrite=args.overwrite, + ) + print(f"✅ 策略模板已生成: {path}") + print(f" 模板类型: {args.template}") + print(f" 接下来编辑该文件, 填入信号生成逻辑") + print(f" 完成后用 'python -m app.main list' 查看是否自动注册") + print(f" 编辑后建议用 'python -m app.main check {args.name}' 检测前视偏差") + except FileExistsError as e: + print(f"❌ {e}") + except ValueError as e: + print(f"❌ {e}") + + +def cmd_check(args): + """前视偏差检测: 静态扫描源码 + 可选动态验证""" + # 模式 1: 检测所有策略 + if args.strategy == "all": + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"前视偏差检测: all") + print(f"{'═' * 60}") + from .lookahead_check import check_all_strategies + print(f"\n扫描 strategies/ 目录下所有策略...\n") + results = check_all_strategies("strategies") + n_pass = 0 + n_fail = 0 + for path, report in results: + print(f"{'─' * 60}") + print(f"文件: {os.path.basename(path)}") + print(report.summary()) + if report.passed: + n_pass += 1 + else: + n_fail += 1 + print(f"\n{'─' * 60}") + print(f"总结: {n_pass} 通过, {n_fail} 未通过") + + if args.json: + payload = { + "command": "check", + "mode": "all", + "n_pass": n_pass, + "n_fail": n_fail, + "strategies": [ + {"file": os.path.basename(p), **r.to_dict()} + for p, r in results + ], + } + _emit_json(payload) + return + + # 模式 2: 检测单个策略 + from strategies import get_strategies + strategies = get_strategies() + + report = None + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"前视偏差检测: {args.strategy}") + print(f"{'═' * 60}") + + if args.strategy not in strategies: + print(f" ❌ 未知策略: {args.strategy}") + print(f" 可用策略: {', '.join(strategies.keys())}") + return + + from .lookahead_check import full_check + strat_cls = strategies[args.strategy] + strat_file = f"strategies/{args.strategy}.py" + + # 动态验证需要数据 + df = None + if args.dynamic: + try: + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + except Exception as e: + print(f" ⚠️ 无法加载数据, 跳过动态检测: {e}") + args.dynamic = False + + report = full_check( + strat_cls, strat_file, df=df, + run_dynamic=args.dynamic and df is not None, + ) + print(f"\n{report.summary()}") + + if report.passed: + print(f"\n ✅ 策略无前视偏差, 可安全使用") + else: + print(f"\n ❌ 策略存在前视偏差, 需修复") + print(f" 修复建议:") + print(f" - 禁用 .shift(-N) (N>0, 访问未来 bar)") + print(f" - 禁用 close/high/low/open 的负索引 (如 close[-1])") + print(f" - 禁用 iloc/loc 切片到未来索引") + print(f" - 信号只用当前 bar 及之前的数据生成") + print(f" - 用 cross_above/cross_below (已内置前视安全)") + + if args.json: + payload = { + "command": "check", + "mode": "single", + "strategy": args.strategy, + } + if report is not None: + payload.update(report.to_dict()) + if not report.passed: + payload["fix_suggestions"] = [ + "禁用 .shift(-N) (N>0, 访问未来 bar)", + "禁用 close/high/low/open 的负索引 (如 close[-1])", + "禁用 iloc/loc 切片到未来索引", + "信号只用当前 bar 及之前的数据生成", + "用 cross_above/cross_below (已内置前视安全)", + ] + else: + payload["error"] = f"未知策略: {args.strategy}" + payload["available"] = list(strategies.keys()) + _emit_json(payload) + + +def cmd_deliver(args): + pkg_path = None + lookahead_report = None + opt_result = None + wf_result = None + accept_report = None + error = None + + with _suppress_stdout(args.json): + print(f"{'═' * 60}") + print(f"策略交付包生成: {args.strategy}") + print(f"{'═' * 60}") + if args.source == "mt5": + check_health() + + # 前置: 前视偏差检测 (静态 + 动态), 不通过则拒绝交付 + from .lookahead_check import full_check + from strategies import get_strategies as _get_strategies + _strats = _get_strategies() + if args.strategy not in _strats: + print(f" ❌ 未知策略: {args.strategy}") + error = f"未知策略: {args.strategy}" + return + _strat_cls = _strats[args.strategy] + _strat_file = f"strategies/{args.strategy}.py" + print(f"\n 前视偏差检测 (交付前强制)...") + _lookahead = full_check(_strat_cls, _strat_file, df=None, run_dynamic=False) + print(f" {_lookahead.summary()}") + lookahead_report = _lookahead + if not _lookahead.passed: + print(f"\n ❌ 检测到前视偏差, 拒绝生成交付包") + print(f" 请修复前视问题后再交付 (用 'python -m app.main check {args.strategy}' 查看详情)") + error = "前视偏差检测未通过, 拒绝生成交付包" + return + + df = fetch_klines(args.symbol, args.timeframe, args.bars, + source=args.source, data_file=args.data_file) + print(f" 数据: {args.symbol} {args.timeframe} {len(df)} 根 K 线") + + param_grid = parse_param_grid(args.param) + if not param_grid: + print(" ❌ 未指定参数空间") + error = "未指定参数空间" + return + + from .optimizer import StrategyOptimizer + from .walk_forward import WalkForwardValidator + from .acceptance import StrategyAcceptance + from .exporter import StrategyExporter + + strategies = get_strategies() + if args.strategy not in strategies: + print(f" ❌ 未知策略: {args.strategy}") + error = f"未知策略: {args.strategy}" + return + + print(f"\n Step 1/3: 参数优化...") + opt = StrategyOptimizer(metric=args.metric) + opt_result = opt.optimize( + strategy_class=strategies[args.strategy], + df=df, + param_grid=param_grid, + symbol=args.symbol, + ) + print(f" {opt_result.summary()}") + + print(f"\n Step 2/3: Walk-Forward 验证...") + wf = WalkForwardValidator(train_size=args.train_size, test_size=args.test_size) + wf_result = wf.validate( + strategy_class=strategies[args.strategy], + df=df, + param_grid=param_grid, + metric=args.metric, + symbol=args.symbol, + ) + print(f"\n{wf_result.summary()}") + + print(f"\n Step 3/3: 验收检查...") + checker = StrategyAcceptance() + accept_report = checker.check(wf_result) + print(f"\n{accept_report.summary()}") + + if not accept_report.passed: + print(f"\n ⚠️ 策略未通过验收, 仍可生成交付包 (含未通过标记)") + + print(f"\n 生成交付包...") + exporter = StrategyExporter() + pkg_path = exporter.deliver( + strategy_name=args.strategy, + df=df, + symbol=args.symbol, + opt_result=opt_result, + wf_result=wf_result, + accept_report=accept_report, + param_grid=param_grid, + data_info={ + "source": args.source, + "range": f"{df['time'].iloc[0]} ~ {df['time'].iloc[-1]}", + "bars": len(df), + "timeframe": args.timeframe, + }, + ) + print(f"\n ✅ 交付包已生成: {pkg_path}") + print(f" 包含: 策略源文件 + Markdown 报告 + CSV 明细") + + if args.json: + payload = { + "command": "deliver", + "strategy": args.strategy, + "delivered": pkg_path is not None, + "package_path": pkg_path, + } + if error: + payload["error"] = error + if lookahead_report is not None: + payload["lookahead"] = lookahead_report.to_dict() + if opt_result is not None: + payload["optimization"] = opt_result.to_dict() + if wf_result is not None: + payload["walk_forward"] = wf_result.to_dict() + if accept_report is not None: + payload["acceptance"] = accept_report.to_dict() + _emit_json(payload) + + +def main(): + parser = argparse.ArgumentParser( + description="RaptorBT 统一策略入口", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python -m app.main list + python -m app.main run --strategy sma_cross + python -m app.main optimize --strategy sma_cross --param fast=5,10,15 --param slow=20,30 + python -m app.main walkforward --strategy sma_cross --param fast=5,10 --param slow=20,30 + python -m app.main validate --strategy sma_cross --param fast=5,10 --param slow=20,30 + 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 + """, + ) + sub = parser.add_subparsers(dest="command", required=True) + + # list + p_list = sub.add_parser("list", help="列出所有可用策略 / 可用指标") + p_list.add_argument("--indicators", action="store_true", + help="列出所有可用指标及签名 (供 AI agent 查询)") + p_list.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_list.set_defaults(func=cmd_list) + + # run + p_run = sub.add_parser("run", help="运行单个策略") + p_run.add_argument("--strategy", required=True, help="策略名称 (见 list)") + p_run.add_argument("--symbol", default="XAUUSD", help="品种 (默认 XAUUSD)") + p_run.add_argument("--timeframe", default="H1", help="周期 (默认 H1)") + p_run.add_argument("--bars", type=int, default=500, help="K 线数量 (默认 500)") + p_run.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_run.add_argument("--data-file", default=None, help="CSV 文件路径 (默认按 symbol 自动查找)") + p_run.add_argument("--export", action="store_true", help="导出 CSV 结果") + p_run.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_run.set_defaults(func=cmd_run) + + # compare + p_cmp = sub.add_parser("compare", help="对比所有策略表现") + p_cmp.add_argument("--symbol", default="XAUUSD", help="品种 (默认 XAUUSD)") + p_cmp.add_argument("--timeframe", default="H1", help="周期 (默认 H1)") + p_cmp.add_argument("--bars", type=int, default=500, help="K 线数量 (默认 500)") + p_cmp.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_cmp.add_argument("--data-file", default=None, help="CSV 文件路径") + p_cmp.add_argument("--export", action="store_true", help="导出 CSV 结果") + p_cmp.set_defaults(func=cmd_compare) + + # optimize + p_opt = sub.add_parser("optimize", help="参数网格搜索优化") + p_opt.add_argument("--strategy", required=True, help="策略名称") + p_opt.add_argument("--param", action="append", required=True, + help="参数空间, 格式: name=v1,v2,v3 (可多次指定)") + p_opt.add_argument("--symbol", default="XAUUSD", help="品种") + p_opt.add_argument("--timeframe", default="H1", help="周期") + p_opt.add_argument("--bars", type=int, default=500, help="K 线数量") + p_opt.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_opt.add_argument("--data-file", default=None, help="CSV 文件路径") + p_opt.add_argument("--metric", default="sharpe_ratio", help="优化目标指标") + p_opt.add_argument("--export", action="store_true", help="导出 CSV") + p_opt.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_opt.set_defaults(func=cmd_optimize) + + # walkforward + p_wf = sub.add_parser("walkforward", help="Walk-Forward 验证") + p_wf.add_argument("--strategy", required=True, help="策略名称") + p_wf.add_argument("--param", action="append", required=True, + help="参数空间, 格式: name=v1,v2,v3") + p_wf.add_argument("--symbol", default="XAUUSD", help="品种") + p_wf.add_argument("--timeframe", default="H1", help="周期") + p_wf.add_argument("--bars", type=int, default=1000, help="K 线数量 (需要足够多)") + p_wf.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_wf.add_argument("--data-file", default=None, help="CSV 文件路径") + p_wf.add_argument("--train-size", type=int, default=300, help="训练窗口大小") + p_wf.add_argument("--test-size", type=int, default=100, help="测试窗口大小") + p_wf.add_argument("--metric", default="sharpe_ratio", help="优化目标指标") + p_wf.add_argument("--export", action="store_true", help="导出 CSV") + p_wf.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_wf.set_defaults(func=cmd_walkforward) + + # validate + p_val = sub.add_parser("validate", help="策略验收检查 (walk-forward + 验收标准)") + p_val.add_argument("--strategy", required=True, help="策略名称") + p_val.add_argument("--param", action="append", required=True, + help="参数空间, 格式: name=v1,v2,v3") + p_val.add_argument("--symbol", default="XAUUSD", help="品种") + p_val.add_argument("--timeframe", default="H1", help="周期") + p_val.add_argument("--bars", type=int, default=1000, help="K 线数量") + p_val.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_val.add_argument("--data-file", default=None, help="CSV 文件路径") + p_val.add_argument("--train-size", type=int, default=300, help="训练窗口大小") + p_val.add_argument("--test-size", type=int, default=100, help="测试窗口大小") + p_val.add_argument("--metric", default="sharpe_ratio", help="优化目标指标") + p_val.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_val.set_defaults(func=cmd_validate) + + # scaffold + p_scf = sub.add_parser("scaffold", help="生成策略模板文件") + p_scf.add_argument("--name", required=True, help="策略名称 (snake_case)") + p_scf.add_argument("--template", default="custom", + choices=["crossover", "mean_reversion", "trend_following", "breakout", "custom"], + help="模板类型 (默认 custom)") + p_scf.add_argument("--description", default="", help="策略描述") + p_scf.add_argument("--overwrite", action="store_true", help="覆盖已存在文件") + p_scf.set_defaults(func=cmd_scaffold) + + # check — 前视偏差检测 + p_chk = sub.add_parser("check", help="前视偏差检测 (静态扫描 + 可选动态验证)") + p_chk.add_argument("--strategy", required=True, + help="策略名称, 或 'all' 检测所有策略") + p_chk.add_argument("--dynamic", action="store_true", + help="启用动态验证 (修改未来 bar, 检查历史信号是否变化)") + p_chk.add_argument("--symbol", default="XAUUSD", help="品种 (动态验证用)") + p_chk.add_argument("--timeframe", default="H1", help="周期 (动态验证用)") + p_chk.add_argument("--bars", type=int, default=500, help="K 线数量 (动态验证用)") + p_chk.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源") + p_chk.add_argument("--data-file", default=None, help="CSV 文件路径") + p_chk.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_chk.set_defaults(func=cmd_check) + + # deliver + p_dlv = sub.add_parser("deliver", help="生成策略交付包 (优化+验证+验收+报告)") + p_dlv.add_argument("--strategy", required=True, help="策略名称") + p_dlv.add_argument("--param", action="append", required=True, + help="参数空间, 格式: name=v1,v2,v3") + p_dlv.add_argument("--symbol", default="XAUUSD", help="品种") + p_dlv.add_argument("--timeframe", default="H1", help="周期") + p_dlv.add_argument("--bars", type=int, default=1000, help="K 线数量") + p_dlv.add_argument("--source", default="csv", choices=["csv", "mt5"], help="数据源 (默认 csv 离线)") + p_dlv.add_argument("--data-file", default=None, help="CSV 文件路径") + p_dlv.add_argument("--train-size", type=int, default=300, help="训练窗口大小") + p_dlv.add_argument("--test-size", type=int, default=100, help="测试窗口大小") + p_dlv.add_argument("--metric", default="sharpe_ratio", help="优化目标指标") + p_dlv.add_argument("--json", action="store_true", help="输出 JSON (供 AI agent 解析)") + p_dlv.set_defaults(func=cmd_deliver) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/app/optimizer.py b/app/optimizer.py new file mode 100644 index 0000000..b863ed4 --- /dev/null +++ b/app/optimizer.py @@ -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 diff --git a/app/scaffold.py b/app/scaffold.py new file mode 100644 index 0000000..b65c0a5 --- /dev/null +++ b/app/scaffold.py @@ -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 diff --git a/app/walk_forward.py b/app/walk_forward.py new file mode 100644 index 0000000..935241c --- /dev/null +++ b/app/walk_forward.py @@ -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), + } diff --git a/benches/backtest_benchmark.rs b/benches/backtest_benchmark.rs new file mode 100644 index 0000000..7280c1d --- /dev/null +++ b/benches/backtest_benchmark.rs @@ -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); diff --git a/docs/Mt5Bridge使用指南.md b/docs/Mt5Bridge使用指南.md new file mode 100644 index 0000000..23f94c8 --- /dev/null +++ b/docs/Mt5Bridge使用指南.md @@ -0,0 +1,1196 @@ +# Mt5Bridge API 使用指南 + +> 本文档面向**开发者**,假设 Bridge 已在云端部署运行。直接复制代码即可使用。 + +--- + +## 连接信息 + +| 项目 | 值 | +|------|-----| +| 地址 | `http://61.164.252.86:13485` | +| 认证 | `X-API-Key` Header 或 `?key=` URL 参数 | +| 格式 | 所有返回均为 JSON | +| WebSocket | `ws://61.164.252.86:13485`,握手时用 Header `X-API-Key` | +| SSE | `http://.../stream/ticks-sse/{symbol}`,Header 或 `?key=` 均可 | + +> **浏览器注意**:原生 `WebSocket` 不支持自定义 Header,须用 `?key=` URL 参数;SSE 用 `new EventSource(url + '?key=...')` 同理。Node/Python/Java 客户端用 Header 更干净。 + +--- + +## 快速开始(Python) + +```python +import requests + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +def api(path, params=None): + """统一请求封装""" + resp = requests.get(f"{BRIDGE}{path}", params=params, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +def api_post(path, data): + """POST 请求封装""" + resp = requests.post(f"{BRIDGE}{path}", json=data, headers={"X-API-Key": KEY}) + resp.raise_for_status() + return resp.json() + +# 测试连接 +print(api("/health")) +``` + +--- + +## API 接口速查 + +| 分类 | 方法 | 端点 | 用途 | +|------|------|------|------| +| **系统** | `GET` | `/health` | 健康检查 + MT5 连接状态 | +| **账户** | `GET` | `/account` | 余额/净值/保证金/杠杆 | +| **行情** | `GET` | `/symbols/{symbol}` | 品种信息(点值/手数/合约) | +| | `GET` | `/symbols/{symbol}/tick` | 拉取一次 tick | +| | `WS` | `/stream/ticks/{symbol}` | **实时 tick 推送**(推荐) | +| | `GET` | `/stream/ticks-sse/{symbol}` | SSE 推送(浏览器/内网代理友好) | +| **K 线** | `GET` | `/rates/from-pos` | K 线(按偏移量) | +| | `GET` | `/rates/from-date` | **K 线(按时间范围)⭐** | +| **持仓** | `GET` | `/positions[?symbol=]` | 当前持仓列表 | +| | `POST` | `/position/close` | 平仓(全/部分) | +| | `POST` | `/position/modify` | 改 SL/TP | +| | `POST` | `/position/close-by` | 对冲平仓(节省点差) | +| | `POST` | `/positions/close-batch` | 批量平仓(按 magic/symbol) | +| **挂单** | `GET` | `/orders[?symbol=]` | 挂单列表 | +| | `POST` | `/order/cancel` | 撤单 | +| | `POST` | `/order/modify` | 改挂单价/SL/TP | +| **下单** | `POST` | `/order/check` | 预检(不成交) | +| | `POST` | `/order/send` | 实际下单 | +| **历史** | `GET` | `/history/deals` | 历史成交 | +| **信号** | `GET/POST/DELETE` | `/gvar[/{name}]` | MQL5 全局变量(指标信号桥) | + +> 推送类端点(WS/SSE):每品种最多 50 个订阅者,超出返回 `429`;每 30 秒发一条心跳包用于穿透 NAT 保持连接。 + +--- + +## ⚠️ 隐含约定与已知陷阱(先读) + +下面这些坑都是踩过的,**不读这节直接调接口几乎必踩**: + +### P1. `/history/deals` 的 `date_to` 是 **EXCLUSIVE**(不含当天) + +```bash +# ❌ 0 deals — 07-08 当天全部丢失 +GET /history/deals?date_from=2026-07-06&date_to=2026-07-08 + +# ✅ 42 deals — date_to 设成"明天"才能取到 07-08 当天 +GET /history/deals?date_from=2026-07-08&date_to=2026-07-09 +``` + +**规则**:永远把 `date_to` 设为"目标日期的下一天"。 + +### P2. `/history/deals` 的 `entry` 字段语义跟 MT5 标准 **相反** + +``` +bridge entry = 1 ⇒ OUT(关仓)—— profit 字段是已实现 P&L(USD) +bridge entry = 0 ⇒ IN (开仓)—— profit 固定为 0 +``` + +MT5 MQL5 原生约定是 `DEAL_ENTRY_IN=0 / DEAL_ENTRY_OUT=1`,这个 bridge 的 C# 实现把语义反过来了。 +**如果不验证就用 close 路径过滤 deal,会一个都匹配不到**(结果 P&L 永远是 0)。 + +```python +# ✅ 正确:找关仓 deal +exits = [d for d in deals if d.get('entry') == 1 and d.get('magic') == 88001] +``` + +### P3. `/order/send` 只返回 `{retcode, order, comment}`,**没有成交价、没有 deal ticket** + +```json +// 实际响应(成功) +{"data": {"retcode": 10009, "order": 1797395084, "comment": "Request executed"}} +``` + +bridge 不返回 `price` 也不返回 `deal` 字段。**所以拿真实 fill 价格和实现 P&L,唯一办法是 close 之后查 `/history/deals`**。 + +```python +# ❌ 永远拿不到正确价格 +result = api_post('/order/send', {...})['data'] +result.get('price') # None + +# ✅ 正确:成交后从 history/deals 拿 +deals = api('/history/deals', params={'date_from': today, 'date_to': tomorrow})['data'] +exit_deal = next(d for d in deals if d['entry'] == 1 and d['symbol'] == sym) +realized_pnl_usd = exit_deal['profit'] # broker 已经换算成 deposit currency +actual_fill_price = exit_deal['price'] +``` + +### P4. `/positions.profit` **是 deposit currency(USD),不是 quote currency** + +```python +# USDCAD SELL 当前浮动 -0.17 +# 这是 USD 真实值 (-0.24 CAD ÷ 1.417 USD/CAD = -0.169 USD ≈ -0.17) +# 不是 CAD! +``` + +MT5 `POSITION_PROFIT` 的官方语义就是 in deposit currency,bridge 严格遵循。 +**如果手动算 USDCAD / USDJPY P&L 时按 quote currency 处理,会差一个汇率倍数**(USDCAD 大约 1.4×)。 + +### P5. bridge 拿到的 tick 跟 broker 实际 fill 差 1-4 ticks + +`/positions` 的 `price_open` / `price_current` 和 `/order/send` 时看到的 tick 跟 broker 服务器**真实成交价**有几毫秒级的时间差,导致值差 0.0001-0.0004(约 0.1-0.4 pip)。 + +``` +broker 实际 fill: 1.41715 +/positions.price_open: 1.41711 ← 差 0.00004 (0.4 pip) +``` + +**永远以 `/history/deals` 里的 `price` 为准做对账,不要用 `/positions` 的 price_open**。 + +### P6. `/position/close` 与 `/order/send` 的成功 retcode 含义不同 + +| 端点 | 成功 retcode | 失败 retcode | 来源 | +|------|--------------|--------------|------| +| `/order/send` | MT5 原生(通常 `10009`) | MT5 原生 | 直接透传 `result.Retcode` | +| `/position/close` | **合成** `10009` | **合成** `10004` | C# 代码 `ok ? 10009u : 10004u` | + +两者都是 `10009 = success`,但**别假设 0 是 success**。建议统一判 `retcode == 10009`。 + +### P7. `data: []` 与"默认值数据"的语义混淆 + +Bridge 的所有 list 端点(`/account`, `/positions`, `/orders`, `/history/deals`, `/symbols` 等)都遵循这个统一约定: + +- **"有数据"** ⇒ `{"data": [{...}], "count": 1, ...}` +- **"无数据"** ⇒ `{"data": [], "count": 0, ...}`(**HTTP 仍 200**,不是错误) + +客户端很容易把"无数据"误判成"默认值数据"。例: + +```python +# ❌ 错:data:[] 时 fallback 到 {},默认零值看着像合法数据 +info = api('/account') +acc = (info.get('data') or [{}])[0] +if not acc.get('trade_allowed'): + raise PermissionError('trading disabled') + # ↑ 实际可能是"账户未登录",不是"Algo Trading 真关闭" + +# ❌ 错:bool({})==True,让健康检查通过 +if api('/account'): + mark_healthy() +``` + +**正确做法**:用 `data` 长度 + 身份字段(如 `login`、`ticket`)作为"真实数据就绪"的标志: + +```python +def data_ready(info, key='login'): + """data 非空 + 身份字段 > 0 ⇒ 真实数据""" + items = info.get('data') or [] + return bool(items) and (items[0].get(key, 0) if items else 0) not in (0, None, '') + +# ✅ +info = api('/account') +if not data_ready(info, 'login'): + raise ConnectionError('account not loaded yet') +``` + +**适用所有 list 端点**。判 `positions`、`orders`、`deals` 同理。 + +--- + +## 1. 健康检查 + +``` +GET /health +``` + +```python +status = api("/health") +# {"status": "healthy", "mt5_connected": true, "api_version": "1.0.0"} +``` + +--- + +## 2. 账户信息 + +``` +GET /account +``` + +```python +acc = api("/account")["data"][0] +print(f"余额: {acc['balance']}, 净值: {acc['equity']}, 浮动盈亏: {acc['profit']}") +print(f"保证金: {acc['margin']}, 可用保证金: {acc['margin_free']}, 比例: {acc['margin_level']}%") +print(f"杠杆: 1:{acc['leverage']}, 币种: {acc['currency']}") +``` + +**返回字段:** + +| 字段 | 含义 | +|------|------| +| login | 账户号 | +| balance | 余额 | +| equity | 净值 | +| profit | 浮动盈亏 | +| margin | 已用保证金 | +| margin_free | 可用保证金 | +| margin_level | 保证金比例 | +| leverage | 杠杆 | +| currency | 账户币种 | +| trade_allowed | 是否允许交易 | +| trade_expert | 是否允许 EA 交易 | + +--- + +## 3. 实时行情 + +#### 3-1. 主动拉取(一次性) + +``` +GET /symbols/{symbol}/tick +``` + +自动将品种加入 MT5 Market Watch,并等待最多 3 秒获取真实 tick 数据(解决品种未订阅时返回空值的问题)。 + +```python +def get_tick(symbol): + data = api(f"/symbols/{symbol}/tick")["data"][0] + return data["bid"], data["ask"] + +bid, ask = get_tick("XAUUSD") +print(f"XAUUSD Bid: {bid} Ask: {ask} Spread: {ask - bid}") +``` + +**返回字段:** + +| 字段 | 含义 | +|------|------| +| bid | 卖价 | +| ask | 买价 | +| last | 最新成交价 | +| volume | 成交量 | +| time | 时间 | + +#### 3-2. 订阅推送(WebSocket 流式)⭐ 推荐 + +``` +WS /stream/ticks/{symbol} +``` + +MT5 每收到一个 tick 就立即推给所有订阅者,省去轮询。 + +- 走 `X-API-Key` 认证(Header `X-API-Key: your-key`,WebSocket 客户端在握手 Header 里带) +- 连上时自动把品种加入 MT5 Market Watch;最后一个订阅者断开时自动移除 +- 每个品种最多 50 个订阅者(含 WS + SSE 总数),超出返回 `429` +- 每 30 秒发一条心跳(无 tick 时也发),客户端可用于保活与断线检测 + +**推送格式(每条 tick 一帧 JSON 文本):** + +```json +{ + "type": "tick", + "symbol": "XAUUSDc", + "time": "2026-07-08T10:30:45", + "bid": 4180.0, + "ask": 4180.5, + "last": 4180.2, + "volume": 100, + "time_msc": "2026-07-08T10:30:45.123000", + "flags": 6 +} +``` + +**心跳包:** + +```json +{"type":"heartbeat","time":"2026-07-08T10:31:15"} +``` + +#### 3-3. SSE 推送(不能用 WebSocket 的环境) + +``` +GET /stream/ticks-sse/{symbol} → Content-Type: text/event-stream +``` + +面向无法建 WebSocket 的客户端(部分老浏览器、内网代理、curl 测试等)。语义同 3-2,每条 tick 一帧 SSE: + +``` +data: {"type":"tick","symbol":"XAUUSDc","bid":4180.0,...} + +data: {"type":"tick","symbol":"XAUUSDc","bid":4181.0,...} + +``` + +**curl 测试:** + +```bash +curl -N -H "X-API-Key: your-api-key" \ + http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc +``` + +**Python SSE 客户端(`sseclient-py`):** + +```python +from sseclient import SSEClient +import json + +messages = SSEClient("http://61.164.252.86:13485/stream/ticks-sse/XAUUSDc", + headers={"X-API-Key": "your-api-key"}) +for msg in messages: + data = json.loads(msg.data) + if data.get("type") == "heartbeat": + continue + print(data["symbol"], data["bid"], data["ask"]) +``` + +**Python 示例(需安装 `websocket-client`):** + +```python +import websocket +import threading + +def on_message(ws, msg): + tick = eval(msg) # 或 json.loads(msg) + print(f"{tick['symbol']} Bid:{tick['bid']} Ask:{tick['ask']}") + +def on_open(ws): + print("connected") + +def on_close(ws, code, reason): + print(f"disconnected: {code} {reason}") + +ws = websocket.WebSocketApp( + f"ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + header=[f"X-API-Key: your-api-key"], + on_message=on_message, + on_open=on_open, + on_close=on_close, +) +ws.run_forever() +``` + +**`websockets` 库(asyncio 版):** + +```python +import asyncio +import websockets +import json + +async def watch_ticks(): + headers = {"X-API-Key": "your-api-key"} + async with websockets.connect( + "ws://61.164.252.86:13485/stream/ticks/XAUUSDc", + additional_headers=headers, + ) as ws: + async for raw in ws: + tick = json.loads(raw) + print(tick["symbol"], tick["bid"], tick["ask"]) + +asyncio.run(watch_ticks()) +``` + +**浏览器控制台测试:** + +```js +const ws = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc", { + headers: { "X-API-Key": "your-api-key" } // 浏览器原生 WS 不支持自定义 Header,需走 ?key= 参数,见下 +}); +// 浏览器场景:用 query 参数传 key +const ws2 = new WebSocket("ws://61.164.252.86:13485/stream/ticks/XAUUSDc?key=your-api-key"); +ws2.onmessage = (e) => console.log(JSON.parse(e.data)); +``` + +--- + +## 4. 品种信息 + +``` +GET /symbols/{symbol} +``` + +bid/ask 从实时 tick 数据获取(自动等待最多 3 秒),避免品种刚加入 Market Watch 时返回 0 的问题。 + +```python +def get_symbol_info(symbol): + info = api(f"/symbols/{symbol}")["data"][0] + print(f"品种: {info['name']}, 描述: {info['description']}") + print(f"小数位: {info['digits']}, 点值: {info['point']}") + print(f"最小手数: {info['volume_min']}, 最大: {info['volume_max']}, 步长: {info['volume_step']}") + print(f"合约大小: {info['trade_contract_size']}") + return info +``` + +--- + +## 5. 历史 K 线(按偏移量) + +``` +GET /rates/from-pos?symbol={symbol}&timeframe={timeframe}&start_pos={start}&count={count} +``` + +| 参数 | 可选值 | +|------|--------| +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| start_pos | 0 = 最新,1 = 前一根,以此类推 | +| count | 获取数量,最大 10000 | + +```python +import pandas as pd + +def get_rates(symbol, timeframe, count): + """获取 K 线并转为 DataFrame""" + data = api("/rates/from-pos", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, + "count": count + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取最近 100 根 H1 K 线 +df = get_rates("XAUUSD", "H1", 100) +print(df.head()) +``` + +**返回字段:** `time`, `open`, `high`, `low`, `close`, `tick_volume`, `spread`, `real_volume` + +--- + +### 5-2. 历史 K 线(按时间范围)⭐ 推荐 + +``` +GET /rates/from-date?symbol={symbol}&timeframe={timeframe}&date_from={date_from}&date_to={date_to} +``` + +| 参数 | 可选值 | +|------|--------| +| timeframe | `TIMEFRAME_M1` / `M5` / `M15` / `M30` / `H1` / `H4` / `D1` | +| date_from | 起始日期,ISO-8601 或 `yyyy-MM-dd` | +| date_to | 结束日期,ISO-8601 或 `yyyy-MM-dd` | + +```python +def get_rates_by_date(symbol, timeframe, date_from, date_to): + """按时间范围获取 K 线""" + data = api("/rates/from-date", params={ + "symbol": symbol, + "timeframe": f"TIMEFRAME_{timeframe}", + "date_from": date_from, + "date_to": date_to, + })["data"] + df = pd.DataFrame(data) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + +# 获取 2026年7月1日 ~ 7月3日 的 H1 K 线 +df = get_rates_by_date("XAUUSD", "H1", "2026-07-01", "2026-07-03") +print(df.head()) +``` + +**返回字段:** 同 `/rates/from-pos` + +--- + +## 6. 当前持仓 + +``` +GET /positions[?symbol={symbol}] +``` + +symbol 可选过滤。 + +```python +def get_positions(symbol=None): + return api("/positions", params={"symbol": symbol} if symbol else None)["data"] + +positions = get_positions() +for pos in positions: + print(f"{pos['ticket']} {pos['symbol']} " + f"{'买' if pos['type'] == 0 else '卖'} " + f"手数:{pos['volume']} 盈亏:{pos['profit']}") +``` + +**返回字段:** `ticket`, `symbol`, `type`(0=买,1=卖), `volume`, `price_open`, `sl`, `tp`, `price_current`, `swap`, `profit`, `comment`, `magic` + +### 6-1. 平仓 + +``` +POST /position/close +``` + +```json +// 全平 +{ "ticket": 12345678 } +// 部分平仓 +{ "ticket": 12345678, "volume": 0.05 } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| ticket | ✅ | 持仓编号 | +| volume | ❌ | 平仓手数;不传/0 = 全平;>0 = 部分平仓 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_position(ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return api_post("/position/close", body)["data"] + +close_position(12345678) # 全平 +close_position(12345678, volume=0.05) # 部分平 +``` + +### 6-2. 改持仓 SL/TP + +``` +POST /position/modify +``` + +```json +{ "ticket": 12345678, "sl": 4170.0, "tp": 4190.0 } +``` + +SL/TP 设为 0 表示清除对应止损/止盈。 + +```python +def modify_position(ticket, sl=0, tp=0): + return api_post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] +``` + +### 6-3. 对冲平仓(节省点差) + +``` +POST /position/close-by +``` + +用一张反向持仓对冲平仓,只收一次点差(MT5 净额结算),适合双向网格 / 锁仓策略快速离场。 + +```json +{ "position": 111, "position_by": 222 } +``` + +要求:两张持仓 **同品种 + 反向**。 + +```python +def close_by(ticket_a, ticket_b): + return api_post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] +``` + +### 6-4. 移动止损(客户端轮询模式) + +Bridge 不在服务端跑轮询,暴露 `/position/modify` 由客户端自己做: + +```python +def trail_stop(ticket, distance, step): + """distance: 跟踪距离(如 50 点);step: 最小推进步长(如 10 点)""" + pos = next((p for p in bridge.positions() if p["ticket"] == ticket), None) + if not pos: + return + current = pos["price_current"] + if pos["type"] == 0: # 多单 + new_sl = current - distance + if new_sl - pos["sl"] >= step: + bridge.modify_position(ticket, sl=new_sl) + else: # 空单 + new_sl = current + distance + if pos["sl"] - new_sl >= step: + bridge.modify_position(ticket, sl=new_sl) + +# 每秒跑一次 +while True: + for p in bridge.positions(): + trail_stop(p["ticket"], distance=50, step=10) + time.sleep(1) +``` + +也可以用 WebSocket tick 流推送驱动,把 `time.sleep(1)` 换成 tick 回调,反应更快。 + +### 6-5. 批量平仓 + +``` +POST /positions/close-batch +``` + +按 `symbol` 和/或 `magic` 批量平仓。**至少传一个**过滤条件,避免误清整个账户。 + +```json +{ "magic": 123456 } +{ "symbol": "XAUUSDc", "magic": 123456 } +{ "symbol": "XAUUSDc" } +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| symbol | 一 | 品种过滤 | +| magic | 一 | Magic Number 过滤 | +| deviation | ❌ | 允许滑点(默认 10) | + +```python +def close_by_magic(magic): + return api_post("/positions/close-batch", {"magic": magic})["data"] + +result = close_by_magic(123456) +print(f"已平 {result['closed']} 单,失败 {result['failed']} 单") +# data 数组里有每张单的 ticket / symbol / retcode / comment +``` + +--- + +## 7. 挂单 + +``` +GET /orders?symbol={symbol} +``` + +symbol 可选,不传返回全部。 + +```python +orders = api("/orders")["data"] +for o in orders: + print(f"{o['ticket']} {o['symbol']} 类型:{o['type']} 手数:{o['volume_initial']}") +``` + +### 7-1. 撤单 + +``` +POST /order/cancel +``` + +```json +{ "ticket": 87654321 } +``` + +```python +def cancel_order(ticket): + return api_post("/order/cancel", {"ticket": ticket})["data"] + +cancel_order(87654321) +``` + +### 7-2. 改挂单 + +``` +POST /order/modify +``` + +```json +{ "ticket": 87654321, "price": 4180.0, "sl": 4170.0, "tp": 4190.0 } +``` + +sl/tp 设为 0 表示清除。 + +```python +def modify_order(ticket, price, sl=0, tp=0): + return api_post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] +``` + +--- + +## 8. 订单预检 + +``` +POST /order/check +``` + +下单前验证,不会真正执行。检查保证金是否足够、价格是否有效等。 + +**填充模式自动适配**:服务端会根据品种的 `SYMBOL_FILLING_MODE` 自动选择经纪商支持的填充模式。如果请求的 `type_filling` 不被支持,会按 IOC(1) → FOK(0) → RETURN(2) 顺序降级,无需客户端手动判断。 + +```python +def check_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """预检订单""" + data = { + "action": 1, # 1=即时成交 + "symbol": symbol, + "volume": volume, + "order_type": order_type, # 0=市价买, 1=市价卖 + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 0=FOK, 1=IOC, 2=RETURN — 服务端自动适配,不传也行 + } + result = api_post("/order/check", data)["data"] + print(f"预检结果: retcode={result['retcode']}, comment={result['comment']}") + if result['retcode'] == 0: + print("✅ 可以下单") + else: + print("❌ 不可下单") + return result + +check_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**type_filling 说明:** + +| 值 | 含义 | 说明 | +|------|------|------| +| 0 | FOK (Fill or Kill) | 必须全部成交,否则取消 | +| 1 | IOC (Immediate or Cancel) | 能成交多少成交多少 | +| 2 | RETURN | 剩余部分留在订单簿 | + +> 不同经纪商支持的填充模式不同(如 ICMarkets 只支持 IOC),服务端会自动降级,客户端无需关心。 + +--- + +## 9. 下单 + +``` +POST /order/send +``` + +与 `/order/check` 相同,`type_filling` 会自动适配经纪商支持的填充模式。 + +```python +def send_order(symbol, volume, order_type, price, sl=None, tp=None, magic=0, comment=""): + """下单""" + data = { + "request": { + "action": 1, + "symbol": symbol, + "volume": volume, + "order_type": order_type, + "price": price, + "sl": sl or 0, + "tp": tp or 0, + "magic": magic, + "comment": comment, + "deviation": 10, + "type_filling": 0 # 自动适配,不传也行 + } + } + result = api_post("/order/send", data)["data"] + print(f"下单结果: retcode={result['retcode']}, order={result['order']}, comment={result['comment']}") + return result + +# 市价买入 0.01 手 XAUUSD +result = send_order("XAUUSD", 0.01, 0, 4180.0, sl=4170.0, tp=4190.0) +``` + +**order_type 说明:** + +| 值 | 含义 | +|------|------| +| 0 | 市价买入 | +| 1 | 市价卖出 | +| 2 | 限价买入 | +| 3 | 限价卖出 | +| 4 | 止损买入 | +| 5 | 止损卖出 | + +--- + +## 10. 历史成交 + +``` +GET /history/deals?date_from={from}&date_to={to}&symbol={symbol} +``` + +```python +deals = api("/history/deals", params={ + "date_from": "2026-07-01", + "date_to": "2026-07-03", + "symbol": "XAUUSD" +})["data"] + +for d in deals: + print(f"{d['ticket']} {d['time']} {d['symbol']} " + f"手数:{d['volume']} 价格:{d['price']} 盈亏:{d['profit']}") +``` + +--- + +## 11. 全局变量(MQL5 信号桥) + +> 让 MQL5 指标/EA 把信号写出来,Python 通过 Bridge 读取。不用翻译 MQL5 代码。 + +说明: + +- 这部分属于“MT5 指标/EA 信号导出”能力,不影响 Bridge 的账户、行情、历史、持仓、挂单、预检、下单等基础 API。 +- 如果你只更新了远程 `Mt5Bridge.dll`,没有替换 `Alpha Trend.ex5`,Bridge 仍然可以正常工作,`/gvar` 也仍会返回旧版指标写出的变量名。 +- 只有在你需要“已收盘 K 线信号”和“带周期/参数作用域的新变量名”时,才需要重新编译并替换新版 `Alpha Trend.ex5`。 + +#### 列出所有全局变量 + +``` +GET /gvar +``` + +```python +gvars = api("/gvar") +print(gvars) +# {"data": [{"name": "AT_Trend_XAUUSD", "value": 1}, ...], "count": 5} +``` + +#### 读取指定变量 + +``` +GET /gvar/{name} +``` + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +print(f"趋势方向: {'多头' if trend == 1 else '空头'}") +``` + +#### 写入变量 + +``` +POST /gvar/{name} +``` + +Body: `{"value": 75.5}` + +```python +def set_gvar(name, value): + return api_post(f"/gvar/{name}", {"value": value}) + +set_gvar("MY_RSI", 75.5) +``` + +#### 删除变量 + +``` +DELETE /gvar/{name} +``` + +--- + +## MQL5 指标 → Python 完整流程 + +### 第一步:改指标源码,输出已收盘 K 线信号 + +这一步是“升级指标导出行为”的可选步骤,不是 Bridge 基础 API 的必需步骤。 + +```mql5 +// 在 OnCalculate 末尾加 +int last_closed = rates_total - 2; +string key = StringFormat("MY_SIGNAL_%s_%s", _Symbol, EnumToString(_Period)); +GlobalVariableSet(key, signal_value[last_closed]); +``` + +### 第二步:Python 读取信号 + +```python +def read_signal(): + try: + return api(f"/gvar/MY_SIGNAL_XAUUSD_PERIOD_H1")["value"] + except: + return None + +signal = read_signal() +print(f"指标信号: {signal}") +``` + +如果你仍在使用旧版 `Alpha Trend.ex5`,则读取方式应继续对应旧键名,例如: + +```python +trend = api("/gvar/AT_Trend_XAUUSD")["value"] +buy_signal = api("/gvar/AT_Buy_XAUUSD")["value"] +``` + +### 第三步:根据信号做决策 + +```python +def on_tick(): + signal = read_signal() + if signal != 1: + return # 没信号,不动 + + if not bridge.has_position("XAUUSD"): + bid, ask = bridge.tick("XAUUSD") + bridge.buy("XAUUSD", 0.01, ask, sl=ask - 50, tp=ask + 100) + print("指标发出买入信号,已开多") +``` + +## 完整策略模板 + +```python +import requests +import pandas as pd +import time +from datetime import datetime + +BRIDGE = "http://61.164.252.86:13485" +KEY = "your-api-key" + +class Mt5Bridge: + def __init__(self): + self.headers = {"X-API-Key": KEY} + + def _get(self, path, params=None): + r = requests.get(f"{BRIDGE}{path}", params=params, headers=self.headers) + r.raise_for_status() + return r.json() + + def _post(self, path, data): + r = requests.post(f"{BRIDGE}{path}", json=data, headers=self.headers) + r.raise_for_status() + return r.json() + + def stream_ticks(self, symbol, on_tick): + """订阅 tick 推送,on_tick 回调收到 dict,阻塞运行。需 pip install websocket-client""" + import websocket + url = f"{BRIDGE.replace('http', 'ws', 1)}/stream/ticks/{symbol}" + ws = websocket.WebSocketApp( + url, + header=[f"X-API-Key: {KEY}"], + on_message=lambda ws, msg: on_tick(eval(msg)), + on_error=lambda ws, err: print(f"ws error: {err}"), + ) + ws.run_forever() + + def stream_ticks_async(self, symbol, on_tick): + """后台线程订阅 tick,不阻塞主线程""" + import threading + t = threading.Thread(target=self.stream_ticks, args=(symbol, on_tick), daemon=True) + t.start() + return t + + # ── 行情 ── + def tick(self, symbol): + d = self._get(f"/symbols/{symbol}/tick")["data"][0] + return d["bid"], d["ask"] + + def rates(self, symbol, timeframe, count): + return self._get("/rates/from-pos", params={ + "symbol": symbol, "timeframe": f"TIMEFRAME_{timeframe}", + "start_pos": 0, "count": count + })["data"] + + def to_df(self, symbol, timeframe, count): + df = pd.DataFrame(self.rates(symbol, timeframe, count)) + df["time"] = pd.to_datetime(df["time"]) + df.set_index("time", inplace=True) + return df + + # ── 账户 ── + def account(self): + return self._get("/account")["data"][0] + + # ── 持仓 ── + def positions(self, symbol=None): + return self._get("/positions", params={"symbol": symbol} if symbol else None)["data"] + + def has_position(self, symbol): + return any(p["symbol"] == symbol for p in self.positions()) + + def close(self, ticket, volume=None): + body = {"ticket": ticket} + if volume: body["volume"] = volume + return self._post("/position/close", body)["data"] + + def modify_position(self, ticket, sl=0, tp=0): + return self._post("/position/modify", {"ticket": ticket, "sl": sl, "tp": tp})["data"] + + def close_by(self, ticket_a, ticket_b): + return self._post("/position/close-by", {"position": ticket_a, "position_by": ticket_b})["data"] + + def close_batch(self, magic=None, symbol=None, deviation=None): + body = {k: v for k, v in {"magic": magic, "symbol": symbol, "deviation": deviation}.items() if v is not None} + return self._post("/positions/close-batch", body) + + # ── 挂单 ── + def orders(self, symbol=None): + return self._get("/orders", params={"symbol": symbol} if symbol else None)["data"] + + def cancel_order(self, ticket): + return self._post("/order/cancel", {"ticket": ticket})["data"] + + def modify_order(self, ticket, price, sl=0, tp=0): + return self._post("/order/modify", {"ticket": ticket, "price": price, "sl": sl, "tp": tp})["data"] + + # ── 下单 ── + def buy(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 0, price, sl, tp, magic, comment) + + def sell(self, symbol, volume, price, sl=0, tp=0, magic=0, comment=""): + return self._send(symbol, volume, 1, price, sl, tp, magic, comment) + + def _send(self, symbol, volume, order_type, price, sl, tp, magic, comment): + return self._post("/order/send", { + "request": { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": magic, + "comment": comment, "deviation": 10 + } + })["data"] + + def check(self, symbol, volume, order_type, price, sl=0, tp=0): + return self._post("/order/check", { + "action": 1, "symbol": symbol, "volume": volume, + "order_type": order_type, "price": price, + "sl": sl, "tp": tp, "magic": 0, "comment": "", "deviation": 10 + })["data"] + + +# ══════════════════════════════════════════════ +# 策略示例:均线金叉死叉 +# ══════════════════════════════════════════════ + +class MAStrategy: + def __init__(self, bridge, symbol, fast=20, slow=60): + self.bridge = bridge + self.symbol = symbol + self.fast = fast + self.slow = slow + + def signal(self): + """计算信号:1=买入, -1=卖出, 0=观望""" + df = self.bridge.to_df(self.symbol, "H1", self.slow + 5) + df["ma_fast"] = df["close"].rolling(self.fast).mean() + df["ma_slow"] = df["close"].rolling(self.slow).mean() + + # 使用最近两根已收盘 K 线 + prev = df.iloc[-3] + curr = df.iloc[-2] + + # 金叉 + if prev["ma_fast"] <= prev["ma_slow"] and curr["ma_fast"] > curr["ma_slow"]: + return 1 + # 死叉 + if prev["ma_fast"] >= prev["ma_slow"] and curr["ma_fast"] < curr["ma_slow"]: + return -1 + return 0 + + def run(self): + sig = self.signal() + bid, ask = self.bridge.tick(self.symbol) + acc = self.bridge.account() + print(f"[{datetime.now()}] {self.symbol} Bid:{bid} Ask:{ask} " + f"Balance:{acc['balance']} Equity:{acc['equity']} Signal:{sig}") + + if sig == 1 and not self.bridge.has_position(self.symbol): + print(" → 金叉,开多") + self.bridge.buy(self.symbol, 0.01, ask, sl=ask - 50, tp=ask + 100) + elif sig == -1 and not self.bridge.has_position(self.symbol): + print(" → 死叉,开空") + self.bridge.sell(self.symbol, 0.01, bid, sl=bid + 50, tp=bid - 100) + + +# ══════════════════════════════════════════════ +# 运行 +# ══════════════════════════════════════════════ + +if __name__ == "__main__": + bridge = Mt5Bridge() + strategy = MAStrategy(bridge, "XAUUSD", fast=20, slow=60) + + while True: + try: + strategy.run() + except Exception as e: + print(f"Error: {e}") + time.sleep(60) # 每分钟检查一次 +``` + +--- + +## 浏览器快速验证 + +在浏览器地址栏直接输入: + +``` +http://61.164.252.86:13485/health?key=your-api-key +http://61.164.252.86:13485/account?key=your-api-key +http://61.164.252.86:13485/symbols/XAUUSD/tick?key=your-api-key +http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03&key=your-api-key +``` + +--- + +## PowerShell 快速测试 + +```powershell +$headers = @{ "X-API-Key" = "your-api-key" } +Invoke-RestMethod "http://61.164.252.86:13485/health" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/account" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/symbols/XAUUSD/tick" -Headers $headers +Invoke-RestMethod "http://61.164.252.86:13485/rates/from-date?symbol=XAUUSD&timeframe=TIMEFRAME_H1&date_from=2026-07-01&date_to=2026-07-03" -Headers $headers +``` + +--- + +## 常见问题 + +> **第一次调这个 bridge?先去读 [§「⚠️ 隐含约定与已知陷阱」](#-隐含约定与已知陷阱先读)** —— 7 个非显而易见的约定(date_to 排他、`entry` 字段语义反、`/order/send` 不返回 fill 价、`/account` 空 data 误判 等),不读这一节直接调接口几乎必踩。 + +### 返回 "Unauthorized" +API Key 错误或没带。检查 Header 中的 `X-API-Key` 或 URL 中的 `?key=`。 + +### 返回 "对于该符号,不支持市场执行" +`order_type` 填错了,MT5 中有些品种不支持市价单,有些不支持挂单。先调用 `/order/check` 预检。 + +### 返回 "没有足够的资金" +保证金不足,减小手数或检查 `account.margin_free`。 + +### 闭仓 P&L 永远是 0 / 跟 MT5 terminal 对不上 +大概率是过滤了 `entry==0` 的 deal(以为 OUT),实际这个 bridge 是 `entry==1` 才是 OUT(关仓)。详见 §隐含约定 P2。 + +### 算出来的 P&L 跟 broker 对差 1.4× / 1.5× +多半是手动把 `positions.profit` 当 quote currency 处理。`positions.profit` 是 deposit currency(USD),不是 quote currency。详见 §隐含约定 P4。 + +### 返回 "无法连接到远程服务器" +Bridge 未运行或网络不通,先检查 `/health`。 + +### 业务字段返回默认值 → 怎么区分"无数据"和"真状态" +`/account` 在账户未加载完成时返回 `data:[]` + 全零字段(HTTP 200)。客户端若只看 `bool(response)` 或 `field == 0`,会把"无数据"误判成"零值正常状态"。详见 §隐含约定 P7。 + +--- + +## 与 RaptorBT 策略框架集成 + +RaptorBT 的 `app/main.py` 已内置 Mt5Bridge 数据加载,通过环境变量配置连接: + +### 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `MT5_BRIDGE_URL` | `http://61.164.252.86:13485` | Bridge 服务地址 | +| `MT5_BRIDGE_KEY` | (内置默认) | API Key | + +### 设置方式 + +**Windows PowerShell**: +```powershell +$env:MT5_BRIDGE_URL = "http://61.164.252.86:13485" +$env:MT5_BRIDGE_KEY = "your-api-key" +``` + +**Linux/macOS**: +```bash +export MT5_BRIDGE_URL="http://61.164.252.86:13485" +export MT5_BRIDGE_KEY="your-api-key" +``` + +**或写入 `.env` 文件**(已被 `.gitignore` 忽略): +``` +MT5_BRIDGE_URL=http://61.164.252.86:13485 +MT5_BRIDGE_KEY=your-api-key +``` + +### 在策略中使用 + +```bash +# 设置环境变量后,直接用 CLI 拉取真实数据回测 +python -m app.main run --strategy sma_cross --symbol XAUUSD --timeframe H1 --bars 500 + +# 优化参数 +python -m app.main optimize --strategy sma_cross \ + --param fast=5,10,15 --param slow=20,30 --symbol XAUUSD --bars 1000 + +# 完整交付 +python -m app.main deliver --strategy sma_cross \ + --param fast=5,10 --param slow=20,30 --symbol XAUUSD --bars 1000 +``` + +### 安全提示 + +- **生产环境务必使用环境变量**,不要将 API Key 硬编码到策略文件或提交到版本控制 +- `.env` 文件已被 `.gitignore` 忽略,可安全存放密钥 +- 若 Key 泄露,联系 Bridge 管理员重置 \ No newline at end of file diff --git a/docs/RaptorBT使用手册.md b/docs/RaptorBT使用手册.md new file mode 100644 index 0000000..3f4c661 --- /dev/null +++ b/docs/RaptorBT使用手册.md @@ -0,0 +1,1011 @@ +# RaptorBT 使用手册(含 ferro-ta 扩展版) + +> 基于 RaptorBT v0.4.1,集成 ferro-ta 指标库,提供 80 个技术指标 + +--- + +## 目录 + +- [项目简介](#项目简介) +- [安装与部署](#安装与部署) +- [快速开始](#快速开始) +- [策略类型](#策略类型) +- [指标完整参考](#指标完整参考) + - [趋势指标 (Trend)](#趋势指标-trend) + - [动量指标 (Momentum)](#动量指标-momentum) + - [波动率指标 (Volatility)](#波动率指标-volatility) + - [强度指标 (Strength)](#强度指标-strength) + - [成交量指标 (Volume)](#成交量指标-volume) + - [价格变换指标 (Price Transform)](#价格变换指标-price-transform) + - [统计指标 (Statistic)](#统计指标-statistic) + - [P0 扩展指标 (P0 Extended)](#p0-扩展指标-p0-extended) + - [周期变换指标 (Hilbert Transform / Cycle)](#周期变换指标-hilbert-transform-cycle) + - [市场状态检测 (Market Regime)](#市场状态检测-market-regime) + - [投资组合工具 (Portfolio Tools)](#投资组合工具-portfolio-tools) + - [Tick 微结构函数](#tick-微结构函数) +- [止损与止盈](#止损与止盈) +- [蒙特卡洛组合模拟](#蒙特卡洛组合模拟) +- [回测结果与指标](#回测结果与指标) +- [前视偏差防范](#前视偏差防范) +- [ sourcing修改与编译指南](#源码修改与编译指南) + - [项目结构](#项目结构) + - [添加新指标](#添加新指标) + - [编译与打包](#编译与打包) + - [常见编译问题](#常见编译问题) +- [移植与分发](#移植与分发) + +--- + +## 项目简介 + +RaptorBT 是一个高性能 Rust 回测引擎,通过 PyO3 提供 Python 绑定: + +- **亚毫秒级回测**:1K bars ~0.03ms,50K bars ~1.4ms +- **7 种策略类型**:单标的、篮子、配对、期权、价差、多策略、Tick 级 +- **33 项绩效指标**:Sharpe、Sortino、Calmar、Omega、SQN 等 +- **确定性执行**:相同输入产生 bit-for-bit 相同结果 +- **原生并行**:Rayon 并行 + SIMD 优化 +- **80 个技术指标**:集成 ferro-ta 指标库,覆盖趋势、动量、波动率、强度、成交量、价格变换、统计、周期变换、市场状态检测、投资组合工具 + +本扩展版在原版 12 个指标基础上,集成 ferro-ta 指标库,将指标总数扩展至 **51 个**。 + +--- + +## 安装与部署 + +### 方式一:安装修改版 whl(推荐) + +适用于同平台同 Python 版本的电脑,无需编译环境: + +```powershell +pip install raptorbt-0.4.1-cp312-cp312-win_amd64.whl +pip install -r requirements.txt +``` + +`requirements.txt` 内容: + +``` +numpy +pandas +requests +``` + +> **注意**:whl 文件名中 `cp312` 表示 CPython 3.12,`win_amd64` 表示 Windows 64位。目标电脑必须满足相同条件。 + +### 方式二:从源码编译 + +需要 Rust 1.70+、Python 3.10+、maturin: + +```powershell +cd raptorbt +pip install maturin +maturin develop --release +``` + +### 验证安装 + +```python +import raptorbt +print(raptorbt.__version__) # 0.4.1 +print(raptorbt.cci) # — 扩展指标可用 +``` + +--- + +## 快速开始 + +```python +import numpy as np +import raptorbt + +# 准备数据 +n = 500 +close = np.cumprod(1 + np.random.randn(n) * 0.02) * 100 +timestamps = np.arange(n, dtype=np.int64) +open_ = close * 1.001 +high = close * 1.01 +low = close * 0.99 +volume = np.ones(n) * 1000 + +# 使用指标生成信号 +sma_fast = raptorbt.sma(close, period=10) +sma_slow = raptorbt.sma(close, period=20) +entries = (sma_fast > sma_slow) & np.roll(sma_fast <= sma_slow, 1) +exits = (sma_fast < sma_slow) & np.roll(sma_fast >= sma_slow, 1) + +# 配置回测 +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="TEST", + 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}%") +print(f"交易次数: {result.metrics.total_trades}") +``` + +--- + +## 策略类型 + +### 1. 单标的回测 (run_single_backtest) + +```python +result = raptorbt.run_single_backtest( + timestamps=timestamps, # int64 数组 + open=open, high=high, low=low, close=close, # float64 数组 + volume=volume, # float64 数组 + entries=entries, # bool 数组 + exits=exits, # bool 数组 + direction=1, # 1=做多, -1=做空 + weight=1.0, + symbol="AAPL", + config=config, + instrument_config=raptorbt.PyInstrumentConfig(lot_size=1.0), # 可选 +) +``` + +### 2. 篮子回测 (run_basket_backtest) + +多标的同步信号交易: + +```python +instruments = [ + (ts, o1, h1, l1, c1, v1, entries1, exits1, 1, 0.33, "AAPL"), + (ts, o2, h2, l2, c2, v2, entries2, exits2, 1, 0.33, "GOOGL"), + (ts, o3, h3, l3, c3, v3, entries3, exits3, 1, 0.34, "MSFT"), +] + +result = raptorbt.run_basket_backtest( + instruments=instruments, + config=config, + sync_mode="all", # "all" | "any" | "majority" | "master" +) +``` + +### 3. 配对交易 (run_pairs_backtest) + +做多一个标的,做空另一个: + +```python +result = raptorbt.run_pairs_backtest( + leg1_timestamps=ts, leg1_open=..., leg1_high=..., leg1_low=..., leg1_close=..., leg1_volume=..., + leg2_timestamps=ts, leg2_open=..., leg2_high=..., leg2_low=..., leg2_close=..., leg2_volume=..., + entries=entries, exits=exits, + direction=1, symbol="PAIR", + config=config, + hedge_ratio=1.5, # 空头 1.5 倍 + dynamic_hedge=False, +) +``` + +### 4. 期权回测 (run_options_backtest) + +```python +result = raptorbt.run_options_backtest( + timestamps=ts, open=..., high=..., low=..., close=..., volume=..., + 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, + strike_interval=50.0, +) +``` + +### 5. 多策略回测 (run_multi_backtest) + +同一标的上组合多个策略: + +```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=..., high=..., low=..., close=..., volume=..., + strategies=strategies, + config=config, + combine_mode="any", # "any" | "all" | "majority" | "weighted" | "independent" +) +``` + +### 6. 批量价差回测 (batch_spread_backtest) + +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 级回测 (run_tick_backtest) + +全 Tick 分辨率模拟,无 bar 重采样: + +```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="NIFTY26APR24600PE", + 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)` + +--- + +## 指标完整参考 + +所有指标函数接收 NumPy `float64` 数组,返回 NumPy 数组。预热期返回 `NaN`。 + +### 趋势指标 (Trend) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `sma` | `sma(data, period)` | `ndarray` | 简单移动平均 | +| `ema` | `ema(data, period)` | `ndarray` | 指数移动平均 | +| `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 | +| `supertrend` | `supertrend(high, low, close, period=10, multiplier=3.0)` | `(ndarray, ndarray)` | Supertrend 线 + 方向 (1=多, -1=空) | + +**用法示例:** + +```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) + +sma20 = raptorbt.sma(close, period=20) +ema20 = raptorbt.ema(close, period=20) +dema20 = raptorbt.dema(close, period=20) +kama10 = raptorbt.kama(close, period=10) +t3_val = raptorbt.t3(close, period=20, vfactor=0.7) +sar_val = raptorbt.sar(high, low, acceleration=0.02, maximum=0.2) +st_line, st_dir = raptorbt.supertrend(high, low, close, period=10, multiplier=3.0) +``` + +### 动量指标 (Momentum) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rsi` | `rsi(data, period)` | `ndarray` | 相对强弱指数 (0~100) | +| `macd` | `macd(data, fast_period=12, slow_period=26, signal_period=9)` | `(line, signal, hist)` | MACD | +| `stochastic` | `stochastic(high, low, close, k_period=14, d_period=3)` | `(k, d)` | 随机振荡器 (0~100) | +| `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` | 绝对价格振荡器 | + +**用法示例:** + +```python +rsi14 = raptorbt.rsi(close, period=14) +macd_line, macd_signal, macd_hist = raptorbt.macd(close, 12, 26, 9) +stoch_k, stoch_d = raptorbt.stochastic(high, low, close, k_period=14, d_period=3) +cci20 = raptorbt.cci(high, low, close, period=20) +ppo_line, ppo_signal, ppo_hist = raptorbt.ppo(close, fastperiod=12, slowperiod=26, signalperiod=9) +aroon_up, aroon_down = raptorbt.aroon(high, low, period=14) +``` + +### 波动率指标 (Volatility) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `atr` | `atr(high, low, close, period)` | `ndarray` | 平均真实波幅 | +| `natr` | `natr(high, low, close, period)` | `ndarray` | 归一化 ATR (%) | +| `trange` | `trange(high, low, close)` | `ndarray` | 真实波幅 | +| `bollinger_bands` | `bollinger_bands(data, period=20, std_dev=2.0)` | `(upper, middle, lower)` | 布林带 | +| `stddev` | `stddev(data, period, nbdev=1.0)` | `ndarray` | 标准差 | +| `var` | `var(data, period, nbdev=1.0)` | `ndarray` | 方差 | + +**用法示例:** + +```python +atr14 = raptorbt.atr(high, low, close, period=14) +natr14 = raptorbt.natr(high, low, close, period=14) +bb_upper, bb_middle, bb_lower = raptorbt.bollinger_bands(close, period=20, std_dev=2.0) +std20 = raptorbt.stddev(close, period=20, nbdev=1.0) +``` + +### 强度指标 (Strength) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `adx` | `adx(high, low, close, period)` | `ndarray` | 平均方向指数 (0~100) | +| `adx_all` | `adx_all(high, low, close, period)` | `(adx, +di, -di)` | ADX + 正DI + 负DI | +| `adxr` | `adxr(high, low, close, period)` | `ndarray` | ADX 评级 | +| `plus_di` | `plus_di(high, low, close, period)` | `ndarray` | +DI 方向指标 | +| `minus_di` | `minus_di(high, low, close, period)` | `ndarray` | -DI 方向指标 | + +**用法示例:** + +```python +adx14 = raptorbt.adx(high, low, close, period=14) +adx_val, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) +adxr14 = raptorbt.adxr(high, low, close, period=14) +``` + +### 成交量指标 (Volume) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `vwap` | `vwap(high, low, close, volume)` | `ndarray` | 成交量加权平均价 | +| `obv` | `obv(close, volume)` | `ndarray` | 能量潮 | +| `ad` | `ad(high, low, close, volume)` | `ndarray` | 累积/派发线 | +| `adosc` | `adosc(high, low, close, volume, fastperiod=3, slowperiod=10)` | `ndarray` | 累积/派发振荡器 | +| `mfi` | `mfi(high, low, close, volume, period)` | `ndarray` | 资金流量指数 (0~100) | + +**用法示例:** + +```python +vwap_val = raptorbt.vwap(high, low, close, volume) +obv_val = raptorbt.obv(close, volume) +ad_val = raptorbt.ad(high, low, close, volume) +adosc_val = raptorbt.adosc(high, low, close, volume, fastperiod=3, slowperiod=10) +mfi14 = raptorbt.mfi(high, low, close, volume, period=14) +``` + +### 价格变换指标 (Price Transform) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `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 | + +### 统计指标 (Statistic) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `linearreg` | `linearreg(data, period)` | `ndarray` | 线性回归 | +| `linearreg_slope` | `linearreg_slope(data, period)` | `ndarray` | 线性回归斜率 | +| `linearreg_intercept` | `linearreg_intercept(data, period)` | `ndarray` | 线性回归截距 | +| `linearreg_angle` | `linearreg_angle(data, period)` | `ndarray` | 线性回归角度 | +| `tsf` | `tsf(data, period)` | `ndarray` | 时间序列预测 | +| `beta` | `beta(data0, data1, period)` | `ndarray` | Beta 系数 | +| `correl` | `correl(data0, data1, period)` | `ndarray` | 相关系数 | + +### P0 扩展指标 (P0 Extended) + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `vwma` | `vwma(data, volume, period=20)` | `ndarray` | 成交量加权移动平均 | +| `donchian` | `donchian(high, low, period)` | `(upper, middle, lower)` | 唐奇安通道 | +| `choppiness_index` | `choppiness_index(high, low, close, period=14)` | `ndarray` | 混沌指标 (0=趋势, 100=震荡) | +| `hull_ma` | `hull_ma(data, period)` | `ndarray` | Hull 移动平均 | +| `chandelier_exit` | `chandelier_exit(high, low, close, period=22, multiplier=3.0)` | `(long_exit, short_exit)` | 吊灯止损 (ATR追踪止损) | +| `ichimoku` | `ichimoku(high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=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) | + +**用法示例:** + +```python +vwap_val = raptorbt.vwma(close, volume, period=20) +donchian_upper, donchian_middle, donchian_lower = raptorbt.donchian(high, low, 20) +ci_val = raptorbt.choppiness_index(high, low, close, period=14) +hma_val = raptorbt.hull_ma(close, period=14) +clong, cshort = raptorbt.chandelier_exit(high, low, close, period=22, multiplier=3.0) +tenkan, kijun, senkou_a, senkou_b, chikou = raptorbt.ichimoku(high, low, close) +pivot, r1, s1, r2, s2 = raptorbt.pivot_points(high, low, close, method="classic") +``` + +### 周期变换指标 (Hilbert Transform / Cycle) + +基于希尔伯特变换的周期分析工具。要求数据至少 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=周期) | + +**用法示例:** + +```python +trendline = raptorbt.ht_trendline(close) +dcperiod = raptorbt.ht_dcperiod(close) +dcphase = raptorbt.ht_dcphase(close) +in_phase, quadrature = raptorbt.ht_phasor(close) +sine, lead_sine = raptorbt.ht_sine(close) +mode = raptorbt.ht_trendmode(close) # i32: 1=trend, 0=cycle +``` + +### 市场状态检测 (Market Regime) + +用于判断当前市场处于趋势或震荡状态,以及检测结构性突变。 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `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=突变点) | + +**用法示例:** + +```python +adx_vals = raptorbt.adx(high, low, close, 14) +regime = raptorbt.regime_adx(adx_vals, threshold=25.0) # 1=trend, 0=range + +# 组合判断 +regime2 = raptorbt.regime_combined(adx_vals, atr_vals, close, 25.0, 2.0) + +# 结构突变 +breaks = raptorbt.detect_breaks_cusum(close, window=30, threshold=1.5, slack=0.5) +vol_breaks = raptorbt.rolling_variance_break(close, 10, 30, 2.0) +``` + +### 投资组合工具 (Portfolio Tools) + +跨序列分析工具,用于配对交易、风险管理、相对强度计算。 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `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) | + +**用法示例:** + +```python +# 滚动 Beta +rb = raptorbt.rolling_beta(asset_returns, benchmark_returns, 30) + +# 回撤分析 +dd_series, max_dd = raptorbt.drawdown_series(equity_curve) + +# Z-Score +zscore = raptorbt.zscore_series(close, window=20) + +# 配对交易价差 +spread_val = raptorbt.spread(series_a, series_b, hedge=1.0) +ratio_val = raptorbt.ratio(series_a, series_b) +``` + +### 滚动统计 + +| 函数 | 签名 | 返回 | 说明 | +|------|------|------|------| +| `rolling_min` | `rolling_min(data, period)` | `ndarray` | 周期内最低值 (LLV) | +| `rolling_max` | `rolling_max(data, period)` | `ndarray` | 周期内最高值 (HHV) | + +### Tick 微结构函数 + +用于 Tick 级回测的信号和特征函数: + +```python +# 入场/出场信号 +entries = raptorbt.compute_tick_entry_signals( + spread_pct=raptorbt.tick_spread_pct(bid, ask), + bsi_delta=raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum), + return_1m=raptorbt.return_window(timestamps_ns, ltp, window_seconds=60.0), + spread_pct_max=3.0, + bsi_min=0.55, + return_1m_min_abs=0.3, + return_direction=1, + cooldown_ticks=10, +) +exits = raptorbt.compute_tick_exit_signals(timestamps_ns, eod_exit_time_ns) + +# 特征数组 +spread = raptorbt.tick_spread_pct(bid, ask) # 价差百分比 +bsi = raptorbt.buy_sell_imbalance_delta(buy_cum, sell_cum) # 买卖失衡 +ret_1m = raptorbt.return_window(ts_ns, ltp, 60.0) # 1分钟回报 +vol = raptorbt.realized_vol_rolling(ts_ns, ltp, 300.0) # 5分钟已实现波动率 +oi_pos = raptorbt.oi_position_pct(oi, oi_high, oi_low) # OI 位置百分比 +velocity = raptorbt.tick_velocity(ts_ns, 60.0) # Tick 速度 (ticks/min) +``` + +--- + +## 止损与止盈 + +### 固定百分比 + +```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` | Omega 比率 | + +**回撤:** + +| 字段 | 说明 | +|------|------| +| `max_drawdown_pct` | 最大回撤 (%) | +| `max_drawdown_duration` | 最长回撤持续期 (bars) | + +**交易统计:** + +| 字段 | 说明 | +|------|------| +| `total_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` | 最差交易 (%) | +| `payoff_ratio` | 盈亏比 | +| `recovery_factor` | 恢复因子 | + +**其他:** + +| 字段 | 说明 | +|------|------| +| `start_value` | 初始资金 | +| `end_value` | 终值 | +| `total_fees_paid` | 总手续费 | +| `open_trade_pnl` | 未平仓盈亏 | +| `exposure_pct` | 市场暴露度 (%) | +| `avg_holding_period` | 平均持仓时间 (bars) | +| `max_consecutive_wins` | 最大连胜 | +| `max_consecutive_losses` | 最大连败 | + +```python +m = result.metrics +print(m.total_return_pct, m.sharpe_ratio, m.max_drawdown_pct) + +# 转为字典(24 个常用指标,带中文友好标签) +stats = m.to_dict() +``` + +### 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 # 出场原因 +``` + +--- + +## 前视偏差防范 + +前视偏差(Look-ahead Bias)是回测中最危险的陷阱之一——无意中使用了"未来数据"来做出"当前决策",导致回测结果虚高。 + +### RaptorBT 的默认防护 + +RaptorBT 默认 `upon_bar_close=True`,含义是: + +- 当前 K 线收盘后产生的信号,在**下一根 K 线开盘时**执行 +- 这保证了信号生成时只能看到当前及之前的数据,不可能用到未来数据 + +### 需要额外注意的场景 + +1. **指标预热期**:前 N 根 K 线的指标值为 NaN,不要用这些 NaN 值生成信号 +2. **未来函数**:避免使用 `shift(-1)` 等"偷看未来"的操作 +3. **日线数据**:如果用日线回测,确保信号不依赖当日收盘价之后的信息 +4. **复权数据**:前复权/后复权可能引入未来信息,建议使用不复权数据 + +--- + +## 源码修改与编译指南 + +### 项目结构 + +``` +my-python-backteat/ +├── app/ # 应用层 (Python) ★ +│ ├── __init__.py # 包入口 + 版本号 +│ ├── main.py # CLI 入口 (7 个子命令) +│ ├── indicators.py # 自定义指标库 (转发 ferro-ta 原生) +│ ├── optimizer.py # 参数网格搜索优化器 +│ ├── walk_forward.py # Walk-Forward 验证 + 过拟合检测 +│ ├── acceptance.py # 策略验收标准 +│ ├── scaffold.py # 策略模板生成器 +│ └── exporter.py # 交付包打包导出 +├── strategies/ # 策略框架 (用户扩展区) ★ +│ ├── __init__.py # 自动发现注册表 +│ ├── base.py # Strategy 基类 + SignalResult +│ └── *.py # 各策略实现文件 +├── docs/ # 文档 +├── src/ # RaptorBT 引擎 (Rust) +│ ├── lib.rs # Rust 库入口 +│ ├── core/ # 核心类型与错误 +│ ├── indicators/ # 技术指标 +│ │ ├── mod.rs +│ │ ├── trend.rs # 趋势指标 (SMA, EMA, Supertrend) +│ │ ├── momentum.rs # 动量指标 (RSI, MACD, Stochastic) +│ │ ├── volatility.rs # 波动率指标 (ATR, Bollinger Bands) +│ │ ├── strength.rs # 强度指标 (ADX) +│ │ ├── volume.rs # 成交量指标 (VWAP, OBV, MFI) +│ │ ├── rolling.rs # 滚动统计 (Rolling Min/Max) +│ │ ├── tick_features.rs # Tick 微结构函数 +│ │ └── ferro_bridge.rs # ferro-ta 指标桥接层 ★ +│ ├── portfolio/ # 回测引擎核心 +│ ├── signals/ # 信号处理 +│ ├── stops/ # 止损止盈 +│ ├── metrics/ # 绩效指标 +│ └── python/ +│ └── bindings.rs # PyO3 Python 绑定 ★ +├── python/ +│ └── raptorbt/ +│ └── __init__.py # Python 包导出 ★ +├── benches/ # Rust 基准测试 +├── tests/ # Rust 单元测试 +├── Cargo.toml # Rust 依赖配置 +├── pyproject.toml # Python 项目配置 +├── requirements.txt # Python 运行时依赖 +└── rustfmt.toml # Rust 代码格式配置 +``` + +标 ★ 的文件是添加新指标或扩展应用层时需要修改的。 + +> **注意**:`ferro-ta` 已作为独立包单独管理,不再放在本项目目录下。新增指标时在 `src/indicators/ferro_bridge.rs` 中桥接即可。 + +### 添加新指标 + +以添加一个 ferro-ta 中的指标为例,需要修改 3 个文件: + +#### 步骤 1:在 `ferro_bridge.rs` 中添加桥接函数 + +```rust +// src/indicators/ferro_bridge.rs + +pub fn my_indicator(data: &[f64], period: usize) -> Result> { + if period == 0 { + return Err(RaptorError::invalid_parameter("MyIndicator period must be > 0")); + } + Ok(ferro_ta_core::category::my_indicator(data, period)) +} +``` + +**关键注意事项:** + +- 如果指标内部使用 EMA 处理中间结果(如 DEMA、TEMA、TRIX、PPO),必须使用 `ema_nan_safe` 而非 `ferro_ta_core::overlap::ema`,因为后者在输入含 NaN 时会全部输出 NaN +- 多输出指标需要定义 Result 结构体(参考 `PpoResult`、`AroonResult`) + +#### 步骤 2:在 `bindings.rs` 中添加 Python 绑定 + +```rust +// src/python/bindings.rs + +#[pyfunction] +pub fn my_indicator<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::my_indicator(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} +``` + +然后在 `#[pymodule]` 注册函数中添加: + +```rust +m.add_function(wrap_pyfunction!(my_indicator, m)?)?; +``` + +#### 步骤 3:在 `__init__.py` 中导出 + +```python +# python/raptorbt/__init__.py + +from raptorbt._raptorbt import ( + # ... 已有导出 ... + my_indicator, +) + +__all__ = [ + # ... 已有导出 ... + "my_indicator", +] +``` + +#### 步骤 4:在 `mod.rs` 中导出(如果需要在 Rust 内部使用) + +```rust +// src/indicators/mod.rs + +pub use ferro_bridge::{ /* ... */, my_indicator }; +``` + +### 编译与打包 + +#### 开发编译(直接安装到当前虚拟环境) + +```powershell +maturin develop --release +``` + +#### 产出 whl 文件 + +```powershell +maturin build --release +# 产出: target/wheels/raptorbt-0.4.1-cp312-cp312-win_amd64.whl +``` + +#### 安装 whl + +```powershell +pip uninstall raptorbt -y +pip install target\wheels\raptorbt-0.4.1-cp312-cp312-win_amd64.whl +``` + +### 常见编译问题 + +#### 1. 拒绝访问 (os error 5) + +**原因**:环境变量 `CARGO` 被错误设置为目录路径 `C:\Users\Administrator\.cargo\bin`,maturin 把目录当可执行文件调用。 + +**修复**: + +```powershell +# 临时修复(当前终端,推荐) +$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe" + +# 永久修复:系统属性 → 环境变量 → 删除或修正小写 CARGO 变量 +# 确保值指向可执行文件而非目录 +``` + +> 如果不想设置环境变量,也可以在 PowerShell profile 中配置: +> `$HOME\.config\powershell\Microsoft.PowerShell_profile.ps1` 中添加 `$env:CARGO = "C:\Users\Administrator\.cargo\bin\cargo.exe"` + +#### 2. .pyd 文件被锁定 + +**原因**:Python 进程占用了旧的 `.pyd` 文件。 + +**修复**:退出虚拟环境,关闭所有 Python 进程,重新打开终端编译。 + +#### 3. 找不到 ferro_ta_core + +**原因**:`Cargo.toml` 中的本地依赖路径不正确。 + +**修复**:确认 `Cargo.toml` 中路径指向正确的 ferro-ta 源码位置: + +```toml +[dependencies] +ferro_ta_core = { path = "./ferro-ta-main/crates/ferro_ta_core", default-features = false } +``` + +#### 4. EMA 链式指标全输出 NaN + +**原因**:`ferro_ta_core::overlap::ema` 在输入含 NaN 时会传播 NaN,导致链式 EMA(DEMA、TEMA、TRIX、PPO)全部为 NaN。 + +**修复**:在 `ferro_bridge.rs` 中使用 `ema_nan_safe` 函数替代直接调用 `ferro_ta_core::overlap::ema` 处理中间结果。`ema_nan_safe` 会跳过 NaN 值计算初始种子,确保链式 EMA 正常工作。 + +--- + +## 移植与分发 + +### whl 文件分发 + +修改版 whl 已静态链接所有 Rust 依赖(包括 ferro-ta),目标电脑**不需要**安装 Rust 或 ferro-ta 源码。 + +**前提条件**: +- 相同操作系统 + 架构(如 Windows x64) +- 相同 Python 版本(如 3.12) + +**安装步骤**: + +```powershell +pip install raptorbt-0.4.1-cp312-cp312-win_amd64.whl +pip install numpy pandas requests +``` + +### 源码分发 + +如果目标电脑 Python 版本不同或需要进一步修改,需要携带源码: + +**必须保留的文件/目录**: +- `src/` — RaptorBT 修改后的源码 +- `python/` — Python 包代码 +- `Cargo.toml`、`pyproject.toml` — 项目配置 +- `ferro-ta-main/` — ferro-ta 源码(编译时需要) +- `requirements.txt` — Python 依赖 + +**在新电脑上编译**: + +```powershell +# 安装 Rust (https://rustup.rs) +# 安装 Python 3.10+ +pip install maturin numpy +maturin develop --release +``` + +### 关系图 + +``` +源码 (src/ + ferro-ta-main/) + │ + ├── maturin develop ──→ 直接安装到当前环境(开发用) + │ + └── maturin build ───→ .whl 文件(分发用) + │ + └── pip install xxx.whl → 可移植到同平台电脑 +``` + +--- + +## 附录:指标分类速查 + +### 原版指标 (12个) + +SMA, EMA, RSI, MACD, Stochastic, ATR, Bollinger Bands, ADX, VWAP, Supertrend, Rolling Min, Rolling Max + +### ferro-ta 扩展指标 (68个) + +**趋势类**:WMA, DEMA, TEMA, KAMA, T3, TRIMA, Midpoint, Midprice, SAR, HullMA, Donchian, ChoppinessIndex, ChandelierExit, Ichimoku, PivotPoints + +**动量类**:CCI, WillR, ROC, MOM, CMO, TRIX, StochRSI, Aroon, AroonOsc, BOP, UltOSC, PPO, APO + +**波动率类**:NATR, TRange, StdDev, VAR + +**强度类**:ADXr, Plus_DI, Minus_DI, ADX_all + +**成交量类**:AD, ADOSC, OBV, MFI, VWMA + +**价格变换类**:TypPrice, MedPrice, AvgPrice, WclPrice + +**统计类**:LinearReg, LinearReg_Slope, LinearReg_Intercept, LinearReg_Angle, TSF, Beta, Correl + +**周期变换类 (Hilbert)**:HT_Trendline, HT_DCPeriod, HT_DCPhase, HT_Phasor, HT_Sine, HT_TrendMode + +**市场状态类**:Regime_ADX, Regime_Combined, DetectBreaks_CUSUM, RollingVarianceBreak + +**投资组合工具类**:RollingBeta, DrawdownSeries, ZScoreSeries, RelativeStrength, Spread, Ratio \ No newline at end of file diff --git a/docs/策略自动化框架.md b/docs/策略自动化框架.md new file mode 100644 index 0000000..e38cb8c --- /dev/null +++ b/docs/策略自动化框架.md @@ -0,0 +1,1238 @@ +# 策略自动化框架 + +> RaptorBT v0.5.0+ 内置的 AI 友好策略自动化框架 + +RaptorBT 策略自动化框架覆盖从策略开发到交付的完整流程: + +``` +scaffold 生成模板 → 编写信号逻辑 → optimize 参数优化 +→ walkforward 验证 → acceptance 验收 → deliver 生成交付包 +``` + +设计目标:让 AI agent 能自主完成策略研发、优化、验证、交付的全流程,无需人工干预。 + +--- + +## 目录 + +- [快速开始](#快速开始) +- [CLI 命令参考](#cli-命令参考) +- [策略框架 (strategies/)](#策略框架-strategies) +- [CSV 数据加载器 (data_loader.py)](#csv-数据加载器-data_loaderpy) +- [参数优化器 (optimizer.py)](#参数优化器-optimizerpy) +- [Walk-Forward 验证 (walk_forward.py)](#walk-forward-验证-walk_forwardpy) +- [验收检查 (acceptance.py)](#验收检查-acceptancepy) +- [策略模板生成器 (scaffold.py)](#策略模板生成器-scaffoldpy) +- [交付包导出 (exporter.py)](#交付包导出-exporterpy) +- [前视偏差防护 (lookahead_check.py)](#前视偏差防护-lookahead_checkpy) +- [自定义指标库 (indicators.py)](#自定义指标库-indicatorspy) +- [指标目录 (indicator_catalog.py)](#指标目录-indicator_catalogpy) +- [AI agent 集成指南](#ai-agent-集成指南) + +--- + +## 快速开始 + +### 环境准备 + +```bash +# 1. 安装依赖 +pip install -r requirements.txt + +# 2. 设置 Mt5Bridge (可选, 用于最终验证) +set MT5_BRIDGE_URL=http://61.164.252.86:13485 +set MT5_BRIDGE_KEY=your-api-key +``` + +### 编译 RaptorBT 引擎 (换电脑必读) + +策略自动化框架依赖 `raptorbt` 原生扩展。项目已内置 `vendor/ferro-ta-main/`(80+ 指标的 Rust 源码,**随项目提交**),换电脑或部署新环境后只需按以下步骤编译: + +**前置依赖**: + +| 依赖 | 版本 | 说明 | +|------|------|------| +| Rust toolchain | 1.70+ | `cargo`、`rustc`(从 https://rustup.rs 安装) | +| Python | 3.10+ | 与编译时 Python 版本一致 | +| maturin | latest | `pip install maturin`,Rust → Python 扩展构建工具 | +| `vendor/ferro-ta-main/` | — | **已随项目提交**,`Cargo.toml` 通过 `path` 引用,无需联网拉取 | + +**编译方式**: + +```bash +# 方式 A: 构建 whl 包 (推荐, 全局安装) +maturin build --release +# 产物: target/wheels/raptorbt-0.4.1-cp312-cp312-win_amd64.whl +pip install --force-reinstall target/wheels/raptorbt-*.whl + +# 方式 B: 虚拟环境开发模式 (需先激活 venv/conda) +maturin develop --release +# 编译到 python/raptorbt/_raptorbt.*.pyd +``` + +> ⚠️ **`maturin develop` 需要虚拟环境**:必须在已激活的 venv/conda 环境中运行,否则会报 "Couldn't find a virtualenv or conda environment"。全局安装请用 `maturin build` + `pip install` 方式。 + +> 💡 **可移植性保障**:[Cargo.toml](../Cargo.toml) 中 `ferro_ta_core` 通过 `path = "vendor/ferro-ta-main/crates/ferro_ta_core"` 引用源码,**vendor/ 目录必须随项目提交**(已在 .gitignore 中注明不可忽略)。换电脑 clone 项目后无需联网拉取外部 crate,直接 `maturin build --release` 即可编译。 + +### 数据准备 + +框架支持两种数据源,默认使用 CSV 离线数据(适合策略研究): + +- **CSV(默认)**:把 MT5 History Center / quant data manager 导出的 M1 CSV 放到项目根目录的 `data/` 目录。文件名需包含品种代码(如 `XAUUSD`)和时区信息(如 `UTCPlus02`),加载器会自动识别品种、时区并重采样到目标周期。spread 列会自动转为 slippage。 +- **Mt5Bridge**:用 `--source mt5` 切换,从远程 MT5 拉取实时数据。适合最终策略验证,不建议用于参数搜索阶段。 + +```bash +# data/ 目录示例 +# data/2021.7.6-2026.07.03M1XAUUSD_TICK_UTCPlus02.csv +# data/2021.7.6-2026.07.03M1EURUSD_TICK_UTCPlus02.csv + +python -m app.main list # 会自动列出 data/ 下可用的 CSV 品种 +``` + +### 30 秒体验 + +```bash +# 列出所有策略 +python -m app.main list + +# 生成一个 RSI 策略模板 +python -m app.main scaffold --name my_rsi --template mean_reversion + +# 优化 SMA 交叉策略参数 (默认用 CSV 离线数据) +python -m app.main optimize --strategy sma_cross \ + --param fast=5,10,15 --param slow=20,30,40 --export + +# 生成完整交付包 +python -m app.main deliver --strategy sma_cross \ + --param fast=5,10 --param slow=20,30 +``` + +--- + +## CLI 命令参考 + +### 1. list — 列出所有策略 / 可用指标 + +```bash +python -m app.main list # 列出策略 +python -m app.main list --indicators # 列出所有可用指标 (80 个) +python -m app.main list --indicators --json # JSON 结构化输出 (供 AI agent 解析) +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--indicators` | false | 列出所有可用指标及签名 (供 AI agent 查询) | +| `--json` | false | 输出 JSON (供 AI agent 解析) | + +输出示例 (策略): +``` +可用策略 (4 个): +════════════════════════════════════════════════════════════ + atr_stop_rr SMA(10/20) 交叉 + 2.0×ATR(14) 止损 + 2.0:1 风险回报止盈 + rsi_mean_reversion RSI(14) 均值回归, 买入<30.0/卖出>70.0, 3% 追踪止损 + sar_adx_cci SAR(0.02/0.2) + ADX(14)>25.0 + CCI(20)±100.0, 2.5×ATR 止损/4% 止盈 + sma_cross SMA(10)/SMA(20) 双均线交叉, 2% 止损/4% 止盈 +════════════════════════════════════════════════════════════ +``` + +输出示例 (指标 `--indicators`): +``` +可用指标目录 (80 个) +════════════════════════════════════════════════════════════ +调用方式: import raptorbt; raptorbt.(numpy_array, ...) +输入约定: close/high/low/open/volume 都是 float64 numpy array + +── 趋势 (8 个) ── + sma(close, period) + → 1 array + 简单移动平均, 最基础的趋势指标 + ... +── 动量 (13 个) ── + ... +``` + +> 💡 **AI agent 开发策略前建议先 `list --indicators --json`**,获取 80 个指标的完整签名、输入需求、默认值和返回值结构,详见 [指标目录](#指标目录-indicator_catalogpy) 章节。 + +### 2. run — 运行单个策略 + +```bash +python -m app.main run --strategy sma_cross \ + --symbol XAUUSD --timeframe H1 --bars 500 --export +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--strategy` | (必填) | 策略名称 | +| `--symbol` | XAUUSD | 交易品种 | +| `--timeframe` | H1 | K 线周期 | +| `--bars` | 500 | K 线数量 | +| `--source` | csv | 数据源 (`csv` 离线 / `mt5` 实时) | +| `--data-file` | None | 显式指定 CSV 文件路径 (默认按 symbol 自动查找) | +| `--export` | false | 导出 CSV 结果 (trades/curves/metrics) | +| `--json` | false | 输出 JSON (含 metrics + 前 50 条 trades, 供 AI agent 解析) | + +### 3. compare — 对比所有策略 + +```bash +python -m app.main compare --symbol XAUUSD --bars 500 --export +``` + +在相同数据上跑所有策略,输出对比表格。 + +### 4. optimize — 参数网格搜索 + +```bash +python -m app.main optimize --strategy sma_cross \ + --param fast=5,10,15,20 --param slow=20,30,40,50 \ + --metric sharpe_ratio --symbol XAUUSD --bars 1000 --export +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--param` | (必填,可多次) | 参数空间,格式 `name=v1,v2,v3` | +| `--metric` | sharpe_ratio | 优化目标指标 | +| `--export` | false | 导出完整响应面到 CSV | +| `--json` | false | 输出 JSON (含 best_params + Top 10 组合, 供 AI agent 解析) | + +**支持的 metric**:`sharpe_ratio`、`total_return_pct`、`max_drawdown_pct`(最小化)、`profit_factor`、`win_rate_pct`、`sortino_ratio` 等。 + +### 5. walkforward — Walk-Forward 验证 + +```bash +python -m app.main walkforward --strategy sma_cross \ + --param fast=5,10 --param slow=20,30 \ + --bars 1000 --train-size 300 --test-size 100 --export +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--train-size` | 300 | 训练窗口大小 (bars) | +| `--test-size` | 100 | 测试窗口大小 (bars) | +| `--export` | false | 导出逐窗口明细到 CSV | +| `--json` | false | 输出 JSON (含逐窗口 IS/OOS 明细, 供 AI agent 解析) | + +输出包含:IS/OOS 夏普对比、衰减比、过拟合判定、参数稳定性分布。 + +### 6. validate — 策略验收 + +```bash +python -m app.main validate --strategy sma_cross \ + --param fast=5,10 --param slow=20,30 --bars 1000 +``` + +执行 Walk-Forward + 三层盈利优先验收标准检查(L1 盈利性必须 / L2 风险可控 / L3 健壮性),输出通过/失败判定。 + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--json` | false | 输出 JSON (含 lookahead + walk_forward + acceptance, 供 AI agent 解析) | + +> 💡 **失败诊断**:当某条标准未通过时,输出会附带该标准的 `suggestions` 修复建议(每条标准 5 条具体可执行建议,按 L1/L2/L3 分级)。**L1 失败时建议是根本性调整(换策略/换品种),不要在参数上浪费时间**。AI agent 可直接读取建议决定下一步调整方向。详见 [验收检查](#验收检查-acceptancepy) 章节。 + +### 7. scaffold — 生成策略模板 + +```bash +python -m app.main scaffold --name my_rsi \ + --template mean_reversion \ + --description "RSI 超卖反弹策略" +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--name` | (必填) | 策略名称 (snake_case) | +| `--template` | custom | 模板类型 | +| `--description` | "" | 策略描述 | +| `--overwrite` | false | 覆盖已存在文件 | + +**模板类型**: +- `crossover` — 均线交叉 (SMA) +- `mean_reversion` — RSI 均值回归 +- `trend_following` — ADX 趋势跟踪 +- `breakout` — Donchian 通道突破 +- `custom` — 空白模板 + +### 8. deliver — 生成交付包 + +```bash +python -m app.main deliver --strategy sma_cross \ + --param fast=5,10 --param slow=20,30 \ + --symbol XAUUSD --bars 1000 +``` + +一键执行:优化 → Walk-Forward → 验收 → 导出交付包。 + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--json` | false | 输出 JSON (含 lookahead + optimization + walk_forward + acceptance + package_path) | + +> ⚠️ **强制前视检测**:deliver 在执行前会强制运行前视偏差静态扫描,若检测到 HIGH 级别前视模式(如 `.shift(-N)`、负索引访问未来 bar),会**拒绝生成交付包**,JSON 中 `delivered=false` 且 `error` 字段说明原因。详见 [前视偏差防护](#前视偏差防护-lookahead_checkpy) 章节。 + +### 9. check — 前视偏差检测 ★ + +```bash +# 静态扫描所有策略 +python -m app.main check --strategy all + +# 静态扫描 + 动态验证单个策略 (修改未来 bar, 看历史信号是否变化) +python -m app.main check --strategy sma_cross --dynamic \ + --symbol XAUUSD --timeframe H1 --bars 500 +``` + +| 参数 | 默认 | 说明 | +|------|------|------| +| `--strategy` | (必填) | 策略名称,或 `all` 扫描所有策略 | +| `--dynamic` | false | 启用动态扰动验证(修改未来 bar,检查历史信号是否变化) | +| `--symbol` | XAUUSD | 品种(动态验证用) | +| `--timeframe` | H1 | 周期(动态验证用) | +| `--bars` | 500 | K 线数量(动态验证用) | +| `--source` | csv | 数据源 | +| `--data-file` | None | CSV 文件路径 | +| `--json` | false | 输出 JSON (单个策略含 issues + fix_suggestions; all 含所有策略汇总) | + +输出示例(检测到前视): +``` +🔴 L27: 使用 .shift(-N) 访问未来 bar, 这是明确的前视偏差 + ...future_close = df["close"].shift(-1)... + +总结: ❌ 未通过 +``` + +--- + +## 策略框架 (strategies/) + +### Strategy 基类 + +所有策略继承 `Strategy` 基类(位于 [strategies/base.py](../strategies/base.py)): + +```python +class Strategy(ABC): + name: str # 策略唯一标识 + + @abstractmethod + def warmup_bars(self) -> int: + """返回指标预热所需的最小 bar 数""" + + @abstractmethod + def generate_signals(self, df: pd.DataFrame) -> SignalResult: + """根据 K 线数据生成入场/出场信号""" + + @abstractmethod + def build_config(self) -> raptorbt.PyBacktestConfig: + """构建回测配置 (止损/止盈/资金/费率)""" + + def description(self) -> str: + """策略描述 (用于 list 和报告)""" +``` + +### SignalResult + +```python +@dataclass +class SignalResult: + entries: np.ndarray # bool 数组, True=入场 + exits: np.ndarray # bool 数组, True=出场 + direction: int = 1 # 1=做多, -1=做空 + extra: dict = None # 可选: 附加指标数据 +``` + +### 内置辅助方法 + +```python +# 交叉信号 +entries = self.cross_above(ma_fast, ma_slow) # fast 上穿 slow +exits = self.cross_below(ma_fast, ma_slow) # fast 下穿 slow + +# 预热期处理 (预热期内的信号置 False) +entries, exits = self.apply_warmup(entries, exits) +``` + +### 自动注册机制 + +[strategies/\_\_init\_\_.py](../strategies/__init__.py) 实现自动发现:扫描 `strategies/` 目录下所有 `.py` 文件,若文件中定义了 `STRATEGY_CLASS` 常量,则自动注册。 + +**新增策略只需 3 步**: +1. 在 `strategies/` 下创建 `.py` 文件 +2. 继承 `Strategy` 实现子类 +3. 在文件末尾添加 `STRATEGY_CLASS = YourStrategy` + +无需修改任何注册代码,`python -m app.main list` 立即可见。 + +### 完整策略示例 + +```python +"""my_strategy — 我的策略描述""" + +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, threshold: float = 30.0): + self.period = period + self.threshold = threshold + + 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.threshold).astype(bool) + exits = (rsi > 70).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_fixed_stop(0.02) + config.set_fixed_target(0.04) + return config + + def description(self) -> str: + return f"RSI({self.period}) < {self.threshold} 入场" + + +STRATEGY_CLASS = MyStrategy +``` + +--- + +## CSV 数据加载器 (data_loader.py) + +[app/data_loader.py](../app/data_loader.py) 加载 MT5 History Center / quant data manager 导出的 M1 CSV,支持自动品种识别、时区处理、多周期重采样、spread→slippage 自动转换。 + +### CSV 格式 + +加载器期望 9 列无表头格式(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 +``` + +| 列 | 说明 | +|---|------| +| date / time | 日期和时间,格式 `YYYY.MM.DD` / `HH:MM` | +| open / high / low / close | OHLC | +| vol | tick volume | +| vol_real | real volume(加载时丢弃) | +| spread | 点差点数(自动转 slippage) | + +### 文件名约定 + +文件名应包含品种代码和时区,加载器自动解析: + +``` +2021.7.6-2026.07.03M1XAUUSD_TICK_UTCPlus02.csv + ^^^^^ ^^^^^^^^ + 品种 时区 UTC+2 +``` + +- **品种识别**:内置 17 个常见品种(XAUUSD、EURUSD、USDJPY 等),按长度降序匹配,避免子串误匹配。 +- **时区识别**:从 `UTCPlusNN` / `UTCMINUSNN` 提取,未识别时默认 UTC。 +- **自动查找**:`find_csv_for_symbol("XAUUSD")` 优先匹配 `M1` 文件,回退到任意匹配。 + +### 多周期重采样 + +加载 M1 后可重采样到 13 种周期: + +```python +from app.data_loader import load_csv + +# 加载并重采样到 H1 +df = load_csv("data/XAUUSD.csv", symbol="XAUUSD", timeframe="H1") +``` + +支持周期:`M1`、`M5`、`M15`、`M30`、`H1`、`H2`、`H4`、`H6`、`H8`、`H12`、`D1`、`W1`、`MN1`。 + +重采样规则(pandas 风格,`label="left", closed="left"`): +- `open` = 窗口第一根 +- `high` = 窗口最高 +- `low` = 窗口最低 +- `close` = 窗口最后一根 +- `tick_volume` = 求和 +- `spread` = 平均 + +### spread → slippage 自动转换 + +CSV 模式下,`fetch_klines` 会自动计算 slippage 并注入 `PyBacktestConfig`: + +``` +slippage = (avg_spread × tick_size) / avg_price +``` + +示例:XAUUSD `avg_spread=98`、`tick_size=0.01`、`avg_price=1791` → `slippage ≈ 0.000547 (0.055%)`。 + +内置 17 个品种的 tick_size 配置(`SYMBOL_TICK_SIZE` 字典)。未识别品种回退到 `0.0001`。 + +### Python API + +```python +from app.data_loader import ( + load_csv, find_csv_for_symbol, compute_slippage, + list_available_symbols, get_tick_size, +) + +# 1. 自动查找 + 加载 +path = find_csv_for_symbol("XAUUSD") # 在 data/ 下查找 +df = load_csv(path, timeframe="H1") + +# 2. 显式指定文件 +df = load_csv("data/XAUUSD.csv", symbol="XAUUSD", timeframe="H1") + +# 3. 列出所有可用品种 +for symbol, filename in list_available_symbols(): + print(symbol, filename) + +# 4. 单独计算 slippage +slippage = compute_slippage(df["spread"], df["close"], "XAUUSD") +``` + +### 周末数据处理 + +quant data manager 导出的外汇 CSV **已自动过滤周末**(外汇市场周六周日休市),因此 `load_csv` 默认 `drop_weekend=False`。若你的 CSV 含周末数据(如加密货币),调用时传 `drop_weekend=True`: + +```python +df = load_csv("data/BTCUSD.csv", timeframe="H1", drop_weekend=True) +``` + +返回 DataFrame 列:`time`、`open`、`high`、`low`、`close`、`tick_volume`、`spread`。`time` 列为带时区的 `datetime64[ns, tz]`。 + +--- + +## 参数优化器 (optimizer.py) + +[app/optimizer.py](../app/optimizer.py) 提供策略参数网格搜索。 + +### Python API + +```python +from app.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]}, + symbol="XAUUSD", +) + +print(result.best_params) # {"fast": 10, "slow": 30} +print(result.best_score) # 1.234 +print(result.summary()) # 优化完成: 9 个组合, ... +print(result.top_n(5)) # 前 5 个参数组合 DataFrame + +result.export("optimization.csv") # 导出完整响应面 +``` + +### 优化方向 + +- `sharpe_ratio`、`total_return_pct`、`profit_factor`、`win_rate_pct` → 最大化 +- `max_drawdown_pct` → 最小化 +- 其他指标自动推断方向 + +### 错误恢复 + +优化器内置错误恢复:参数组合导致 0 信号或回测异常时,记录 `error` 字段但不中断搜索,对应 metric 设为 NaN。 + +--- + +## Walk-Forward 验证 (walk_forward.py) + +[app/walk_forward.py](../app/walk_forward.py) 提供滚动窗口验证。 + +### 验证流程 + +``` +数据: |----train1----|--test1--|----train2----|--test2--|----train3----|--test3--| +``` + +每个窗口: +1. 在 `train` 上跑参数优化 → 得到最优参数 (IS) +2. 用最优参数在 `test` 上回测 → 得到 OOS 指标 +3. 记录 IS/OOS 对比 + +### Python API + +```python +from app.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]}, + metric="sharpe_ratio", +) + +print(result.is_sharpe_avg) # IS 平均夏普 +print(result.oos_sharpe_avg) # OOS 平均夏普 +print(result.decay_ratio) # 衰减比 OOS/IS +print(result.is_overfit) # 是否过拟合 (< 0.5) +print(result.param_stability) # 参数频次分布 +print(result.summary()) # 完整报告 + +result.export("walkforward.csv") +``` + +### 过拟合判定 + +- **衰减比** = OOS 平均夏普 / IS 平均夏普 +- 衰减比 < 0.5 → 判定过拟合 +- 衰减比 ≥ 0.5 → 非过拟合 + +### 参数稳定性 + +输出各参数值被选中的频次分布。若同一参数值在多个窗口被频繁选中,说明参数稳定;若参数值频繁变化,说明对数据过拟合。 + +--- + +## 验收检查 (acceptance.py) + +[app/acceptance.py](../app/acceptance.py) 采用**盈利优先三层结构**的量化验收标准。 + +### 设计哲学 + +策略的最终目的是赚钱,不是为了优化指标而优化指标。 + +> "当一个指标变成目标时,它就不再是好指标。" — Goodhart 法则 + +旧的"4 条平等标准"有一个根本问题:**全部是过程质量指标,没有一条直接验证"是否赚钱"**。这会导致两类误判: + +- **假阳性**:Sharpe=1.06 ✅、衰减比=3.0 ✅ 看似通过,但年化收益只有 2.5%(跑不赢通胀),根本没赚到钱 +- **假阴性**:PF=1.8、年化 25% 的策略因 Sharpe=0.8 被拒收(实际是赚钱策略,只是曲线不够平滑) +- **误导性通过**:IS/OOS 都在亏损的策略,衰减比反而很高(3.09),这个"通过"毫无意义 + +新标准把**盈利性(PF/收益/期望)放到 L1 必须层**,先确认策略真的赚钱,再看风险和健壮性。 + +### 三层默认标准 + +| 层级 | 标准 | 阈值 | 说明 | +|------|------|------|------| +| **L1 盈利性(必须)** | `oos_profit_factor_min` | 1.3 | OOS 盈利因子下限(总盈利/总亏损多 30%) | +| | `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 衰减比下限(非过拟合) | + +**判定逻辑**: +- `passed = L1 全过 AND L2 全过 AND L3 全过`(保持严格) +- `l1_passed=False` 时直接拒收,不再看 L2/L3 +- 分层让 AI agent 一眼看出"问题严重程度",优先解决 L1 + +### Python API + +```python +from app.acceptance import StrategyAcceptance + +# 使用默认三层标准 +checker = StrategyAcceptance() +report = checker.check(wf_result) + +# 自定义标准 (可只覆盖某几条) +checker = StrategyAcceptance(criteria={ + "oos_profit_factor_min": 1.5, # L1 更严格 + "oos_max_drawdown_max_pct": 15.0, # L2 更紧 + # 其他条目用默认值 +}) +report = checker.check(wf_result) + +print(report.passed) # 总判定 +print(report.l1_passed) # L1 盈利性是否全过 (最关键) +print(report.l2_passed) # L2 风险可控 +print(report.l3_passed) # L3 健壮性 +print(report.summary()) # 分层人类可读报告 +``` + +### 验收报告(分层) + +``` +验收结果: ❌ 未通过 (L1 盈利性未达标 — 策略不赚钱, 无需看 L2/L3) +────────────────────────────────────────────────────────────────────── + [L1 盈利性] 必须 — 策略是否真的赚钱, 任一失败即拒收 ✗ + ────────────────────────────────────────────────────────────────── + OOS 盈利因子 阈值= 1.30 实际= 0.00 ✗ + └ 总盈利/总亏损 = 0.00, < 阈值 1.3 (亏损或勉强盈利) + └ 修复建议: + 1. 策略逻辑本身可能不盈利, 重新审视入场/出场条件是否真的捕捉到正向期望 + 2. 切换策略类型 (趋势跟踪在震荡市会持续亏 PF<1, 均值回归在趋势市会亏) + 3. 切换品种或周期 (当前 XAUUSD M5 可能不适合本策略逻辑) + ... + OOS 净收益率 阈值= 0.00 实际= -0.02 ✗ + OOS 每笔期望 阈值= 0.00 实际= -119.53 ✗ + [L2 风险可控] 应该 — 回撤是否在可接受范围 ✓ + ────────────────────────────────────────────────────────────────── + OOS 最大回撤 阈值= 20.00 实际= 0.46 ✓ + [L3 健壮性] 建议 — 统计显著性和非过拟合, 参考性指标 ✗ + ────────────────────────────────────────────────────────────────── + OOS 夏普比率 阈值= 0.70 实际= -0.39 ✗ + OOS 总交易数 阈值= 30.00 实际= 11.00 ✗ + IS/OOS 衰减比 阈值= 0.50 实际= -21.01 ✗ + └ OOS/IS = -2101.3%, 过拟合风险 (注意: 若 IS 也是亏损, 高衰减比不代表策略好) +────────────────────────────────────────────────────────────────────── +``` + +### 失败诊断建议(按层分级) ★ + +L1 失败的建议是**根本性调整**(换策略/换市场/换周期),不是小修小补;L2 是风控调整;L3 是统计性问题。 + +| 失败标准 | 层 | 建议方向 | +|---------|----|---------| +| OOS 盈利因子 < 1.3 | L1 | 重新审视信号逻辑、切换策略类型(趋势↔均值回归)、切换品种/周期、检查止损是否过紧、尝试反向信号 | +| OOS 净收益 ≤ 0 | L1 | 检查 IS 是否也亏(若也亏=逻辑问题)、评估交易成本侵蚀、减少交易频率、切换顺势品种/周期、反向信号 | +| OOS 每笔期望 ≤ 0 | L1 | 检查胜率×盈亏比、盈亏比失衡→放大止盈/追踪止损、胜率低→加过滤、止损过紧→放宽或换 ATR 止损 | +| OOS 最大回撤 > 20% | L2 | 收紧止损、ATR 动态止损、追踪止损、降仓位、加趋势过滤 | +| OOS 夏普 < 0.7 | L3 | 放宽止损、加趋势过滤、切大周期、加成交量过滤(注:已降为参考,L1 全过时可接受略低) | +| OOS 交易数 < 30 | L3 | 缩短指标周期、降低入场阈值、切更小周期、放宽过滤、检查 warmup | +| 衰减比 < 0.5 | L3 | 缩小参数空间、增加 WF 窗口数、简化策略、用中位数参数、检查前视 | + +> ⚠️ **关键提示**:衰减比高不代表策略好。若 IS 和 OOS 都在亏损,衰减比反而会很高(如 3.0),这个"通过"是假象。**必须先看 L1 的 PF/收益/期望,L1 通过后衰减比才有意义**。 + +### JSON 输出 + +`AcceptanceReport.to_dict()` 返回分层结构的 JSON,CLI 加 `--json` 标志后直接输出: + +```json +{ + "passed": false, + "l1_passed": false, + "l2_passed": true, + "l3_passed": false, + "verdict": "L1 盈利性未达标 — 策略不赚钱, 无需看 L2/L3", + "criteria": [ + { + "name": "OOS 盈利因子", + "layer": "L1", + "layer_name": "盈利性", + "level": "必须", + "threshold": 1.3, + "actual": 0.0, + "passed": false, + "description": "总盈利/总亏损 = 0.00, < 阈值 1.3 (亏损或勉强盈利)", + "suggestions": [ + "策略逻辑本身可能不盈利, 重新审视入场/出场条件是否真的捕捉到正向期望", + "切换策略类型 (趋势跟踪在震荡市会持续亏 PF<1, 均值回归在趋势市会亏)", + ... + ] + }, + { + "name": "OOS 最大回撤", + "layer": "L2", + "layer_name": "风险可控", + "level": "应该", + "threshold": 20.0, + "actual": 0.46, + "passed": true, + "description": "低于最大回撤限制 20.0%", + "suggestions": [] + } + ] +} +``` + +**AI agent 决策逻辑**: + +``` +1. 调用 validate --json +2. 检查 l1_passed: + - False → 策略不赚钱, 不要在参数上浪费时间, 按 L1 suggestions 换策略逻辑/品种/周期 + - True → 进入 L2/L3 检查 +3. 检查 l2_passed: False → 按 L2 suggestions 调整风控 (止损/仓位) +4. 检查 l3_passed: False → 按 L3 suggestions 处理统计性问题 +5. 全过 → deliver --json 生成交付包 +``` + +> 💡 通过的标准 `suggestions` 为空数组,未通过的才填充建议。NaN/Inf 已自动转为 `null`。 + +--- + +## 策略模板生成器 (scaffold.py) + +[app/scaffold.py](../app/scaffold.py) 生成符合框架约定的策略模板。 + +### Python API + +```python +from app.scaffold import scaffold_strategy + +# 生成 RSI 均值回归模板 +path = scaffold_strategy( + name="my_rsi", + template="mean_reversion", + description="RSI 超卖反弹策略", +) +print(f"模板已生成: {path}") + +# 覆盖已存在文件 +path = scaffold_strategy( + name="my_rsi", + template="mean_reversion", + overwrite=True, +) +``` + +### 模板类型 + +| 模板 | 策略逻辑 | 适用场景 | +|------|---------|---------| +| `crossover` | SMA 双均线交叉 | 趋势市场 | +| `mean_reversion` | RSI 超卖买入/超买卖出 | 震荡市场 | +| `trend_following` | ADX + DI 方向确认 | 强趋势市场 | +| `breakout` | Donchian 通道突破 | 突破行情 | +| `custom` | 空白模板 | 自定义逻辑 | + +生成的模板已包含完整的 `Strategy` 子类骨架,包括 `__init__`、`warmup_bars`、`generate_signals`、`build_config`、`description` 和 `STRATEGY_CLASS` 常量。 + +--- + +## 交付包导出 (exporter.py) + +[app/exporter.py](../app/exporter.py) 生成完整的策略交付包。 + +### Python API + +```python +from app.exporter import StrategyExporter + +exporter = StrategyExporter() +pkg_path = exporter.deliver( + strategy_name="sma_cross", + df=df, + symbol="XAUUSD", + opt_result=opt_result, + wf_result=wf_result, + accept_report=accept_report, + param_grid={"fast": [5, 10], "slow": [20, 30]}, + data_info={ + "source": "Mt5Bridge", + "range": "2026-01-01 ~ 2026-06-30", + "bars": 1000, + "timeframe": "H1", + }, +) +``` + +### 交付包结构 + +``` +deliverables/{strategy_name}_{timestamp}/ +├── {strategy_name}.py 策略源文件 (从 strategies/ 复制) +├── STRATEGY_REPORT.md 完整交付报告 (8 个章节) +├── optimization.csv 参数优化响应面 +├── walkforward.csv Walk-Forward 逐窗口明细 +└── backtest_metrics.csv 最优参数回测指标 +``` + +### Markdown 报告章节 + +1. 策略概述 (名称/描述/标的/最优参数/预热期) +2. 参数优化 (目标/组合数/Top 5 参数表) +3. Walk-Forward 验证 (IS/OOS 对比/衰减比/过拟合判定/参数稳定性) +4. 验收结果 (三层盈利优先标准/L1-L2-L3 分层) +5. 最优参数完整回测指标 +6. 数据信息 (来源/范围/K 线数/周期) +7. 风险提示 (5 条) +8. 复现方式 (CLI 命令) + +--- + +## 前视偏差防护 (lookahead_check.py) + +[app/lookahead_check.py](../app/lookahead_check.py) 是防止 AI agent 在自动开发策略时引入前视偏差的核心防护层。两层防护:静态 AST 扫描 + 动态扰动验证。 + +### 为什么需要前视防护 + +前视偏差(look-ahead bias)是量化策略最隐蔽的 bug:策略生成信号时使用了未来才能获取的数据,回测结果虚高,实盘后立即崩溃。常见诱因: + +- `.shift(-N)` — 访问未来第 N 根 bar +- `close[-1]` / `high[-1]` — 负索引访问(numpy 从数组末尾取,等价于未来) +- `df.iloc[i+N:]` — 切片到未来索引 +- `rolling(...).mean().shift(-1)` — 滚动统计后向未来偏移 +- `np.roll` — 把末尾元素循环到开头(本项目曾因此 bug 修复) + +### 静态扫描规则 + +11 条正则 + AST 检测,分三级严重度: + +| 严重度 | 含义 | 示例 | +|------|------|------| +| 🔴 HIGH | 确定前视,拒绝交付 | `.shift(-1)`、`close[-1]`、`iloc[i+10:]` | +| 🟡 MEDIUM | 可疑模式,需人工确认 | `future`/`lookahead` 关键词、`np.roll`、`.shift(0)` | +| 🟢 LOW | 建议检查 | `values[i + N]` 基于索引访问 | + +判定规则:**只要存在 HIGH 级别问题就判定失败**。 + +### 动态扰动验证 + +原理:策略只用过去 + 当前数据生成信号,所以修改未来 bar 不应影响历史信号。 + +``` +1. 用原始数据生成基准信号 → base_entries[:N] +2. 修改 bar N+1..N+K 的 OHLC ±5% → 生成扰动信号 perturbed_entries[:N] +3. 比较 base_entries[:N] 与 perturbed_entries[:N] +4. 若有差异 → 存在前视偏差 +``` + +参数:`check_bars=50`(检查前 50 根)、`perturb_range=10`(扰动 10 根未来 bar)。 + +### Python API + +```python +from app.lookahead_check import ( + check_strategy_code, check_strategy_file, check_all_strategies, + dynamic_check, full_check, +) + +# 1. 静态扫描源码字符串 +report = check_strategy_code(code, "my_strategy.py") +print(report.passed) # True/False +print(report.summary()) # 人类可读报告 + +# 2. 静态扫描文件 +report = check_strategy_file("strategies/sma_cross.py") + +# 3. 批量扫描 strategies/ 目录 +for path, report in check_all_strategies("strategies"): + print(os.path.basename(path), report.passed) + +# 4. 动态验证 (需策略类 + 数据) +result = dynamic_check( + SmaCrossStrategy, df, + params={"fast": 10, "slow": 20}, + check_bars=50, perturb_range=10, +) +print(result.passed, result.reason) + +# 5. 综合检查 (静态 + 动态) +report = full_check( + SmaCrossStrategy, "strategies/sma_cross.py", + df=df, params={"fast": 10, "slow": 20}, run_dynamic=True, +) +print(report.summary()) +``` + +### 强制集成点 + +前视检测已在三处强制接入,AI agent 无法绕过: + +| 集成点 | 行为 | +|------|------| +| `check` 命令 | 单独运行静态 + 动态检测 | +| `validate` 命令 | 前置检测,未通过则**终止验收** | +| `deliver` 命令 | 前置检测,未通过则**拒绝生成交付包** | +| `scaffold` 模板 | 生成的策略文件头部自带 ⚠️ 前视警告,列出禁用模式 | + +### scaffold 模板的前视警告 + +用 `scaffold` 生成的策略文件头部会自动包含警告块: + +```python +"""my_strategy — RSI 超卖反弹策略 + +⚠️ 前视偏差 (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 my_strategy +""" +``` + +### 引擎层的前视防护 + +RaptorBT 引擎层已内置前视防护:`PyBacktestConfig(upon_bar_close=True)`(默认)确保信号在 bar 收盘后生成、以 `close` 价成交,杜绝同一根 bar 内的信号→成交前视。 + +--- + +## 自定义指标库 (indicators.py) + +[app/indicators.py](../app/indicators.py) 提供自定义指标库,全部转发到 ferro-ta Rust 原生实现以获得亚毫秒级性能。 + +### 可用指标 + +| 函数 | 转发到 | 说明 | +|------|--------|------| +| `typical_price(h, l, c)` | `raptorbt.typprice` | 典型价格 | +| `cci(h, l, c, period)` | `raptorbt.cci` | 商品通道指数 | +| `williams_r(h, l, c, period)` | `raptorbt.willr` | 威廉指标 | +| `roc(data, period)` | `raptorbt.roc` | 变化率 | +| `trix(data, period)` | `raptorbt.trix` | 三重指数平滑变化率 | +| `dmi(h, l, c, period)` | `raptorbt.adx_all` | DMI (ADX + DI) | +| `ichimoku(h, l, c, ...)` | `raptorbt.ichimoku` | 一目均衡表 | +| `parabolic_sar(h, l, ...)` | `raptorbt.sar` | 抛物线 SAR | +| `mfi(h, l, c, v, period)` | `raptorbt.mfi` | 资金流量指数 | +| `obv(c, v)` | `raptorbt.obv` | 能量潮 | +| `awesome_oscillator(h, l, ...)` | `sma(medprice)` 组合 | 震荡指标 (原生组合) | + +### 用法 + +```python +from app.indicators import cci, williams_r, dmi + +cci_vals = cci(high, low, close, period=20) +willr_vals = williams_r(high, low, close, period=14) +adx, plus_di, minus_di = dmi(high, low, close, period=14) +``` + +--- + +## 指标目录 (indicator_catalog.py) + +[app/indicator_catalog.py](../app/indicator_catalog.py) 提供 **80 个原生指标**的完整目录,让 AI agent 在不读完整手册的情况下也能快速查询可用指标、签名、输入需求和返回值结构。 + +### 覆盖范围 + +| 分类 | 数量 | 代表指标 | +|------|------|---------| +| 趋势 | 8 | sma / ema / wma / dema / tema / kama / supertrend / sar | +| 动量 | 13 | rsi / macd / cci / stochastic / willr / roc / mom / trix / cmo / bop / ultosc / stochrsi / ppo | +| 强度 | 7 | adx / adx_all / plus_di / minus_di / adxr / aroon / aroonosc | +| 波动率 | 6 | atr / natr / trange / bollinger_bands / stddev / var | +| 成交量 | 4 | obv / mfi / ad / adosc | +| 统计 | 7 | linearreg / linearreg_slope / linearreg_intercept / linearreg_angle / tsf / beta / correl | +| 滚动 | 2 | rolling_min / rolling_max | +| 量价 | 1 | vwap | +| 价格变换 | 6 | typprice / medprice / avgprice / wclprice / midpoint / midprice | +| 高阶均线 | 5 | t3 / trima / vwma / hull_ma / apo | +| 通道 | 4 | donchian / chandelier_exit / ichimoku / pivot_points | +| Hilbert | 6 | ht_trendline / ht_dcperiod / ht_dcphase / ht_phasor / ht_sine / ht_trendmode | +| 市场状态 | 5 | choppiness_index / regime_adx / regime_combined / detect_breaks_cusum / rolling_variance_break | +| 投资组合 | 6 | rolling_beta / drawdown_series / zscore_series / relative_strength / spread / ratio | + +### IndicatorInfo 字段 + +每个指标记录 9 个字段: + +| 字段 | 说明 | 示例 | +|------|------|------| +| `name` | 函数名 (raptorbt.xxx) | `adx_all` | +| `category` | 分类 | `强度` | +| `inputs` | 输入数组需求 | `["high", "low", "close"]` | +| `params` | 参数名列表 | `["period"]` | +| `defaults` | 参数默认值 (与 params 一一对应, "—" 为必填) | `["—"]` | +| `returns` | 返回说明 | `"3 arrays (adx, plus_di, minus_di)"` | +| `description` | 简短中文说明 | `"ADX + +DI - DI, 一次拿全方向信息"` | +| `signature()` | 可读签名 | `adx_all(high, low, close, period)` | +| `usage` | 完整调用示例 | `adx, plus_di, minus_di = raptorbt.adx_all(...)` | + +### CLI 查询 + +```bash +# 文本版 (人读, 按分类展示) +python -m app.main list --indicators + +# JSON 版 (AI agent 解析) +python -m app.main list --indicators --json +``` + +JSON 结构: +```json +{ + "total": 48, + "categories": { + "趋势": [{...}, ...], + "动量": [{...}, ...], + ... + }, + "all_names": ["sma", "ema", "wma", ...], + "usage_note": "调用方式: import raptorbt; raptorbt.(numpy_array, ...)" +} +``` + +### Python API + +```python +from app.indicator_catalog import CATALOG, list_by_category, find, format_text, format_json + +# 1. 按名称查找单个指标 +info = find("adx_all") +print(info.signature()) # adx_all(high, low, close, period) +print(info.returns) # 3 arrays (adx, plus_di, minus_di) +print(info.usage) # adx, plus_di, minus_di = raptorbt.adx_all(high, low, close, period=14) + +# 2. 按分类遍历 +for cat, inds in list_by_category().items(): + print(f"{cat}: {len(inds)} 个") + +# 3. 获取所有指标名称 (快速判断某指标是否存在) +all_names = [i.name for i in CATALOG] # ['sma', 'ema', 'wma', ...] + +# 4. 生成文本/JSON 目录 +print(format_text()) +payload = format_json() +``` + +--- + +## AI agent 集成指南 + +### 推荐工作流 + +AI agent 可按以下流程自主开发策略: + +``` +0. list --indicators --json 查询可用指标 (80 个, 含签名/默认值/返回值) + ↓ +1. scaffold 生成策略模板 (自带前视警告头部) + ↓ +2. 编辑模板,填入信号生成逻辑 (用 raptorbt.<指标名> 调用) + ↓ +3. check 前视偏差检测 (静态 + 动态) ★ 强制 + ↓ +4. optimize 搜索参数空间 + ↓ +5. walkforward 验证泛化能力 + ↓ +6. acceptance 检查是否达标 (失败时自动给修复建议) + ↓ +7. 若未通过 → 读取 suggestions 调整逻辑或参数, 回到 2/4 + 若通过 → 继续 8 + ↓ +8. deliver 生成交付包 (再次强制前视检测, 失败则拒绝生成) +``` + +> ⚠️ **前视检测是强制门槛**:步骤 3 和 8 都会运行前视偏差检测。即使 AI agent 在步骤 2 不小心引入了 `.shift(-N)` 等前视模式,也会在交付前被拦截。详见 [前视偏差防护](#前视偏差防护-lookahead_checkpy) 章节。 + +> 💡 **步骤 0 的价值**:AI agent 在编写策略前先查询指标目录,可以避免调用不存在的指标或用错参数签名。JSON 输出中 `all_names` 数组可快速判断某指标是否存在,`usage` 字段提供完整调用示例。 + +### 通过 CLI 调用 (推荐) + +AI agent 可通过 `RunCommand` 工具直接调用 CLI: + +```bash +# 0. 查询可用指标 (AI agent 开发策略前先了解工具箱) +python -m app.main list --indicators --json + +# 1. 生成模板 (自带前视警告头部) +python -m app.main scaffold --name ai_strategy_v1 --template breakout + +# 2. (AI 编辑 strategies/ai_strategy_v1.py 填入逻辑) + +# 3. 前视偏差检测 (静态 + 动态) — 强制门槛 +python -m app.main check --strategy ai_strategy_v1 --dynamic --bars 500 + +# 4. 参数优化 +python -m app.main optimize --strategy ai_strategy_v1 \ + --param period=10,15,20,25 --param threshold=0.5,1.0,1.5 + +# 5. Walk-Forward 验证 +python -m app.main walkforward --strategy ai_strategy_v1 \ + --param period=10,15,20 --bars 2000 + +# 6. 验收 +python -m app.main validate --strategy ai_strategy_v1 \ + --param period=10,15,20 --bars 2000 + +# 7. 交付 (内置强制前视检测, 失败则拒绝生成) +python -m app.main deliver --strategy ai_strategy_v1 \ + --param period=10,15 --bars 2000 +``` + +### JSON 输出 + 失败驱动迭代 ★ + +所有命令支持 `--json` 标志,输出结构化 JSON(自动清理 NaN/Inf,不转义中文),AI agent 可直接解析无需正则匹配表格: + +```bash +# 推荐: AI agent 用 --json 获取结构化输出 +python -m app.main validate --strategy ai_strategy_v1 \ + --param period=10,15,20 --bars 2000 --json +``` + +**失败驱动的自动迭代**:当 `validate` 未通过时,JSON 中 `acceptance.criteria` 数组的每条失败标准都带 `suggestions` 字段(5 条具体可执行建议)。AI agent 可按以下逻辑自动调整: + +``` +1. 调用 validate --json +2. 解析 JSON, 找到 passed=false 的标准 +3. 读取该标准的 suggestions 数组 +4. 根据 suggestions 修改策略 (如 "缩短指标周期" → 把 RSI 14 改为 7) +5. 重新 check → optimize → validate, 直到全部通过 +6. 通过后 deliver --json 生成交付包 +``` + +各命令的 JSON 结构: + +| 命令 | JSON 关键字段 | +|------|--------------| +| `list --indicators --json` | `total` + `categories` (按分类) + `all_names` (快速判断指标是否存在) + `usage_note` | +| `run --json` | `metrics` (33 项指标) + `trades` (前 50 条) + `n_trades` | +| `optimize --json` | `best_params` + `best_score` + `top_10` (Top 10 参数组合) | +| `walkforward --json` | `is_sharpe_avg` + `oos_sharpe_avg` + `decay_ratio` + `is_overfit` + `windows` (逐窗口明细) | +| `validate --json` | `lookahead` + `walk_forward` + `acceptance` (含 `suggestions`) | +| `check --json` | 单个策略: `passed` + `issues` + `fix_suggestions`; all: `n_pass` + `n_fail` + `strategies` | +| `deliver --json` | `delivered` + `package_path` + `lookahead` + `optimization` + `walk_forward` + `acceptance` | + +> 💡 **NaN 处理**:JSON 输出已自动把所有 NaN/Inf 转为 `null`,AI agent 无需特殊处理。 + +### 通过 Python API 调用 + +```python +from app.optimizer import StrategyOptimizer +from app.walk_forward import WalkForwardValidator +from app.acceptance import StrategyAcceptance +from app.exporter import StrategyExporter + +# 完整流程 +opt = StrategyOptimizer(metric="sharpe_ratio") +opt_result = opt.optimize(...) + +wf = WalkForwardValidator(train_size=300, test_size=100) +wf_result = wf.validate(...) + +checker = StrategyAcceptance() +report = checker.check(wf_result) + +if report.passed: + exporter = StrategyExporter() + pkg = exporter.deliver(...) +``` + +### 验收通过判据 + +策略通过验收需满足**盈利优先三层全部**标准: + +- **L1 盈利性(必须,任一失败即拒收)** + 1. OOS 盈利因子 ≥ 1.3(总盈利比总亏损多 30%) + 2. OOS 净收益率 > 0%(样本外真的赚到钱) + 3. OOS 每笔期望 > 0(平均每单为正) +- **L2 风险可控(应该)** + 4. OOS 最大回撤 ≤ 20% +- **L3 健壮性(建议)** + 5. OOS 夏普比率 ≥ 0.7(已降为参考) + 6. OOS 总交易数 ≥ 30 + 7. IS/OOS 衰减比 ≥ 0.5(注意:IS 也亏时高衰减比无意义) + +**优先级**:L1 失败 = 策略根本不赚钱,不要在参数优化上浪费时间,直接换策略逻辑或品种/周期。详见 [验收检查](#验收检查-acceptancepy) 章节。 + +通过后 `deliver` 生成的交付包可作为最终交付物。 + +--- + +## 环境变量 + +| 变量 | 默认 | 说明 | +|------|------|------| +| `MT5_BRIDGE_URL` | `http://61.164.252.86:13485` | Mt5Bridge 服务地址 | +| `MT5_BRIDGE_KEY` | (内置默认) | Mt5Bridge API Key | + +**安全建议**:生产环境务必通过环境变量设置 `MT5_BRIDGE_KEY`,不要硬编码到代码中。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6cde2b3 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py new file mode 100644 index 0000000..438ab1d --- /dev/null +++ b/python/raptorbt/__init__.py @@ -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", +] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d139eea --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy +pandas +requests \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..976fd7d --- /dev/null +++ b/rustfmt.toml @@ -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" \ No newline at end of file diff --git a/src/core/error.rs b/src/core/error.rs new file mode 100644 index 0000000..8f1815d --- /dev/null +++ b/src/core/error.rs @@ -0,0 +1,80 @@ +//! Error types for RaptorBT. + +use thiserror::Error; + +/// Result type alias for RaptorBT operations. +pub type Result = std::result::Result; + +/// 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) -> 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) -> Self { + Self::InvalidConfig { message: message.into() } + } + + /// Create a division by zero error. + pub fn division_by_zero(context: impl Into) -> Self { + Self::DivisionByZero { context: context.into() } + } + + /// Create an empty data error. + pub fn empty_data(context: impl Into) -> Self { + Self::EmptyData { context: context.into() } + } +} + +impl From for pyo3::PyErr { + fn from(err: RaptorError) -> pyo3::PyErr { + pyo3::exceptions::PyValueError::new_err(err.to_string()) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..653cb7a --- /dev/null +++ b/src/core/mod.rs @@ -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::*; diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..76f648a --- /dev/null +++ b/src/core/session.rs @@ -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) -> 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, + next_timestamp_ns: Option, + ) -> (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); + } +} diff --git a/src/core/timeseries.rs b/src/core/timeseries.rs new file mode 100644 index 0000000..655cbea --- /dev/null +++ b/src/core/timeseries.rs @@ -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 { + /// Timestamps for each value. + pub timestamps: Vec, + /// Values. + pub values: Vec, +} + +impl TimeSeries { + /// Create a new time series. + pub fn new(timestamps: Vec, values: Vec) -> Self { + debug_assert_eq!(timestamps.len(), values.len()); + Self { timestamps, values } + } + + /// Create from values only (no timestamps). + pub fn from_values(values: Vec) -> 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 { + 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(&self, f: F) -> TimeSeries + 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 { + self.timestamps.iter().copied().zip(self.values.iter()) + } +} + +impl TimeSeries { + /// Create with default values. + pub fn with_default(timestamps: Vec) -> Self { + let len = timestamps.len(); + Self { timestamps, values: vec![T::default(); len] } + } +} + +impl TimeSeries { + /// 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::() / 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::() / (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(&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::() / 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::() / slice.len() as f64; + let variance = + slice.iter().map(|v| (v - mean).powi(2)).sum::() / (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 { + /// 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 { + 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); + } +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..ac6b6cc --- /dev/null +++ b/src/core/types.rs @@ -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 { + 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, + pub open: Vec, + pub high: Vec, + pub low: Vec, + pub close: Vec, + pub volume: Vec, +} + +impl OhlcvData { + /// Create new OHLCV data from vectors. + pub fn new( + timestamps: Vec, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> 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 { + 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, + /// Last traded price at each tick. + pub ltp: Vec, + /// Best bid price at each tick (0.0 if unavailable). + pub bid: Vec, + /// Best ask price at each tick (0.0 if unavailable). + pub ask: Vec, + /// Per-tick buy quantity delta (not cumulative). + pub buy_qty_delta: Vec, + /// Per-tick sell quantity delta (not cumulative). + pub sell_qty_delta: Vec, + /// Open interest at each tick (0 if unavailable). + pub oi: Vec, +} + +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, + /// Exit signals (true = exit position). + pub exits: Vec, + /// Optional position sizes (fraction of capital). + pub position_sizes: Option>, + /// 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, + exits: Vec, + 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) -> 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, + /// Per-instrument capital cap. + pub alloted_capital: Option, + /// Per-instrument stop override. + pub stop: Option, + /// Per-instrument target override. + pub target: Option, + /// Existing position quantity (future use). + pub existing_qty: Option, + /// Existing position average price (future use). + pub avg_price: Option, +} + +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, + /// Drawdown curve (drawdown percentage over time). + pub drawdown_curve: Vec, + /// List of executed trades. + pub trades: Vec, + /// Daily returns. + pub returns: Vec, +} + +impl BacktestResult { + /// Create a new backtest result. + pub fn new( + metrics: BacktestMetrics, + equity_curve: Vec, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, + ) -> 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, + /// Current target price. + pub target_price: Option, + /// 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, + target_price: Option, + 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); + } +} diff --git a/src/execution/fees.rs b/src/execution/fees.rs new file mode 100644 index 0000000..3c90de2 --- /dev/null +++ b/src/execution/fees.rs @@ -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); + } +} diff --git a/src/execution/fill.rs b/src/execution/fill.rs new file mode 100644 index 0000000..c5ef7d3 --- /dev/null +++ b/src/execution/fill.rs @@ -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 { + 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 { + 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)); + } +} diff --git a/src/execution/mod.rs b/src/execution/mod.rs new file mode 100644 index 0000000..261e273 --- /dev/null +++ b/src/execution/mod.rs @@ -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; diff --git a/src/execution/slippage.rs b/src/execution/slippage.rs new file mode 100644 index 0000000..44cbb09 --- /dev/null +++ b/src/execution/slippage.rs @@ -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 { + 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, + ) -> 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); + } +} diff --git a/src/indicators/ferro_bridge.rs b/src/indicators/ferro_bridge.rs new file mode 100644 index 0000000..dc6e2cd --- /dev/null +++ b/src/indicators/ferro_bridge.rs @@ -0,0 +1,847 @@ +use crate::core::error::RaptorError; +use crate::core::Result; + +pub struct AroonResult { + pub up: Vec, + pub down: Vec, +} + +pub struct AdxAllResult { + pub adx: Vec, + pub plus_di: Vec, + pub minus_di: Vec, +} + +fn ema_nan_safe(data: &[f64], period: usize) -> Vec { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, Vec)> { + 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 { + 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> { + 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> { + 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> { + Ok(ferro_ta_core::volatility::trange(high, low, close)) +} + +pub fn stddev(real: &[f64], period: usize, nbdev: f64) -> Result> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + Ok(ferro_ta_core::volume::obv(close, volume)) +} + +pub fn mom(close: &[f64], period: usize) -> Result> { + 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, + pub signal_line: Vec, + pub histogram: Vec, +} + +pub fn ppo( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Result { + 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> { + 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> { + 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> { + 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> { + 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> { + Ok(ferro_ta_core::price_transform::typprice(high, low, close)) +} + +pub fn medprice(high: &[f64], low: &[f64]) -> Result> { + Ok(ferro_ta_core::price_transform::medprice(high, low)) +} + +pub fn avgprice( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], +) -> Result> { + Ok(ferro_ta_core::price_transform::avgprice(open, high, low, close)) +} + +pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Result> { + Ok(ferro_ta_core::price_transform::wclprice(high, low, close)) +} + +pub fn midpoint(close: &[f64], period: usize) -> Result> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, + pub middle: Vec, + pub lower: Vec, +} + +/// Donchian Channels — rolling highest high / lowest low. +/// +/// # Returns +/// `(upper, middle, lower)` arrays. +pub fn donchian(high: &[f64], low: &[f64], period: usize) -> Result { + 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> { + 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> { + 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, + pub short_exit: Vec, +} + +/// 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 { + 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, + pub kijun: Vec, + pub senkou_a: Vec, + pub senkou_b: Vec, + pub chikou: Vec, +} + +/// 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 { + 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, + pub r1: Vec, + pub s1: Vec, + pub r2: Vec, + pub s2: Vec, +} + +/// 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 { + 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> { + 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> { + 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> { + 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, + pub quadrature: Vec, +} + +/// Hilbert Transform — Phasor Components `(in_phase, quadrature)`. +pub fn ht_phasor(close: &[f64]) -> Result { + 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, + pub lead_sine: Vec, +} + +/// Hilbert Transform — Sine Wave `(sine, lead_sine)`. +pub fn ht_sine(close: &[f64]) -> Result { + 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`: `1` = trend mode, `0` = cycle mode. +pub fn ht_trendmode(close: &[f64]) -> Result> { + 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`: `1` = trend, `0` = range, `-1` = warmup/NaN. +pub fn regime_adx(adx: &[f64], threshold: f64) -> Result> { + Ok(ferro_ta_core::regime::regime_adx(adx, threshold)) +} + +/// Trend/range regime using ADX + ATR-ratio rule. +/// +/// Returns `Vec`: `1` = trend, `0` = range, `-1` = NaN. +pub fn regime_combined( + adx: &[f64], + atr: &[f64], + close: &[f64], + adx_threshold: f64, + atr_pct_threshold: f64, +) -> Result> { + Ok(ferro_ta_core::regime::regime_combined( + adx, + atr, + close, + adx_threshold, + atr_pct_threshold, + )) +} + +/// Detect structural breaks via CUSUM test. +/// +/// Returns `Vec`: `1` at break bars, `0` elsewhere. +pub fn detect_breaks_cusum( + series: &[f64], + window: usize, + threshold: f64, + slack: f64, +) -> Result> { + 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`: +/// `1` at break bars, `0` elsewhere. +pub fn rolling_variance_break( + series: &[f64], + short_window: usize, + long_window: usize, + threshold: f64, +) -> Result> { + 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> { + 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, + /// 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 { + 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> { + 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> { + 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> { + 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> { + 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)) +} \ No newline at end of file diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs new file mode 100644 index 0000000..9e9a65d --- /dev/null +++ b/src/indicators/mod.rs @@ -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}; \ No newline at end of file diff --git a/src/indicators/momentum.rs b/src/indicators/momentum.rs new file mode 100644 index 0000000..f6a6753 --- /dev/null +++ b/src/indicators/momentum.rs @@ -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> { + 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, + /// Signal line (EMA of MACD line). + pub signal_line: Vec, + /// Histogram (MACD line - signal line). + pub histogram: Vec, +} + +/// 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 { + 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, + /// %D line (slow stochastic, SMA of %K). + pub d: Vec, +} + +/// 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 { + 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 = (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()); + } +} \ No newline at end of file diff --git a/src/indicators/rolling.rs b/src/indicators/rolling.rs new file mode 100644 index 0000000..ee872fd --- /dev/null +++ b/src/indicators/rolling.rs @@ -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, 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, 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()); + } +} diff --git a/src/indicators/strength.rs b/src/indicators/strength.rs new file mode 100644 index 0000000..f093971 --- /dev/null +++ b/src/indicators/strength.rs @@ -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> { + 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, + /// -DI values. + pub minus_di: Vec, + /// ADX values. + pub adx: Vec, +} + +/// 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 { + 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 = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (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 = (0..n).map(|i| 100.0 + i as f64 + 2.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 2.0).collect(); + let close: Vec = (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]); + } +} \ No newline at end of file diff --git a/src/indicators/tick_features.rs b/src/indicators/tick_features.rs new file mode 100644 index 0000000..d9a6855 --- /dev/null +++ b/src/indicators/tick_features.rs @@ -0,0 +1,246 @@ +//! Tick-level feature extraction functions. +//! +//! All functions accept parallel arrays (one element per tick) and return a +//! Vec 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 { + 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 { + 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 { + 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 { + 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::() / log_rets.len() as f64; + let variance = log_rets.iter().map(|r| (r - mean).powi(2)).sum::() + / (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 { + 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 { + 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); + } +} diff --git a/src/indicators/trend.rs b/src/indicators/trend.rs new file mode 100644 index 0000000..ac021aa --- /dev/null +++ b/src/indicators/trend.rs @@ -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> { + 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> { + 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 { + 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, + /// Direction: 1 = bullish (below price), -1 = bearish (above price). + pub direction: Vec, +} + +/// 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 { + 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())); + } +} \ No newline at end of file diff --git a/src/indicators/volatility.rs b/src/indicators/volatility.rs new file mode 100644 index 0000000..2208ca9 --- /dev/null +++ b/src/indicators/volatility.rs @@ -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> { + 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, + /// Upper band (SMA + std_dev * multiplier). + pub upper: Vec, + /// Lower band (SMA - std_dev * multiplier). + pub lower: Vec, + /// Bandwidth: (upper - lower) / middle. + pub bandwidth: Vec, + /// %B: (price - lower) / (upper - lower). + pub percent_b: Vec, +} + +/// 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 { + 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, Vec, Vec)> { + 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 = (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); + } +} \ No newline at end of file diff --git a/src/indicators/volume.rs b/src/indicators/volume.rs new file mode 100644 index 0000000..87c52b9 --- /dev/null +++ b/src/indicators/volume.rs @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6cbac98 --- /dev/null +++ b/src/lib.rs @@ -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::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // Register result classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + // 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::()?; + 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(()) +} \ No newline at end of file diff --git a/src/metrics/drawdown.rs b/src/metrics/drawdown.rs new file mode 100644 index 0000000..481cdf6 --- /dev/null +++ b/src/metrics/drawdown.rs @@ -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 { + 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::() / 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); + } +} diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs new file mode 100644 index 0000000..a45da02 --- /dev/null +++ b/src/metrics/mod.rs @@ -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; diff --git a/src/metrics/streaming.rs b/src/metrics/streaming.rs new file mode 100644 index 0000000..1e40678 --- /dev/null +++ b/src/metrics/streaming.rs @@ -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); + } +} diff --git a/src/metrics/trade_stats.rs b/src/metrics/trade_stats.rs new file mode 100644 index 0000000..bdec756 --- /dev/null +++ b/src/metrics/trade_stats.rs @@ -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::() / 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::() + / 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 { + use crate::core::types::ExitReason; + use std::collections::HashMap; + + let mut grouped: HashMap> = 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_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 = + trades.iter().filter(|t| t.direction == Direction::Long).cloned().collect(); + + let short_trades: Vec = + 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 { + 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); + } +} diff --git a/src/portfolio/allocation.rs b/src/portfolio/allocation.rs new file mode 100644 index 0000000..13bf1e8 --- /dev/null +++ b/src/portfolio/allocation.rs @@ -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), + /// 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 { + 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 { + 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 = 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 = 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); + } +} diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs new file mode 100644 index 0000000..39e2228 --- /dev/null +++ b/src/portfolio/engine.rs @@ -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 = 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 = 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, Option) { + 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, Option) { + 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::() / 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 = closed_trades.iter().map(|t| t.pnl).collect(); + let mean = expectancy; + let variance = trade_pnls.iter().map(|p| (p - mean).powi(2)).sum::() + / (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::() / 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::() + / 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::() + / 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::() + / 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::() + / 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::() / 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 = 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::() / n_valid; + + // Calculate standard deviation + let variance = + valid_returns.iter().map(|r| (r - mean).powi(2)).sum::() / (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 = + 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::() / 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); + } +} diff --git a/src/portfolio/mod.rs b/src/portfolio/mod.rs new file mode 100644 index 0000000..00890d0 --- /dev/null +++ b/src/portfolio/mod.rs @@ -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; diff --git a/src/portfolio/monte_carlo.rs b/src/portfolio/monte_carlo.rs new file mode 100644 index 0000000..1ee95c1 --- /dev/null +++ b/src/portfolio/monte_carlo.rs @@ -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)>, + /// Terminal value for each simulation + pub final_values: Vec, + /// 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]) -> Result>, &'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], + weights: &[f64], + correlation_matrix: &[Vec], + 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::() / ret.len() as f64; + let var = ret.iter().map(|r| (r - mean).powi(2)).sum::() / 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 = (0..n_chunks) + .map(|_| { + let rng = base_rng.clone(); + base_rng.jump(); + rng + }) + .collect(); + + // Run simulations in parallel chunks + let all_paths: Vec> = 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 = (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 = 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)> = 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::() / 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::() / 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); + } +} diff --git a/src/portfolio/position.rs b/src/portfolio/position.rs new file mode 100644 index 0000000..3e45097 --- /dev/null +++ b/src/portfolio/position.rs @@ -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 { + 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, + target_price: Option, + 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 { + 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 + } +} diff --git a/src/python/bindings.rs b/src/python/bindings.rs new file mode 100644 index 0000000..ff63a4f --- /dev/null +++ b/src/python/bindings.rs @@ -0,0 +1,2751 @@ +//! PyO3 function bindings for RaptorBT. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use std::collections::HashMap; + +use crate::core::types::{ + BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig, + TargetConfig, +}; +use crate::indicators; +use crate::signals::synchronizer::SyncMode; +use crate::strategies::basket::{BasketBacktest, BasketConfig}; +use crate::strategies::multi::{CombineMode, MultiStrategyBacktest, MultiStrategyConfig}; +use crate::strategies::options::{ + OptionType, OptionsBacktest, OptionsConfig, SizeType, StrikeSelection, +}; +use crate::strategies::pairs::{PairsBacktest, PairsConfig}; +use crate::strategies::single::SingleBacktest; +use crate::strategies::spreads::{ + LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, +}; +use crate::strategies::tick::{TickBacktest, TickBacktestConfig}; + +use super::numpy_bridge::*; + +// ============================================================================ +// Configuration Classes +// ============================================================================ + +/// Python-exposed backtest configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestConfig { + #[pyo3(get, set)] + pub initial_capital: f64, + #[pyo3(get, set)] + pub fees: f64, + #[pyo3(get, set)] + pub slippage: f64, + #[pyo3(get, set)] + pub upon_bar_close: bool, + stop_config: StopConfig, + target_config: TargetConfig, +} + +#[pymethods] +impl PyBacktestConfig { + #[new] + #[pyo3(signature = (initial_capital=100000.0, fees=0.001, slippage=0.0, upon_bar_close=true))] + fn new(initial_capital: f64, fees: f64, slippage: f64, upon_bar_close: bool) -> Self { + Self { + initial_capital, + fees, + slippage, + upon_bar_close, + stop_config: StopConfig::None, + target_config: TargetConfig::None, + } + } + + /// Set fixed percentage stop-loss. + fn set_fixed_stop(&mut self, percent: f64) { + self.stop_config = StopConfig::Fixed { percent }; + } + + /// Set ATR-based stop-loss. + fn set_atr_stop(&mut self, multiplier: f64, period: usize) { + self.stop_config = StopConfig::Atr { multiplier, period }; + } + + /// Set trailing stop-loss. + fn set_trailing_stop(&mut self, percent: f64) { + self.stop_config = StopConfig::Trailing { percent }; + } + + /// Set fixed percentage take-profit. + fn set_fixed_target(&mut self, percent: f64) { + self.target_config = TargetConfig::Fixed { percent }; + } + + /// Set ATR-based take-profit. + fn set_atr_target(&mut self, multiplier: f64, period: usize) { + self.target_config = TargetConfig::Atr { multiplier, period }; + } + + /// Set risk-reward based take-profit. + fn set_risk_reward_target(&mut self, ratio: f64) { + self.target_config = TargetConfig::RiskReward { ratio }; + } +} + +impl From<&PyBacktestConfig> for BacktestConfig { + fn from(py_config: &PyBacktestConfig) -> Self { + BacktestConfig { + initial_capital: py_config.initial_capital, + fees: py_config.fees, + slippage: py_config.slippage, + stop: py_config.stop_config, + target: py_config.target_config, + upon_bar_close: py_config.upon_bar_close, + } + } +} + +/// Python-exposed per-instrument configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyInstrumentConfig { + #[pyo3(get, set)] + pub lot_size: Option, + #[pyo3(get, set)] + pub alloted_capital: Option, + #[pyo3(get, set)] + pub existing_qty: Option, + #[pyo3(get, set)] + pub avg_price: Option, + stop_config: Option, + target_config: Option, +} + +#[pymethods] +impl PyInstrumentConfig { + #[new] + #[pyo3(signature = (lot_size=None, alloted_capital=None, existing_qty=None, avg_price=None))] + fn new( + lot_size: Option, + alloted_capital: Option, + existing_qty: Option, + avg_price: Option, + ) -> Self { + Self { + lot_size, + alloted_capital, + existing_qty, + avg_price, + stop_config: None, + target_config: None, + } + } + + /// Set fixed percentage stop-loss override. + fn set_fixed_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Fixed { percent }); + } + + /// Set ATR-based stop-loss override. + fn set_atr_stop(&mut self, multiplier: f64, period: usize) { + self.stop_config = Some(StopConfig::Atr { multiplier, period }); + } + + /// Set trailing stop-loss override. + fn set_trailing_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Trailing { percent }); + } + + /// Set fixed percentage take-profit override. + fn set_fixed_target(&mut self, percent: f64) { + self.target_config = Some(TargetConfig::Fixed { percent }); + } + + /// Set ATR-based take-profit override. + fn set_atr_target(&mut self, multiplier: f64, period: usize) { + self.target_config = Some(TargetConfig::Atr { multiplier, period }); + } + + /// Set risk-reward based take-profit override. + fn set_risk_reward_target(&mut self, ratio: f64) { + self.target_config = Some(TargetConfig::RiskReward { ratio }); + } + + fn __repr__(&self) -> String { + format!( + "InstrumentConfig(lot_size={:?}, alloted_capital={:?})", + self.lot_size, self.alloted_capital + ) + } +} + +impl From<&PyInstrumentConfig> for InstrumentConfig { + fn from(py_config: &PyInstrumentConfig) -> Self { + InstrumentConfig { + lot_size: py_config.lot_size, + alloted_capital: py_config.alloted_capital, + stop: py_config.stop_config, + target: py_config.target_config, + existing_qty: py_config.existing_qty, + avg_price: py_config.avg_price, + } + } +} + +/// Python-exposed stop configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyStopConfig { + #[pyo3(get, set)] + pub stop_type: String, + #[pyo3(get, set)] + pub percent: Option, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, +} + +#[pymethods] +impl PyStopConfig { + #[new] + fn new() -> Self { + Self { stop_type: "none".to_string(), percent: None, multiplier: None, period: None } + } + + #[staticmethod] + fn fixed(percent: f64) -> Self { + Self { + stop_type: "fixed".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + } + } + + #[staticmethod] + fn atr(multiplier: f64, period: usize) -> Self { + Self { + stop_type: "atr".to_string(), + percent: None, + multiplier: Some(multiplier), + period: Some(period), + } + } + + #[staticmethod] + fn trailing(percent: f64) -> Self { + Self { + stop_type: "trailing".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + } + } +} + +/// Python-exposed target configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyTargetConfig { + #[pyo3(get, set)] + pub target_type: String, + #[pyo3(get, set)] + pub percent: Option, + #[pyo3(get, set)] + pub multiplier: Option, + #[pyo3(get, set)] + pub period: Option, + #[pyo3(get, set)] + pub ratio: Option, +} + +#[pymethods] +impl PyTargetConfig { + #[new] + fn new() -> Self { + Self { + target_type: "none".to_string(), + percent: None, + multiplier: None, + period: None, + ratio: None, + } + } + + #[staticmethod] + fn fixed(percent: f64) -> Self { + Self { + target_type: "fixed".to_string(), + percent: Some(percent), + multiplier: None, + period: None, + ratio: None, + } + } + + #[staticmethod] + fn atr(multiplier: f64, period: usize) -> Self { + Self { + target_type: "atr".to_string(), + percent: None, + multiplier: Some(multiplier), + period: Some(period), + ratio: None, + } + } + + #[staticmethod] + fn risk_reward(ratio: f64) -> Self { + Self { + target_type: "risk_reward".to_string(), + percent: None, + multiplier: None, + period: None, + ratio: Some(ratio), + } + } +} + +// ============================================================================ +// Result Classes +// ============================================================================ + +/// Python-exposed trade. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyTrade { + #[pyo3(get)] + pub id: u64, + #[pyo3(get)] + pub symbol: String, + #[pyo3(get)] + pub entry_idx: usize, + #[pyo3(get)] + pub exit_idx: usize, + #[pyo3(get)] + pub entry_price: f64, + #[pyo3(get)] + pub exit_price: f64, + #[pyo3(get)] + pub size: f64, + #[pyo3(get)] + pub direction: i32, + #[pyo3(get)] + pub pnl: f64, + #[pyo3(get)] + pub return_pct: f64, + #[pyo3(get)] + pub entry_time: i64, + #[pyo3(get)] + pub exit_time: i64, + #[pyo3(get)] + pub fees: f64, + #[pyo3(get)] + pub exit_reason: String, +} + +#[pymethods] +impl PyTrade { + fn __repr__(&self) -> String { + format!( + "Trade(symbol={}, entry={:.2}, exit={:.2}, pnl={:.2}, return={:.2}%)", + self.symbol, self.entry_price, self.exit_price, self.pnl, self.return_pct + ) + } +} + +/// Python-exposed backtest metrics. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestMetrics { + #[pyo3(get)] + pub total_return_pct: f64, + #[pyo3(get)] + pub sharpe_ratio: f64, + #[pyo3(get)] + pub sortino_ratio: f64, + #[pyo3(get)] + pub calmar_ratio: f64, + #[pyo3(get)] + pub omega_ratio: f64, + #[pyo3(get)] + pub max_drawdown_pct: f64, + #[pyo3(get)] + pub max_drawdown_duration: usize, + #[pyo3(get)] + pub win_rate_pct: f64, + #[pyo3(get)] + pub profit_factor: f64, + #[pyo3(get)] + pub expectancy: f64, + #[pyo3(get)] + pub sqn: f64, + #[pyo3(get)] + pub total_trades: usize, + #[pyo3(get)] + pub total_closed_trades: usize, + #[pyo3(get)] + pub total_open_trades: usize, + #[pyo3(get)] + pub open_trade_pnl: f64, + #[pyo3(get)] + pub winning_trades: usize, + #[pyo3(get)] + pub losing_trades: usize, + #[pyo3(get)] + pub start_value: f64, + #[pyo3(get)] + pub end_value: f64, + #[pyo3(get)] + pub total_fees_paid: f64, + #[pyo3(get)] + pub best_trade_pct: f64, + #[pyo3(get)] + pub worst_trade_pct: f64, + #[pyo3(get)] + pub avg_trade_return_pct: f64, + #[pyo3(get)] + pub avg_win_pct: f64, + #[pyo3(get)] + pub avg_loss_pct: f64, + #[pyo3(get)] + pub avg_winning_duration: f64, + #[pyo3(get)] + pub avg_losing_duration: f64, + #[pyo3(get)] + pub max_consecutive_wins: usize, + #[pyo3(get)] + pub max_consecutive_losses: usize, + #[pyo3(get)] + pub avg_holding_period: f64, + #[pyo3(get)] + pub exposure_pct: f64, + #[pyo3(get)] + pub payoff_ratio: f64, + #[pyo3(get)] + pub recovery_factor: f64, +} + +#[pymethods] +impl PyBacktestMetrics { + fn __repr__(&self) -> String { + format!( + "BacktestMetrics(return={:.2}%, sharpe={:.2}, max_dd={:.2}%, trades={})", + self.total_return_pct, self.sharpe_ratio, self.max_drawdown_pct, self.total_trades + ) + } + + /// Convert to dictionary of all metrics. + fn to_dict(&self, py: Python) -> PyResult { + let dict = pyo3::types::PyDict::new(py); + dict.set_item("Start Value", self.start_value)?; + dict.set_item("End Value", self.end_value)?; + dict.set_item("Total Return [%]", self.total_return_pct)?; + dict.set_item("Total Fees Paid", self.total_fees_paid)?; + dict.set_item("Max Drawdown [%]", self.max_drawdown_pct)?; + dict.set_item("Max Drawdown Duration", self.max_drawdown_duration)?; + dict.set_item("Total Trades", self.total_trades)?; + dict.set_item("Total Closed Trades", self.total_closed_trades)?; + dict.set_item("Total Open Trades", self.total_open_trades)?; + dict.set_item("Open Trade PnL", self.open_trade_pnl)?; + dict.set_item("Win Rate [%]", self.win_rate_pct)?; + dict.set_item("Best Trade [%]", self.best_trade_pct)?; + dict.set_item("Worst Trade [%]", self.worst_trade_pct)?; + dict.set_item("Avg Winning Trade [%]", self.avg_win_pct)?; + dict.set_item("Avg Losing Trade [%]", self.avg_loss_pct)?; + dict.set_item("Avg Winning Trade Duration", self.avg_winning_duration)?; + dict.set_item("Avg Losing Trade Duration", self.avg_losing_duration)?; + dict.set_item("Profit Factor", self.profit_factor)?; + dict.set_item("Expectancy", self.expectancy)?; + dict.set_item("SQN", self.sqn)?; + dict.set_item("Sharpe Ratio", self.sharpe_ratio)?; + dict.set_item("Sortino Ratio", self.sortino_ratio)?; + dict.set_item("Calmar Ratio", self.calmar_ratio)?; + dict.set_item("Omega Ratio", self.omega_ratio)?; + Ok(dict.into()) + } +} + +/// Python-exposed backtest result. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyBacktestResult { + #[pyo3(get)] + pub metrics: PyBacktestMetrics, + equity_curve: Vec, + drawdown_curve: Vec, + trades: Vec, + returns: Vec, +} + +#[pymethods] +impl PyBacktestResult { + /// Get equity curve as numpy array. + fn equity_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.equity_curve.clone()) + } + + /// Get drawdown curve as numpy array. + fn drawdown_curve<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.drawdown_curve.clone()) + } + + /// Get returns as numpy array. + fn returns<'py>(&self, py: Python<'py>) -> &'py PyArray1 { + vec_to_numpy_f64(py, self.returns.clone()) + } + + /// Get list of trades. + fn trades(&self) -> Vec { + self.trades.clone() + } + + fn __repr__(&self) -> String { + format!( + "BacktestResult(return={:.2}%, trades={}, max_dd={:.2}%)", + self.metrics.total_return_pct, self.metrics.total_trades, self.metrics.max_drawdown_pct + ) + } +} + +// ============================================================================ +// Backtest Functions +// ============================================================================ + +/// Run single instrument backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None, instrument_config=None))] +pub fn run_single_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + weight: f64, + symbol: &str, + config: Option<&PyBacktestConfig>, + position_sizes: Option>, + instrument_config: Option<&PyInstrumentConfig>, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: position_sizes.map(numpy_to_vec_f64), + direction: dir, + weight, + }; + + let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default(); + let inst_config = instrument_config.map(InstrumentConfig::from); + + let backtest = SingleBacktest::new(rust_config); + let result = backtest.run_with_instrument_config(&ohlcv, &signals, inst_config.as_ref()); + + Ok(convert_result(result)) +} + +/// Run basket/collective backtest. +#[pyfunction] +#[pyo3(signature = (instruments, config=None, sync_mode="all", instrument_configs=None))] +pub fn run_basket_backtest<'py>( + _py: Python<'py>, + instruments: Vec<( + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + PyReadonlyArray1, + i32, + f64, + String, + )>, + config: Option<&PyBacktestConfig>, + sync_mode: &str, + instrument_configs: Option>, +) -> PyResult { + let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments + .into_iter() + .map(|(ts, o, h, l, c, v, entries, exits, dir, weight, sym)| { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(ts), + open: numpy_to_vec_f64(o), + high: numpy_to_vec_f64(h), + low: numpy_to_vec_f64(l), + close: numpy_to_vec_f64(c), + volume: numpy_to_vec_f64(v), + }; + let signals = CompiledSignals { + symbol: sym, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: Direction::from_int(dir).unwrap_or(Direction::Long), + weight, + }; + (ohlcv, signals) + }) + .collect(); + + let mode = match sync_mode { + "any" => SyncMode::Any, + "majority" => SyncMode::Majority, + "master" => SyncMode::Master, + _ => SyncMode::All, + }; + + let basket_config = BasketConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + sync_mode: mode, + ..Default::default() + }; + + // Convert PyInstrumentConfig map to InstrumentConfig map + let rust_inst_configs: Option> = + instrument_configs.map(|configs| { + configs.iter().map(|(k, v)| (k.clone(), InstrumentConfig::from(v))).collect() + }); + + let backtest = BasketBacktest::new(basket_config); + let result = + backtest.run_with_instrument_configs(&rust_instruments, rust_inst_configs.as_ref()); + + Ok(convert_result(result)) +} + +/// Run options backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, option_prices, entries, exits, direction=1, symbol="OPTION", config=None, option_type="call", strike_selection="atm", size_type="percent", size_value=1.0, lot_size=1, strike_interval=50.0))] +pub fn run_options_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + option_prices: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + symbol: &str, + config: Option<&PyBacktestConfig>, + option_type: &str, + strike_selection: &str, + size_type: &str, + size_value: f64, + lot_size: usize, + strike_interval: f64, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let opt_prices = numpy_to_vec_f64(option_prices); + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: dir, + weight: 1.0, + }; + + let opt_type = match option_type { + "put" => OptionType::Put, + _ => OptionType::Call, + }; + + let strike_sel = match strike_selection { + "otm1" => StrikeSelection::Otm(1), + "otm2" => StrikeSelection::Otm(2), + "itm1" => StrikeSelection::Itm(1), + "itm2" => StrikeSelection::Itm(2), + _ => StrikeSelection::Atm, + }; + + let size = match size_type { + "contracts" => SizeType::Contracts(size_value as usize), + "notional" => SizeType::Notional(size_value), + "risk" => SizeType::RiskPercent(size_value), + _ => SizeType::Percent(size_value), + }; + + let options_config = OptionsConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + option_type: opt_type, + strike_selection: strike_sel, + size_type: size, + lot_size, + strike_interval, + target_dte: None, + }; + + let backtest = OptionsBacktest::new(options_config); + let result = backtest.run(&ohlcv, &opt_prices, &signals); + + Ok(convert_result(result)) +} + +/// Run pairs trading backtest. +#[pyfunction] +#[pyo3(signature = (leg1_timestamps, leg1_open, leg1_high, leg1_low, leg1_close, leg1_volume, leg2_timestamps, leg2_open, leg2_high, leg2_low, leg2_close, leg2_volume, entries, exits, direction=1, symbol="PAIR", config=None, hedge_ratio=1.0, dynamic_hedge=false))] +pub fn run_pairs_backtest<'py>( + _py: Python<'py>, + leg1_timestamps: PyReadonlyArray1, + leg1_open: PyReadonlyArray1, + leg1_high: PyReadonlyArray1, + leg1_low: PyReadonlyArray1, + leg1_close: PyReadonlyArray1, + leg1_volume: PyReadonlyArray1, + leg2_timestamps: PyReadonlyArray1, + leg2_open: PyReadonlyArray1, + leg2_high: PyReadonlyArray1, + leg2_low: PyReadonlyArray1, + leg2_close: PyReadonlyArray1, + leg2_volume: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + direction: i32, + symbol: &str, + config: Option<&PyBacktestConfig>, + hedge_ratio: f64, + dynamic_hedge: bool, +) -> PyResult { + let leg1_ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(leg1_timestamps), + open: numpy_to_vec_f64(leg1_open), + high: numpy_to_vec_f64(leg1_high), + low: numpy_to_vec_f64(leg1_low), + close: numpy_to_vec_f64(leg1_close), + volume: numpy_to_vec_f64(leg1_volume), + }; + + let leg2_ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(leg2_timestamps), + open: numpy_to_vec_f64(leg2_open), + high: numpy_to_vec_f64(leg2_high), + low: numpy_to_vec_f64(leg2_low), + close: numpy_to_vec_f64(leg2_close), + volume: numpy_to_vec_f64(leg2_volume), + }; + + let dir = Direction::from_int(direction).unwrap_or(Direction::Long); + + let signals = CompiledSignals { + symbol: symbol.to_string(), + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: dir, + weight: 1.0, + }; + + let pairs_config = PairsConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + hedge_ratio, + dynamic_hedge, + ..Default::default() + }; + + let backtest = PairsBacktest::new(pairs_config); + let result = backtest.run(&leg1_ohlcv, &leg2_ohlcv, &signals); + + Ok(convert_result(result)) +} + +/// Run spread backtest (multi-leg options). +#[pyfunction] +#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None, leg_expiry_timestamps=None))] +pub fn run_spread_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + underlying_close: PyReadonlyArray1, + legs_premiums: Vec>, + leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size) + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + config: Option<&PyBacktestConfig>, + spread_type: &str, + max_loss: Option, + target_profit: Option, + leg_expiry_timestamps: Option>, +) -> PyResult { + let ts = numpy_to_vec_i64(timestamps); + let underlying = numpy_to_vec_f64(underlying_close); + let premiums: Vec> = legs_premiums.into_iter().map(numpy_to_vec_f64).collect(); + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + // Convert leg configs + let rust_leg_configs: Vec = leg_configs + .into_iter() + .map(|(opt_type, strike, quantity, lot_size)| { + let option_type = + SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call); + LegConfig::new(option_type, strike, quantity, lot_size) + }) + .collect(); + + // Parse spread type + let spread_type_enum = match spread_type.to_lowercase().as_str() { + "straddle" => SpreadType::Straddle, + "strangle" => SpreadType::Strangle, + "vertical_call" | "verticalcall" => SpreadType::VerticalCall, + "vertical_put" | "verticalput" => SpreadType::VerticalPut, + "iron_condor" | "ironcondor" => SpreadType::IronCondor, + "iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly, + "butterfly_call" | "butterflycall" => SpreadType::ButterflyCall, + "butterfly_put" | "butterflyput" => SpreadType::ButterflyPut, + "calendar" => SpreadType::Calendar, + "diagonal" => SpreadType::Diagonal, + "long_call" | "longcall" => SpreadType::LongCall, + "long_put" | "longput" => SpreadType::LongPut, + "naked_call" | "nakedcall" => SpreadType::NakedCall, + "naked_put" | "nakedput" => SpreadType::NakedPut, + _ => SpreadType::Custom, + }; + + let spread_config = SpreadConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + spread_type: spread_type_enum, + leg_configs: rust_leg_configs, + max_loss, + target_profit, + close_at_eod: false, + leg_expiry_timestamps, + }; + + let backtest = SpreadBacktest::new(spread_config); + let result = backtest.run(&ts, &underlying, &premiums, &entry_signals, &exit_signals); + + Ok(convert_result(result)) +} + +/// A single spread backtest item for batch execution. +#[pyclass] +#[derive(Clone)] +pub struct PyBatchSpreadItem { + #[pyo3(get, set)] + pub strategy_id: String, + pub legs_premiums: Vec>, + pub leg_configs: Vec<(String, f64, i32, usize)>, + pub entries: Vec, + pub exits: Vec, + #[pyo3(get, set)] + pub spread_type: String, + #[pyo3(get, set)] + pub max_loss: Option, + #[pyo3(get, set)] + pub target_profit: Option, +} + +#[pymethods] +impl PyBatchSpreadItem { + #[new] + #[pyo3(signature = (strategy_id, legs_premiums, leg_configs, entries, exits, + spread_type="custom", max_loss=None, target_profit=None))] + fn new( + strategy_id: String, + legs_premiums: Vec>, + leg_configs: Vec<(String, f64, i32, usize)>, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + spread_type: &str, + max_loss: Option, + target_profit: Option, + ) -> Self { + Self { + strategy_id, + legs_premiums: legs_premiums.into_iter().map(numpy_to_vec_f64).collect(), + leg_configs, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + spread_type: spread_type.to_string(), + max_loss, + target_profit, + } + } +} + +/// Run multiple spread backtests in parallel via Rayon. +/// +/// Shared data (timestamps, underlying_close) is converted once, then each +/// item is backtested on its own Rayon thread with the GIL released. +/// +/// Returns a Vec of (strategy_id, PyBacktestResult) tuples. +#[pyfunction] +#[pyo3(signature = (timestamps, underlying_close, items, config=None))] +pub fn batch_spread_backtest( + py: Python<'_>, + timestamps: PyReadonlyArray1, + underlying_close: PyReadonlyArray1, + items: Vec, + config: Option<&PyBacktestConfig>, +) -> PyResult> { + use rayon::prelude::*; + + // Convert shared data while holding GIL + let ts = numpy_to_vec_i64(timestamps); + let underlying = numpy_to_vec_f64(underlying_close); + let base_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default(); + + // Prepare each item into a self-contained struct for parallel execution + struct PreparedItem { + strategy_id: String, + premiums: Vec>, + entries: Vec, + exits: Vec, + spread_config: SpreadConfig, + } + + let prepared: Vec = items + .into_iter() + .map(|item| { + let rust_leg_configs: Vec = item + .leg_configs + .into_iter() + .map(|(opt_type, strike, quantity, lot_size)| { + let option_type = + SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call); + LegConfig::new(option_type, strike, quantity, lot_size) + }) + .collect(); + + let spread_type_enum = match item.spread_type.to_lowercase().as_str() { + "straddle" => SpreadType::Straddle, + "strangle" => SpreadType::Strangle, + "vertical_call" | "verticalcall" => SpreadType::VerticalCall, + "vertical_put" | "verticalput" => SpreadType::VerticalPut, + "iron_condor" | "ironcondor" => SpreadType::IronCondor, + "iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly, + "butterfly_call" | "butterflycall" => SpreadType::ButterflyCall, + "butterfly_put" | "butterflyput" => SpreadType::ButterflyPut, + "calendar" => SpreadType::Calendar, + "diagonal" => SpreadType::Diagonal, + "long_call" | "longcall" => SpreadType::LongCall, + "long_put" | "longput" => SpreadType::LongPut, + "naked_call" | "nakedcall" => SpreadType::NakedCall, + "naked_put" | "nakedput" => SpreadType::NakedPut, + _ => SpreadType::Custom, + }; + + let spread_config = SpreadConfig { + base: base_config.clone(), + spread_type: spread_type_enum, + leg_configs: rust_leg_configs.clone(), + max_loss: item.max_loss, + target_profit: item.target_profit, + close_at_eod: false, + leg_expiry_timestamps: None, + }; + + PreparedItem { + strategy_id: item.strategy_id, + premiums: item.legs_premiums, + entries: item.entries, + exits: item.exits, + spread_config, + } + }) + .collect(); + + // Release GIL and run all backtests in parallel via Rayon + let results: Vec<(String, crate::core::types::BacktestResult)> = py.allow_threads(|| { + prepared + .into_par_iter() + .map(|item| { + let backtest = SpreadBacktest::new(item.spread_config); + let result = + backtest.run(&ts, &underlying, &item.premiums, &item.entries, &item.exits); + (item.strategy_id, result) + }) + .collect() + }); + + // Re-acquire GIL and convert results to Python objects + Ok(results.into_iter().map(|(id, result)| (id, convert_result(result))).collect()) +} + +/// Run multi-strategy backtest. +#[pyfunction] +#[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))] +pub fn run_multi_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + strategies: Vec<(PyReadonlyArray1, PyReadonlyArray1, i32, f64, String)>, + config: Option<&PyBacktestConfig>, + combine_mode: &str, +) -> PyResult { + let ohlcv = OhlcvData { + timestamps: numpy_to_vec_i64(timestamps), + open: numpy_to_vec_f64(open), + high: numpy_to_vec_f64(high), + low: numpy_to_vec_f64(low), + close: numpy_to_vec_f64(close), + volume: numpy_to_vec_f64(volume), + }; + + let rust_strategies: Vec = strategies + .into_iter() + .map(|(entries, exits, dir, weight, symbol)| CompiledSignals { + symbol, + entries: numpy_to_vec_bool(entries), + exits: numpy_to_vec_bool(exits), + position_sizes: None, + direction: Direction::from_int(dir).unwrap_or(Direction::Long), + weight, + }) + .collect(); + + let mode = match combine_mode { + "all" => CombineMode::All, + "majority" => CombineMode::Majority, + "independent" => CombineMode::Independent, + "weighted" => CombineMode::Weighted, + _ => CombineMode::Any, + }; + + let multi_config = MultiStrategyConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + combine_mode: mode, + ..Default::default() + }; + + let backtest = MultiStrategyBacktest::new(multi_config); + let result = backtest.run(&ohlcv, &rust_strategies); + + Ok(convert_result(result)) +} + +/// Run tick-level backtest on a single instrument. +/// +/// All arrays must be the same length N (one element per tick). +/// `buy_qty_delta` and `sell_qty_delta` must already be per-tick deltas — +/// pass the difference from the previous tick, not Zerodha's cumulative totals. +/// `entries` / `exits` are caller-computed boolean signal arrays. +/// +/// Returns a `PyBacktestResult` with the same fields as `run_single_backtest`. +#[pyfunction] +#[pyo3(signature = ( + timestamps, + ltp, + bid, + ask, + buy_qty_delta, + sell_qty_delta, + oi, + entries, + exits, + symbol = "TICK", + initial_capital = 100_000.0, + fees = 0.001, + slippage = 0.0, + stop_loss_pct = 5.0, + take_profit_pct = 10.0, + max_hold_seconds = 1800_u64, + entry_cooldown_ticks = 10_usize, + max_trades = 50_usize, +))] +pub fn run_tick_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + ltp: PyReadonlyArray1, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, + buy_qty_delta: PyReadonlyArray1, + sell_qty_delta: PyReadonlyArray1, + oi: PyReadonlyArray1, + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + symbol: &str, + initial_capital: f64, + fees: f64, + slippage: f64, + stop_loss_pct: f64, + take_profit_pct: f64, + max_hold_seconds: u64, + entry_cooldown_ticks: usize, + max_trades: usize, +) -> PyResult { + let tick_data = crate::core::types::TickData { + timestamps: numpy_to_vec_i64(timestamps), + ltp: numpy_to_vec_f64(ltp), + bid: numpy_to_vec_f64(bid), + ask: numpy_to_vec_f64(ask), + buy_qty_delta: numpy_to_vec_f64(buy_qty_delta), + sell_qty_delta: numpy_to_vec_f64(sell_qty_delta), + oi: numpy_to_vec_f64(oi), + }; + + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + let config = TickBacktestConfig { + base: crate::core::types::BacktestConfig { + initial_capital, + fees, + slippage, + stop: crate::core::types::StopConfig::None, + target: crate::core::types::TargetConfig::None, + upon_bar_close: false, + }, + stop_loss_pct, + take_profit_pct, + max_hold_seconds, + entry_cooldown_ticks, + max_trades, + }; + + let backtest = TickBacktest::new(config); + let result = backtest.run(&tick_data, &entry_signals, &exit_signals, symbol); + + Ok(convert_result(result)) +} + +// ============================================================================ +// Tick Signal Functions +// ============================================================================ + +/// Compute tick momentum entry signals from per-tick feature arrays. +/// +/// All input arrays must have the same length N. Returns a bool array of length N +/// where True indicates a valid entry tick (all gates passed, not in cooldown). +/// +/// Gates (each can be disabled by setting threshold to 0.0): +/// - spread_pct[i] <= spread_pct_max +/// - bsi_delta[i] >= bsi_min (0.0 = disabled) +/// - |return_1m[i]| >= return_1m_min_abs (0.0 = disabled; NaN always fails) +/// - cooldown_ticks between consecutive entries +/// +/// return_direction: +1 for long (needs positive return_1m), -1 for short. +#[pyfunction] +#[pyo3(signature = ( + spread_pct, + bsi_delta, + return_1m, + spread_pct_max = 5.0, + bsi_min = 0.0, + return_1m_min_abs = 0.0, + return_direction = 1_i8, + cooldown_ticks = 10_usize, +))] +pub fn compute_tick_entry_signals<'py>( + py: Python<'py>, + spread_pct: PyReadonlyArray1, + bsi_delta: PyReadonlyArray1, + return_1m: PyReadonlyArray1, + spread_pct_max: f64, + bsi_min: f64, + return_1m_min_abs: f64, + return_direction: i8, + cooldown_ticks: usize, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_entry( + &numpy_to_vec_f64(spread_pct), + &numpy_to_vec_f64(bsi_delta), + &numpy_to_vec_f64(return_1m), + spread_pct_max, + bsi_min, + return_1m_min_abs, + return_direction, + cooldown_ticks, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +/// Compute time-based exit signals (EOD / session-end). +/// +/// Sets exit[i] = True for every tick with timestamp >= eod_exit_time_ns. +/// Set eod_exit_time_ns = 0 to disable (returns all False). +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick (int64 array). +#[pyfunction] +#[pyo3(signature = (timestamps_ns, eod_exit_time_ns = 0_i64))] +pub fn compute_tick_exit_signals<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + eod_exit_time_ns: i64, +) -> PyResult<&'py PyArray1> { + let result = crate::signals::tick_signals::tick_momentum_exit( + &numpy_to_vec_i64(timestamps_ns), + eod_exit_time_ns, + ); + Ok(vec_to_numpy_bool(py, result)) +} + +// ============================================================================ +// Tick Feature Functions +// ============================================================================ + +/// Per-tick bid/ask spread as percentage of mid price. +/// Returns 0.0 where both bid and ask are zero. +#[pyfunction] +pub fn tick_spread_pct<'py>( + py: Python<'py>, + bid: PyReadonlyArray1, + ask: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::spread_pct(&numpy_to_vec_f64(bid), &numpy_to_vec_f64(ask)), + )) +} + +/// Per-tick delta BSI from Zerodha cumulative session totals. +/// +/// buy_qty_cumulative / sell_qty_cumulative must be the raw cumulative running sums +/// from Zerodha (NOT already-converted deltas). Returns [0, 1] per tick; 0.5 = neutral. +#[pyfunction] +pub fn buy_sell_imbalance_delta<'py>( + py: Python<'py>, + buy_qty_cumulative: PyReadonlyArray1, + sell_qty_cumulative: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::buy_sell_imbalance_delta( + &numpy_to_vec_f64(buy_qty_cumulative), + &numpy_to_vec_f64(sell_qty_cumulative), + ), + )) +} + +/// Per-tick lookback return over a time window. +/// +/// timestamps_ns: nanoseconds-since-epoch for each tick. +/// Returns NaN for ticks without sufficient history. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 60.0))] +pub fn return_window<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::return_window( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Rolling realized volatility proxy: stddev of log-returns over a time window (as %). +/// Returns NaN for ticks without at least 2 data points in the window. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, ltp, window_seconds = 300.0))] +pub fn realized_vol_rolling<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + ltp: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::realized_vol_rolling( + &numpy_to_vec_i64(timestamps_ns), + &numpy_to_vec_f64(ltp), + window_seconds, + ), + )) +} + +/// Per-tick OI position within the day's high/low range: [0, 100]. +/// Returns NaN where oi_day_high <= oi_day_low. +#[pyfunction] +pub fn oi_position_pct<'py>( + py: Python<'py>, + oi: PyReadonlyArray1, + oi_day_high: f64, + oi_day_low: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::oi_position_pct( + &numpy_to_vec_f64(oi), + oi_day_high, + oi_day_low, + ), + )) +} + +/// Rolling tick velocity: ticks per minute over the preceding window_seconds. +#[pyfunction] +#[pyo3(signature = (timestamps_ns, window_seconds = 60.0))] +pub fn tick_velocity<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1, + window_seconds: f64, +) -> PyResult<&'py PyArray1> { + Ok(vec_to_numpy_f64( + py, + crate::indicators::tick_features::tick_velocity( + &numpy_to_vec_i64(timestamps_ns), + window_seconds, + ), + )) +} + +// ============================================================================ +// Indicator Functions +// ============================================================================ + +/// Simple Moving Average. +#[pyfunction] +pub fn sma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::trend::sma(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Exponential Moving Average. +#[pyfunction] +pub fn ema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::trend::ema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Relative Strength Index. +#[pyfunction] +pub fn rsi<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::momentum::rsi(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// MACD indicator. +#[pyfunction] +#[pyo3(signature = (data, fast_period=12, slow_period=26, signal_period=9))] +pub fn macd<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fast_period: usize, + slow_period: usize, + signal_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::momentum::macd(&vec, fast_period, slow_period, signal_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.macd_line), + vec_to_numpy_f64(py, result.signal_line), + vec_to_numpy_f64(py, result.histogram), + )) +} + +/// Stochastic oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, k_period=14, d_period=3))] +pub fn stochastic<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + k_period: usize, + d_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::momentum::stochastic(&h, &l, &c, k_period, d_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.k), vec_to_numpy_f64(py, result.d))) +} + +/// Average True Range. +#[pyfunction] +pub fn atr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::volatility::atr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Bollinger Bands. +#[pyfunction] +#[pyo3(signature = (data, period=20, std_dev=2.0))] +pub fn bollinger_bands<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + std_dev: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::volatility::bollinger_bands(&vec, period, std_dev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.upper), + vec_to_numpy_f64(py, result.middle), + vec_to_numpy_f64(py, result.lower), + )) +} + +/// Average Directional Index. +#[pyfunction] +pub fn adx<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::strength::adx(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Volume Weighted Average Price. +#[pyfunction] +pub fn vwap<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::volume::vwap(&h, &l, &c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Supertrend indicator. +#[pyfunction] +#[pyo3(signature = (high, low, close, period=10, multiplier=3.0))] +pub fn supertrend<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, + multiplier: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::trend::supertrend(&h, &l, &c, period, multiplier) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + + let direction_array = PyArray1::from_vec(py, result.direction); + Ok((vec_to_numpy_f64(py, result.supertrend), direction_array)) +} + +/// Rolling minimum (Lowest Low Value). +#[pyfunction] +pub fn rolling_min<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_min(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Rolling maximum (Highest High Value). +#[pyfunction] +pub fn rolling_max<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_max(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +// ============================================================================ +// Extended Indicator Functions (via ferro_ta_core) +// ============================================================================ + +/// Commodity Channel Index. +#[pyfunction] +pub fn cci<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::cci(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Williams %R. +#[pyfunction] +pub fn willr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::willr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Parabolic SAR. +#[pyfunction] +#[pyo3(signature = (high, low, acceleration=0.02, maximum=0.2))] +pub fn sar<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + acceleration: f64, + maximum: f64, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::sar(&h, &l, acceleration, maximum) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Plus Directional Indicator (+DI). +#[pyfunction] +pub fn plus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::plus_di(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Minus Directional Indicator (-DI). +#[pyfunction] +pub fn minus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::minus_di(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// ADX with +DI and -DI. +#[pyfunction] +pub fn adx_all<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::adx_all(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.adx), + vec_to_numpy_f64(py, result.plus_di), + vec_to_numpy_f64(py, result.minus_di), + )) +} + +/// Average Directional Movement Rating. +#[pyfunction] +pub fn adxr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::adxr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Rate of Change. +#[pyfunction] +pub fn roc<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::roc(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Money Flow Index. +#[pyfunction] +pub fn mfi<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::mfi(&h, &l, &c, &v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Weighted Moving Average. +#[pyfunction] +pub fn wma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::wma(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Double Exponential Moving Average. +#[pyfunction] +pub fn dema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::dema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triple Exponential Moving Average. +#[pyfunction] +pub fn tema<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::tema(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Kaufman Adaptive Moving Average. +#[pyfunction] +pub fn kama<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::kama(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Stochastic RSI. +#[pyfunction] +#[pyo3(signature = (data, timeperiod=14, fastk_period=5, fastd_period=3))] +pub fn stochrsi<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let (fastk, fastd) = indicators::ferro_bridge::stochrsi(&vec, timeperiod, fastk_period, fastd_period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, fastk), vec_to_numpy_f64(py, fastd))) +} + +/// Aroon indicator (up, down). +#[pyfunction] +pub fn aroon<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::aroon(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.up), vec_to_numpy_f64(py, result.down))) +} + +/// TRIX indicator. +#[pyfunction] +pub fn trix<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::trix(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Normalized Average True Range. +#[pyfunction] +pub fn natr<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::natr(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// True Range. +#[pyfunction] +pub fn trange<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::trange(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Standard Deviation. +#[pyfunction] +#[pyo3(signature = (data, period, nbdev=1.0))] +pub fn stddev<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + nbdev: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::stddev(&vec, period, nbdev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Variance. +#[pyfunction] +#[pyo3(signature = (data, period, nbdev=1.0))] +pub fn var<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + nbdev: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::var(&vec, period, nbdev) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression. +#[pyfunction] +pub fn linearreg<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Slope. +#[pyfunction] +pub fn linearreg_slope<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_slope(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Intercept. +#[pyfunction] +pub fn linearreg_intercept<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_intercept(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Linear Regression Angle. +#[pyfunction] +pub fn linearreg_angle<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::linearreg_angle(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Time Series Forecast. +#[pyfunction] +pub fn tsf<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::tsf(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Beta. +#[pyfunction] +pub fn beta<'py>( + py: Python<'py>, + data0: PyReadonlyArray1, + data1: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v0 = numpy_to_vec_f64(data0); + let v1 = numpy_to_vec_f64(data1); + let result = indicators::ferro_bridge::beta(&v0, &v1, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Correlation. +#[pyfunction] +pub fn correl<'py>( + py: Python<'py>, + data0: PyReadonlyArray1, + data1: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v0 = numpy_to_vec_f64(data0); + let v1 = numpy_to_vec_f64(data1); + let result = indicators::ferro_bridge::correl(&v0, &v1, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chaikin A/D Line. +#[pyfunction] +pub fn ad<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::ad(&h, &l, &c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chaikin A/D Oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, fastperiod=3, slowperiod=10))] +pub fn adosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::adosc(&h, &l, &c, &v, fastperiod, slowperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// On Balance Volume (ferro_ta_core version). +#[pyfunction] +pub fn obv<'py>( + py: Python<'py>, + close: PyReadonlyArray1, + volume: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let c = numpy_to_vec_f64(close); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::obv(&c, &v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Momentum. +#[pyfunction] +pub fn mom<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::mom(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Percentage Price Oscillator. +#[pyfunction] +#[pyo3(signature = (data, fastperiod=12, slowperiod=26, signalperiod=9))] +pub fn ppo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::ppo(&vec, fastperiod, slowperiod, signalperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.ppo_line), + vec_to_numpy_f64(py, result.signal_line), + vec_to_numpy_f64(py, result.histogram), + )) +} + +/// Chande Momentum Oscillator. +#[pyfunction] +pub fn cmo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::cmo(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Aroon Oscillator. +#[pyfunction] +pub fn aroonosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::aroonosc(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Balance of Power. +#[pyfunction] +pub fn bop<'py>( + py: Python<'py>, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let o = numpy_to_vec_f64(open); + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::bop(&o, &h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Ultimate Oscillator. +#[pyfunction] +#[pyo3(signature = (high, low, close, period1=7, period2=14, period3=28))] +pub fn ultosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period1: usize, + period2: usize, + period3: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::ultosc(&h, &l, &c, period1, period2, period3) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Typical Price. +#[pyfunction] +pub fn typprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::typprice(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Median Price. +#[pyfunction] +pub fn medprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::medprice(&h, &l) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Average Price. +#[pyfunction] +pub fn avgprice<'py>( + py: Python<'py>, + open: PyReadonlyArray1, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let o = numpy_to_vec_f64(open); + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::avgprice(&o, &h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Weighted Close Price. +#[pyfunction] +pub fn wclprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::wclprice(&h, &l, &c) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Midpoint over period. +#[pyfunction] +pub fn midpoint<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::midpoint(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Midprice over period. +#[pyfunction] +pub fn midprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::midprice(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triple Exponential Moving Average (T3). +#[pyfunction] +#[pyo3(signature = (data, period=5, vfactor=0.7))] +pub fn t3<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, + vfactor: f64, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::t3(&vec, period, vfactor) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Triangular Moving Average. +#[pyfunction] +pub fn trima<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::trima(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Absolute Price Oscillator. +#[pyfunction] +#[pyo3(signature = (data, fastperiod=12, slowperiod=26))] +pub fn apo<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + fastperiod: usize, + slowperiod: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::apo(&vec, fastperiod, slowperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +// ============================================================================ +// Extended indicators (P0 batch) +// ============================================================================ + +/// Volume-Weighted Moving Average. +#[pyfunction] +#[pyo3(signature = (data, volume, period=20))] +pub fn vwma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + volume: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let c = numpy_to_vec_f64(data); + let v = numpy_to_vec_f64(volume); + let result = indicators::ferro_bridge::vwma(&c, &v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Donchian Channels — returns (upper, middle, lower). +#[pyfunction] +pub fn donchian<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + period: usize, +) -> PyResult<(&'py PyArray1, &'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let result = indicators::ferro_bridge::donchian(&h, &l, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, result.upper), + vec_to_numpy_f64(py, result.middle), + vec_to_numpy_f64(py, result.lower), + )) +} + +/// Choppiness Index. +#[pyfunction] +#[pyo3(signature = (high, low, close, period=14))] +pub fn choppiness_index<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::choppiness_index(&h, &l, &c, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Hull Moving Average. +#[pyfunction] +pub fn hull_ma<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let result = indicators::ferro_bridge::hull_ma(&v, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Chandelier Exit — returns (long_exit, short_exit). +#[pyfunction] +#[pyo3(signature = (high, low, close, period=22, multiplier=3.0))] +pub fn chandelier_exit<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + period: usize, + multiplier: f64, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let result = indicators::ferro_bridge::chandelier_exit(&h, &l, &c, period, multiplier) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, result.long_exit), vec_to_numpy_f64(py, result.short_exit))) +} + +/// Ichimoku Cloud — returns (tenkan, kijun, senkou_a, senkou_b, chikou). +#[pyfunction] +#[pyo3(signature = (high, low, close, tenkan_period=9, kijun_period=26, senkou_b_period=52, displacement=26))] +#[allow(clippy::too_many_arguments)] +pub fn ichimoku<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> PyResult<( + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, +)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::ichimoku( + &h, &l, &c, tenkan_period, kijun_period, senkou_b_period, displacement, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, r.tenkan), + vec_to_numpy_f64(py, r.kijun), + vec_to_numpy_f64(py, r.senkou_a), + vec_to_numpy_f64(py, r.senkou_b), + vec_to_numpy_f64(py, r.chikou), + )) +} + +/// Pivot Points — method: "classic" | "fibonacci" | "camarilla". +/// +/// Returns (pivot, r1, s1, r2, s2). +#[pyfunction] +pub fn pivot_points<'py>( + py: Python<'py>, + high: PyReadonlyArray1, + low: PyReadonlyArray1, + close: PyReadonlyArray1, + method: &str, +) -> PyResult<( + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, + &'py PyArray1, +)> { + let h = numpy_to_vec_f64(high); + let l = numpy_to_vec_f64(low); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::pivot_points(&h, &l, &c, method) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(( + vec_to_numpy_f64(py, r.pivot), + vec_to_numpy_f64(py, r.r1), + vec_to_numpy_f64(py, r.s1), + vec_to_numpy_f64(py, r.r2), + vec_to_numpy_f64(py, r.s2), + )) +} + +// ============================================================================ +// Hilbert Transform (cycle) indicators +// ============================================================================ + +/// Hilbert Transform — Instantaneous Trendline. +#[pyfunction] +pub fn ht_trendline<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_trendline(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Dominant Cycle Period. +#[pyfunction] +pub fn ht_dcperiod<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_dcperiod(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Dominant Cycle Phase (degrees). +#[pyfunction] +pub fn ht_dcphase<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_dcphase(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Hilbert Transform — Phasor Components (in_phase, quadrature). +#[pyfunction] +pub fn ht_phasor<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_phasor(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.in_phase), vec_to_numpy_f64(py, r.quadrature))) +} + +/// Hilbert Transform — Sine Wave (sine, lead_sine). +#[pyfunction] +pub fn ht_sine<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, &'py PyArray1)> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_sine(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.sine), vec_to_numpy_f64(py, r.lead_sine))) +} + +/// Hilbert Transform — Trend vs Cycle Mode. +/// +/// Returns i32 numpy array: 1 = trend, 0 = cycle. +#[pyfunction] +pub fn ht_trendmode<'py>( + py: Python<'py>, + data: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::ht_trendmode(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +// ============================================================================ +// Market regime detection +// ============================================================================ + +/// Trend/range regime from ADX. Returns i8: 1=trend, 0=range, -1=warmup. +#[pyfunction] +pub fn regime_adx<'py>( + py: Python<'py>, + adx: PyReadonlyArray1, + threshold: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(adx); + let r = indicators::ferro_bridge::regime_adx(&v, threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// Combined ADX + ATR-ratio regime. Returns i8: 1=trend, 0=range, -1=NaN. +#[pyfunction] +pub fn regime_combined<'py>( + py: Python<'py>, + adx: PyReadonlyArray1, + atr: PyReadonlyArray1, + close: PyReadonlyArray1, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(adx); + let t = numpy_to_vec_f64(atr); + let c = numpy_to_vec_f64(close); + let r = indicators::ferro_bridge::regime_combined(&a, &t, &c, adx_threshold, atr_pct_threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// CUSUM-based structural break detection. Returns i8: 1 at break bars. +#[pyfunction] +pub fn detect_breaks_cusum<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + window: usize, + threshold: f64, + slack: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::detect_breaks_cusum(&v, window, threshold, slack) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +/// Rolling variance break. Returns i8: 1 at break bars. +#[pyfunction] +pub fn rolling_variance_break<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + short_window: usize, + long_window: usize, + threshold: f64, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::rolling_variance_break(&v, short_window, long_window, threshold) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyArray1::from_vec(py, r)) +} + +// ============================================================================ +// Portfolio / cross-series tools +// ============================================================================ + +/// Rolling beta of asset vs benchmark. +#[pyfunction] +pub fn rolling_beta<'py>( + py: Python<'py>, + asset: PyReadonlyArray1, + benchmark: PyReadonlyArray1, + window: usize, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(asset); + let b = numpy_to_vec_f64(benchmark); + let r = indicators::ferro_bridge::rolling_beta(&a, &b, window) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Drawdown series from an equity curve. Returns (per_bar_dd, max_dd). +#[pyfunction] +pub fn drawdown_series<'py>( + py: Python<'py>, + equity: PyReadonlyArray1, +) -> PyResult<(&'py PyArray1, f64)> { + let v = numpy_to_vec_f64(equity); + let r = indicators::ferro_bridge::drawdown_series(&v) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((vec_to_numpy_f64(py, r.series), r.max_drawdown)) +} + +/// Rolling z-score. +#[pyfunction] +pub fn zscore_series<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + window: usize, +) -> PyResult<&'py PyArray1> { + let v = numpy_to_vec_f64(data); + let r = indicators::ferro_bridge::zscore_series(&v, window) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Relative strength = asset - beta*benchmark (excess-return style). +#[pyfunction] +pub fn relative_strength<'py>( + py: Python<'py>, + asset_returns: PyReadonlyArray1, + benchmark_returns: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let a = numpy_to_vec_f64(asset_returns); + let b = numpy_to_vec_f64(benchmark_returns); + let r = indicators::ferro_bridge::relative_strength(&a, &b) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Spread = a - hedge*b. +#[pyfunction] +pub fn spread<'py>( + py: Python<'py>, + a: PyReadonlyArray1, + b: PyReadonlyArray1, + hedge: f64, +) -> PyResult<&'py PyArray1> { + let av = numpy_to_vec_f64(a); + let bv = numpy_to_vec_f64(b); + let r = indicators::ferro_bridge::spread(&av, &bv, hedge) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +/// Ratio = a/b element-wise. +#[pyfunction] +pub fn ratio<'py>( + py: Python<'py>, + a: PyReadonlyArray1, + b: PyReadonlyArray1, +) -> PyResult<&'py PyArray1> { + let av = numpy_to_vec_f64(a); + let bv = numpy_to_vec_f64(b); + let r = indicators::ferro_bridge::ratio(&av, &bv) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, r)) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +// ============================================================================ +// Monte Carlo Forward Simulation +// ============================================================================ + +/// Run Monte Carlo forward simulation for a portfolio. +/// +/// Uses Geometric Brownian Motion with Cholesky-decomposed correlated random +/// draws, parallelized via Rayon. +/// +/// # Arguments +/// * `returns` - List of per-strategy return arrays (N strategies) +/// * `weights` - Portfolio weight vector (length N, sums to 1) +/// * `correlation_matrix` - N x N correlation matrix (flattened row-major as 2D list) +/// * `initial_value` - Starting portfolio value +/// * `n_simulations` - Number of simulation paths (default: 10000) +/// * `horizon_days` - Forward simulation horizon in trading days (default: 252) +/// * `seed` - Random seed for reproducibility (default: 42) +#[pyfunction] +#[pyo3(signature = (returns, weights, correlation_matrix, initial_value, n_simulations=10000, horizon_days=252, seed=42))] +pub fn simulate_portfolio_mc( + py: Python<'_>, + returns: Vec>, + weights: PyReadonlyArray1<'_, f64>, + correlation_matrix: Vec>, + initial_value: f64, + n_simulations: usize, + horizon_days: usize, + seed: u64, +) -> PyResult { + use crate::portfolio::monte_carlo::{simulate_portfolio_forward, MonteCarloConfig}; + + // Convert numpy arrays to Rust vecs + let rust_returns: Vec> = + returns.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let rust_weights: Vec = weights.as_slice().unwrap().to_vec(); + + let rust_corr: Vec> = + correlation_matrix.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let config = MonteCarloConfig { n_simulations, horizon_days, seed }; + + // Run simulation (releases GIL for Rayon parallelism) + let result = py.allow_threads(|| { + simulate_portfolio_forward(&rust_returns, &rust_weights, &rust_corr, initial_value, &config) + }); + + // Build Python dict result + let dict = pyo3::types::PyDict::new(py); + + // percentile_paths: list of (percentile, list[float]) + let paths_list = pyo3::types::PyList::empty(py); + for (pct, path) in &result.percentile_paths { + let path_list = pyo3::types::PyList::new(py, path); + let tuple = pyo3::types::PyTuple::new(py, &[pct.to_object(py), path_list.to_object(py)]); + paths_list.append(tuple)?; + } + dict.set_item("percentile_paths", paths_list)?; + + // final_values as numpy array for efficiency + let final_arr = PyArray1::from_vec(py, result.final_values); + dict.set_item("final_values", final_arr)?; + + dict.set_item("expected_return", result.expected_return)?; + dict.set_item("probability_of_loss", result.probability_of_loss)?; + dict.set_item("var_95", result.var_95)?; + dict.set_item("cvar_95", result.cvar_95)?; + + Ok(dict.into()) +} + +/// Convert Rust BacktestResult to Python PyBacktestResult. +fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult { + let metrics = PyBacktestMetrics { + total_return_pct: result.metrics.total_return_pct, + sharpe_ratio: result.metrics.sharpe_ratio, + sortino_ratio: result.metrics.sortino_ratio, + calmar_ratio: result.metrics.calmar_ratio, + omega_ratio: result.metrics.omega_ratio, + max_drawdown_pct: result.metrics.max_drawdown_pct, + max_drawdown_duration: result.metrics.max_drawdown_duration, + win_rate_pct: result.metrics.win_rate_pct, + profit_factor: result.metrics.profit_factor, + expectancy: result.metrics.expectancy, + sqn: result.metrics.sqn, + total_trades: result.metrics.total_trades, + total_closed_trades: result.metrics.total_closed_trades, + total_open_trades: result.metrics.total_open_trades, + open_trade_pnl: result.metrics.open_trade_pnl, + winning_trades: result.metrics.winning_trades, + losing_trades: result.metrics.losing_trades, + start_value: result.metrics.start_value, + end_value: result.metrics.end_value, + total_fees_paid: result.metrics.total_fees_paid, + best_trade_pct: result.metrics.best_trade_pct, + worst_trade_pct: result.metrics.worst_trade_pct, + avg_trade_return_pct: result.metrics.avg_trade_return_pct, + avg_win_pct: result.metrics.avg_win_pct, + avg_loss_pct: result.metrics.avg_loss_pct, + avg_winning_duration: result.metrics.avg_winning_duration, + avg_losing_duration: result.metrics.avg_losing_duration, + max_consecutive_wins: result.metrics.max_consecutive_wins, + max_consecutive_losses: result.metrics.max_consecutive_losses, + avg_holding_period: result.metrics.avg_holding_period, + exposure_pct: result.metrics.exposure_pct, + payoff_ratio: result.metrics.payoff_ratio, + recovery_factor: result.metrics.recovery_factor, + }; + + let trades: Vec = result + .trades + .into_iter() + .map(|t| PyTrade { + id: t.id, + symbol: t.symbol, + entry_idx: t.entry_idx, + exit_idx: t.exit_idx, + entry_price: t.entry_price, + exit_price: t.exit_price, + size: t.size, + direction: t.direction as i32, + pnl: t.pnl, + return_pct: t.return_pct, + entry_time: t.entry_time, + exit_time: t.exit_time, + fees: t.fees, + exit_reason: format!("{:?}", t.exit_reason), + }) + .collect(); + + PyBacktestResult { + metrics, + equity_curve: result.equity_curve, + drawdown_curve: result.drawdown_curve, + trades, + returns: result.returns, + } +} \ No newline at end of file diff --git a/src/python/mod.rs b/src/python/mod.rs new file mode 100644 index 0000000..c4527d9 --- /dev/null +++ b/src/python/mod.rs @@ -0,0 +1,4 @@ +//! Python bindings for RaptorBT. + +pub mod bindings; +pub mod numpy_bridge; diff --git a/src/python/numpy_bridge.rs b/src/python/numpy_bridge.rs new file mode 100644 index 0000000..9770b53 --- /dev/null +++ b/src/python/numpy_bridge.rs @@ -0,0 +1,34 @@ +//! Zero-copy numpy array interface. + +use numpy::{PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_f64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy array to Vec. +pub fn numpy_to_vec_i64(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert numpy bool array to Vec. +pub fn numpy_to_vec_bool(arr: PyReadonlyArray1) -> Vec { + arr.as_slice().unwrap().to_vec() +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_f64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_i64<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} + +/// Convert Vec to numpy array. +pub fn vec_to_numpy_bool<'py>(py: Python<'py>, vec: Vec) -> &'py PyArray1 { + PyArray1::from_vec(py, vec) +} diff --git a/src/signals/expression.rs b/src/signals/expression.rs new file mode 100644 index 0000000..6d83f6b --- /dev/null +++ b/src/signals/expression.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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] + } +} diff --git a/src/signals/mod.rs b/src/signals/mod.rs new file mode 100644 index 0000000..fb7b401 --- /dev/null +++ b/src/signals/mod.rs @@ -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}; diff --git a/src/signals/processor.rs b/src/signals/processor.rs new file mode 100644 index 0000000..046f128 --- /dev/null +++ b/src/signals/processor.rs @@ -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, Vec) { + 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, Vec, Vec, Vec) { + 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 = 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, Vec) { + 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, Vec) { + let entry_indices: Vec = entries + .iter() + .enumerate() + .filter_map(|(i, &e)| if e { Some(i) } else { None }) + .collect(); + + let exit_indices: Vec = + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/signals/synchronizer.rs b/src/signals/synchronizer.rs new file mode 100644 index 0000000..dced71c --- /dev/null +++ b/src/signals/synchronizer.rs @@ -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, +} + +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 { + 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 { + 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, Vec) { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/signals/tick_signals.rs b/src/signals/tick_signals.rs new file mode 100644 index 0000000..5e57931 --- /dev/null +++ b/src/signals/tick_signals.rs @@ -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 { + 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 { + 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 { + 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]); + } +} diff --git a/src/stops/atr.rs b/src/stops/atr.rs new file mode 100644 index 0000000..3ab3b21 --- /dev/null +++ b/src/stops/atr.rs @@ -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 { + 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, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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, + direction: Direction, + ) -> Option { + 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 { + 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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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()); + } +} diff --git a/src/stops/fixed.rs b/src/stops/fixed.rs new file mode 100644 index 0000000..b2dface --- /dev/null +++ b/src/stops/fixed.rs @@ -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 { + 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, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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, + direction: Direction, + ) -> Option { + 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, + direction: Direction, + ) -> Option { + 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()); + } +} diff --git a/src/stops/mod.rs b/src/stops/mod.rs new file mode 100644 index 0000000..1baa81d --- /dev/null +++ b/src/stops/mod.rs @@ -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; + + /// Update stop price for trailing stops. + fn update_stop( + &self, + current_stop: Option, + current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option; +} + +/// Take-profit calculator trait. +pub trait TargetCalculator { + /// Calculate target price for a new position. + fn calculate_target( + &self, + entry_price: Price, + stop_price: Option, + direction: Direction, + ) -> Option; +} diff --git a/src/stops/trailing.rs b/src/stops/trailing.rs new file mode 100644 index 0000000..ec87a2b --- /dev/null +++ b/src/stops/trailing.rs @@ -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, +} + +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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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 { + 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, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + 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 { + Some(self.stop_for_step(entry_price, 0, direction)) + } + + fn update_stop( + &self, + current_stop: Option, + _current_price: Price, + high: Price, + low: Price, + direction: Direction, + ) -> Option { + // 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 { + if self.current_sar > 0.0 { + Some(self.current_sar) + } else { + None + } + } + + fn update_stop( + &self, + _current_stop: Option, + _current_price: Price, + _high: Price, + _low: Price, + _direction: Direction, + ) -> Option { + // 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); + } +} diff --git a/src/strategies/basket.rs b/src/strategies/basket.rs new file mode 100644 index 0000000..4e8985a --- /dev/null +++ b/src/strategies/basket.rs @@ -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>, + ) -> 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> = 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 = 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 = instruments.iter().map(|(o, _)| o.close[i]).collect(); + let weights: Vec = 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 { + 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>, + ) -> Vec { + 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()); + } +} diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs new file mode 100644 index 0000000..c1fb5ee --- /dev/null +++ b/src/strategies/mod.rs @@ -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}; diff --git a/src/strategies/multi.rs b/src/strategies/multi.rs new file mode 100644 index 0000000..b0a1c80 --- /dev/null +++ b/src/strategies/multi.rs @@ -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, + /// Strategy weights (for weighted mode). + pub strategy_weights: Vec, +} + +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 = Vec::new(); + let mut strategy_equities: Vec> = 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::().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) { + 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); + } +} diff --git a/src/strategies/options.rs b/src/strategies/options.rs new file mode 100644 index 0000000..cb6b4fc --- /dev/null +++ b/src/strategies/options.rs @@ -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, +} + +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 = 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 = 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); + } +} diff --git a/src/strategies/pairs.rs b/src/strategies/pairs.rs new file mode 100644 index 0000000..10c6391 --- /dev/null +++ b/src/strategies/pairs.rs @@ -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, + /// 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 = 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 = 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::() > 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); + } +} diff --git a/src/strategies/single.rs b/src/strategies/single.rs new file mode 100644 index 0000000..84ef85f --- /dev/null +++ b/src/strategies/single.rs @@ -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, + ) -> 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 = (0..10).collect(); + let close: Vec = (100..110).map(|x| x as f64).collect(); + let open = close.clone(); + let high: Vec = close.iter().map(|x| x + 1.0).collect(); + let low: Vec = 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); + } +} diff --git a/src/strategies/spreads.rs b/src/strategies/spreads.rs new file mode 100644 index 0000000..c7afced --- /dev/null +++ b/src/strategies/spreads.rs @@ -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 { + 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, + /// Maximum loss threshold (optional, for early exit). + pub max_loss: Option, + /// Target profit threshold (optional, for early exit). + pub target_profit: Option, + /// 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>, +} + +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, + /// 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, 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], + 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 = Vec::new(); + let mut trade_id: u64 = 0; + + let mut cash = self.config.base.initial_capital; + let mut position: Option = None; + let mut prev_equity = cash; + + // Single-pass O(n) algorithm + for i in 0..n { + // Get current leg premiums + let current_premiums: Vec = 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 = 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, 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, 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, Vec, Vec>, Vec, Vec) { + let n = 20; + let timestamps: Vec = (0..n as i64).collect(); + let underlying: Vec = (100..120).map(|x| x as f64).collect(); + + // Call and Put premiums + let call_premiums: Vec = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect(); + let put_premiums: Vec = (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 = (0..n as i64).collect(); + let underlying: Vec = 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); + } +} diff --git a/src/strategies/tick.rs b/src/strategies/tick.rs new file mode 100644 index 0000000..1ef6335 --- /dev/null +++ b/src/strategies/tick.rs @@ -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 = 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, 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 = 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 = (0..n).map(|i| base_price + i as f64 * trend).collect(); + let bid: Vec = ltp.iter().map(|p| p - 0.5).collect(); + let ask: Vec = 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 = (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); + } +} diff --git a/strategies/__init__.py b/strategies/__init__.py new file mode 100644 index 0000000..611f4d4 --- /dev/null +++ b/strategies/__init__.py @@ -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]() diff --git a/strategies/atr_stop_rr.py b/strategies/atr_stop_rr.py new file mode 100644 index 0000000..eec3679 --- /dev/null +++ b/strategies/atr_stop_rr.py @@ -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 diff --git a/strategies/base.py b/strategies/base.py new file mode 100644 index 0000000..c15bfe7 --- /dev/null +++ b/strategies/base.py @@ -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 diff --git a/strategies/rsi_mean_reversion.py b/strategies/rsi_mean_reversion.py new file mode 100644 index 0000000..2ac2d9f --- /dev/null +++ b/strategies/rsi_mean_reversion.py @@ -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 diff --git a/strategies/sar_adx_cci.py b/strategies/sar_adx_cci.py new file mode 100644 index 0000000..b32660d --- /dev/null +++ b/strategies/sar_adx_cci.py @@ -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 diff --git a/strategies/sma_cross.py b/strategies/sma_cross.py new file mode 100644 index 0000000..52e8c46 --- /dev/null +++ b/strategies/sma_cross.py @@ -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 diff --git a/strategies/xau_m5_supertrend.py b/strategies/xau_m5_supertrend.py new file mode 100644 index 0000000..f0e2939 --- /dev/null +++ b/strategies/xau_m5_supertrend.py @@ -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 diff --git a/tests/test_indicators.rs b/tests/test_indicators.rs new file mode 100644 index 0000000..0c7de8c --- /dev/null +++ b/tests/test_indicators.rs @@ -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, Vec, Vec, Vec, Vec) { + // Create sample OHLCV data with 50 bars + let n = 50; + let mut close: Vec = vec![100.0]; + let mut high: Vec = vec![101.0]; + let mut low: Vec = vec![99.0]; + let mut open: Vec = vec![100.0]; + let volume: Vec = 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 = 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 = (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 = (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 = (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 = 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 = (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 = (0..50).map(|i| (i as f64) * 0.01).collect(); + let b: Vec = (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()); +} diff --git a/tests/test_portfolio.rs b/tests/test_portfolio.rs new file mode 100644 index 0000000..35dcaf7 --- /dev/null +++ b/tests/test_portfolio.rs @@ -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); +} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c1170e5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" + +[[package]] +name = "raptorbt" +version = "0.2.0" +source = { editable = "." } diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..b263107 --- /dev/null +++ b/vendor/README.md @@ -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 等 diff --git a/vendor/ferro-ta-main/.cargo/config.toml b/vendor/ferro-ta-main/.cargo/config.toml new file mode 100644 index 0000000..b73ec86 --- /dev/null +++ b/vendor/ferro-ta-main/.cargo/config.toml @@ -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"] diff --git a/vendor/ferro-ta-main/.devcontainer/devcontainer.json b/vendor/ferro-ta-main/.devcontainer/devcontainer.json new file mode 100644 index 0000000..f7c676d --- /dev/null +++ b/vendor/ferro-ta-main/.devcontainer/devcontainer.json @@ -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" +} diff --git a/vendor/ferro-ta-main/.gitignore b/vendor/ferro-ta-main/.gitignore new file mode 100644 index 0000000..77ff27d --- /dev/null +++ b/vendor/ferro-ta-main/.gitignore @@ -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 diff --git a/vendor/ferro-ta-main/.pre-commit-config.yaml b/vendor/ferro-ta-main/.pre-commit-config.yaml new file mode 100644 index 0000000..e849b64 --- /dev/null +++ b/vendor/ferro-ta-main/.pre-commit-config.yaml @@ -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] diff --git a/vendor/ferro-ta-main/CHANGELOG.md b/vendor/ferro-ta-main/CHANGELOG.md new file mode 100644 index 0000000..5397a72 --- /dev/null +++ b/vendor/ferro-ta-main/CHANGELOG.md @@ -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 `. +- **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 `. +- **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 diff --git a/vendor/ferro-ta-main/CODE_OF_CONDUCT.md b/vendor/ferro-ta-main/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5c2eb52 --- /dev/null +++ b/vendor/ferro-ta-main/CODE_OF_CONDUCT.md @@ -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 diff --git a/vendor/ferro-ta-main/CONTRIBUTING.md b/vendor/ferro-ta-main/CONTRIBUTING.md new file mode 100644 index 0000000..cfbda68 --- /dev/null +++ b/vendor/ferro-ta-main/CONTRIBUTING.md @@ -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>> { + 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>> { + 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/.rs` with a unit test. +2. Add a thin `#[pyfunction]` wrapper in `src/.rs` (or the appropriate submodule under `src//`) that calls into the core. +3. Add the Python wrapper in `python/ferro_ta/indicators/.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//`. 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. diff --git a/vendor/ferro-ta-main/Cargo.lock b/vendor/ferro-ta-main/Cargo.lock new file mode 100644 index 0000000..93b5e9c --- /dev/null +++ b/vendor/ferro-ta-main/Cargo.lock @@ -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" diff --git a/vendor/ferro-ta-main/Cargo.toml b/vendor/ferro-ta-main/Cargo.toml new file mode 100644 index 0000000..14ef96a --- /dev/null +++ b/vendor/ferro-ta-main/Cargo.toml @@ -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"] diff --git a/vendor/ferro-ta-main/GOVERNANCE.md b/vendor/ferro-ta-main/GOVERNANCE.md new file mode 100644 index 0000000..c5b6259 --- /dev/null +++ b/vendor/ferro-ta-main/GOVERNANCE.md @@ -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. diff --git a/vendor/ferro-ta-main/LICENSE b/vendor/ferro-ta-main/LICENSE new file mode 100644 index 0000000..835e937 --- /dev/null +++ b/vendor/ferro-ta-main/LICENSE @@ -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. diff --git a/vendor/ferro-ta-main/Makefile b/vendor/ferro-ta-main/Makefile new file mode 100644 index 0000000..5015682 --- /dev/null +++ b/vendor/ferro-ta-main/Makefile @@ -0,0 +1,72 @@ +# ferro-ta development Makefile +# Usage: make + +.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 diff --git a/vendor/ferro-ta-main/PACKAGING.md b/vendor/ferro-ta-main/PACKAGING.md new file mode 100644 index 0000000..1d2cd4e --- /dev/null +++ b/vendor/ferro-ta-main/PACKAGING.md @@ -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. diff --git a/vendor/ferro-ta-main/PERFORMANCE_ROADMAP.md b/vendor/ferro-ta-main/PERFORMANCE_ROADMAP.md new file mode 100644 index 0000000..cfffbdf --- /dev/null +++ b/vendor/ferro-ta-main/PERFORMANCE_ROADMAP.md @@ -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) diff --git a/vendor/ferro-ta-main/PLATFORMS.md b/vendor/ferro-ta-main/PLATFORMS.md new file mode 100644 index 0000000..3829077 --- /dev/null +++ b/vendor/ferro-ta-main/PLATFORMS.md @@ -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. diff --git a/vendor/ferro-ta-main/README.md b/vendor/ferro-ta-main/README.md new file mode 100644 index 0000000..6a0c491 --- /dev/null +++ b/vendor/ferro-ta-main/README.md @@ -0,0 +1,139 @@ +
+ +# ⚡ ferro-ta + +### Rust-powered Python technical analysis with a TA-Lib-compatible API + +**Focused on one primary job: fast, reproducible technical analysis for Python users who want TA-Lib-style ergonomics without native build friction.** + +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb) +[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb) +[![Documentation](https://img.shields.io/badge/docs-github.io-blue)](https://pratikbhadane24.github.io/ferro-ta/) + +
+ +--- + +> `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). diff --git a/vendor/ferro-ta-main/RELEASE.md b/vendor/ferro-ta-main/RELEASE.md new file mode 100644 index 0000000..52e2a5d --- /dev/null +++ b/vendor/ferro-ta-main/RELEASE.md @@ -0,0 +1,212 @@ +# Release Playbook + +This document describes the step-by-step process for cutting a new **ferro-ta** release. +Follow every step in order to produce a consistent, reproducible release. + +For the packaging and release overview, see [PACKAGING.md](PACKAGING.md). + +--- + +## Publish matrix (all automatic on release) + +| Artifact | How | +|-------------|-----| +| **PyPI** | CI job `publish` using PyPI Trusted Publishing (OIDC) | +| **npm (WASM)** | Workflow `wasm-publish`| +| **crates.io** | CI job `publish-cratesio` | + +PyPI releases are expected to include: + +- Wheels for CPython 3.10, 3.11, 3.12, and 3.13 +- Linux x86_64 (`manylinux_2_17`) +- macOS universal2 +- Windows x86_64 +- One source distribution (`sdist`) + +--- + +## Pre-release checklist + +Before starting a release: + +- [ ] All CI checks are green on `main`: Rust (fmt, clippy), tests (with coverage gate), + lint (ruff), typecheck (mypy, pyright), docs (Sphinx), WASM, audit (cargo-audit, + pip-audit), fuzz (no crashes). +- [ ] **Security audit clean:** Run `cargo audit` and `pip-audit` locally and confirm + no high/critical vulnerabilities. Address any findings before tagging. + ```bash + cargo audit + pip-audit # or: uv run --with pip-audit pip-audit + ``` +- [ ] No open blocking issues or PRs that must land first. +- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section (not `[Unreleased]`) with all + changes since the last release documented under `### Added`, `### Changed`, + `### Fixed`, `### Removed` headings. +- [ ] Public docs match the release: `docs/conf.py`, `docs/changelog.rst`, and + `docs/support_matrix.rst` reflect the version and current support status. + +--- + +## Step 1 — Decide the version number + +Follow [Semantic Versioning 2.0.0](https://semver.org/) and the policy in +[VERSIONING.md](VERSIONING.md): + +| Change type | Version component to bump | +|---|---| +| Breaking API change (indicator removed, parameter renamed, return type changed) | **MAJOR** | +| New indicators, features, or bindings (backward-compatible) | **MINOR** | +| Bug fixes, performance, docs-only, dependency bumps | **PATCH** | + +Example: current version is `0.1.0` and you are adding new indicators → new version is `0.2.0`. + +--- + +## Step 2 — Sync version everywhere + +These files must carry **the same version string** (e.g. `0.2.0`). The easiest +way to do that is: + +```bash +python3 scripts/bump_version.py 0.2.0 +python3 scripts/bump_version.py --check +``` + +That script updates the tracked release-version carriers for you. + +Files covered by the bump script: + +| File | Location | +|------|----------| +| `Cargo.toml` | Root (source of truth) | +| `crates/ferro_ta_core/Cargo.toml` | Same version for crates.io publish | +| `crates/ferro_ta_core/README.md` | Installation snippet should show the current crate version | +| `pyproject.toml` | Root | +| `wasm/package.json` | Package version | +| `conda/meta.yaml` | Conda recipe version | +| `docs/conf.py` | Default Sphinx release must resolve to the same version | + +**`Cargo.toml`** (root): +```toml +[package] +name = "ferro_ta" +version = "X.Y.Z" # ← or use scripts/bump_version.py X.Y.Z +``` + +**`pyproject.toml`**: +```toml +[project] +version = "X.Y.Z" # ← must match Cargo.toml exactly +``` + +> **Rule:** `Cargo.toml` is the source of truth. Sync the others to match before tagging. + +--- + +## Step 3 — Update CHANGELOG.md + +1. Open `CHANGELOG.md`. +2. Rename the `[Unreleased]` section to `[X.Y.Z] — YYYY-MM-DD` (today's date). +3. Add a fresh empty `[Unreleased]` section at the top. +4. Update the comparison links at the bottom: + +```markdown +[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/vX.Y.Z...HEAD +[X.Y.Z]: https://github.com/pratikbhadane24/ferro-ta/compare/vPREVIOUS...vX.Y.Z +``` + +Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format: +`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +Also update the docs-facing release surfaces for the same version: + +- `docs/changelog.rst` with a concise release-notes entry +- `docs/support_matrix.rst` if supported versions, tested wheels, or module + stability changed + +--- + +## Step 4 — Commit the version bump + +```bash +git add Cargo.toml crates/ferro_ta_core/Cargo.toml pyproject.toml wasm/package.json CHANGELOG.md +git commit -m "chore: release v0.2.0" +git push origin main +``` + +Wait for CI to pass on this commit before proceeding. + +--- + +## Step 5 — Create and push the tag + +```bash +git tag v0.2.0 +git push origin v0.2.0 +``` + +> Tags must be in the form `vMAJOR.MINOR.PATCH` (e.g. `v0.2.0`). + +--- + +## Step 6 — Create a GitHub Release + +1. Go to **Releases → Draft a new release** in the GitHub UI. +2. Select the tag `v0.2.0` you just pushed. +3. Set the release title to `v0.2.0`. +4. Paste the changelog section for `v0.2.0` into the release notes. +5. Click **Publish release**. + +Publishing the release triggers the CI wheel build jobs, `build-sdist`, and `publish` +automatically (the workflow responds to `release: published`). The PyPI upload +uses Trusted Publishing via GitHub OIDC, so no `PYPI_API_TOKEN` secret is used. + +--- + +## Step 7 — Monitor CI and verify PyPI + +1. Watch the **Actions** tab: the release wheel jobs, `build-sdist`, `publish` (PyPI), `publish-cratesio` (crates.io), and the **wasm-publish** workflow (npm). +2. After the `publish` job succeeds, verify the package is live: + +```bash +pip install ferro-ta==0.2.0 +python -c "import ferro_ta; print(ferro_ta.__version__ if hasattr(ferro_ta,'__version__') else 'ok')" +``` + +For version-specific verification, also check at least one install on each +supported Python line, for example: + +```bash +uv venv --python 3.13 .venv-313 +. .venv-313/bin/activate +uv pip install ferro-ta==0.2.0 +python -c "import ferro_ta; print(ferro_ta.SMA([1.0, 2.0, 3.0], 2))" +``` + +3. If anything fails: fix the issue, bump to a patch version (`0.2.1`), and repeat. + +--- + + +--- + +## Hotfix releases + +For urgent bug fixes on a released version: + +1. Branch from the release tag: `git checkout -b hotfix/0.1.1 v0.1.0` +2. Apply the fix, bump to `0.1.1`, update CHANGELOG. +3. Merge the branch into `main`. +4. Tag and release as above. + +--- + +> **Note:** `ferro_ta_core` is published to crates.io automatically by the CI job `publish-cratesio` when you publish a release (requires `CARGO_REGISTRY_TOKEN` secret). + +--- + +## See also + +- [VERSIONING.md](VERSIONING.md) — versioning policy and breaking-change rules +- [CHANGELOG.md](CHANGELOG.md) — changelog history +- [CONTRIBUTING.md](CONTRIBUTING.md) — development setup and PR guidelines diff --git a/vendor/ferro-ta-main/SECURITY.md b/vendor/ferro-ta-main/SECURITY.md new file mode 100644 index 0000000..92cf977 --- /dev/null +++ b/vendor/ferro-ta-main/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| latest | ✅ | + +## Reporting a Vulnerability + +If you discover a security vulnerability in ferro-ta, please **do not** open a +public GitHub issue. + +Instead, report it privately by emailing **pratikbhadane24@gmail.com** with: + +- A description of the vulnerability +- Steps to reproduce (or a minimal proof-of-concept) +- The potential impact + +You will receive a response within 7 days acknowledging receipt, and a +follow-up within 14 days with next steps. + +We will coordinate a fix and public disclosure together. We appreciate +responsible disclosure and will credit researchers who report issues in good +faith. + +## Scope + +ferro-ta is a numerical computation library. Security-relevant concerns include: + +- Memory safety issues in the Rust extension (buffer overflows, use-after-free, + etc.) +- Unsafe behaviour triggered by crafted input arrays +- Dependency vulnerabilities (tracked via `cargo audit` and Dependabot) + +Out of scope: issues in user code that calls ferro-ta, or theoretical attacks +that require direct file-system or network access. + +## Hardening and audits + +- **Fuzzing:** The project runs `cargo fuzz` targets (e.g. `fuzz_sma`, `fuzz_rsi`) in CI. Crashes are treated as failures; artifacts are uploaded for investigation. +- **Dependency audits:** CI runs `cargo audit` (Rust) and `pip-audit` (Python). Critical and high-severity vulnerabilities should be addressed before release. +- **Reporting:** If you have performed a security assessment or audit, we welcome a private summary to the contact above. diff --git a/vendor/ferro-ta-main/TA_LIB_COMPATIBILITY.md b/vendor/ferro-ta-main/TA_LIB_COMPATIBILITY.md new file mode 100644 index 0000000..52fea9c --- /dev/null +++ b/vendor/ferro-ta-main/TA_LIB_COMPATIBILITY.md @@ -0,0 +1,261 @@ +# TA-Lib Compatibility + +`ferro-ta` covers **100% of TA-Lib's function set** (`162+` indicators). This +file keeps the full GitHub-facing parity matrix in one place so the root +`README.md` can stay product-focused. + +See also: + +- [docs/migration_talib.rst](docs/migration_talib.rst) +- [docs/compatibility/talib.md](docs/compatibility/talib.md) +- [docs/support_matrix.rst](docs/support_matrix.rst) + +## Legend + + +| Symbol | Meaning | +| -------- | ------------------------------------------------------------------------------------------------------ | +| ✅ Exact | Values match TA-Lib to floating-point precision | +| ✅ Close | Values match after a short convergence window (EMA-seed difference) | +| ⚠️ Corr | Strong correlation (> 0.95) but not numerically identical (Wilder smoothing seed or algorithm variant) | +| ⚠️ Shape | Same output shape / NaN structure; values differ due to algorithm variant | +| ❌ | Not yet implemented | + + +## Overlap Studies + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ----------------------------------------------------- | +| `BBANDS` | ✅ | ✅ Exact | Bollinger Bands | +| `DEMA` | ✅ | ✅ Close | Double EMA; converges after ~20 bars | +| `EMA` | ✅ | ✅ Close | Exponential Moving Average; converges after ~20 bars | +| `KAMA` | ✅ | ✅ Exact | Kaufman Adaptive MA (values match after seed bar) | +| `MA` | ✅ | ✅ Exact | Moving average (generic, type-selectable) | +| `MAMA` | ✅ | ⚠️ Corr | MESA Adaptive MA | +| `MAVP` | ✅ | ✅ Exact | MA with variable period | +| `MIDPOINT` | ✅ | ✅ Exact | Midpoint over period | +| `MIDPRICE` | ✅ | ✅ Exact | Midpoint price over period | +| `SAR` | ✅ | ⚠️ Shape | Parabolic SAR (same shape; reversal history diverges) | +| `SAREXT` | ✅ | ⚠️ Shape | Parabolic SAR Extended | +| `SMA` | ✅ | ✅ Exact | Simple Moving Average | +| `T3` | ✅ | ✅ Close | Triple Exponential MA (T3); converges after ~50 bars | +| `TEMA` | ✅ | ✅ Close | Triple EMA; converges after ~20 bars | +| `TRIMA` | ✅ | ✅ Exact | Triangular Moving Average | +| `WMA` | ✅ | ✅ Exact | Weighted Moving Average | + + +## Momentum Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ---------------------------------------------------------------------------- | +| `ADX` | ✅ | ✅ Close | Avg Directional Movement Index (TA-Lib Wilder sum-seeding) | +| `ADXR` | ✅ | ✅ Close | ADX Rating (inherits ADX; TA-Lib seeding) | +| `APO` | ✅ | ✅ Close | Absolute Price Oscillator (EMA-based) | +| `AROON` | ✅ | ✅ Exact | Aroon Up/Down | +| `AROONOSC` | ✅ | ✅ Exact | Aroon Oscillator | +| `BOP` | ✅ | ✅ Exact | Balance Of Power | +| `CCI` | ✅ | ✅ Exact | Commodity Channel Index (TA-Lib-compatible MAD formula) | +| `CMO` | ✅ | ✅ Close | Chande Momentum Oscillator (rolling window, TA-Lib-compatible) | +| `DX` | ✅ | ✅ Close | Directional Movement Index (TA-Lib Wilder sum-seeding) | +| `MACD` | ✅ | ✅ Close | MACD (EMA-based; converges after ~30 bars) | +| `MACDEXT` | ✅ | ✅ Close | MACD with controllable MA type (EMA-based; converges) | +| `MACDFIX` | ✅ | ✅ Close | MACD Fixed 12/26 (EMA-based; converges) | +| `MFI` | ✅ | ✅ Exact | Money Flow Index | +| `MINUS_DI` | ✅ | ✅ Close | Minus Directional Indicator (TA-Lib Wilder sum-seeding) | +| `MINUS_DM` | ✅ | ✅ Close | Minus Directional Movement (TA-Lib Wilder sum-seeding) | +| `MOM` | ✅ | ✅ Exact | Momentum | +| `PLUS_DI` | ✅ | ✅ Close | Plus Directional Indicator (TA-Lib Wilder sum-seeding) | +| `PLUS_DM` | ✅ | ✅ Close | Plus Directional Movement (TA-Lib Wilder sum-seeding) | +| `PPO` | ✅ | ✅ Close | Percentage Price Oscillator (EMA-based) | +| `ROC` | ✅ | ✅ Exact | Rate of Change | +| `ROCP` | ✅ | ✅ Exact | Rate of Change Percentage | +| `ROCR` | ✅ | ✅ Exact | Rate of Change Ratio | +| `ROCR100` | ✅ | ✅ Exact | Rate of Change Ratio × 100 | +| `RSI` | ✅ | ✅ Close | Relative Strength Index (TA-Lib Wilder seeding; converges after ~1 seed bar) | +| `STOCH` | ✅ | ✅ Close | Stochastic (TA-Lib-compatible SMA smoothing for slowk and slowd) | +| `STOCHF` | ✅ | ✅ Exact | Stochastic Fast (%K exact; %D NaN offset ±2) | +| `STOCHRSI` | ✅ | ✅ Close | Stochastic RSI (TA-Lib-compatible; SMA fastd, Wilder-seeded RSI) | +| `TRIX` | ✅ | ✅ Close | 1-day ROC of Triple EMA (EMA-based; converges) | +| `ULTOSC` | ✅ | ✅ Exact | Ultimate Oscillator | +| `WILLR` | ✅ | ✅ Exact | Williams' %R | + + +## Volume Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ------------------------------------------------------------------ | +| `AD` | ✅ | ✅ Exact | Chaikin A/D Line | +| `ADOSC` | ✅ | ✅ Exact | Chaikin A/D Oscillator | +| `OBV` | ✅ | ✅ Exact | On Balance Volume (increments identical; constant offset at bar 0) | + + +## Volatility Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ----------------------------------------------------------------------- | +| `ATR` | ✅ | ✅ Close | Average True Range (TA-Lib Wilder seeding; matches from bar timeperiod) | +| `NATR` | ✅ | ✅ Close | Normalized ATR (TA-Lib Wilder seeding) | +| `TRANGE` | ✅ | ✅ Exact | True Range (bar 0 differs; all others identical) | + + +## Cycle Indicators + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | ---------------------------------------------------------- | +| `HT_DCPERIOD` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Period (Ehlers algorithm) | +| `HT_DCPHASE` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Phase | +| `HT_PHASOR` | ✅ | ⚠️ Shape | Hilbert Transform Phasor Components (inphase, quadrature) | +| `HT_SINE` | ✅ | ⚠️ Shape | Hilbert Transform SineWave (sine, leadsine) | +| `HT_TRENDLINE` | ✅ | ⚠️ Shape | Hilbert Transform Instantaneous Trendline | +| `HT_TRENDMODE` | ✅ | ⚠️ Shape | Hilbert Transform Trend vs Cycle Mode (1=trend, 0=cycle) | + + +## Price Transformations + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------- | -------- | -------- | -------------------- | +| `AVGPRICE` | ✅ | ✅ Exact | Average Price | +| `MEDPRICE` | ✅ | ✅ Exact | Median Price | +| `TYPPRICE` | ✅ | ✅ Exact | Typical Price | +| `WCLPRICE` | ✅ | ✅ Exact | Weighted Close Price | + + +## Statistic Functions + + +| TA-Lib Function | ferro-ta | Accuracy | Notes | +| --------------------- | -------- | -------- | ----------------------------------------------------------- | +| `BETA` | ✅ | ✅ Close | Beta coefficient (returns-based regression matching TA-Lib) | +| `CORREL` | ✅ | ✅ Exact | Pearson Correlation Coefficient | +| `LINEARREG` | ✅ | ✅ Exact | Linear Regression | +| `LINEARREG_ANGLE` | ✅ | ✅ Exact | Linear Regression Angle | +| `LINEARREG_INTERCEPT` | ✅ | ✅ Exact | Linear Regression Intercept | +| `LINEARREG_SLOPE` | ✅ | ✅ Exact | Linear Regression Slope | +| `STDDEV` | ✅ | ✅ Exact | Standard Deviation | +| `TSF` | ✅ | ✅ Exact | Time Series Forecast | +| `VAR` | ✅ | ✅ Exact | Variance | + + +## Pattern Recognition + +`ferro-ta` implements all 61 candlestick patterns. All return the same +`{-100, 0, 100}` convention as TA-Lib. Pattern thresholds may differ slightly +from the full TA-Lib implementation. + + +| TA-Lib Function | ferro-ta | Notes | +| --------------------- | -------- | --------------------------------------------------- | +| `CDL2CROWS` | ✅ | Two Crows | +| `CDL3BLACKCROWS` | ✅ | Three Black Crows | +| `CDL3INSIDE` | ✅ | Three Inside Up/Down | +| `CDL3LINESTRIKE` | ✅ | Three-Line Strike | +| `CDL3OUTSIDE` | ✅ | Three Outside Up/Down | +| `CDL3STARSINSOUTH` | ✅ | Three Stars In The South | +| `CDL3WHITESOLDIERS` | ✅ | Three Advancing White Soldiers | +| `CDLABANDONEDBABY` | ✅ | Abandoned Baby | +| `CDLADVANCEBLOCK` | ✅ | Advance Block | +| `CDLBELTHOLD` | ✅ | Belt-hold | +| `CDLBREAKAWAY` | ✅ | Breakaway | +| `CDLCLOSINGMARUBOZU` | ✅ | Closing Marubozu | +| `CDLCONCEALBABYSWALL` | ✅ | Concealing Baby Swallow | +| `CDLCOUNTERATTACK` | ✅ | Counterattack | +| `CDLDARKCLOUDCOVER` | ✅ | Dark Cloud Cover | +| `CDLDOJI` | ✅ | Doji | +| `CDLDOJISTAR` | ✅ | Doji Star | +| `CDLDRAGONFLYDOJI` | ✅ | Dragonfly Doji | +| `CDLENGULFING` | ✅ | Engulfing Pattern | +| `CDLEVENINGDOJISTAR` | ✅ | Evening Doji Star | +| `CDLEVENINGSTAR` | ✅ | Evening Star | +| `CDLGAPSIDESIDEWHITE` | ✅ | Up/Down-gap side-by-side white lines | +| `CDLGRAVESTONEDOJI` | ✅ | Gravestone Doji | +| `CDLHAMMER` | ✅ | Hammer | +| `CDLHANGINGMAN` | ✅ | Hanging Man | +| `CDLHARAMI` | ✅ | Harami Pattern | +| `CDLHARAMICROSS` | ✅ | Harami Cross Pattern | +| `CDLHIGHWAVE` | ✅ | High-Wave Candle | +| `CDLHIKKAKE` | ✅ | Hikkake Pattern | +| `CDLHIKKAKEMOD` | ✅ | Modified Hikkake Pattern | +| `CDLHOMINGPIGEON` | ✅ | Homing Pigeon | +| `CDLIDENTICAL3CROWS` | ✅ | Identical Three Crows | +| `CDLINNECK` | ✅ | In-Neck Pattern | +| `CDLINVERTEDHAMMER` | ✅ | Inverted Hammer | +| `CDLKICKING` | ✅ | Kicking | +| `CDLKICKINGBYLENGTH` | ✅ | Kicking by the longer Marubozu | +| `CDLLADDERBOTTOM` | ✅ | Ladder Bottom | +| `CDLLONGLEGGEDDOJI` | ✅ | Long Legged Doji | +| `CDLLONGLINE` | ✅ | Long Line Candle | +| `CDLMARUBOZU` | ✅ | Marubozu | +| `CDLMATCHINGLOW` | ✅ | Matching Low | +| `CDLMATHOLD` | ✅ | Mat Hold | +| `CDLMORNINGDOJISTAR` | ✅ | Morning Doji Star | +| `CDLMORNINGSTAR` | ✅ | Morning Star | +| `CDLONNECK` | ✅ | On-Neck Pattern | +| `CDLPIERCING` | ✅ | Piercing Pattern | +| `CDLRICKSHAWMAN` | ✅ | Rickshaw Man | +| `CDLRISEFALL3METHODS` | ✅ | Rising/Falling Three Methods | +| `CDLSEPARATINGLINES` | ✅ | Separating Lines | +| `CDLSHOOTINGSTAR` | ✅ | Shooting Star | +| `CDLSHORTLINE` | ✅ | Short Line Candle | +| `CDLSPINNINGTOP` | ✅ | Spinning Top | +| `CDLSTALLEDPATTERN` | ✅ | Stalled Pattern | +| `CDLSTICKSANDWICH` | ✅ | Stick Sandwich | +| `CDLTAKURI` | ✅ | Takuri (Dragonfly Doji with very long lower shadow) | +| `CDLTASUKIGAP` | ✅ | Tasuki Gap | +| `CDLTHRUSTING` | ✅ | Thrusting Pattern | +| `CDLTRISTAR` | ✅ | Tristar Pattern | +| `CDLUNIQUE3RIVER` | ✅ | Unique 3 River | +| `CDLUPSIDEGAP2CROWS` | ✅ | Upside Gap Two Crows | +| `CDLXSIDEGAP3METHODS` | ✅ | Upside/Downside Gap Three Methods | + + +## Math Operators / Math Transforms + +`ferro-ta` provides TA-Lib-compatible wrappers for all arithmetic and +math-transform functions. Rolling functions (`SUM`, `MAX`, `MIN`) produce `NaN` +for the first `timeperiod - 1` bars. + + +| TA-Lib Function | ferro-ta | Notes | +| ------------------------ | -------- | ----------------------------- | +| `ADD` | ✅ | Element-wise addition | +| `SUB` | ✅ | Element-wise subtraction | +| `MULT` | ✅ | Element-wise multiplication | +| `DIV` | ✅ | Element-wise division | +| `SUM` | ✅ | Rolling sum over *timeperiod* | +| `MAX` / `MAXINDEX` | ✅ | Rolling maximum / index | +| `MIN` / `MININDEX` | ✅ | Rolling minimum / index | +| `ACOS` / `ASIN` / `ATAN` | ✅ | Arc trig transforms | +| `CEIL` / `FLOOR` | ✅ | Round up / down | +| `COS` / `SIN` / `TAN` | ✅ | Trig transforms | +| `COSH` / `SINH` / `TANH` | ✅ | Hyperbolic transforms | +| `EXP` / `LN` / `LOG10` | ✅ | Exponential / log transforms | +| `SQRT` | ✅ | Square root | + + +## Implementation Coverage Summary + + +| Category | Implemented | Not Implemented | +| --------------------------- | ----------- | --------------- | +| Overlap Studies | 19 | 0 | +| Momentum Indicators | 28 | 0 | +| Volume Indicators | 3 | 0 | +| Volatility Indicators | 3 | 0 | +| Cycle Indicators | 6 | 0 | +| Price Transforms | 4 | 0 | +| Statistic Functions | 9 | 0 | +| Pattern Recognition | 61 | 0 | +| Math Operators / Transforms | 24 | 0 | +| Extended Indicators | 10 | - | +| Streaming Classes | 9 | - | +| **Total** | **162+** | **0** | + + +> `ferro-ta` implements 100% of TA-Lib's function set. NaN values are placed +> at the beginning of each output array for the warmup period. diff --git a/vendor/ferro-ta-main/TROUBLESHOOTING.md b/vendor/ferro-ta-main/TROUBLESHOOTING.md new file mode 100644 index 0000000..aea8418 --- /dev/null +++ b/vendor/ferro-ta-main/TROUBLESHOOTING.md @@ -0,0 +1,186 @@ +# ferro-ta Troubleshooting Guide + +Common build and runtime issues and how to fix them. + +--- + +## Table of Contents + +1. [maturin build fails](#maturin-build-fails) +2. [PyO3 version mismatches](#pyo3-version-mismatches) +3. [ImportError: cannot import name '_ferro_ta'](#importerror-cannot-import-name-_ferro_ta) +4. [Rust toolchain not found](#rust-toolchain-not-found) +5. [tests fail with 'ferro_ta not installed'](#tests-fail-with-ferro_ta-not-installed) +6. [mypy / pyright type errors after install](#mypy--pyright-type-errors-after-install) +7. [WASM build fails](#wasm-build-fails) +8. [GPU / CuPy errors](#gpu--cupy-errors) +9. [Coverage below threshold](#coverage-below-threshold) +10. [Common Rust compilation errors](#common-rust-compilation-errors) + +--- + +## maturin build fails + +**Symptom:** `maturin develop` or `maturin build` exits with a Rust compilation error. + +**Fixes:** +- Ensure you have the **stable** Rust toolchain installed: + ```bash + rustup toolchain install stable + rustup default stable + ``` +- Ensure `rustfmt` and `clippy` components are installed: + ```bash + rustup component add rustfmt clippy + ``` +- Make sure Python headers are available. On Debian/Ubuntu: + ```bash + sudo apt-get install python3-dev + ``` +- If you changed `Cargo.toml`, run `cargo check` first to isolate Rust errors from maturin wrapping issues. + +--- + +## PyO3 version mismatches + +**Symptom:** `pyo3` version conflict between your Python interpreter and the version pinned in `Cargo.toml`. + +**Fix:** ferro-ta uses PyO3 with the `abi3` feature flag which supports Python 3.10+. If you need a specific version: +```toml +# Cargo.toml +[dependencies] +pyo3 = { version = "0.22", features = ["extension-module", "abi3-py310"] } +``` +Run `cargo update -p pyo3` to pull the latest compatible version. + +--- + +## ImportError: cannot import name '_ferro_ta' + +**Symptom:** +``` +ImportError: cannot import name '_ferro_ta' from 'ferro_ta' +``` + +**Causes and fixes:** +1. The Rust extension has not been compiled yet — run `maturin develop --release` or `make build`. +2. The `.so` file was compiled for a different Python version — rebuild with the current interpreter. +3. You are running `python` from a different virtualenv — activate the correct environment. + +Check that the compiled extension is present: +```bash +python -c "import ferro_ta._ferro_ta; print('OK')" +``` + +--- + +## Rust toolchain not found + +**Symptom:** `cargo: command not found` or `rustup: command not found`. + +**Fix:** +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source "$HOME/.cargo/env" +``` + +--- + +## tests fail with 'ferro_ta not installed' + +**Symptom:** pytest reports import errors for `ferro_ta`. + +**Fix:** Build and install the development wheel first: +```bash +maturin develop --release +# or +make build +``` +Then re-run tests: +```bash +pytest tests/ +``` + +--- + +## mypy / pyright type errors after install + +**Symptom:** mypy or pyright reports errors for optional dependencies (cupy, polars, etc.). + +**Fix:** ferro-ta ships a `pyrightconfig.json` that sets `reportMissingImports = false` for optional deps. For mypy, pass `--ignore-missing-imports`: +```bash +mypy python/ferro_ta --ignore-missing-imports +``` +The CI uses this flag by default. + +--- + +## WASM build fails + +**Symptom:** `wasm-pack build` fails inside `wasm/`. + +**Fix:** +1. Install `wasm-pack`: + ```bash + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + ``` +2. Add the WASM target: + ```bash + rustup target add wasm32-unknown-unknown + ``` +3. Run from the `wasm/` subdirectory (it has its own `[workspace]` table): + ```bash + cd wasm && wasm-pack build --target nodejs + ``` + +--- + +## GPU / CuPy errors + +**Symptom:** `ImportError: No module named 'cupy'` or CUDA errors in `ferro_ta.gpu`. + +**Fix:** The GPU module is **optional**. Install CuPy matching your CUDA version: +```bash +pip install cupy-cuda12x # for CUDA 12.x +``` +If no GPU is available, all ferro_ta functions fall back silently to CPU (NumPy) computation. + +--- + +## Coverage below threshold + +**Symptom:** `pytest --cov-fail-under=65` fails with a coverage percentage below 65 %. + +**Fix:** +- Run `pytest tests/ --cov=ferro_ta --cov-report=term-missing` to see which lines are uncovered. +- The threshold in CI is 65 %. Local runs may vary if optional dependencies (pandas, polars) are not installed. +- Install all test extras: + ```bash + pip install pandas polars hypothesis pyyaml + ``` + +--- + +## Common Rust compilation errors + +### `error[E0425]: cannot find function 'compute_ema'` + +Dead code was removed in a refactor. Run `cargo clean` then `cargo build --release`. + +### `error: current package believes it's in a workspace when it's not` + +This happens in `fuzz/` or `wasm/` sub-crates. Both have `[workspace]` in their `Cargo.toml` to opt out of the root workspace. If you create a new sub-crate, add `[workspace]` to its `Cargo.toml`. + +### Linker errors on macOS (Apple Silicon) + +```bash +export MACOSX_DEPLOYMENT_TARGET=11.0 +maturin develop --release +``` + +--- + +## Getting help + +- Open an issue: +- See `CONTRIBUTING.md` for development guidelines. diff --git a/vendor/ferro-ta-main/VERSIONING.md b/vendor/ferro-ta-main/VERSIONING.md new file mode 100644 index 0000000..2568dc2 --- /dev/null +++ b/vendor/ferro-ta-main/VERSIONING.md @@ -0,0 +1,124 @@ +# Versioning & Release Policy + +**ferro-ta** uses [Semantic Versioning 2.0.0](https://semver.org/). + +## Version numbers: `MAJOR.MINOR.PATCH` + +| Component | Increment when… | +|-----------|-----------------| +| **MAJOR** | A breaking API change is introduced (indicator removed, parameter renamed, return-type changed). | +| **MINOR** | New indicators, features, or bindings are added in a backward-compatible way. | +| **PATCH** | Bug fixes, performance improvements, documentation-only changes, or dependency bumps that do not change the public API. | + +## Supported Python versions + +We support the **three most recent stable Python minor releases** at the time +of a MINOR or MAJOR release. Python versions that have reached end-of-life +(EOL) per the [Python release calendar](https://devguide.python.org/versions/) +are removed in the next MINOR release; this counts as a non-breaking change. + +Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`). + +## Release playbook + +### Fast path + +1. **Bump tracked version files with one command**: + ```bash + python3 scripts/bump_version.py 1.0.3 + ``` + or: + ```bash + make version VERSION=1.0.3 + ``` +2. **Verify everything matches**: + ```bash + python3 scripts/bump_version.py --check + ``` +3. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section + `[1.0.1] — YYYY-MM-DD` and open a fresh `[Unreleased]` block. +4. **Commit** the version bump and changelog update with message + `chore: release v1.0.1`. +5. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`. +6. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and + `publish` jobs trigger automatically on `release: published`. + +The bump script updates the tracked release-version carriers that are easy to +miss manually: root Cargo, Python packaging, the core crate, the core crate +README install snippet, the WASM package, the Conda recipe, and the docs pages +that show the current released version. + +## Breaking-change policy + +- Removing an indicator or changing its signature is a **MAJOR** change. +- Changing a default parameter value is a **MINOR** change (with a deprecation + notice in the changelog). +- Fixing a numeric output to match TA-Lib more closely is a **PATCH** change + (but noted clearly in the changelog). + +## Changelog maintenance + +Every PR that changes user-visible behaviour must add an entry to the +`[Unreleased]` section of `CHANGELOG.md`. CI enforces this for PRs that +touch `src/`, `python/`, or `wasm/`. + +--- + +## API Stability Guarantees + +The following modules are considered **stable API** as of `1.0.0` and will not +have breaking changes in minor releases: + +| Module | Stability | +|---|---| +| `ferro_ta` (top-level) — all `__all__` names | Stable | +| `ferro_ta.overlap`, `ferro_ta.momentum`, etc. | Stable | +| `ferro_ta.batch` | Stable | +| `ferro_ta.streaming` | Stable | +| `ferro_ta.extended` | Stable | +| `ferro_ta.exceptions` | Stable | +| `ferro_ta.registry` | Stable | +| `ferro_ta.backtest` | Stable | +| `ferro_ta.pipeline` | Stable | +| `ferro_ta.config` | Stable | +| `ferro_ta.gpu` (optional) | Beta — API may evolve | +| `ferro_ta._utils` (private) | Not stable — do not import directly | + +### v1.0 readiness checklist + +- [x] All 155+ TA-Lib indicators implemented and tested +- [x] Type stubs (`.pyi`) for all public functions +- [x] Sphinx documentation for all modules +- [x] Streaming bar-by-bar API (9 classes) +- [x] Batch execution API +- [x] Extended indicators (10 beyond TA-Lib) +- [x] WASM bindings (9 indicators) +- [x] Pandas integration (transparent) +- [x] Polars integration (transparent) +- [x] Backtesting harness +- [x] Plugin registry +- [x] Error model and input validation +- [x] Release playbook (RELEASE.md) +- [x] Changelog (CHANGELOG.md) +- [x] Version consistency CI check +- [x] Fuzzing (cargo-fuzz, SMA + RSI) +- [x] Optional GPU backend (CuPy) +- [x] Indicator pipeline API +- [x] Configuration defaults API +- [x] Jupyter notebook examples + +### Post-1.0 notes + +With `1.0.0` released: +1. The package now uses the `Development Status :: 5 - Production/Stable` classifier. +2. `CHANGELOG.md` now contains the `[1.0.0]` release section. +3. This file now reflects the stable-series SemVer contract. + +### Compatibility matrix + +| Python | Platform | Status | +|---|---|---| +| 3.10–3.13 | Linux x86_64 (manylinux) | ✅ Supported | +| 3.10–3.13 | macOS x86_64 | ✅ Supported | +| 3.10–3.13 | macOS aarch64 (Apple Silicon) | ✅ Supported | +| 3.10–3.13 | Windows x86_64 | ✅ Supported | diff --git a/vendor/ferro-ta-main/api/Dockerfile b/vendor/ferro-ta-main/api/Dockerfile new file mode 100644 index 0000000..5443bb9 --- /dev/null +++ b/vendor/ferro-ta-main/api/Dockerfile @@ -0,0 +1,50 @@ +# ferro-ta API — Docker image +# +# Build: +# docker build -t ferro-ta-api . +# +# Run: +# docker run -p 8000:8000 ferro-ta-api +# +# Environment variables (override at runtime): +# MAX_SERIES_LENGTH=100000 # maximum data-point count per request +# +# CPU portability +# --------------- +# This image installs the PRE-BUILT ferro-ta wheel from PyPI — we do NOT +# recompile from sdist with `RUSTFLAGS=-C target-cpu=...`. The wheel is built +# at the manylinux baseline (x86-64-v1) and selects AVX2/AVX-512/NEON kernels +# at RUNTIME via CPU dispatch. One image therefore runs on any node — old or +# new CPU, x86_64 or arm64 — with no illegal-instruction (SIGILL) crashes. +# Pinning a target-cpu would be faster on a uniform fleet but would crash on +# any older/heterogeneous node, which is the opposite of broad coverage. +# +# Build this image for whichever arch your nodes use: +# docker build --platform linux/amd64 -t ferro-ta-api . +# docker build --platform linux/arm64 -t ferro-ta-api . # Graviton/Ampere +# Both resolve a matching manylinux wheel — no Rust toolchain needed here. + +FROM python:3.11-slim + +WORKDIR /app + +# Copy and install dependencies first (cache layer). No compiler is needed: +# ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux +# x86_64 and aarch64. +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Fail the build immediately if the wheel did not resolve for this arch +# (e.g. an exotic platform that fell back to an sdist build without Rust). +RUN python -c "import ferro_ta, numpy as np; ferro_ta.SMA(np.arange(10.0), 3); print('ferro_ta', ferro_ta.__version__, 'import OK')" + +# Copy API source +COPY main.py ./ + +# Expose API port +EXPOSE 8000 + +ENV MAX_SERIES_LENGTH=100000 + +# Run with uvicorn (single worker; scale horizontally via Docker Compose / k8s) +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/vendor/ferro-ta-main/api/main.py b/vendor/ferro-ta-main/api/main.py new file mode 100644 index 0000000..70efbb7 --- /dev/null +++ b/vendor/ferro-ta-main/api/main.py @@ -0,0 +1,305 @@ +""" +ferro-ta REST API +============================ + +A minimal FastAPI service that exposes ferro-ta indicators and backtest +over HTTP so that any client can compute technical analysis via REST. + +Endpoints +--------- +GET /health — readiness / liveness probe +POST /indicators/sma — Simple Moving Average +POST /indicators/ema — Exponential Moving Average +POST /indicators/rsi — Relative Strength Index +POST /indicators/macd — MACD (line, signal, histogram) +POST /indicators/bbands — Bollinger Bands +POST /backtest — Vectorized backtest + +Request / Response format +------------------------- +All indicator endpoints accept JSON: + { + "close": [1.0, 2.0, ...], // required; array of floats + "timeperiod": 14 // optional parameter + } + +And return: + { + "result": [null, null, ..., 14.0, ...] // null for NaN warm-up + } + +Or for multi-output indicators (MACD, BBANDS): + { + "result": { + "macd": [...], + "signal": [...], + "hist": [...] + } + } + +For the backtest endpoint the request is: + { + "close": [1.0, 2.0, ...], + "strategy": "rsi_30_70", // or "sma_crossover", "macd_crossover" + "commission_per_trade": 0.0, + "slippage_bps": 0.0 + } + +And the response is: + { + "final_equity": 1.123, + "n_trades": 7, + "equity": [1.0, ...] + } + +Running +------- +Development:: + + uvicorn api.main:app --reload --port 8000 + +Production:: + + uvicorn api.main:app --host 0.0.0.0 --port 8000 --workers 4 + +Docker:: + + docker build -t ferro-ta-api ./api + docker run -p 8000:8000 ferro-ta-api + +Environment variables +--------------------- +MAX_SERIES_LENGTH : int — maximum number of data points per request + (default 100 000). Requests exceeding this limit return HTTP 413. +""" + +from __future__ import annotations + +import math +import os +from typing import Any + +import numpy as np + +try: + from fastapi import FastAPI, HTTPException + from pydantic import BaseModel, Field, field_validator +except ImportError as exc: # pragma: no cover + raise ImportError( + "The ferro-ta API requires fastapi and pydantic.\n" + "Install with: pip install 'ferro_ta[api]'" + ) from exc + +import ferro_ta as ft +from ferro_ta.analysis.backtest import backtest as _backtest + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +MAX_SERIES_LENGTH = int(os.environ.get("MAX_SERIES_LENGTH", "100000")) + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastAPI( + title="ferro-ta API", + description="REST API for ferro-ta technical analysis indicators and backtesting.", + version=ft.__version__, + docs_url="/docs", + redoc_url="/redoc", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _nan_to_none(arr: np.ndarray) -> list[float | None]: + """Convert numpy array to list, replacing NaN/Inf with None.""" + return [None if not math.isfinite(v) else float(v) for v in arr] + + +def _validate_series(close: list[float]) -> np.ndarray: + if len(close) > MAX_SERIES_LENGTH: + raise HTTPException( + status_code=413, + detail=f"Series length {len(close)} exceeds maximum {MAX_SERIES_LENGTH}.", + ) + if len(close) < 2: + raise HTTPException( + status_code=422, + detail="Series must contain at least 2 values.", + ) + return np.asarray(close, dtype=np.float64) + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class IndicatorRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + timeperiod: int = Field(default=14, ge=1, description="Look-back period") + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class MACDRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + fastperiod: int = Field(default=12, ge=1) + slowperiod: int = Field(default=26, ge=1) + signalperiod: int = Field(default=9, ge=1) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class BBANDSRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + timeperiod: int = Field(default=5, ge=2) + nbdevup: float = Field(default=2.0, gt=0) + nbdevdn: float = Field(default=2.0, gt=0) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +class BacktestRequest(BaseModel): + close: list[float] = Field(..., description="Close price series") + strategy: str = Field(default="rsi_30_70") + commission_per_trade: float = Field(default=0.0, ge=0.0) + slippage_bps: float = Field(default=0.0, ge=0.0) + + @field_validator("close") + @classmethod + def close_must_be_finite(cls, v: list[float]) -> list[float]: + if not all(math.isfinite(x) for x in v): + raise ValueError("close series must contain only finite values") + return v + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@app.get("/health", summary="Health check") +def health() -> dict[str, str]: + """Readiness / liveness probe.""" + return {"status": "ok", "version": app.version} + + +@app.post("/indicators/sma", summary="Simple Moving Average") +def compute_sma(req: IndicatorRequest) -> dict[str, Any]: + """Compute Simple Moving Average (SMA). + + Returns ``result``: list of floats (null for warm-up bars). + """ + c = _validate_series(req.close) + out = np.asarray(ft.SMA(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/ema", summary="Exponential Moving Average") +def compute_ema(req: IndicatorRequest) -> dict[str, Any]: + """Compute Exponential Moving Average (EMA).""" + c = _validate_series(req.close) + out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/rsi", summary="Relative Strength Index") +def compute_rsi(req: IndicatorRequest) -> dict[str, Any]: + """Compute Relative Strength Index (RSI).""" + c = _validate_series(req.close) + out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64) + return {"result": _nan_to_none(out)} + + +@app.post("/indicators/macd", summary="MACD") +def compute_macd(req: MACDRequest) -> dict[str, Any]: + """Compute MACD (line, signal, histogram). + + Returns ``result`` with keys ``macd``, ``signal``, ``hist``. + """ + c = _validate_series(req.close) + macd, signal, hist = ft.MACD( + c, + fastperiod=req.fastperiod, + slowperiod=req.slowperiod, + signalperiod=req.signalperiod, + ) + return { + "result": { + "macd": _nan_to_none(np.asarray(macd, dtype=np.float64)), + "signal": _nan_to_none(np.asarray(signal, dtype=np.float64)), + "hist": _nan_to_none(np.asarray(hist, dtype=np.float64)), + } + } + + +@app.post("/indicators/bbands", summary="Bollinger Bands") +def compute_bbands(req: BBANDSRequest) -> dict[str, Any]: + """Compute Bollinger Bands (upper, middle, lower). + + Returns ``result`` with keys ``upper``, ``middle``, ``lower``. + """ + c = _validate_series(req.close) + upper, middle, lower = ft.BBANDS( + c, + timeperiod=req.timeperiod, + nbdevup=req.nbdevup, + nbdevdn=req.nbdevdn, + ) + return { + "result": { + "upper": _nan_to_none(np.asarray(upper, dtype=np.float64)), + "middle": _nan_to_none(np.asarray(middle, dtype=np.float64)), + "lower": _nan_to_none(np.asarray(lower, dtype=np.float64)), + } + } + + +@app.post("/backtest", summary="Vectorized backtest") +def run_backtest(req: BacktestRequest) -> dict[str, Any]: + """Run a vectorized backtest using a named strategy. + + Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``. + Returns ``final_equity``, ``n_trades``, and the full ``equity`` curve. + """ + c = _validate_series(req.close) + valid_strategies = {"rsi_30_70", "sma_crossover", "macd_crossover"} + if req.strategy not in valid_strategies: + raise HTTPException( + status_code=422, + detail=f"Unknown strategy '{req.strategy}'. " + f"Available: {sorted(valid_strategies)}", + ) + result = _backtest( + c, + strategy=req.strategy, + commission_per_trade=req.commission_per_trade, + slippage_bps=req.slippage_bps, + ) + return { + "final_equity": float(result.final_equity), + "n_trades": int(result.n_trades), + "equity": _nan_to_none(result.equity), + } diff --git a/vendor/ferro-ta-main/api/requirements.txt b/vendor/ferro-ta-main/api/requirements.txt new file mode 100644 index 0000000..bb102cd --- /dev/null +++ b/vendor/ferro-ta-main/api/requirements.txt @@ -0,0 +1,6 @@ +# Runtime dependencies for ferro-ta API +ferro_ta>=1.1.4 +fastapi>=0.110.0 +uvicorn[standard]>=0.49.0 +pydantic>=2.13.4 +numpy>=1.20 diff --git a/vendor/ferro-ta-main/benchmarks/README.md b/vendor/ferro-ta-main/benchmarks/README.md new file mode 100644 index 0000000..eb890d7 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/README.md @@ -0,0 +1,321 @@ +# ferro-ta Benchmark Suite + +> Reproducible speed and accuracy comparisons across 62 indicators and the +> libraries available in your environment. + +## Overview + +The benchmark suite compares **ferro-ta** against other Python +technical-analysis libraries on a common dataset and shared wrappers so the +results are easier to reproduce and critique. + +It is not designed to prove that ferro-ta wins everywhere. It is designed to +show where ferro-ta is faster, where it only ties, and where another library +still wins. + +| Library | Notes | +|-----------|-------| +| **TA-Lib** | C extension; widely used comparison baseline | +| **pandas-ta** | Pure Python; broad indicator set | +| **ta** | Simple API; some indicators use O(n²) loops and are very slow | +| **Tulipy** | C extension; truncated output (no leading NaN padding) | +| **finta** | Expects DatetimeIndex DataFrame; some indicators very slow | + +--- + +## Dataset (LARGE = 100k bars) + +All **speed benchmarks** use the **LARGE** dataset: **100,000 bars** of OHLCV data. + +- **Source:** `benchmarks/data_generator.py` — geometric Brownian motion for realistic prices; C-contiguous `float64` arrays for all libraries. +- **Why 100k:** Reflects backtesting and batch workloads; stresses memory and CPU so differences between libraries are clear. +- **Scales available:** `SMALL` (1k), `MEDIUM` (10k), `LARGE` (100k). Speed suite uses **LARGE** by default. + +```python +from benchmarks.data_generator import SMALL, MEDIUM, LARGE +# SMALL = 1,000 bars +# MEDIUM = 10,000 bars (e.g. accuracy tests) +# LARGE = 100,000 bars (speed benchmarks) +``` + +--- + +## Methodology + +- **Harness:** [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) with `benchmark.pedantic(..., iterations=5, rounds=20, warmup_rounds=2)`. +- **Reported metric:** **Median time per call** in **microseconds (µs)** — lower is better. +- **TA-Lib head-to-head JSON:** `benchmarks/bench_vs_talib.py` records per-run samples, variance stats, machine/runtime/build metadata, and Python-tracked peak allocation snapshots. +- **Machine info:** Stored in the generated JSON artifacts for reproducibility. +- **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped. + +## Current checked-in TA-Lib artifact + +The checked-in `benchmarks/artifacts/latest/benchmark_vs_talib.json` artifact +uses contiguous `float64` arrays at 10k and 100k bars on an Apple M3 Max, +CPython 3.13.5, and Rust 1.91.1 with the default release profile +(`lto = true`, `codegen-units = 1`). + +- ferro-ta is ahead outside the tie band on 6 of 12 rows at 10k bars and 6 of 12 rows at 100k bars. +- TA-Lib still wins in the current artifact on `STOCH` and `ADX`, and remains close on `EMA`, `RSI`, `ATR`, and `OBV` depending on size. +- The public claim should therefore be read as "often faster on selected indicators," not "faster everywhere." +- When publishing performance statements, point readers to the raw JSON artifact, not just the summary table. +- The artifact now includes per-run samples, variance stats, and Python-tracked allocation snapshots for each compared indicator. + +## Reproducible Perf Artifacts + +Use the perf-contract runner when you want a compact set of machine-readable +artifacts for single-series latency, batch throughput, streaming throughput, +and hotspot attribution in one directory: + +```bash +uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest --skip-talib +``` + +That command writes: + +- `indicator_latency.json` — canonical-fixture timings for the benchmark suite indicators +- `batch.json` — 2-D batch throughput plus grouped multi-indicator timings +- `streaming.json` — streaming update throughput vs batch baselines +- `runtime_hotspots.json` — ranked hotspot report with reference speedups +- `manifest.json` — runtime/git metadata plus hashes for the generated artifacts + +For CI or local guardrails, validate the hotspot report with: + +```bash +uv run python benchmarks/check_hotspot_regression.py --input benchmarks/artifacts/latest/runtime_hotspots.json +``` + +--- + +## Speed comparison (100k bars, median µs — lower is better) + +The speed table includes **all 62 indicators**. **Number** = median µs; **N/A** = library does not support that indicator. To regenerate: run the full suite, then `uv run python benchmarks/benchmark_table.py`. + +| Indicator | ferro_ta | talib | pandas_ta | ta | tulipy | finta | +|-----------|--------:|--------:|--------:|--------:|--------:|--------:| +| SMA | 256 | 327 | 425 | 798 | 338 | 856 | +| EMA | 369 | 365 | 427 | 641 | 358 | 722 | +| WMA | 257 | 356 | 433 | N/A | 356 | 112422 | +| DEMA | 444 | 588 | 670 | N/A | 335 | 1830 | +| TEMA | 437 | 768 | 866 | N/A | 358 | 3481 | +| T3 | 462 | 407 | 478 | N/A | N/A | 496 | +| TRIMA | 598 | 400 | 474 | N/A | 386 | 1722 | +| KAMA | 992 | 369 | 140751 | N/A | 367 | 2501 | +| HULL_MA | 547 | N/A | 957 | N/A | 372 | 329392 | +| VWMA | 376 | N/A | 669 | N/A | 391 | N/A | +| MIDPOINT | 1345 | 4685 | N/A | N/A | N/A | N/A | +| MIDPRICE | 1273 | 831 | N/A | N/A | N/A | N/A | +| RSI | 653 | 647 | 728 | 1762 | 404 | 2429 | +| MACD | 833 | 793 | 1058 | 1657 | 423 | 1726 | +| STOCH | 2445 | 941 | 1253 | 3233 | 901 | 3321 | +| CCI | 918 | 1029 | 1122 | 367074 | 676 | 321471 | +| WILLR | 1303 | 750 | 859 | 3409 | 775 | 3575 | +| AROON | 1418 | 587 | 1322 | 130842 | 737 | N/A | +| AROONOSC | 1464 | 586 | N/A | N/A | 773 | N/A | +| ADX | 855 | 746 | 27637 | 321625 | 614 | N/A | +| MOM | 189 | 180 | 254 | N/A | 186 | 352 | +| ROC | 578 | 204 | 272 | 361 | 202 | 463 | +| CMO | 876 | 634 | 707 | N/A | 312 | 2301 | +| PPO | 391 | 538 | 1045 | N/A | 380 | 2395 | +| TRIX | 488 | 831 | 1831 | 1891 | 426 | 1773 | +| TSF | 1519 | 678 | N/A | N/A | 363 | N/A | +| ULTOSC | 2069 | 619 | N/A | 14142 | 588 | N/A | +| BOP | 249 | 228 | 361 | N/A | 226 | N/A | +| PLUS_DI | 794 | 629 | 26792 | N/A | 690 | N/A | +| MINUS_DI | 796 | 600 | N/A | N/A | 642 | N/A | +| BBANDS | 345 | 581 | 1079 | 2163 | 406 | 2432 | +| ATR | 640 | 660 | 800 | 157763 | 370 | 6835 | +| NATR | 722 | 662 | 782 | N/A | 396 | N/A | +| TRANGE | 217 | 205 | 374 | N/A | 199 | 6606 | +| STDDEV | 611 | 408 | 461 | N/A | 400 | 1552 | +| VAR | 1281 | 357 | 398 | N/A | 417 | N/A | +| SAR | 520 | 459 | N/A | N/A | 454 | N/A | +| KELTNER_CHANNELS | 926 | N/A | 1062 | 2369 | N/A | N/A | +| DONCHIAN | 2399 | N/A | 3334 | 3145 | N/A | N/A | +| SUPERTREND | 1242 | N/A | 638613 | N/A | N/A | N/A | +| CHOPPINESS_INDEX | 2442 | N/A | 4892 | N/A | N/A | N/A | +| OBV | 482 | 475 | 592 | 496 | 515 | 4646 | +| AD | 271 | 282 | 424 | 615 | 291 | N/A | +| ADOSC | 482 | 409 | 544 | N/A | 376 | N/A | +| MFI | 350 | 779 | 925 | 433698 | 692 | 401076 | +| VWAP | 288 | N/A | 11460 | N/A | N/A | 880 | +| AVGPRICE | 215 | 211 | N/A | N/A | 229 | N/A | +| MEDPRICE | 203 | 188 | N/A | N/A | 197 | 445 | +| TYPPRICE | 195 | 205 | N/A | N/A | 204 | 435 | +| WCLPRICE | 199 | 197 | N/A | N/A | 210 | 292 | +| SQRT | 204 | 208 | N/A | N/A | 199 | N/A | +| LOG10 | 434 | 408 | N/A | N/A | 411 | N/A | +| ADD | 188 | 186 | N/A | N/A | 189 | N/A | +| LINEARREG | 1555 | 704 | N/A | N/A | 368 | N/A | +| LINEARREG_SLOPE | 1548 | 665 | N/A | N/A | 370 | N/A | +| CORREL | 4277 | 413 | N/A | N/A | N/A | N/A | +| BETA | 5226 | 483 | N/A | N/A | N/A | N/A | +| HT_DCPERIOD | 10864 | 4187 | N/A | N/A | N/A | N/A | +| HT_TRENDMODE | 10984 | 23020 | N/A | N/A | N/A | N/A | +| CDLENGULFING | 308 | 617 | N/A | N/A | N/A | N/A | +| CDLDOJI | 273 | 312 | N/A | N/A | N/A | N/A | +| CDLHAMMER | 304 | 1418 | N/A | N/A | N/A | N/A | + +*Apple M3 Max, Python 3.13; 273 passed, 121 skipped (unsupported = N/A). Regenerate with [Running benchmarks](#running-benchmarks).* + +**Takeaways:** + +- **`ta`** is 20–350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops). +- **ferro-ta** is often materially faster than **pandas-ta** on the checked-in 100k-bar table. +- **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies. + +--- + +## Running benchmarks + +```bash +# Full speed suite (100k bars, all indicator × library pairs) — writes results.json +uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +# Head-to-head only (12 indicators × ferro_ta) — quick check +uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_head_to_head" -v + +# Large-dataset scaling only (ferro_ta at 100k) +uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_large_dataset" -v + +# Regenerate the Speed Comparison markdown table from results.json +uv run python benchmarks/benchmark_table.py + +# TA-Lib head-to-head with machine/runtime/build metadata, per-run samples, +# variance stats, and Python-tracked allocation snapshots +uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json + +# Selected derivatives analytics comparison (BSM price, IV, Greeks, Black-76) +# against built-in analytical references plus optional installed libraries +uv run python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --json benchmark_derivatives_compare.json + +# Optional regression check used in CI +uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json + +# Batch throughput + grouped multi-indicator calls +uv run python benchmarks/bench_batch.py --samples 100000 --series 100 --json batch_benchmark.json + +# Streaming update throughput vs batch baselines +uv run python benchmarks/bench_streaming.py --bars 100000 --json streaming_benchmark.json + +# Ranked hotspot attribution against bundled reference implementations +uv run python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json + +# Portable vs SIMD-enabled build comparison +uv run python benchmarks/bench_simd.py --json simd_benchmark.json + +# One-shot perf artifact bundle +uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest +``` + +Without `uv`: use `pytest` and `python` from the same environment where `ferro_ta` and optional libs (e.g. `talib`, `pandas_ta`, `ta`, `tulipy`, `finta`) are installed. + +### Derivatives analytics + +`benchmarks/bench_derivatives_compare.py` focuses on selected options-analytics +paths rather than the full surface area: + +- `BSM` call pricing +- call implied-volatility recovery +- call Greeks +- `Black-76` call pricing + +The script always includes two analytical baselines: + +- `reference_numpy` — pure NumPy formulas with vectorized IV bisection +- `reference_python_loop` — scalar `math`-based reference for sanity checking + +If `py_vollib` is installed, it is added automatically as an extra baseline. +The output JSON includes runtime/build metadata, per-run timing samples, +variance stats, and Python-tracked peak allocation snapshots. + +### WASM + +From the `wasm/` directory: + +```bash +wasm-pack build --target nodejs --out-dir pkg +node bench.js --json ../wasm_benchmark.json +``` + +--- + +## Indicator coverage + +### Overlap (12) +`SMA` `EMA` `WMA` `DEMA` `TEMA` `T3` `TRIMA` `KAMA` `HULL_MA` `VWMA` `MIDPOINT` `MIDPRICE` + +### Momentum (18) +`RSI` `MACD` `STOCH` `CCI` `WILLR` `AROON` `AROONOSC` `ADX` `MOM` `ROC` `CMO` `PPO` `TRIX` `TSF` `ULTOSC` `BOP` `PLUS_DI` `MINUS_DI` + +### Volatility (11) +`BBANDS` `ATR` `NATR` `TRANGE` `STDDEV` `VAR` `SAR` `KELTNER_CHANNELS` `DONCHIAN` `SUPERTREND` `CHOPPINESS_INDEX` + +### Volume (5) +`OBV` `AD` `ADOSC` `MFI` `VWAP` + +### Price Transform (4) +`AVGPRICE` `MEDPRICE` `TYPPRICE` `WCLPRICE` + +### Math (3) +`SQRT` `LOG10` `ADD` + +### Statistics (4) +`LINEARREG` `LINEARREG_SLOPE` `CORREL` `BETA` + +### Cycle (2) +`HT_DCPERIOD` `HT_TRENDMODE` + +### Candlestick patterns (3) +`CDLENGULFING` `CDLDOJI` `CDLHAMMER` + +--- + +## Accuracy results + +Accuracy is tested separately; ferro_ta is the reference. + +- **243 pairs pass** (allclose or correlation). +- **138 pairs skipped** (known formula/anchoring/scaling differences). +- **0 failures.** + +### Known structural differences + +| Pair | Reason | +|------|--------| +| CMO vs talib/pandas_ta/finta | ferro-ta CMO uses different smoothing variant | +| BBANDS vs finta | finta normalizes bands differently | +| ATR vs finta | finta uses simple TR instead of Wilder smoothing | +| VWAP vs pandas_ta | pandas_ta anchors to session start | +| HT_TRENDMODE vs talib | Hilbert Transform seed divergence | +| RSI vs ta/finta | ta/finta use SMA warmup vs Wilder EMA | +| Tulipy ROC | Fraction (0.01 = 1%) vs ferro-ta (1.0 = 1%) | +| Tulipy BBANDS | (lower, mid, upper) order differs from ferro-ta | + +```bash +# Accuracy tests (62 indicators × 6 libraries) +uv run pytest benchmarks/test_accuracy.py -v +``` + +--- + +## Data generator + +`benchmarks/data_generator.py`: + +- **`generate_ohlcv(size)`** — dict of C-contiguous `float64` arrays: `open`, `high`, `low`, `close`, `volume`. High ≥ close ≥ low > 0; volume > 0. +- **`get_pandas_ohlcv(data)`** — DataFrame with DatetimeIndex for pandas-ta and finta. + +Pre-built: `SMALL`, `MEDIUM`, `LARGE` (and `*_DF` variants). + +--- + +## Library compatibility + +Detailed notes per library: + +- [TA-Lib](../docs/compatibility/talib.md) +- [pandas-ta](../docs/compatibility/pandas_ta.md) +- [ta](../docs/compatibility/ta.md) +- [Tulipy](../docs/compatibility/tulipy.md) +- [finta](../docs/compatibility/finta.md) diff --git a/vendor/ferro-ta-main/benchmarks/__init__.py b/vendor/ferro-ta-main/benchmarks/__init__.py new file mode 100644 index 0000000..16f9fa1 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/__init__.py @@ -0,0 +1 @@ +"""benchmarks package — cross-library accuracy and speed comparison suite.""" diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/batch.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/batch.json new file mode 100644 index 0000000..58d152d --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/batch.json @@ -0,0 +1,71 @@ +{ + "metadata": { + "suite": "batch", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:58.345834+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "n_samples": 100000, + "n_series": 100, + "total_bars": 10000000, + "seed": 42 + } + }, + "results": [ + { + "indicator": "SMA", + "parallel_ms": 37.86, + "sequential_ms": 43.5625, + "loop_ms": 17.8136, + "parallel_speedup_vs_loop": 0.4705, + "sequential_speedup_vs_loop": 0.4089 + }, + { + "indicator": "RSI", + "parallel_ms": 40.9229, + "sequential_ms": 79.5345, + "loop_ms": 53.3368, + "parallel_speedup_vs_loop": 1.3033, + "sequential_speedup_vs_loop": 0.6706 + }, + { + "indicator": "ATR", + "parallel_ms": 91.76, + "sequential_ms": 130.1404, + "loop_ms": 99.5885, + "parallel_speedup_vs_loop": 1.0853, + "sequential_speedup_vs_loop": 0.7652 + }, + { + "indicator": "ADX", + "parallel_ms": 100.1362, + "sequential_ms": 149.3412, + "loop_ms": 125.3319, + "parallel_speedup_vs_loop": 1.2516, + "sequential_speedup_vs_loop": 0.8392 + } + ], + "grouped_results": [ + { + "case": "close_bundle_3", + "grouped_ms": 0.652, + "separate_ms": 0.9124, + "speedup_vs_separate": 1.3994 + }, + { + "case": "hlc_bundle_3", + "grouped_ms": 1.4784, + "separate_ms": 3.3724, + "speedup_vs_separate": 2.2811 + } + ] +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json new file mode 100644 index 0000000..1404773 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/bench_backtest_results.json @@ -0,0 +1,153 @@ +{ + "metadata": { + "suite": "backtest", + "runtime": { + "generated_at_utc": "2026-03-27T16:31:53.866252+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "2d776b6f908fd1a4f30a696972b7df5e5fe2ca00", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.6" + } + }, + "results": { + "backtest_core_single": [ + { + "n_bars": 10000, + "ferro_ta_ms": 0.024, + "ferro_ta_mbars_s": 415.9388, + "vectorbt_ms": 1.2843, + "speedup_vs_vectorbt": 53.4187 + }, + { + "n_bars": 100000, + "ferro_ta_ms": 0.1964, + "ferro_ta_mbars_s": 509.1209, + "vectorbt_ms": 3.047, + "speedup_vs_vectorbt": 15.5129 + } + ], + "backtest_ohlcv_core": [ + { + "n_bars": 10000, + "ferro_ta_ms": 0.0573, + "ferro_ta_mbars_s": 174.4166 + }, + { + "n_bars": 100000, + "ferro_ta_ms": 0.7068, + "ferro_ta_mbars_s": 141.4927 + } + ], + "performance_metrics": [ + { + "n_bars": 10000, + "ferro_ta_ms": 0.2182, + "numpy_partial_ms": 0.0496, + "speedup_vs_numpy": 0.2272, + "note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)" + }, + { + "n_bars": 100000, + "ferro_ta_ms": 3.0303, + "numpy_partial_ms": 0.351, + "speedup_vs_numpy": 0.1158, + "note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)" + } + ], + "multi_asset": [ + { + "n_bars": 10000, + "n_assets": 50, + "parallel_ms": 2.4245, + "serial_ms": 4.1751, + "loop_ms": 2.0349, + "parallel_speedup_vs_loop": 0.8393, + "parallel_speedup_vs_serial": 1.722 + }, + { + "n_bars": 100000, + "n_assets": 50, + "parallel_ms": 24.0349, + "serial_ms": 47.9311, + "loop_ms": 24.7476, + "parallel_speedup_vs_loop": 1.0297, + "parallel_speedup_vs_serial": 1.9942 + } + ], + "monte_carlo": [ + { + "n_bars": 10000, + "n_sims": 500, + "ferro_ta_ms": 3.862, + "numpy_loop_ms": 51.1589, + "speedup_vs_numpy": 13.2469 + }, + { + "n_bars": 100000, + "n_sims": 500, + "ferro_ta_ms": 26.0019, + "numpy_loop_ms": 310.582, + "speedup_vs_numpy": 11.9446 + } + ], + "engine_full_pipeline": [ + { + "n_bars": 10000, + "ferro_ta_ms": 0.4402, + "description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown" + }, + { + "n_bars": 100000, + "ferro_ta_ms": 4.445, + "description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown" + } + ], + "walk_forward_indices": [ + { + "n_bars": 10000, + "train_bars": 2000, + "test_bars": 500, + "ferro_ta_us": 0.333 + }, + { + "n_bars": 100000, + "train_bars": 20000, + "test_bars": 5000, + "ferro_ta_us": 0.292 + } + ], + "kelly_fraction": [ + { + "n_calls": 1000, + "ferro_ta_us": 86.458 + } + ] + } +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json new file mode 100644 index 0000000..20f88c0 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/benchmark_derivatives_compare.json @@ -0,0 +1,1162 @@ +{ + "schema_version": 1, + "command": "python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --accuracy-size 512 --json benchmarks/artifacts/latest/benchmark_derivatives_compare.json", + "n_warmup": 1, + "n_runs": 7, + "accuracy_size": 512, + "sizes": [ + 1000, + 10000 + ], + "metadata": { + "suite": "benchmark_derivatives_compare", + "runtime": { + "generated_at_utc": "2026-03-24T05:39:25.642936+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "0382c4e3024c96809ffd89dd76d820efcd56479d", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.91.1 (ed61e7d7e 2025-11-07) (Homebrew)\nbinary: rustc\ncommit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb\ncommit-date: 2025-11-07\nhost: aarch64-apple-darwin\nrelease: 1.91.1\nLLVM version: 21.1.5", + "cargo": "cargo 1.91.1 (Homebrew)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.3", + "py_vollib": null + }, + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": [ + 1000, + 10000 + ], + "accuracy_size": 512, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + "ranges": { + "spot": [ + 80.0, + 120.0 + ], + "strike": [ + 70.0, + 130.0 + ], + "rate": [ + 0.0, + 0.07 + ], + "carry": [ + 0.0, + 0.03 + ], + "time_to_expiry_years": [ + 0.019178082191780823, + 2.0 + ], + "volatility": [ + 0.08, + 0.65 + ] + } + }, + "methodology": { + "warmup_runs": 1, + "measured_runs": 7, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": "Scalar analytical Black-Scholes-Merton and Black-76 formulas using math.erf; IV accuracy is measured as repriced error from the recovered volatility because direct volatility differences can be unstable on low-vega contracts.", + "input_layout_notes": "Benchmarks use contiguous float64 arrays. If your workload passes non-contiguous arrays or mixed dtypes, benchmark that path separately.", + "allocation_notes": "python_peak_allocation_bytes is a tracemalloc snapshot of Python-tracked allocations only; it does not measure native RSS.", + "provider_notes": "reference_python_loop and py_vollib are scalar baselines and are size-capped in the speed table to keep runtime reasonable." + }, + "providers": [ + { + "name": "ferro_ta", + "kind": "project", + "note": "Rust-backed vectorized implementation.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_numpy", + "kind": "reference", + "note": "Pure NumPy analytical formulas with vectorized IV bisection.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_python_loop", + "kind": "reference", + "note": "Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + "max_speed_size": 1000, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + } + ] + }, + "accuracy": { + "summary": [ + { + "case": "bsm_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + }, + { + "case": "bsm_call_iv", + "best_provider": "reference_python_loop", + "best_max_abs_error": 1e-10, + "worst_provider": "reference_numpy", + "worst_max_abs_error": 1.6276264e-05 + }, + { + "case": "bsm_call_greeks", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.7335858e-05 + }, + { + "case": "black76_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6275836e-05, + "mean_abs_error": 5.249474e-06, + "rmse": 6.489527e-06, + "max_rel_error": 0.475843909884 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6276264e-05, + "mean_abs_error": 5.249487e-06, + "rmse": 6.489634e-06, + "max_rel_error": 0.009628987742 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1e-10, + "mean_abs_error": 4.5e-11, + "rmse": 5.3e-11, + "max_rel_error": 0.00547208151 + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + } + ] + }, + "speed": { + "summary": [ + { + "case": "bsm_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0336, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91 + } + ] + }, + { + "case": "bsm_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2752, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3 + }, + { + "provider": "reference_numpy", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.4121, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49 + }, + { + "provider": "reference_numpy", + "median_ms": 1.8036, + "contracts_per_s": 554446.66 + }, + { + "provider": "reference_python_loop", + "median_ms": 13.4274, + "contracts_per_s": 74474.58 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 4.0739, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34 + }, + { + "provider": "reference_numpy", + "median_ms": 11.7949, + "contracts_per_s": 847824.06 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0497, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26 + }, + { + "provider": "reference_python_loop", + "median_ms": 1.189, + "contracts_per_s": 841042.89 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 10000, + "fastest_provider": "reference_numpy", + "fastest_median_ms": 0.572, + "ranking": [ + { + "provider": "reference_numpy", + "median_ms": 0.572, + "contracts_per_s": 17482517.48 + }, + { + "provider": "ferro_ta", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77 + } + ] + }, + { + "case": "black76_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0254, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96 + } + ] + }, + { + "case": "black76_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2184, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79 + }, + { + "provider": "reference_numpy", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54 + } + ] + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76, + "runs_ms": [ + 0.0337, + 0.0404, + 0.0336, + 0.033, + 0.0332, + 0.0329, + 0.0361 + ], + "stats": { + "median_ms": 0.0336, + "mean_ms": 0.0347, + "min_ms": 0.0329, + "max_ms": 0.0404, + "stddev_ms": 0.0027, + "cv_pct": 7.901 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56, + "runs_ms": [ + 0.0468, + 0.0456, + 0.0483, + 0.045, + 0.0548, + 0.0452, + 0.0449 + ], + "stats": { + "median_ms": 0.0456, + "mean_ms": 0.0472, + "min_ms": 0.0449, + "max_ms": 0.0548, + "stddev_ms": 0.0036, + "cv_pct": 7.526 + }, + "python_peak_allocation_bytes": 115672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91, + "runs_ms": [ + 0.8154, + 0.8078, + 0.8184, + 0.8265, + 0.8157, + 0.8155, + 0.8267 + ], + "stats": { + "median_ms": 0.8157, + "mean_ms": 0.818, + "min_ms": 0.8078, + "max_ms": 0.8267, + "stddev_ms": 0.0067, + "cv_pct": 0.82 + }, + "python_peak_allocation_bytes": 39464, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3, + "runs_ms": [ + 0.2632, + 0.2812, + 0.2666, + 0.2666, + 0.281, + 0.2752, + 0.276 + ], + "stats": { + "median_ms": 0.2752, + "mean_ms": 0.2728, + "min_ms": 0.2632, + "max_ms": 0.2812, + "stddev_ms": 0.0073, + "cv_pct": 2.684 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18, + "runs_ms": [ + 0.304, + 0.315, + 0.3261, + 0.309, + 0.3218, + 0.3109, + 0.3084 + ], + "stats": { + "median_ms": 0.3109, + "mean_ms": 0.3136, + "min_ms": 0.304, + "max_ms": 0.3261, + "stddev_ms": 0.0079, + "cv_pct": 2.515 + }, + "python_peak_allocation_bytes": 1132672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49, + "runs_ms": [ + 0.4309, + 0.4121, + 0.4068, + 0.418, + 0.4082, + 0.4285, + 0.4093 + ], + "stats": { + "median_ms": 0.4121, + "mean_ms": 0.4163, + "min_ms": 0.4068, + "max_ms": 0.4309, + "stddev_ms": 0.0099, + "cv_pct": 2.378 + }, + "python_peak_allocation_bytes": 20709, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 1.8036, + "contracts_per_s": 554446.66, + "runs_ms": [ + 1.8522, + 1.8036, + 1.8552, + 1.7976, + 1.7981, + 1.8119, + 1.7963 + ], + "stats": { + "median_ms": 1.8036, + "mean_ms": 1.8164, + "min_ms": 1.7963, + "max_ms": 1.8552, + "stddev_ms": 0.026, + "cv_pct": 1.434 + }, + "python_peak_allocation_bytes": 149448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 13.4274, + "contracts_per_s": 74474.58, + "runs_ms": [ + 13.4787, + 13.4274, + 13.4138, + 13.4148, + 13.4643, + 13.6664, + 13.3763 + ], + "stats": { + "median_ms": 13.4274, + "mean_ms": 13.4631, + "min_ms": 13.3763, + "max_ms": 13.6664, + "stddev_ms": 0.0959, + "cv_pct": 0.712 + }, + "python_peak_allocation_bytes": 39584, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34, + "runs_ms": [ + 4.0739, + 4.0792, + 3.9101, + 4.0077, + 4.2279, + 4.076, + 3.9563 + ], + "stats": { + "median_ms": 4.0739, + "mean_ms": 4.0473, + "min_ms": 3.9101, + "max_ms": 4.2279, + "stddev_ms": 0.1032, + "cv_pct": 2.549 + }, + "python_peak_allocation_bytes": 82830, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 11.7949, + "contracts_per_s": 847824.06, + "runs_ms": [ + 11.713, + 11.7014, + 11.8743, + 11.7949, + 11.4815, + 11.8635, + 11.8188 + ], + "stats": { + "median_ms": 11.7949, + "mean_ms": 11.7496, + "min_ms": 11.4815, + "max_ms": 11.8743, + "stddev_ms": 0.1359, + "cv_pct": 1.157 + }, + "python_peak_allocation_bytes": 1463448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35, + "runs_ms": [ + 0.0558, + 0.0497, + 0.0486, + 0.0503, + 0.051, + 0.0494, + 0.0486 + ], + "stats": { + "median_ms": 0.0497, + "mean_ms": 0.0505, + "min_ms": 0.0486, + "max_ms": 0.0558, + "stddev_ms": 0.0025, + "cv_pct": 4.902 + }, + "python_peak_allocation_bytes": 41928, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26, + "runs_ms": [ + 0.0883, + 0.0871, + 0.0876, + 0.0871, + 0.0868, + 0.0864, + 0.1533 + ], + "stats": { + "median_ms": 0.0871, + "mean_ms": 0.0967, + "min_ms": 0.0864, + "max_ms": 0.1533, + "stddev_ms": 0.025, + "cv_pct": 25.847 + }, + "python_peak_allocation_bytes": 138128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 1.189, + "contracts_per_s": 841042.89, + "runs_ms": [ + 1.243, + 1.3353, + 1.189, + 1.1827, + 1.1821, + 1.1827, + 1.2165 + ], + "stats": { + "median_ms": 1.189, + "mean_ms": 1.2188, + "min_ms": 1.1821, + "max_ms": 1.3353, + "stddev_ms": 0.0563, + "cv_pct": 4.619 + }, + "python_peak_allocation_bytes": 199408, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77, + "runs_ms": [ + 0.7306, + 0.7328, + 0.7688, + 0.732, + 0.7792, + 0.7486, + 0.8081 + ], + "stats": { + "median_ms": 0.7486, + "mean_ms": 0.7572, + "min_ms": 0.7306, + "max_ms": 0.8081, + "stddev_ms": 0.0295, + "cv_pct": 3.897 + }, + "python_peak_allocation_bytes": 401848, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.572, + "contracts_per_s": 17482517.48, + "runs_ms": [ + 0.6104, + 0.5705, + 0.5715, + 0.5817, + 0.572, + 0.5747, + 0.5708 + ], + "stats": { + "median_ms": 0.572, + "mean_ms": 0.5788, + "min_ms": 0.5705, + "max_ms": 0.6104, + "stddev_ms": 0.0145, + "cv_pct": 2.499 + }, + "python_peak_allocation_bytes": 1362128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74, + "runs_ms": [ + 0.0286, + 0.0263, + 0.0258, + 0.0254, + 0.0253, + 0.0253, + 0.0254 + ], + "stats": { + "median_ms": 0.0254, + "mean_ms": 0.026, + "min_ms": 0.0253, + "max_ms": 0.0286, + "stddev_ms": 0.0012, + "cv_pct": 4.639 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67, + "runs_ms": [ + 0.0395, + 0.0385, + 0.0387, + 0.0383, + 0.0384, + 0.0382, + 0.0383 + ], + "stats": { + "median_ms": 0.0384, + "mean_ms": 0.0385, + "min_ms": 0.0382, + "max_ms": 0.0395, + "stddev_ms": 0.0005, + "cv_pct": 1.22 + }, + "python_peak_allocation_bytes": 99448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96, + "runs_ms": [ + 0.7271, + 0.7251, + 0.845, + 0.7809, + 0.7345, + 0.7358, + 0.7418 + ], + "stats": { + "median_ms": 0.7358, + "mean_ms": 0.7558, + "min_ms": 0.7251, + "max_ms": 0.845, + "stddev_ms": 0.0436, + "cv_pct": 5.769 + }, + "python_peak_allocation_bytes": 39416, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79, + "runs_ms": [ + 0.2286, + 0.2183, + 0.219, + 0.2184, + 0.2223, + 0.218, + 0.2176 + ], + "stats": { + "median_ms": 0.2184, + "mean_ms": 0.2203, + "min_ms": 0.2176, + "max_ms": 0.2286, + "stddev_ms": 0.004, + "cv_pct": 1.8 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54, + "runs_ms": [ + 0.2823, + 0.276, + 0.275, + 0.3064, + 0.3196, + 0.2835, + 0.2734 + ], + "stats": { + "median_ms": 0.2823, + "mean_ms": 0.288, + "min_ms": 0.2734, + "max_ms": 0.3196, + "stddev_ms": 0.0179, + "cv_pct": 6.201 + }, + "python_peak_allocation_bytes": 972448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + } + ] + } +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json new file mode 100644 index 0000000..0ecfaaa --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/indicator_latency.json @@ -0,0 +1,163 @@ +{ + "metadata": { + "suite": "indicator_latency", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:52.160357+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "dataset": { + "fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "bars": 2000, + "rounds": 5 + } + }, + "results": [ + { + "name": "VAR_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0229 + }, + { + "name": "STOCH", + "inputs": "hlc", + "kwargs": {}, + "elapsed_ms": 0.0201 + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0201 + }, + { + "name": "CCI_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0163 + }, + { + "name": "ADX_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.015 + }, + { + "name": "MACD", + "inputs": "close", + "kwargs": {}, + "elapsed_ms": 0.0135 + }, + { + "name": "ATR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0105 + }, + { + "name": "RSI_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0098 + }, + { + "name": "STDDEV_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.009 + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 5 + }, + "elapsed_ms": 0.0083 + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 30 + }, + "elapsed_ms": 0.0076 + }, + { + "name": "BBANDS_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "TSF_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0054 + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0052 + }, + { + "name": "EMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.005 + }, + { + "name": "SMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0026 + } + ] +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/manifest.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/manifest.json new file mode 100644 index 0000000..1befe60 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/manifest.json @@ -0,0 +1,67 @@ +{ + "metadata": { + "suite": "perf_contract", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:40.776130+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "output_dir": "benchmarks/artifacts/latest" + }, + "artifacts": { + "indicator_latency": { + "path": "benchmarks/artifacts/latest/indicator_latency.json", + "size_bytes": 3217, + "sha256": "43b88a50a4d7f91e30ff8e57dbf859ae5e76ecabaaf05cfcb7d8db67df920f7f" + }, + "batch": { + "path": "benchmarks/artifacts/latest/batch.json", + "size_bytes": 1701, + "sha256": "bc900c885c48ec1903ea4870ca1de8cb9f33c609d2cefa4688cbdd18cb977f11" + }, + "streaming": { + "path": "benchmarks/artifacts/latest/streaming.json", + "size_bytes": 1944, + "sha256": "925ba1be66d0d499daa81dfc148b03ac325ad685ce0c71e28ca1fc6927f16415" + }, + "runtime_hotspots": { + "path": "benchmarks/artifacts/latest/runtime_hotspots.json", + "size_bytes": 2366, + "sha256": "920553b14b545f211b119c099ec59885de8b9e8056271cb2d2ac34c0c69b0906" + }, + "simd": { + "path": "benchmarks/artifacts/latest/simd.json", + "size_bytes": 7700, + "sha256": "d48943a5dfcf4f8d8d2ca42f0004f02f9fc894de7477791b686231da665e3335" + }, + "benchmark_vs_talib": { + "path": "benchmarks/artifacts/latest/benchmark_vs_talib.json", + "size_bytes": 5923, + "sha256": "8a4e847517f1334255353982a5266c0323bf433a1eb78dafeff808d5ad3bf7f0" + }, + "wasm": { + "path": "benchmarks/artifacts/latest/wasm.json", + "size_bytes": 935, + "sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7" + }, + "bench_backtest": { + "path": "benchmarks/artifacts/latest/bench_backtest_results.json", + "size_bytes": 4022, + "sha256": "acf27cd5d5077aff51194e31936aba2b9304a8a62d993b2ec496d6f347545316" + } + } +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json new file mode 100644 index 0000000..a56f261 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/runtime_hotspots.json @@ -0,0 +1,96 @@ +{ + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:02.236710+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 35.984, + "reference_ms": 944.5804, + "speedup_vs_reference": 26.25, + "share_of_suite_pct": 77.27 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 7.6624, + "reference_ms": 81.581, + "speedup_vs_reference": 10.6469, + "share_of_suite_pct": 16.45 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.2905, + "reference_ms": 198.2937, + "speedup_vs_reference": 86.5738, + "share_of_suite_pct": 4.92 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2872, + "reference_ms": 0.2377, + "speedup_vs_reference": 0.8275, + "share_of_suite_pct": 0.62 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1448, + "reference_ms": 0.1505, + "speedup_vs_reference": 1.0391, + "share_of_suite_pct": 0.31 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0637, + "reference_ms": 164.1752, + "speedup_vs_reference": 2575.2975, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0553, + "reference_ms": 159.6473, + "speedup_vs_reference": 2885.1573, + "share_of_suite_pct": 0.12 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0415, + "reference_ms": 47.4665, + "speedup_vs_reference": 1143.77, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0414, + "reference_ms": 47.921, + "speedup_vs_reference": 1157.036, + "share_of_suite_pct": 0.09 + } + ] +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/simd.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/simd.json new file mode 100644 index 0000000..3a64e88 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/simd.json @@ -0,0 +1,285 @@ +{ + "metadata": { + "suite": "simd", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:40.566511+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + }, + "variants": [ + "portable_release", + "simd_release" + ] + }, + "results": [ + { + "name": "BETA", + "category": "rust_kernel", + "portable_ms": 0.0635, + "simd_ms": 0.0636, + "speedup_simd_vs_portable": 0.9984 + }, + { + "name": "TSF", + "category": "rust_kernel", + "portable_ms": 0.0415, + "simd_ms": 0.0417, + "speedup_simd_vs_portable": 0.9952 + }, + { + "name": "compute_many_close", + "category": "ffi_grouping", + "portable_ms": 0.1548, + "simd_ms": 0.1572, + "speedup_simd_vs_portable": 0.9847 + }, + { + "name": "iv_zscore", + "category": "python_analysis", + "portable_ms": 36.0643, + "simd_ms": 37.2041, + "speedup_simd_vs_portable": 0.9694 + }, + { + "name": "feature_matrix", + "category": "ffi_grouping", + "portable_ms": 0.2556, + "simd_ms": 0.2667, + "speedup_simd_vs_portable": 0.9584 + }, + { + "name": "iv_percentile", + "category": "python_analysis", + "portable_ms": 7.7548, + "simd_ms": 8.1565, + "speedup_simd_vs_portable": 0.9508 + }, + { + "name": "LINEARREG", + "category": "rust_kernel", + "portable_ms": 0.0416, + "simd_ms": 0.0443, + "speedup_simd_vs_portable": 0.9391 + }, + { + "name": "iv_rank", + "category": "python_analysis", + "portable_ms": 2.2813, + "simd_ms": 2.4386, + "speedup_simd_vs_portable": 0.9355 + }, + { + "name": "CORREL", + "category": "rust_kernel", + "portable_ms": 0.0552, + "simd_ms": 0.0633, + "speedup_simd_vs_portable": 0.872 + } + ], + "reports": { + "portable_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:06.920513+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 36.0643, + "reference_ms": 908.5403, + "speedup_vs_reference": 25.1922, + "share_of_suite_pct": 77.2 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 7.7548, + "reference_ms": 82.2352, + "speedup_vs_reference": 10.6045, + "share_of_suite_pct": 16.6 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.2813, + "reference_ms": 202.5375, + "speedup_vs_reference": 88.7803, + "share_of_suite_pct": 4.88 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2556, + "reference_ms": 0.2252, + "speedup_vs_reference": 0.8812, + "share_of_suite_pct": 0.55 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1548, + "reference_ms": 0.1508, + "speedup_vs_reference": 0.9742, + "share_of_suite_pct": 0.33 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0635, + "reference_ms": 162.8972, + "speedup_vs_reference": 2563.6148, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0552, + "reference_ms": 163.1357, + "speedup_vs_reference": 2952.6826, + "share_of_suite_pct": 0.12 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0416, + "reference_ms": 48.0097, + "speedup_vs_reference": 1153.3863, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0415, + "reference_ms": 47.9395, + "speedup_vs_reference": 1155.1696, + "share_of_suite_pct": 0.09 + } + ] + }, + "simd_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T20:26:25.789478+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 37.2041, + "reference_ms": 930.7842, + "speedup_vs_reference": 25.0183, + "share_of_suite_pct": 76.81 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 8.1565, + "reference_ms": 88.3639, + "speedup_vs_reference": 10.8336, + "share_of_suite_pct": 16.84 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 2.4386, + "reference_ms": 221.0389, + "speedup_vs_reference": 90.6424, + "share_of_suite_pct": 5.03 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2667, + "reference_ms": 0.2436, + "speedup_vs_reference": 0.9134, + "share_of_suite_pct": 0.55 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1572, + "reference_ms": 0.1593, + "speedup_vs_reference": 1.0135, + "share_of_suite_pct": 0.32 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0636, + "reference_ms": 172.9198, + "speedup_vs_reference": 2717.7961, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0633, + "reference_ms": 170.0262, + "speedup_vs_reference": 2686.3776, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0443, + "reference_ms": 50.5614, + "speedup_vs_reference": 1141.5474, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0417, + "reference_ms": 50.9599, + "speedup_vs_reference": 1221.8259, + "share_of_suite_pct": 0.09 + } + ] + } + } +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/streaming.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/streaming.json new file mode 100644 index 0000000..e8494b6 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/streaming.json @@ -0,0 +1,73 @@ +{ + "metadata": { + "suite": "streaming", + "runtime": { + "generated_at_utc": "2026-03-23T20:25:58.628657+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "n_bars": 100000, + "seed": 2026 + } + }, + "results": [ + { + "indicator": "StreamingSMA", + "inputs": "close", + "stream_total_ms": 4.5729, + "batch_total_ms": 0.0685, + "stream_ns_per_update": 45.73, + "batch_ns_per_bar": 0.68, + "updates_per_second": 21867879.95, + "stream_over_batch_ratio": 66.7989 + }, + { + "indicator": "StreamingEMA", + "inputs": "close", + "stream_total_ms": 4.535, + "batch_total_ms": 0.1969, + "stream_ns_per_update": 45.35, + "batch_ns_per_bar": 1.97, + "updates_per_second": 22050716.65, + "stream_over_batch_ratio": 23.0301 + }, + { + "indicator": "StreamingRSI", + "inputs": "close", + "stream_total_ms": 4.6421, + "batch_total_ms": 0.4597, + "stream_ns_per_update": 46.42, + "batch_ns_per_bar": 4.6, + "updates_per_second": 21541858.52, + "stream_over_batch_ratio": 10.098 + }, + { + "indicator": "StreamingATR", + "inputs": "hlc", + "stream_total_ms": 10.2098, + "batch_total_ms": 0.4599, + "stream_ns_per_update": 102.1, + "batch_ns_per_bar": 4.6, + "updates_per_second": 9794518.83, + "stream_over_batch_ratio": 22.2012 + }, + { + "indicator": "StreamingVWAP", + "inputs": "hlcv", + "stream_total_ms": 12.5109, + "batch_total_ms": 0.1027, + "stream_ns_per_update": 125.11, + "batch_ns_per_bar": 1.03, + "updates_per_second": 7993046.05, + "stream_over_batch_ratio": 121.7603 + } + ] +} diff --git a/vendor/ferro-ta-main/benchmarks/artifacts/latest/wasm.json b/vendor/ferro-ta-main/benchmarks/artifacts/latest/wasm.json new file mode 100644 index 0000000..32a796a --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/artifacts/latest/wasm.json @@ -0,0 +1,46 @@ +{ + "metadata": { + "suite": "wasm", + "runtime": { + "generated_at_utc": "2026-03-23T20:15:04.885Z", + "node_version": "v25.8.1", + "platform": "darwin", + "arch": "arm64" + }, + "dataset": { + "bars": 100000 + } + }, + "results": [ + { + "indicator": "SMA", + "elapsed_ms": 0.1702, + "ns_per_bar": 1.7, + "million_bars_per_second": 587.52 + }, + { + "indicator": "EMA", + "elapsed_ms": 0.2923, + "ns_per_bar": 2.92, + "million_bars_per_second": 342.08 + }, + { + "indicator": "RSI", + "elapsed_ms": 0.5962, + "ns_per_bar": 5.96, + "million_bars_per_second": 167.73 + }, + { + "indicator": "ATR", + "elapsed_ms": 0.642, + "ns_per_bar": 6.42, + "million_bars_per_second": 155.75 + }, + { + "indicator": "BBANDS", + "elapsed_ms": 1.7411, + "ns_per_bar": 17.41, + "million_bars_per_second": 57.43 + } + ] +} diff --git a/vendor/ferro-ta-main/benchmarks/bench_backtest.py b/vendor/ferro-ta-main/benchmarks/bench_backtest.py new file mode 100644 index 0000000..13068b1 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_backtest.py @@ -0,0 +1,425 @@ +""" +ferro_ta backtesting engine speed benchmark. + +Measures throughput for single-asset, multi-asset, and analytics functions +across multiple bar sizes. Optional competitor comparison (vectorbt, backtrader) +is guarded behind try/except. + +Usage: + python benchmarks/bench_backtest.py + python benchmarks/bench_backtest.py --sizes 10000 100000 + python benchmarks/bench_backtest.py --skip-competitors --json benchmarks/artifacts/bench_backtest_results.json +""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np +from ferro_ta._ferro_ta import ( + backtest_core, + backtest_multi_asset_core, + backtest_ohlcv_core, + compute_performance_metrics, + kelly_fraction, + monte_carlo_bootstrap, + walk_forward_indices, +) + +from ferro_ta.analysis.backtest import BacktestEngine + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover + from metadata import benchmark_metadata # type: ignore[no-redef] + +# Optional competitors ------------------------------------------------------- +try: + import vectorbt as vbt # type: ignore[import] + + VECTORBT_AVAILABLE = True +except ImportError: + VECTORBT_AVAILABLE = False + vbt = None # type: ignore[assignment] + +try: + import backtrader as bt # type: ignore[import] + + BACKTRADER_AVAILABLE = True +except ImportError: + BACKTRADER_AVAILABLE = False + bt = None # type: ignore[assignment] + +# --------------------------------------------------------------------------- +N_WARMUP = 1 +N_RUNS = 5 +DEFAULT_SIZES = [10_000, 100_000, 1_000_000] +N_ASSETS = 50 +N_SIMS = 500 + + +# --------------------------------------------------------------------------- +# Timer helper +# --------------------------------------------------------------------------- + + +def _time_fn( + fn, *args, n_warmup: int = N_WARMUP, n_runs: int = N_RUNS, **kwargs +) -> float: + for _ in range(n_warmup): + fn(*args, **kwargs) + times: list[float] = [] + for _ in range(n_runs): + t0 = time.perf_counter() + fn(*args, **kwargs) + times.append(time.perf_counter() - t0) + return float(np.median(times)) + + +# --------------------------------------------------------------------------- +# Data generators +# --------------------------------------------------------------------------- + + +def _make_ohlcv(n: int, seed: int = 0) -> tuple[np.ndarray, ...]: + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + high = close + rng.uniform(0.1, 1.5, n) + low = close - rng.uniform(0.1, 1.5, n) + open_ = close + rng.standard_normal(n) * 0.3 + return open_, high, low, close + + +def _make_signals(n: int, seed: int = 1) -> np.ndarray: + rng = np.random.default_rng(seed) + raw = np.sign(rng.standard_normal(n)) + raw[raw == 0] = 1.0 + return raw.astype(np.float64) + + +# --------------------------------------------------------------------------- +# Benchmark functions +# --------------------------------------------------------------------------- + + +def bench_backtest_core_single(n: int) -> dict[str, Any]: + _, _, _, close = _make_ohlcv(n) + signals = _make_signals(n) + + t_ferro = _time_fn(backtest_core, close, signals) + + row: dict[str, Any] = { + "n_bars": n, + "ferro_ta_ms": round(t_ferro * 1000, 4), + "ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4), + } + + if VECTORBT_AVAILABLE: + import pandas as pd # noqa: PLC0415 + + close_s = pd.Series(close) + sig_s = pd.Series(signals.astype(bool)) + + def _vbt(): + pf = vbt.Portfolio.from_signals(close_s, sig_s, ~sig_s, freq="1D") + return pf.total_return() + + t_vbt = _time_fn(_vbt) + row["vectorbt_ms"] = round(t_vbt * 1000, 4) + row["speedup_vs_vectorbt"] = round(t_vbt / t_ferro, 4) + + return row + + +def bench_backtest_ohlcv_core(n: int) -> dict[str, Any]: + open_, high, low, close = _make_ohlcv(n) + signals = _make_signals(n) + + t_ferro = _time_fn( + backtest_ohlcv_core, + open_, + high, + low, + close, + signals, + fill_mode="market_open", + stop_loss_pct=0.02, + take_profit_pct=0.04, + ) + + return { + "n_bars": n, + "ferro_ta_ms": round(t_ferro * 1000, 4), + "ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4), + } + + +def bench_performance_metrics(n: int) -> dict[str, Any]: + rng = np.random.default_rng(42) + returns = rng.standard_normal(n) * 0.01 + equity = np.cumprod(1 + returns) + + t_ferro = _time_fn(compute_performance_metrics, returns, equity) + + def _numpy_sharpe(): + mean_r = np.mean(returns) + std_r = np.std(returns, ddof=1) + _ = mean_r / std_r * np.sqrt(252) + rolling_max = np.maximum.accumulate(equity) + drawdown = (equity - rolling_max) / rolling_max + _ = float(drawdown.min()) + + t_numpy = _time_fn(_numpy_sharpe) + + return { + "n_bars": n, + "ferro_ta_ms": round(t_ferro * 1000, 4), + "numpy_partial_ms": round(t_numpy * 1000, 4), + "speedup_vs_numpy": round(t_numpy / t_ferro, 4), + "note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)", + } + + +def bench_multi_asset(n: int, n_assets: int = N_ASSETS) -> dict[str, Any]: + rng = np.random.default_rng(7) + close_2d = np.ascontiguousarray( + np.cumprod(1 + rng.standard_normal((n, n_assets)) * 0.01, axis=0) * 100.0 + ) + weights_2d = np.full((n, n_assets), 1.0 / n_assets) + + t_parallel = _time_fn( + backtest_multi_asset_core, close_2d, weights_2d, parallel=True + ) + t_serial = _time_fn(backtest_multi_asset_core, close_2d, weights_2d, parallel=False) + + def _numpy_loop(): + results = [] + for j in range(n_assets): + col = np.ascontiguousarray(close_2d[:, j]) + sig = np.ones(n) + _, _, sr, _ = backtest_core(col, sig) + results.append(sr) + return np.stack(results, axis=1) + + t_loop = _time_fn(_numpy_loop) + + return { + "n_bars": n, + "n_assets": n_assets, + "parallel_ms": round(t_parallel * 1000, 4), + "serial_ms": round(t_serial * 1000, 4), + "loop_ms": round(t_loop * 1000, 4), + "parallel_speedup_vs_loop": round(t_loop / t_parallel, 4), + "parallel_speedup_vs_serial": round(t_serial / t_parallel, 4), + } + + +def bench_monte_carlo(n: int, n_sims: int = N_SIMS) -> dict[str, Any]: + rng = np.random.default_rng(3) + returns = rng.standard_normal(n) * 0.01 + + t_ferro = _time_fn(monte_carlo_bootstrap, returns, n_sims=n_sims, seed=42) + + def _numpy_mc(): + out = np.empty((n_sims, n)) + for i in range(n_sims): + idx = np.random.choice(len(returns), size=len(returns), replace=True) + out[i] = np.cumprod(1 + returns[idx]) + return out + + t_numpy = _time_fn(_numpy_mc) + + return { + "n_bars": n, + "n_sims": n_sims, + "ferro_ta_ms": round(t_ferro * 1000, 4), + "numpy_loop_ms": round(t_numpy * 1000, 4), + "speedup_vs_numpy": round(t_numpy / t_ferro, 4), + } + + +def bench_engine_pipeline(n: int) -> dict[str, Any]: + _, high, low, open_ = _make_ohlcv(n) + _, _, _, close = _make_ohlcv(n, seed=10) + + engine = ( + BacktestEngine() + .with_commission(0.001) + .with_slippage(5.0) + .with_ohlcv(high=high, low=low, open_=open_) + .with_stop_loss(0.02) + .with_take_profit(0.04) + ) + + t_ferro = _time_fn(engine.run, close, "sma_crossover") + + return { + "n_bars": n, + "ferro_ta_ms": round(t_ferro * 1000, 4), + "description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown", + } + + +def bench_walk_forward_indices(n: int) -> dict[str, Any]: + train = max(n // 5, 100) + test = max(n // 20, 20) + t = _time_fn(walk_forward_indices, n, train, test) + return { + "n_bars": n, + "train_bars": train, + "test_bars": test, + "ferro_ta_us": round(t * 1_000_000, 4), + } + + +def bench_kelly_fraction() -> dict[str, Any]: + win_rates = np.linspace(0.3, 0.7, 1000) + avg_wins = np.linspace(0.01, 0.05, 1000) + avg_losses = np.linspace(0.005, 0.03, 1000) + + def _loop(): + for w, a, b in zip(win_rates, avg_wins, avg_losses): + kelly_fraction(w, a, b) + + t = _time_fn(_loop) + return {"n_calls": 1000, "ferro_ta_us": round(t * 1_000_000, 4)} + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + + +def run_all( + sizes: list[int], + skip_competitors: bool, + n_assets: int, + n_sims: int, +) -> dict[str, Any]: + results: dict[str, list[dict[str, Any]]] = { + "backtest_core_single": [], + "backtest_ohlcv_core": [], + "performance_metrics": [], + "multi_asset": [], + "monte_carlo": [], + "engine_full_pipeline": [], + "walk_forward_indices": [], + } + + for n in sizes: + print(f"\n--- {n:,} bars ---") + + r = bench_backtest_core_single(n) + results["backtest_core_single"].append(r) + print( + f" backtest_core_single: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)" + ) + + r = bench_backtest_ohlcv_core(n) + results["backtest_ohlcv_core"].append(r) + print( + f" backtest_ohlcv_core: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)" + ) + + r = bench_performance_metrics(n) + results["performance_metrics"].append(r) + print( + f" performance_metrics: {r['ferro_ta_ms']:.2f} ms (numpy partial: {r['numpy_partial_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)" + ) + + r = bench_multi_asset(n, n_assets) + results["multi_asset"].append(r) + print( + f" multi_asset ({n_assets}): parallel={r['parallel_ms']:.1f} ms serial={r['serial_ms']:.1f} ms loop={r['loop_ms']:.1f} ms ({r['parallel_speedup_vs_loop']:.2f}x vs loop)" + ) + + r = bench_monte_carlo(n, n_sims) + results["monte_carlo"].append(r) + print( + f" monte_carlo ({n_sims} sims): {r['ferro_ta_ms']:.2f} ms (numpy: {r['numpy_loop_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)" + ) + + r = bench_engine_pipeline(n) + results["engine_full_pipeline"].append(r) + print(f" engine_full_pipeline: {r['ferro_ta_ms']:.2f} ms") + + r = bench_walk_forward_indices(n) + results["walk_forward_indices"].append(r) + print(f" walk_forward_indices: {r['ferro_ta_us']:.1f} µs") + + kelly_row = bench_kelly_fraction() + results["kelly_fraction"] = [kelly_row] + print(f"\n kelly_fraction (1k calls): {kelly_row['ferro_ta_us']:.1f} µs") + + return { + "metadata": benchmark_metadata("backtest"), + "results": results, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benchmark ferro-ta backtesting engine." + ) + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + metavar="N", + help="Bar counts to benchmark (default: 10000 100000 1000000)", + ) + parser.add_argument( + "--skip-competitors", + action="store_true", + help="Skip optional competitor benchmarks", + ) + parser.add_argument( + "--assets", + type=int, + default=N_ASSETS, + help="Number of assets for multi-asset benchmark", + ) + parser.add_argument( + "--sims", + type=int, + default=N_SIMS, + help="Number of simulations for Monte Carlo benchmark", + ) + parser.add_argument( + "--json", dest="json_path", help="Write JSON results to this path" + ) + args = parser.parse_args() + + print( + f"ferro-ta backtest benchmark | sizes={args.sizes} | assets={args.assets} | sims={args.sims}" + ) + print("=" * 72) + + payload = run_all( + sizes=args.sizes, + skip_competitors=args.skip_competitors, + n_assets=args.assets, + n_sims=args.sims, + ) + + if args.json_path: + json_path = Path(args.json_path) + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {json_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/bench_batch.py b/vendor/ferro-ta-main/benchmarks/bench_batch.py new file mode 100644 index 0000000..c008c41 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_batch.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_fn(fn, *args, rounds: int = 5, **kwargs) -> float: + fn(*args, **kwargs) + times: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn(*args, **kwargs) + times.append(time.perf_counter() - t0) + return min(times) + + +def run_batch_benchmark( + *, + n_samples: int = 100_000, + n_series: int = 100, + seed: int = 42, +) -> dict[str, Any]: + rng = np.random.default_rng(seed) + close2d = rng.uniform(100.0, 200.0, (n_samples, n_series)) + high2d = close2d + rng.uniform(0.1, 2.0, (n_samples, n_series)) + low2d = close2d - rng.uniform(0.1, 2.0, (n_samples, n_series)) + close1d = close2d[:, 0] + high1d = high2d[:, 0] + low1d = low2d[:, 0] + + batch_rows: list[dict[str, Any]] = [] + grouped_rows: list[dict[str, Any]] = [] + + indicators = [ + ( + "SMA", + lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True), + lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=False), + lambda: [ + ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series) + ], + ), + ( + "RSI", + lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=True), + lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=False), + lambda: [ + ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series) + ], + ), + ( + "ATR", + lambda: ferro_ta.batch.batch_atr( + high2d, low2d, close2d, timeperiod=14, parallel=True + ), + lambda: ferro_ta.batch.batch_atr( + high2d, low2d, close2d, timeperiod=14, parallel=False + ), + lambda: [ + ferro_ta.ATR(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14) + for j in range(n_series) + ], + ), + ( + "ADX", + lambda: ferro_ta.batch.batch_adx( + high2d, low2d, close2d, timeperiod=14, parallel=True + ), + lambda: ferro_ta.batch.batch_adx( + high2d, low2d, close2d, timeperiod=14, parallel=False + ), + lambda: [ + ferro_ta.ADX(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14) + for j in range(n_series) + ], + ), + ] + + for name, parallel_fn, sequential_fn, loop_fn in indicators: + batch_parallel_s = _time_fn(parallel_fn) + batch_sequential_s = _time_fn(sequential_fn) + loop_s = _time_fn(loop_fn) + batch_rows.append( + { + "indicator": name, + "parallel_ms": round(batch_parallel_s * 1000, 4), + "sequential_ms": round(batch_sequential_s * 1000, 4), + "loop_ms": round(loop_s * 1000, 4), + "parallel_speedup_vs_loop": round(loop_s / batch_parallel_s, 4), + "sequential_speedup_vs_loop": round(loop_s / batch_sequential_s, 4), + } + ) + + grouped_cases = [ + ( + "close_bundle_3", + lambda: ferro_ta.batch.compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close1d, + ), + lambda: ( + ferro_ta.SMA(close1d, timeperiod=10), + ferro_ta.EMA(close1d, timeperiod=12), + ferro_ta.RSI(close1d, timeperiod=14), + ), + ), + ( + "hlc_bundle_3", + lambda: ferro_ta.batch.compute_many( + [ + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ("CCI", {"timeperiod": 14}), + ], + close=close1d, + high=high1d, + low=low1d, + ), + lambda: ( + ferro_ta.ATR(high1d, low1d, close1d, timeperiod=14), + ferro_ta.ADX(high1d, low1d, close1d, timeperiod=14), + ferro_ta.CCI(high1d, low1d, close1d, timeperiod=14), + ), + ), + ] + + for name, grouped_fn, separate_fn in grouped_cases: + grouped_s = _time_fn(grouped_fn) + separate_s = _time_fn(separate_fn) + grouped_rows.append( + { + "case": name, + "grouped_ms": round(grouped_s * 1000, 4), + "separate_ms": round(separate_s * 1000, 4), + "speedup_vs_separate": round(separate_s / grouped_s, 4), + } + ) + + return { + "metadata": benchmark_metadata( + "batch", + extra={ + "dataset": { + "n_samples": n_samples, + "n_series": n_series, + "total_bars": n_samples * n_series, + "seed": seed, + } + }, + ), + "results": batch_rows, + "grouped_results": grouped_rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Benchmark batch indicator execution.") + parser.add_argument("--samples", type=int, default=100_000) + parser.add_argument("--series", type=int, default=100) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_batch_benchmark( + n_samples=args.samples, + n_series=args.series, + seed=args.seed, + ) + + dataset = payload["metadata"]["dataset"] + print( + "Batch Benchmark: " + f"{dataset['n_samples']} bars, {dataset['n_series']} series " + f"(Total: {dataset['total_bars'] / 1e6:.1f} M bars)" + ) + print("-" * 74) + print( + f"{'Indicator':<12} {'Parallel (ms)':>14} {'Sequential (ms)':>16} " + f"{'Loop (ms)':>12} {'P speedup':>10}" + ) + print("-" * 74) + for row in payload["results"]: + print( + f"{row['indicator']:<12} {row['parallel_ms']:14.1f} " + f"{row['sequential_ms']:16.1f} {row['loop_ms']:12.1f} " + f"{row['parallel_speedup_vs_loop']:10.2f}x" + ) + + if payload["grouped_results"]: + print("\nGrouped Multi-Indicator Calls") + print("-" * 64) + print( + f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}" + ) + print("-" * 64) + for row in payload["grouped_results"]: + print( + f"{row['case']:<18} {row['grouped_ms']:14.1f} " + f"{row['separate_ms']:16.1f} {row['speedup_vs_separate']:12.2f}x" + ) + + if args.json_path: + json_path = Path(args.json_path) + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {json_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/bench_derivatives_compare.py b/vendor/ferro-ta-main/benchmarks/bench_derivatives_compare.py new file mode 100644 index 0000000..388f200 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_derivatives_compare.py @@ -0,0 +1,1231 @@ +""" +Compare ferro_ta derivatives analytics against analytical references and +optional third-party libraries available in the current environment. + +The suite focuses on selected core workflows: + +- Black-Scholes-Merton call pricing +- implied-volatility recovery +- first-order Greeks +- Black-76 call pricing + +Outputs include: + +- speed timings with per-run samples and variance stats +- analytical accuracy metrics +- Python-tracked peak allocation snapshots +- machine, runtime, build, and package metadata +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import sys +import time +import tracemalloc +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +from ferro_ta.analysis.options import ( + black_76_price as ft_black_76_price, +) +from ferro_ta.analysis.options import ( + greeks as ft_greeks, +) +from ferro_ta.analysis.options import ( + implied_volatility as ft_implied_volatility, +) +from ferro_ta.analysis.options import ( + option_price as ft_option_price, +) + +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + + +N_WARMUP = 1 +N_RUNS = 7 +DEFAULT_SIZES = [1_000, 10_000] +DEFAULT_ACCURACY_SIZE = 512 +DEFAULT_SEED = 42 +INV_SQRT_2PI = 1.0 / math.sqrt(2.0 * math.pi) + + +@dataclass(frozen=True) +class Case: + name: str + label: str + expected_key: str + component_names: tuple[str, ...] | None = None + accuracy_target: str = "expected_output" + + +@dataclass(frozen=True) +class Provider: + name: str + kind: str + note: str + functions: dict[str, Callable[[dict[str, np.ndarray]], np.ndarray]] + max_speed_size: int | None = None + + def supports(self, case_name: str) -> bool: + return case_name in self.functions + + +CASES = [ + Case("bsm_call_price", "BSM Call Price", "call_price"), + Case( + "bsm_call_iv", + "BSM Call IV Recovery", + "volatility", + accuracy_target="reconstructed_price", + ), + Case( + "bsm_call_greeks", + "BSM Call Greeks", + "call_greeks", + component_names=("delta", "gamma", "vega", "theta", "rho"), + ), + Case("black76_call_price", "Black-76 Call Price", "black76_call_price"), +] + + +def _median(values: list[float]) -> float: + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _summary_stats(samples_ms: list[float]) -> dict[str, float]: + if not samples_ms: + return { + "median_ms": 0.0, + "mean_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + "cv_pct": 0.0, + } + + mean_ms = sum(samples_ms) / len(samples_ms) + variance = ( + sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1) + if len(samples_ms) > 1 + else 0.0 + ) + stddev_ms = math.sqrt(variance) + cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0 + return { + "median_ms": round(_median(samples_ms), 4), + "mean_ms": round(mean_ms, 4), + "min_ms": round(min(samples_ms), 4), + "max_ms": round(max(samples_ms), 4), + "stddev_ms": round(stddev_ms, 4), + "cv_pct": round(cv_pct, 3), + } + + +def _timed_runs_ms( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> list[float]: + for _ in range(N_WARMUP): + fn(chain) + + samples_ms: list[float] = [] + for _ in range(N_RUNS): + t0 = time.perf_counter() + fn(chain) + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(chain) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_contracts_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return size / (median_ms / 1000.0) + + +def _normal_pdf_numpy(x: np.ndarray) -> np.ndarray: + return INV_SQRT_2PI * np.exp(-0.5 * x * x) + + +def _normal_cdf_numpy(x: np.ndarray) -> np.ndarray: + abs_x = np.abs(x) + t = 1.0 / (1.0 + 0.2316419 * abs_x) + poly = ( + (((((1.330274429 * t) - 1.821255978) * t) + 1.781477937) * t - 0.356563782) * t + + 0.319381530 + ) * t + cdf = 1.0 - _normal_pdf_numpy(abs_x) * poly + return np.where(x >= 0.0, cdf, 1.0 - cdf) + + +def _normal_cdf_scalar(x: float) -> float: + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def _normal_pdf_scalar(x: float) -> float: + return INV_SQRT_2PI * math.exp(-0.5 * x * x) + + +def _bsm_price_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = spot * spot_df * _normal_cdf_numpy( + d1 + ) - strike * strike_df * _normal_cdf_numpy(d2) + else: + out = strike * strike_df * _normal_cdf_numpy( + -d2 + ) - spot * spot_df * _normal_cdf_numpy(-d1) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _black76_price_numpy( + forward: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = discount * ( + forward * _normal_cdf_numpy(d1) - strike * _normal_cdf_numpy(d2) + ) + else: + out = discount * ( + strike * _normal_cdf_numpy(-d2) - forward * _normal_cdf_numpy(-d1) + ) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _bsm_greeks_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_numpy(d1) + carry_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_numpy(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_numpy(d2) + + carry * spot * carry_df * _normal_cdf_numpy(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_numpy(d2) + else: + delta = carry_df * (_normal_cdf_numpy(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_numpy(-d2) + - carry * spot * carry_df * _normal_cdf_numpy(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_numpy(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return np.ascontiguousarray( + np.column_stack([delta, gamma, vega, theta, rho]), + dtype=np.float64, + ) + + +def _bsm_price_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + if option_type == "call": + return spot * spot_df * _normal_cdf_scalar( + d1 + ) - strike * strike_df * _normal_cdf_scalar(d2) + return strike * strike_df * _normal_cdf_scalar( + -d2 + ) - spot * spot_df * _normal_cdf_scalar(-d1) + + +def _black76_price_scalar( + forward: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = math.exp(-rate * time_to_expiry) + if option_type == "call": + return discount * ( + forward * _normal_cdf_scalar(d1) - strike * _normal_cdf_scalar(d2) + ) + return discount * ( + strike * _normal_cdf_scalar(-d2) - forward * _normal_cdf_scalar(-d1) + ) + + +def _bsm_greeks_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> tuple[float, float, float, float, float]: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_scalar(d1) + carry_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_scalar(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_scalar(d2) + + carry * spot * carry_df * _normal_cdf_scalar(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_scalar(d2) + else: + delta = carry_df * (_normal_cdf_scalar(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_scalar(-d2) + - carry * spot * carry_df * _normal_cdf_scalar(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_scalar(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return delta, gamma, vega, theta, rho + + +def _implied_vol_bisection_numpy( + price: np.ndarray, + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + *, + option_type: str, + carry: np.ndarray, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-8, + max_iterations: int = 100, +) -> np.ndarray: + lo = np.full_like(price, lower, dtype=np.float64) + hi = np.full_like(price, upper, dtype=np.float64) + mid = np.full_like(price, 0.2, dtype=np.float64) + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_numpy( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + too_low = estimate < price + lo = np.where(too_low, mid, lo) + hi = np.where(too_low, hi, mid) + if float(np.max(np.abs(estimate - price))) < tolerance: + break + return np.ascontiguousarray(mid, dtype=np.float64) + + +def _implied_vol_bisection_scalar( + price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + option_type: str, + carry: float, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-10, + max_iterations: int = 100, +) -> float: + lo = lower + hi = upper + mid = 0.2 + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + if abs(estimate - price) < tolerance: + return mid + if estimate < price: + lo = mid + else: + hi = mid + return mid + + +def _reference_python_loop( + chain: dict[str, np.ndarray], + fn: Callable[..., float | tuple[float, ...]], + *, + include_forward: bool = False, + include_price: bool = False, +) -> np.ndarray: + rows: list[Any] = [] + for idx in range(len(chain["strike"])): + kwargs: dict[str, float | str] = { + "strike": float(chain["strike"][idx]), + "rate": float(chain["rate"][idx]), + "time_to_expiry": float(chain["time_to_expiry"][idx]), + "volatility": float(chain["volatility"][idx]), + "carry": float(chain["carry"][idx]), + } + if include_forward: + kwargs["forward"] = float(chain["forward"][idx]) + else: + kwargs["spot"] = float(chain["spot"][idx]) + if include_price: + kwargs["price"] = float(chain["call_price"][idx]) + rows.append(fn(**kwargs)) + return np.asarray(rows, dtype=np.float64) + + +def _build_chain(n: int, *, seed: int) -> dict[str, np.ndarray]: + rng = np.random.default_rng(seed) + spot = np.ascontiguousarray(rng.uniform(80.0, 120.0, size=n), dtype=np.float64) + strike = np.ascontiguousarray(rng.uniform(70.0, 130.0, size=n), dtype=np.float64) + rate = np.ascontiguousarray(rng.uniform(0.0, 0.07, size=n), dtype=np.float64) + carry = np.ascontiguousarray(rng.uniform(0.0, 0.03, size=n), dtype=np.float64) + time_to_expiry = np.ascontiguousarray( + rng.uniform(7.0 / 365.0, 2.0, size=n), dtype=np.float64 + ) + volatility = np.ascontiguousarray(rng.uniform(0.08, 0.65, size=n), dtype=np.float64) + forward = np.ascontiguousarray( + spot * np.exp((rate - carry) * time_to_expiry), + dtype=np.float64, + ) + + chain = { + "spot": spot, + "strike": strike, + "rate": rate, + "carry": carry, + "time_to_expiry": time_to_expiry, + "volatility": volatility, + "forward": forward, + } + + call_price = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + call_greeks = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + black76_call_price = _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + chain["call_price"] = np.ascontiguousarray(call_price, dtype=np.float64) + chain["call_greeks"] = np.ascontiguousarray(call_greeks, dtype=np.float64) + chain["black76_call_price"] = np.ascontiguousarray( + black76_call_price, dtype=np.float64 + ) + return chain + + +def _ferro_ta_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_option_price( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_implied_volatility( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + result = ft_greeks( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ) + return np.ascontiguousarray( + np.column_stack( + [ + np.asarray(result.delta, dtype=np.float64), + np.asarray(result.gamma, dtype=np.float64), + np.asarray(result.vega, dtype=np.float64), + np.asarray(result.theta, dtype=np.float64), + np.asarray(result.rho, dtype=np.float64), + ] + ), + dtype=np.float64, + ) + + +def _ferro_ta_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_black_76_price( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ), + dtype=np.float64, + ) + + +def _reference_numpy_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_price_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _implied_vol_bisection_numpy( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_greeks_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _black76_price_numpy( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ) + + +def _reference_python_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, price, spot, strike, rate, time_to_expiry, volatility, carry: ( + _implied_vol_bisection_scalar( + price, + spot, + strike, + rate, + time_to_expiry, + option_type="call", + carry=carry, + ) + ), + include_price=True, + ) + + +def _reference_python_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + +def _reprice_bsm_call_from_iv( + chain: dict[str, np.ndarray], + implied_vols: np.ndarray, +) -> np.ndarray: + rows = [ + _bsm_price_scalar( + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + max(float(iv), 1e-12), + option_type="call", + carry=float(carry), + ) + for spot, strike, rate, time_to_expiry, iv, carry in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + np.asarray(implied_vols, dtype=np.float64), + chain["carry"], + ) + ] + return np.asarray(rows, dtype=np.float64) + + +def _py_vollib_provider() -> Provider | None: + if importlib.util.find_spec("py_vollib") is None: + return None + + from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm + from py_vollib.black_scholes_merton.implied_volatility import ( + implied_volatility as py_vollib_iv, + ) + + def _price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_bsm( + "c", + float(s), + float(k), + float(t), + float(r), + float(vol), + float(q), + ) + for s, k, r, t, vol, q in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + def _iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_iv( + float(price), + "c", + float(s), + float(k), + float(t), + float(r), + float(q), + ) + for price, s, k, r, t, q in zip( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + return Provider( + name="py_vollib", + kind="third_party", + note="Scalar Black-Scholes-Merton baseline from py_vollib.", + functions={ + "bsm_call_price": _price, + "bsm_call_iv": _iv, + }, + max_speed_size=1_000, + ) + + +def available_providers() -> list[Provider]: + providers = [ + Provider( + name="ferro_ta", + kind="project", + note="Rust-backed vectorized implementation.", + functions={ + "bsm_call_price": _ferro_ta_call_price, + "bsm_call_iv": _ferro_ta_call_iv, + "bsm_call_greeks": _ferro_ta_call_greeks, + "black76_call_price": _ferro_ta_black76_call_price, + }, + ), + Provider( + name="reference_numpy", + kind="reference", + note="Pure NumPy analytical formulas with vectorized IV bisection.", + functions={ + "bsm_call_price": _reference_numpy_call_price, + "bsm_call_iv": _reference_numpy_call_iv, + "bsm_call_greeks": _reference_numpy_call_greeks, + "black76_call_price": _reference_numpy_black76_call_price, + }, + ), + Provider( + name="reference_python_loop", + kind="reference", + note="Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + functions={ + "bsm_call_price": _reference_python_call_price, + "bsm_call_iv": _reference_python_call_iv, + "bsm_call_greeks": _reference_python_call_greeks, + "black76_call_price": _reference_python_black76_call_price, + }, + max_speed_size=1_000, + ), + ] + optional = _py_vollib_provider() + if optional is not None: + providers.append(optional) + return providers + + +def _accuracy_metrics(actual: np.ndarray, expected: np.ndarray) -> dict[str, Any]: + actual_arr = np.asarray(actual, dtype=np.float64) + expected_arr = np.asarray(expected, dtype=np.float64) + abs_error = np.abs(actual_arr - expected_arr) + rel_error = abs_error / np.maximum(np.abs(expected_arr), 1e-12) + return { + "output_shape": list(actual_arr.shape), + "max_abs_error": round(float(np.max(abs_error)), 12), + "mean_abs_error": round(float(np.mean(abs_error)), 12), + "rmse": round( + float(np.sqrt(np.mean(np.square(actual_arr - expected_arr)))), 12 + ), + "max_rel_error": round(float(np.max(rel_error)), 12), + } + + +def _accuracy_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + case_rows = [ + row for row in rows if row["case"] == case.name and "max_abs_error" in row + ] + if not case_rows: + continue + best = min(case_rows, key=lambda row: float(row["max_abs_error"])) + worst = max(case_rows, key=lambda row: float(row["max_abs_error"])) + summary.append( + { + "case": case.name, + "best_provider": best["provider"], + "best_max_abs_error": best["max_abs_error"], + "worst_provider": worst["provider"], + "worst_max_abs_error": worst["max_abs_error"], + } + ) + return summary + + +def _speed_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + for size in sorted( + { + int(row["size"]) + for row in rows + if row["case"] == case.name and "median_ms" in row + } + ): + case_rows = [ + row + for row in rows + if row["case"] == case.name + and row.get("size") == size + and "median_ms" in row + ] + if not case_rows: + continue + fastest = min(case_rows, key=lambda row: float(row["median_ms"])) + ranking = [ + { + "provider": row["provider"], + "median_ms": row["median_ms"], + "contracts_per_s": row["contracts_per_s"], + } + for row in sorted(case_rows, key=lambda row: float(row["median_ms"])) + ] + summary.append( + { + "case": case.name, + "size": size, + "fastest_provider": fastest["provider"], + "fastest_median_ms": fastest["median_ms"], + "ranking": ranking, + } + ) + return summary + + +def _print_provider_inventory(providers: list[Provider]) -> None: + print("Providers:") + for provider in providers: + supported = ", ".join( + case.name for case in CASES if provider.supports(case.name) + ) + cap = ( + f" (speed cap {provider.max_speed_size})" if provider.max_speed_size else "" + ) + print(f" - {provider.name} [{provider.kind}] {provider.note}{cap}") + print(f" supported: {supported}") + print() + + +def _print_accuracy_table(rows: list[dict[str, Any]], accuracy_size: int) -> None: + print(f"Accuracy ({accuracy_size} contracts)") + print( + "IV accuracy is measured as price reconstruction error from the recovered IV." + ) + header = f"{'Case':<22} {'Provider':<24} {'Max abs err':<14} {'RMSE':<14} {'Max rel err':<14}" + print(header) + print("-" * len(header)) + for row in rows: + if "max_abs_error" not in row: + continue + print( + f"{row['label']:<22} {row['provider']:<24} " + f"{row['max_abs_error']:<14.6g} {row['rmse']:<14.6g} {row['max_rel_error']:<14.6g}" + ) + print() + + +def _print_speed_table(rows: list[dict[str, Any]]) -> None: + print(f"Speed (median of {N_RUNS} measured runs after {N_WARMUP} warmup)") + header = f"{'Case':<22} {'Size':<8} {'Provider':<24} {'Median ms':<12} {'Contracts/s':<14} {'Peak alloc':<12}" + print(header) + print("-" * len(header)) + for row in rows: + if "median_ms" not in row: + continue + peak = row.get("python_peak_allocation_bytes") + peak_label = "n/a" if peak is None else str(peak) + print( + f"{row['label']:<22} {row['size']:<8} {row['provider']:<24} " + f"{row['median_ms']:<12.4f} {row['contracts_per_s']:<14.2f} {peak_label:<12}" + ) + print() + + +def run_benchmark( + *, + sizes: list[int], + accuracy_size: int, + json_path: str | None, +) -> dict[str, Any]: + providers = available_providers() + speed_chains = { + size: _build_chain(size, seed=DEFAULT_SEED + size) for size in sizes + } + accuracy_chain = _build_chain(accuracy_size, seed=DEFAULT_SEED) + + accuracy_rows: list[dict[str, Any]] = [] + speed_rows: list[dict[str, Any]] = [] + + _print_provider_inventory(providers) + + for case in CASES: + for provider in providers: + if not provider.supports(case.name): + continue + fn = provider.functions[case.name] + actual = fn(accuracy_chain) + if case.name == "bsm_call_iv": + compared_actual = _reprice_bsm_call_from_iv(accuracy_chain, actual) + expected = np.asarray(accuracy_chain["call_price"], dtype=np.float64) + else: + compared_actual = actual + expected = np.asarray( + accuracy_chain[case.expected_key], dtype=np.float64 + ) + row = { + "case": case.name, + "label": case.label, + "provider": provider.name, + "provider_kind": provider.kind, + "sample_size": accuracy_size, + "accuracy_target": case.accuracy_target, + } + row.update(_accuracy_metrics(compared_actual, expected)) + if case.component_names is not None: + row["component_names"] = list(case.component_names) + accuracy_rows.append(row) + + _print_accuracy_table(accuracy_rows, accuracy_size) + + for case in CASES: + for size in sizes: + chain = speed_chains[size] + for provider in providers: + if not provider.supports(case.name): + continue + if ( + provider.max_speed_size is not None + and size > provider.max_speed_size + ): + continue + + fn = provider.functions[case.name] + samples_ms = _timed_runs_ms(fn, chain) + stats = _summary_stats(samples_ms) + median_ms = float(stats["median_ms"]) + contracts_per_s = _throughput_contracts_s(size, median_ms) + peak_bytes = _python_peak_bytes(fn, chain) + + speed_rows.append( + { + "case": case.name, + "label": case.label, + "size": size, + "provider": provider.name, + "provider_kind": provider.kind, + "median_ms": round(median_ms, 4), + "contracts_per_s": round(contracts_per_s, 2), + "runs_ms": [round(sample, 4) for sample in samples_ms], + "stats": stats, + "python_peak_allocation_bytes": peak_bytes, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + } + ) + + _print_speed_table(speed_rows) + + package_names = ["numpy", "ferro-ta", "py_vollib"] + metadata = benchmark_metadata( + "benchmark_derivatives_compare", + extra={ + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": sizes, + "accuracy_size": accuracy_size, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": DEFAULT_SEED, + "ranges": { + "spot": [80.0, 120.0], + "strike": [70.0, 130.0], + "rate": [0.0, 0.07], + "carry": [0.0, 0.03], + "time_to_expiry_years": [7.0 / 365.0, 2.0], + "volatility": [0.08, 0.65], + }, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": ( + "Scalar analytical Black-Scholes-Merton and Black-76 formulas " + "using math.erf; IV accuracy is measured as repriced error " + "from the recovered volatility because direct volatility " + "differences can be unstable on low-vega contracts." + ), + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or mixed dtypes, benchmark that " + "path separately." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it does not measure native RSS." + ), + "provider_notes": ( + "reference_python_loop and py_vollib are scalar baselines and " + "are size-capped in the speed table to keep runtime reasonable." + ), + }, + "providers": [ + { + "name": provider.name, + "kind": provider.kind, + "note": provider.note, + "max_speed_size": provider.max_speed_size, + "supported_cases": [ + case.name for case in CASES if provider.supports(case.name) + ], + } + for provider in providers + ], + "packages": package_versions(*package_names), + }, + ) + + result = { + "schema_version": 1, + "command": " ".join(["python", *sys.argv]), + "n_warmup": N_WARMUP, + "n_runs": N_RUNS, + "accuracy_size": accuracy_size, + "sizes": sizes, + "metadata": metadata, + "accuracy": { + "summary": _accuracy_summary(accuracy_rows), + "results": accuracy_rows, + }, + "speed": { + "summary": _speed_summary(speed_rows), + "results": speed_rows, + }, + } + + if json_path: + output_path = Path(json_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(result, indent=2), encoding="utf-8") + print(f"Results written to {output_path}") + + return result + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Compare ferro_ta derivatives analytics against reference implementations" + ) + parser.add_argument( + "--json", + default=None, + help="Write the benchmark artifact to JSON", + ) + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + help="Contract counts to benchmark (default: 1000 10000)", + ) + parser.add_argument( + "--accuracy-size", + type=int, + default=DEFAULT_ACCURACY_SIZE, + help="Contract count used for the accuracy pass (default: 512)", + ) + args = parser.parse_args() + run_benchmark( + sizes=args.sizes, + accuracy_size=args.accuracy_size, + json_path=args.json, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/bench_gpu.py b/vendor/ferro-ta-main/benchmarks/bench_gpu.py new file mode 100644 index 0000000..a46af1a --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_gpu.py @@ -0,0 +1,105 @@ +""" +GPU vs CPU benchmark for ferro_ta.gpu (SMA, EMA, RSI). + +Requires: + pip install "ferro-ta[gpu]" # or pip install torch + +Run: + python benchmarks/bench_gpu.py + +The script compares wall-clock time for 1M-element arrays and prints a +summary table. If PyTorch is not installed or no GPU is found, GPU columns are skipped. +""" + +from __future__ import annotations + +import time + +import numpy as np + +# Try to import PyTorch +try: + import torch + + TORCH_AVAILABLE = True + if torch.cuda.is_available(): + DEVICE = "cuda" + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + DEVICE = "mps" + else: + DEVICE = None +except ImportError: + torch = None # type: ignore[assignment] + TORCH_AVAILABLE = False + DEVICE = None + +from ferro_ta.gpu import ema, rsi, sma + +N = 1_000_000 +REPEATS = 10 + + +def _time_fn(fn, *args, **kwargs) -> float: + """Return minimum wall time (seconds) over REPEATS calls.""" + times = [] + for _ in range(REPEATS): + t0 = time.perf_counter() + fn(*args, **kwargs) + if DEVICE == "cuda": + torch.cuda.synchronize() + elif DEVICE == "mps": + torch.mps.synchronize() + times.append(time.perf_counter() - t0) + return min(times) + + +def main() -> None: + rng = np.random.default_rng(42) + close_cpu = rng.uniform(100.0, 200.0, N) + + print(f"Array size: {N:,} elements") + print(f"Repeats: {REPEATS}") + print(f"Device: {DEVICE if DEVICE else 'CPU'}") + print() + + header = f"{'Indicator':<20} {'CPU (ms)':>10}" + if DEVICE: + header += f" {'GPU (ms)':>10} {'Speedup':>10}" + print(header) + print("-" * len(header)) + + for name, fn, kwargs in [ + ("sma(period=30)", sma, {"timeperiod": 30}), + ("ema(period=30)", ema, {"timeperiod": 30}), + ("rsi(period=14)", rsi, {"timeperiod": 14}), + ]: + cpu_time = _time_fn(fn, close_cpu, **kwargs) * 1000 # ms + + row = f"{name:<20} {cpu_time:>10.3f}" + if DEVICE: + dtype = torch.float32 if DEVICE == "mps" else torch.float64 + close_gpu = torch.tensor(close_cpu, dtype=dtype, device=DEVICE) + # Warm-up + fn(close_gpu, **kwargs) + if DEVICE == "cuda": + torch.cuda.synchronize() + elif DEVICE == "mps": + torch.mps.synchronize() + gpu_time = _time_fn(fn, close_gpu, **kwargs) * 1000 # ms + speedup = cpu_time / gpu_time + row += f" {gpu_time:>10.3f} {speedup:>10.2f}×" + print(row) + + if not TORCH_AVAILABLE: + print() + print("PyTorch not available — GPU columns skipped.") + print("Install with: pip install 'ferro_ta[gpu]'") + elif not DEVICE: + print() + print( + "PyTorch found, but no CUDA or MPS device detected — GPU columns skipped." + ) + + +if __name__ == "__main__": + main() diff --git a/vendor/ferro-ta-main/benchmarks/bench_simd.py b/vendor/ferro-ta-main/benchmarks/bench_simd.py new file mode 100644 index 0000000..d2e4896 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_simd.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + +ROOT = Path(__file__).resolve().parents[1] + + +def _run(cmd: list[str], *, cwd: Path = ROOT) -> None: + subprocess.run(cmd, cwd=cwd, check=True) + + +def _profile_variant( + *, + label: str, + maturin_args: list[str], + price_bars: int, + iv_bars: int, + window: int, +) -> dict[str, Any]: + _run([sys.executable, "-m", "maturin", "develop", "--release", *maturin_args]) + with tempfile.TemporaryDirectory(prefix=f"ferro_ta_{label}_") as tmp_dir: + json_path = Path(tmp_dir) / "runtime_hotspots.json" + _run( + [ + sys.executable, + "benchmarks/profile_runtime_hotspots.py", + "--price-bars", + str(price_bars), + "--iv-bars", + str(iv_bars), + "--window", + str(window), + "--json", + str(json_path), + ] + ) + payload = json.loads(json_path.read_text(encoding="utf-8")) + return payload + + +def run_simd_benchmark( + *, + price_bars: int = 20_000, + iv_bars: int = 50_000, + window: int = 252, +) -> dict[str, Any]: + # `simd` is a default feature, so a pure-scalar baseline must explicitly + # opt out via --no-default-features; otherwise both builds would be + # identical and every reported speedup would collapse to 1.0. + variants = [ + ("portable_release", ["--no-default-features"]), + ("simd_release", ["--features", "simd"]), + ] + reports = { + label: _profile_variant( + label=label, + maturin_args=args, + price_bars=price_bars, + iv_bars=iv_bars, + window=window, + ) + for label, args in variants + } + + portable_rows = {row["name"]: row for row in reports["portable_release"]["results"]} + simd_rows = {row["name"]: row for row in reports["simd_release"]["results"]} + + comparison: list[dict[str, Any]] = [] + for name in sorted(portable_rows): + portable = portable_rows[name] + simd = simd_rows.get(name) + if simd is None: + continue + portable_ms = float(portable["fast_ms"]) + simd_ms = float(simd["fast_ms"]) + comparison.append( + { + "name": name, + "category": portable["category"], + "portable_ms": round(portable_ms, 4), + "simd_ms": round(simd_ms, 4), + "speedup_simd_vs_portable": round( + portable_ms / simd_ms if simd_ms > 0.0 else float("inf"), 4 + ), + } + ) + + comparison.sort( + key=lambda row: float(row["speedup_simd_vs_portable"]), reverse=True + ) + + # Restore the default portable editable build so the workspace ends in the + # distributable configuration. + _run([sys.executable, "-m", "maturin", "develop", "--release"]) + + return { + "metadata": benchmark_metadata( + "simd", + extra={ + "dataset": { + "price_bars": price_bars, + "iv_bars": iv_bars, + "window": window, + }, + "variants": [label for label, _ in variants], + }, + ), + "results": comparison, + "reports": reports, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benchmark portable vs SIMD-enabled ferro-ta builds." + ) + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_simd_benchmark( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ) + + print(f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}") + print("-" * 64) + for row in payload["results"]: + print( + f"{row['name']:<20} {row['portable_ms']:14.4f} " + f"{row['simd_ms']:12.4f} {row['speedup_simd_vs_portable']:14.2f}x" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/bench_streaming.py b/vendor/ferro-ta-main/benchmarks/bench_streaming.py new file mode 100644 index 0000000..afc78d5 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_streaming.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta as ft + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_min(fn: Callable[[], object], rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) + + +def _stream_close(close: np.ndarray, factory: Callable[[], Any]) -> float: + streamer = factory() + last = np.nan + for value in close: + last = streamer.update(float(value)) + return float(last) if not np.isnan(last) else np.nan + + +def _stream_hlc( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + factory: Callable[[], Any], +) -> float: + streamer = factory() + last = np.nan + for high_value, low_value, close_value in zip(high, low, close): + last = streamer.update(float(high_value), float(low_value), float(close_value)) + return float(last) if not np.isnan(last) else np.nan + + +def _stream_hlcv( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + volume: np.ndarray, + factory: Callable[[], Any], +) -> float: + streamer = factory() + last = np.nan + for high_value, low_value, close_value, volume_value in zip( + high, low, close, volume + ): + last = streamer.update( + float(high_value), + float(low_value), + float(close_value), + float(volume_value), + ) + return float(last) if not np.isnan(last) else np.nan + + +def run_streaming_benchmark( + *, + n_bars: int = 100_000, + seed: int = 2026, +) -> dict[str, Any]: + rng = np.random.default_rng(seed) + close = 100.0 + np.cumsum(rng.normal(0.0, 1.0, n_bars)).astype(np.float64) + high = close + rng.uniform(0.1, 2.0, n_bars) + low = close - rng.uniform(0.1, 2.0, n_bars) + volume = rng.uniform(1_000.0, 100_000.0, n_bars) + + cases = [ + ( + "StreamingSMA", + "close", + lambda: _stream_close(close, lambda: ft.StreamingSMA(period=20)), + lambda: ft.SMA(close, timeperiod=20), + ), + ( + "StreamingEMA", + "close", + lambda: _stream_close(close, lambda: ft.StreamingEMA(period=20)), + lambda: ft.EMA(close, timeperiod=20), + ), + ( + "StreamingRSI", + "close", + lambda: _stream_close(close, lambda: ft.StreamingRSI(period=14)), + lambda: ft.RSI(close, timeperiod=14), + ), + ( + "StreamingATR", + "hlc", + lambda: _stream_hlc( + high, + low, + close, + lambda: ft.StreamingATR(period=14), + ), + lambda: ft.ATR(high, low, close, timeperiod=14), + ), + ( + "StreamingVWAP", + "hlcv", + lambda: _stream_hlcv( + high, + low, + close, + volume, + lambda: ft.StreamingVWAP(), + ), + lambda: ft.VWAP(high, low, close, volume), + ), + ] + + rows: list[dict[str, Any]] = [] + for name, input_kind, stream_fn, batch_fn in cases: + stream_s = _time_min(stream_fn) + batch_s = _time_min(batch_fn) + rows.append( + { + "indicator": name, + "inputs": input_kind, + "stream_total_ms": round(stream_s * 1000.0, 4), + "batch_total_ms": round(batch_s * 1000.0, 4), + "stream_ns_per_update": round(stream_s * 1e9 / n_bars, 2), + "batch_ns_per_bar": round(batch_s * 1e9 / n_bars, 2), + "updates_per_second": round(n_bars / stream_s, 2), + "stream_over_batch_ratio": round(stream_s / batch_s, 4), + } + ) + + return { + "metadata": benchmark_metadata( + "streaming", + extra={ + "dataset": { + "n_bars": n_bars, + "seed": seed, + } + }, + ), + "results": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Benchmark streaming indicator execution." + ) + parser.add_argument("--bars", type=int, default=100_000) + parser.add_argument("--seed", type=int, default=2026) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = run_streaming_benchmark(n_bars=args.bars, seed=args.seed) + + dataset = payload["metadata"]["dataset"] + print(f"Streaming Benchmark: {dataset['n_bars']} bars") + print("-" * 86) + print( + f"{'Indicator':<16} {'Stream (ms)':>12} {'Batch (ms)':>12} " + f"{'ns/update':>12} {'upd/s':>12} {'ratio':>10}" + ) + print("-" * 86) + for row in payload["results"]: + print( + f"{row['indicator']:<16} {row['stream_total_ms']:12.2f} " + f"{row['batch_total_ms']:12.2f} {row['stream_ns_per_update']:12.2f} " + f"{row['updates_per_second']:12.1f} {row['stream_over_batch_ratio']:10.2f}" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/bench_vs_talib.py b/vendor/ferro-ta-main/benchmarks/bench_vs_talib.py new file mode 100644 index 0000000..3dae1d5 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/bench_vs_talib.py @@ -0,0 +1,481 @@ +""" +ferro_ta vs TA-Lib speed comparison. + +Measures throughput (M bars/s) for both libraries on the same synthetic data +and parameters. The output is intentionally evidence-heavy: + +- median timings +- per-run timing samples +- variability stats +- Python-tracked peak allocation snapshots +- machine, runtime, and build metadata + +This is meant to support a narrow claim: ferro-ta is often faster on selected +indicators, not universally faster. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +import tracemalloc +from typing import Any + +import numpy as np + +try: + import talib # noqa: F401 + + TALIB_AVAILABLE = True +except ImportError: + TALIB_AVAILABLE = False + talib = None # type: ignore[assignment] + +import ferro_ta + +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +N_WARMUP = 1 +N_RUNS = 7 +DEFAULT_SIZES = [10_000, 100_000, 1_000_000] +TIE_EPSILON = 0.05 + +_rng = np.random.default_rng(42) + + +def _median(values: list[float]) -> float: + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _summary_stats(samples_ms: list[float]) -> dict[str, float]: + if not samples_ms: + return { + "median_ms": 0.0, + "mean_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + "cv_pct": 0.0, + } + + mean_ms = sum(samples_ms) / len(samples_ms) + variance = ( + sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1) + if len(samples_ms) > 1 + else 0.0 + ) + stddev_ms = math.sqrt(variance) + cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0 + return { + "median_ms": round(_median(samples_ms), 4), + "mean_ms": round(mean_ms, 4), + "min_ms": round(min(samples_ms), 4), + "max_ms": round(max(samples_ms), 4), + "stddev_ms": round(stddev_ms, 4), + "cv_pct": round(cv_pct, 3), + } + + +def _outcome(speedup: float) -> str: + if speedup > 1.0 + TIE_EPSILON: + return "ferro_ta_win" + if speedup < 1.0 - TIE_EPSILON: + return "talib_win" + return "tie" + + +def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]: + rows = [row for row in results if row.get("size") == size and "speedup" in row] + if not rows: + return {"size": size, "rows": 0} + + speedups = [float(row["speedup"]) for row in rows] + wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win") + ties = sum(1 for row in rows if row.get("outcome") == "tie") + losses = sum(1 for row in rows if row.get("outcome") == "talib_win") + return { + "size": size, + "rows": len(rows), + "wins": wins, + "ties": ties, + "losses": losses, + "win_rate": round(wins / len(rows), 4), + "non_loss_rate": round((wins + ties) / len(rows), 4), + "median_speedup": round(_median(speedups), 4), + "min_speedup": round(min(speedups), 4), + "max_speedup": round(max(speedups), 4), + "talib_wins_or_ties": [ + row["indicator"] + for row in rows + if row.get("outcome") in {"talib_win", "tie"} + ], + } + + +def _synthetic_ohlcv( + n: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + # Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, + # volume >= 0, and low <= open, close <= high, high >= open. + close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5) + open_ = close + _rng.standard_normal(n) * 0.2 + high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3) + low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3) + high = np.maximum(high, low) + low = np.maximum(low, 0.0) + high = np.maximum(high, low) + open_ = np.clip(open_, low, high) + close = np.clip(close, low, high) + volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000 + return open_, high, low, close, volume + + +def _timed_runs_ms(fn, *args, **kwargs) -> list[float]: + for _ in range(N_WARMUP): + fn(*args, **kwargs) + + samples_ms: list[float] = [] + for _ in range(N_RUNS): + t0 = time.perf_counter() + fn(*args, **kwargs) + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes(fn, *args, **kwargs) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(*args, **kwargs) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_m_bars_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return (size / 1e6) / (median_ms / 1000.0) + + +# --------------------------------------------------------------------------- +# Benchmarked callables +# --------------------------------------------------------------------------- + + +def _run_ft_sma(o, h, l, c, v, n): + return ferro_ta.SMA(c[:n], timeperiod=14) + + +def _run_ta_sma(o, h, l, c, v, n): + return talib.SMA(c[:n], timeperiod=14) + + +def _run_ft_ema(o, h, l, c, v, n): + return ferro_ta.EMA(c[:n], timeperiod=14) + + +def _run_ta_ema(o, h, l, c, v, n): + return talib.EMA(c[:n], timeperiod=14) + + +def _run_ft_rsi(o, h, l, c, v, n): + return ferro_ta.RSI(c[:n], timeperiod=14) + + +def _run_ta_rsi(o, h, l, c, v, n): + return talib.RSI(c[:n], timeperiod=14) + + +def _run_ft_bbands(o, h, l, c, v, n): + return ferro_ta.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0) + + +def _run_ta_bbands(o, h, l, c, v, n): + return talib.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0) + + +def _run_ft_macd(o, h, l, c, v, n): + return ferro_ta.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9) + + +def _run_ta_macd(o, h, l, c, v, n): + return talib.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9) + + +def _run_ft_atr(o, h, l, c, v, n): + return ferro_ta.ATR(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_atr(o, h, l, c, v, n): + return talib.ATR(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_stoch(o, h, l, c, v, n): + return ferro_ta.STOCH(h[:n], l[:n], c[:n]) + + +def _run_ta_stoch(o, h, l, c, v, n): + return talib.STOCH(h[:n], l[:n], c[:n]) + + +def _run_ft_adx(o, h, l, c, v, n): + return ferro_ta.ADX(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_adx(o, h, l, c, v, n): + return talib.ADX(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_cci(o, h, l, c, v, n): + return ferro_ta.CCI(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ta_cci(o, h, l, c, v, n): + return talib.CCI(h[:n], l[:n], c[:n], timeperiod=14) + + +def _run_ft_obv(o, h, l, c, v, n): + return ferro_ta.OBV(c[:n], v[:n]) + + +def _run_ta_obv(o, h, l, c, v, n): + return talib.OBV(c[:n], v[:n]) + + +def _run_ft_mfi(o, h, l, c, v, n): + return ferro_ta.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14) + + +def _run_ta_mfi(o, h, l, c, v, n): + return talib.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14) + + +def _run_ft_wma(o, h, l, c, v, n): + return ferro_ta.WMA(c[:n], timeperiod=14) + + +def _run_ta_wma(o, h, l, c, v, n): + return talib.WMA(c[:n], timeperiod=14) + + +COMPARISON_CASES = [ + ("SMA", _run_ft_sma, _run_ta_sma), + ("EMA", _run_ft_ema, _run_ta_ema), + ("RSI", _run_ft_rsi, _run_ta_rsi), + ("BBANDS", _run_ft_bbands, _run_ta_bbands), + ("MACD", _run_ft_macd, _run_ta_macd), + ("ATR", _run_ft_atr, _run_ta_atr), + ("STOCH", _run_ft_stoch, _run_ta_stoch), + ("ADX", _run_ft_adx, _run_ta_adx), + ("CCI", _run_ft_cci, _run_ta_cci), + ("OBV", _run_ft_obv, _run_ta_obv), + ("MFI", _run_ft_mfi, _run_ta_mfi), + ("WMA", _run_ft_wma, _run_ta_wma), +] + +SKIP_1M_FOR = {"STOCH", "ADX"} + + +def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]: + max_size = max(sizes) + open_, high, low, close, volume = _synthetic_ohlcv(max_size) + results: list[dict[str, Any]] = [] + + col_label = 10 + col_size = 10 + col_ft_ms = 12 + col_ta_ms = 12 + col_speedup = 10 + col_ft_m = 12 + col_ta_m = 12 + + if not TALIB_AVAILABLE: + print("Note: ta-lib not installed. Reporting ferro_ta timings only.") + print( + "Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n" + ) + + print( + f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup" + ) + print(f"Sizes: {sizes}") + print() + + header = ( + f"{'Indicator':<{col_label}} {'Size':<{col_size}} " + f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} " + f"{'Speedup':<{col_speedup}} {'ferro_ta(M/s)':<{col_ft_m}} {'TA-Lib(M/s)':<{col_ta_m}}" + ) + print(header) + print("-" * len(header)) + + for name, ft_run, ta_run in COMPARISON_CASES: + for size in sizes: + if size == 1_000_000 and name in SKIP_1M_FOR: + continue + + ft_samples_ms = _timed_runs_ms( + ft_run, open_, high, low, close, volume, size + ) + ft_stats = _summary_stats(ft_samples_ms) + ft_median_ms = float(ft_stats["median_ms"]) + ft_m_bars_s = _throughput_m_bars_s(size, ft_median_ms) + ft_peak_bytes = _python_peak_bytes( + ft_run, open_, high, low, close, volume, size + ) + + row: dict[str, Any] = { + "indicator": name, + "size": size, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + "ferro_ta_ms": round(ft_median_ms, 4), + "ferro_ta_m_bars_s": round(ft_m_bars_s, 2), + "ferro_ta_runs_ms": [round(sample, 4) for sample in ft_samples_ms], + "ferro_ta_stats": ft_stats, + "python_peak_allocation_bytes": { + "ferro_ta": ft_peak_bytes, + }, + } + + if TALIB_AVAILABLE: + ta_samples_ms = _timed_runs_ms( + ta_run, open_, high, low, close, volume, size + ) + ta_stats = _summary_stats(ta_samples_ms) + ta_median_ms = float(ta_stats["median_ms"]) + ta_m_bars_s = _throughput_m_bars_s(size, ta_median_ms) + speedup = ( + ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf") + ) + outcome = _outcome(speedup) + ta_peak_bytes = _python_peak_bytes( + ta_run, open_, high, low, close, volume, size + ) + + print( + f"{name:<{col_label}} {size:<{col_size}} " + f"{ft_median_ms:<{col_ft_ms}.3f} {ta_median_ms:<{col_ta_ms}.3f} " + f"{speedup:<{col_speedup}.2f}x {ft_m_bars_s:<{col_ft_m}.1f} {ta_m_bars_s:<{col_ta_m}.1f}" + ) + + row.update( + { + "talib_ms": round(ta_median_ms, 4), + "talib_m_bars_s": round(ta_m_bars_s, 2), + "talib_runs_ms": [round(sample, 4) for sample in ta_samples_ms], + "talib_stats": ta_stats, + "speedup": round(speedup, 4), + "outcome": outcome, + } + ) + row["python_peak_allocation_bytes"]["talib"] = ta_peak_bytes + else: + print( + f"{name:<{col_label}} {size:<{col_size}} " + f"{ft_median_ms:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} " + f"{'N/A':<{col_speedup}} {ft_m_bars_s:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}" + ) + + results.append(row) + + print() + if TALIB_AVAILABLE and results: + wins = sum(1 for row in results if row.get("outcome") == "ferro_ta_win") + total = len([row for row in results if "speedup" in row]) + print(f"Summary: ferro_ta ahead outside the tie band on {wins}/{total} rows.") + print() + + if json_path: + metadata = benchmark_metadata( + "benchmark_vs_talib", + extra={ + "dataset": { + "generator": "synthetic_ohlcv", + "sizes": sizes, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speedup_definition": "talib_median_ms / ferro_ta_median_ms", + "tie_band": f"{1.0 - TIE_EPSILON:.2f} to {1.0 + TIE_EPSILON:.2f}", + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or other dtypes, benchmark that " + "separately because wrapper overhead can dominate." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it is not a full native RSS " + "or allocator profile." + ), + }, + "packages": package_versions("numpy", "ferro-ta", "TA-Lib"), + }, + ) + out = { + "schema_version": 2, + "command": " ".join(["python", *sys.argv]), + "n_warmup": N_WARMUP, + "n_runs": N_RUNS, + "sizes": sizes, + "talib_available": TALIB_AVAILABLE, + "runtime": metadata["runtime"], + "git": metadata["git"], + "metadata": metadata, + "summary": { + "total_rows": len(results), + "by_size": [_summary_for_size(results, size) for size in sizes], + }, + "results": results, + } + if not TALIB_AVAILABLE: + out["note"] = "ferro_ta only; ta-lib not installed" + with open(json_path, "w", encoding="utf-8") as handle: + json.dump(out, handle, indent=2) + print(f"Results written to {json_path}") + + return results + + +def main() -> int: + parser = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison") + parser.add_argument("--json", default=None, help="Write results to JSON file") + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + help="Bar counts to benchmark (default: 10000 100000 1000000)", + ) + args = parser.parse_args() + run_comparison(args.sizes, args.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/benchmark_table.py b/vendor/ferro-ta-main/benchmarks/benchmark_table.py new file mode 100644 index 0000000..c7a06bf --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/benchmark_table.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +Generate the Speed Comparison markdown table from benchmarks/results.json. + +Requires results from the full suite: + pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +Reads results.json and prints a markdown table: all indicators × all libraries. +Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark +data show ERR (indicating the benchmark run was incomplete or failed). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +# Ensure project root is on path when run as script +_root = Path(__file__).resolve().parent.parent +if _root not in (Path(p).resolve() for p in sys.path): + sys.path.insert(0, str(_root)) + +from benchmarks.wrapper_registry import ( + INDICATOR_CATEGORIES, + is_supported, +) +from benchmarks.wrapper_registry import ( + LIBRARY_NAMES as LIBS, +) + + +def _all_indicators() -> list[str]: + """All indicators in category order (matches test_speed parametrization).""" + return [ind for cat in INDICATOR_CATEGORIES for ind in INDICATOR_CATEGORIES[cat]] + + +def main(): + p = Path(__file__).parent / "results.json" + if not p.exists(): + print( + "Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v", + file=sys.stderr, + ) + sys.exit(1) + raw = p.read_text().strip() + if not raw: + print( + "results.json is empty. Run the full benchmark suite first.", + file=sys.stderr, + ) + sys.exit(1) + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + print(f"Invalid JSON in results.json: {e}", file=sys.stderr) + sys.exit(1) + benchmarks = data.get("benchmarks", []) + + # Collect test_speed[Category/Indicator/library] -> median µs + table: dict[str, dict[str, float]] = {} + for b in benchmarks: + name = b.get("name") or "" + if "test_speed[" not in name: + continue + params = b.get("params") or {} + ind = params.get("indicator") + lib = params.get("library") + if not ind or not lib or lib not in LIBS: + continue + median_sec = (b.get("stats") or {}).get("median") + if median_sec is None: + continue + if ind not in table: + table[ind] = {} + table[ind][lib] = median_sec * 1e6 # to µs + + all_indicators = _all_indicators() + if not all_indicators: + print("No indicators from INDICATOR_CATEGORIES.", file=sys.stderr) + sys.exit(1) + + # Header: Indicator | ferro_ta | talib | ... + lib_header = " | ".join(LIBS) + print(f"| Indicator | {lib_header} |") + print("|-----------|" + "|".join(["--------:" for _ in LIBS]) + "|") + + for ind in all_indicators: + row = table.get(ind, {}) + cells = [] + for lib in LIBS: + if lib in row: + cells.append(str(round(row[lib]))) + elif not is_supported(lib, ind): + cells.append("N/A") + else: + cells.append("ERR") + print(f"| {ind} | {' | '.join(cells)} |") + + print() + print( + "(Median time in µs, lower is better. N/A = unsupported pair. " + "ERR = supported pair missing benchmark data. Source: results.json from full test_speed run.)" + ) + + +if __name__ == "__main__": + main() diff --git a/vendor/ferro-ta-main/benchmarks/check_hotspot_regression.py b/vendor/ferro-ta-main/benchmarks/check_hotspot_regression.py new file mode 100644 index 0000000..495fad8 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/check_hotspot_regression.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Validate hotspot benchmark JSON against conservative speedup floors. + +This gate is intentionally lightweight: it checks that the optimized paths +remain faster than their bundled reference implementations and that all +expected cases were present in the report. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def _parse_threshold_items(items: list[str]) -> dict[str, float]: + thresholds: dict[str, float] = {} + for item in items: + if "=" not in item: + raise ValueError(f"Invalid threshold '{item}', expected NAME=VALUE") + name, value_s = item.split("=", 1) + thresholds[name] = float(value_s) + return thresholds + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check hotspot benchmark JSON against regression thresholds." + ) + parser.add_argument( + "--input", + default="runtime_hotspots.json", + help="Path to JSON produced by benchmarks/profile_runtime_hotspots.py", + ) + parser.add_argument( + "--min-speedup", + action="append", + default=[ + "CORREL=2.0", + "BETA=2.0", + "LINEARREG=2.0", + "TSF=2.0", + "iv_rank=1.1", + "iv_percentile=1.1", + "iv_zscore=1.05", + "compute_many_close=0.85", + "feature_matrix=0.40", + ], + help="Required minimum speedup per named case, e.g. CORREL=5.0 (repeatable)", + ) + parser.add_argument( + "--min-cases", + type=int, + default=9, + help="Minimum number of benchmark rows expected in the report", + ) + args = parser.parse_args() + + path = Path(args.input) + if not path.exists(): + print(f"ERROR: hotspot benchmark file not found: {path}") + return 1 + + payload = json.loads(path.read_text(encoding="utf-8")) + rows = payload.get("results", []) + if len(rows) < args.min_cases: + print( + f"ERROR: hotspot report contains {len(rows)} rows, expected at least {args.min_cases}" + ) + return 1 + + thresholds = _parse_threshold_items(args.min_speedup) + rows_by_name = {str(row.get("name")): row for row in rows} + failures: list[str] = [] + + for name, floor in thresholds.items(): + row = rows_by_name.get(name) + if row is None: + failures.append(f"missing row for {name}") + continue + + speedup = float(row.get("speedup_vs_reference", 0.0)) + fast_ms = float(row.get("fast_ms", 0.0)) + reference_ms = float(row.get("reference_ms", 0.0)) + print( + f"{name}: fast_ms={fast_ms:.4f}, reference_ms={reference_ms:.4f}, " + f"speedup={speedup:.4f}" + ) + + if fast_ms <= 0.0 or reference_ms <= 0.0: + failures.append(f"{name} has non-positive timing values") + if speedup < floor: + failures.append(f"{name} speedup {speedup:.4f} < floor {floor:.4f}") + + if failures: + print("FAILED hotspot regression policy:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS hotspot regression policy.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/check_vs_talib_regression.py b/vendor/ferro-ta-main/benchmarks/check_vs_talib_regression.py new file mode 100644 index 0000000..5077b72 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/check_vs_talib_regression.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Validate benchmark-vs-TA-Lib results against guardrail thresholds. + +This is intentionally conservative: it catches severe regressions and incomplete +benchmark outputs, without overfitting to one machine. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def _parse_threshold_items(items: list[str]) -> dict[int, float]: + thresholds: dict[int, float] = {} + for item in items: + if "=" not in item: + raise ValueError(f"Invalid threshold '{item}', expected SIZE=VALUE") + size_s, value_s = item.split("=", 1) + thresholds[int(size_s)] = float(value_s) + return thresholds + + +def _percentile(values: list[float], q: float) -> float: + """Return the q percentile using linear interpolation.""" + if not values: + raise ValueError("Cannot compute percentile of empty sequence") + if q <= 0: + return min(values) + if q >= 100: + return max(values) + + values = sorted(values) + rank = (len(values) - 1) * (q / 100.0) + lower = int(rank) + upper = min(lower + 1, len(values) - 1) + weight = rank - lower + return values[lower] * (1.0 - weight) + values[upper] * weight + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check TA-Lib benchmark JSON against regression thresholds." + ) + parser.add_argument( + "--input", + default="benchmark_vs_talib.json", + help="Path to benchmark JSON produced by benchmarks/bench_vs_talib.py", + ) + parser.add_argument( + "--min-rows", + type=int, + default=10, + help="Minimum benchmark rows required per size", + ) + parser.add_argument( + "--median-floor", + action="append", + default=["10000=0.35", "100000=0.35"], + help="Required minimum median speedup per size, e.g. 100000=0.5 (repeatable)", + ) + parser.add_argument( + "--min-speedup-floor", + action="append", + default=["10000=0.10", "100000=0.10"], + help="Hard minimum per-row speedup floor per size, e.g. 100000=0.1 (repeatable)", + ) + parser.add_argument( + "--tail-percentile", + type=float, + default=10.0, + help="Tail percentile used for distribution-based slowdown checks (default: 10)", + ) + parser.add_argument( + "--tail-speedup-floor", + action="append", + default=["10000=0.20", "100000=0.20"], + help="Required minimum tail percentile speedup per size, e.g. 100000=0.2 (repeatable)", + ) + args = parser.parse_args() + + path = Path(args.input) + if not path.exists(): + print(f"ERROR: benchmark file not found: {path}") + return 1 + + data = json.loads(path.read_text(encoding="utf-8")) + if not data.get("talib_available", False): + print( + "ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy." + ) + return 1 + + summary_by_size = { + int(entry.get("size")): entry + for entry in data.get("summary", {}).get("by_size", []) + if entry.get("size") is not None + } + results_by_size: dict[int, list[dict[str, object]]] = {} + for row in data.get("results", []): + if "speedup" not in row or row.get("size") is None: + continue + size = int(row["size"]) + results_by_size.setdefault(size, []).append(row) + + median_floor = _parse_threshold_items(args.median_floor) + min_speedup_floor = _parse_threshold_items(args.min_speedup_floor) + tail_speedup_floor = _parse_threshold_items(args.tail_speedup_floor) + required_sizes = sorted( + set(median_floor) | set(min_speedup_floor) | set(tail_speedup_floor) + ) + + failures: list[str] = [] + for size in required_sizes: + entry = summary_by_size.get(size) + if entry is None: + failures.append(f"missing summary for size={size}") + continue + rows_for_size = results_by_size.get(size, []) + if not rows_for_size: + failures.append(f"missing detailed rows for size={size}") + continue + + rows = int(entry.get("rows", 0)) + med = float(entry.get("median_speedup", 0.0)) + min_s = float(entry.get("min_speedup", 0.0)) + speedups = [float(row["speedup"]) for row in rows_for_size] + tail_s = _percentile(speedups, args.tail_percentile) + print( + "size=" + f"{size}: rows={rows}, median_speedup={med:.4f}, " + f"p{args.tail_percentile:g}_speedup={tail_s:.4f}, min_speedup={min_s:.4f}" + ) + + if rows < args.min_rows: + failures.append(f"size={size} rows {rows} < min_rows {args.min_rows}") + if med < median_floor.get(size, float("-inf")): + failures.append( + f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}" + ) + if tail_s < tail_speedup_floor.get(size, float("-inf")): + failures.append( + "size=" + f"{size} p{args.tail_percentile:g}_speedup {tail_s:.4f} " + f"< floor {tail_speedup_floor[size]:.4f}" + ) + if min_s < min_speedup_floor.get(size, float("-inf")): + failures.append( + f"size={size} min_speedup {min_s:.4f} < floor {min_speedup_floor[size]:.4f}" + ) + + if failures: + print("FAILED benchmark regression policy:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("PASS benchmark regression policy.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/data_generator.py b/vendor/ferro-ta-main/benchmarks/data_generator.py new file mode 100644 index 0000000..949bb58 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/data_generator.py @@ -0,0 +1,68 @@ +""" +Benchmark data generator for cross-library comparison. + +Produces C-contiguous float64 NumPy arrays that work correctly with all +six libraries (ferro-ta, TA-Lib, pandas-ta, ta, Tulipy, finta). +Critical: every array is np.ascontiguousarray(..., dtype=np.float64) to +prevent memory segmentation faults in C-extension libraries. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +_RNG = np.random.default_rng(42) + + +def generate_ohlcv(size: int = 10_000) -> dict[str, np.ndarray]: + """Return a dict of C-contiguous float64 OHLCV arrays. + + Uses a geometric Brownian motion walk so values are realistic (no + negatives, bounded intraday spread). Every array satisfies: + high >= close >= low > 0 + open > 0 + volume > 0 + """ + # Geometric random walk for close + returns = _RNG.normal(0.0002, 0.01, size) + close = 100.0 * np.exp(np.cumsum(returns)) + + noise_hi = np.abs(_RNG.normal(0, 0.005, size)) * close + noise_lo = np.abs(_RNG.normal(0, 0.005, size)) * close + + high = close + noise_hi + low = np.maximum(close - noise_lo, 0.01) # never negative + open_ = low + _RNG.random(size) * (high - low) + volume = _RNG.uniform(1e5, 1e7, size) + + def _c(arr: np.ndarray) -> np.ndarray: + return np.ascontiguousarray(arr, dtype=np.float64) + + return { + "open": _c(open_), + "high": _c(high), + "low": _c(low), + "close": _c(close), + "volume": _c(volume), + } + + +def get_pandas_ohlcv(data: dict[str, np.ndarray]) -> pd.DataFrame: + """Convert an OHLCV dict to a DataFrame with a DatetimeIndex. + + pandas-ta and finta both require a datetime-indexed DataFrame with + lowercase column names (open/high/low/close/volume). + """ + idx = pd.date_range("2015-01-01", periods=len(data["close"]), freq="D") + return pd.DataFrame(data, index=idx) + + +# Pre-built datasets at several scales so benchmarks can import them directly +SMALL = generate_ohlcv(1_000) +MEDIUM = generate_ohlcv(10_000) +LARGE = generate_ohlcv(100_000) + +SMALL_DF = get_pandas_ohlcv(SMALL) +MEDIUM_DF = get_pandas_ohlcv(MEDIUM) +LARGE_DF = get_pandas_ohlcv(LARGE) diff --git a/vendor/ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz b/vendor/ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz new file mode 100644 index 0000000..0eee506 Binary files /dev/null and b/vendor/ferro-ta-main/benchmarks/fixtures/canonical_ohlcv.npz differ diff --git a/vendor/ferro-ta-main/benchmarks/fixtures/generate_canonical.py b/vendor/ferro-ta-main/benchmarks/fixtures/generate_canonical.py new file mode 100644 index 0000000..1f43016 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/fixtures/generate_canonical.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Generate the canonical OHLCV benchmark fixture. + +This script creates benchmarks/fixtures/canonical_ohlcv.npz — a fixed, +deterministic dataset used by the benchmark suite for both numerical-regression +and performance tests. + +Run once (or when you want to regenerate): + python benchmarks/fixtures/generate_canonical.py + +The fixture is checked into the repository so that CI does not need to +regenerate it every run. +""" + +from __future__ import annotations + +import pathlib + +import numpy as np + +SEED = 20240101 +N = 2000 # number of bars + +RNG = np.random.default_rng(SEED) + +# Simulate a GBM-style price series +returns = RNG.normal(0, 0.01, N) +close = np.cumprod(1 + returns) * 100.0 + +open_ = close * RNG.uniform(0.998, 1.002, N) +high = np.maximum(close, open_) + np.abs(RNG.normal(0, 0.2, N)) +low = np.minimum(close, open_) - np.abs(RNG.normal(0, 0.2, N)) +volume = RNG.uniform(500_000, 2_000_000, N) + +out_path = pathlib.Path(__file__).parent / "canonical_ohlcv.npz" +np.savez_compressed( + out_path, + open=open_, + high=high, + low=low, + close=close, + volume=volume, +) +print(f"Written {out_path} (N={N}, seed={SEED})") diff --git a/vendor/ferro-ta-main/benchmarks/metadata.py b/vendor/ferro-ta-main/benchmarks/metadata.py new file mode 100644 index 0000000..3af3af9 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/metadata.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import hashlib +import os +import platform +import re +import subprocess +import sys +from datetime import datetime, timezone +from importlib import metadata as importlib_metadata +from pathlib import Path +from typing import Any + +try: + import tomllib +except ImportError: # pragma: no cover + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + + +_ROOT = Path(__file__).resolve().parent.parent + + +def _run_cmd(command: list[str]) -> str | None: + try: + return subprocess.check_output( + command, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + return None + + +def _read_toml(path: Path) -> dict[str, Any] | None: + if tomllib is None or not path.exists(): + return None + try: + with path.open("rb") as handle: + return tomllib.load(handle) + except Exception: + return None + + +def _cpu_model() -> str | None: + if sys.platform == "darwin": + return ( + _run_cmd(["sysctl", "-n", "machdep.cpu.brand_string"]) + or _run_cmd(["sysctl", "-n", "hw.model"]) + or platform.processor() + or None + ) + if sys.platform.startswith("linux"): + cpuinfo = Path("/proc/cpuinfo") + if cpuinfo.exists(): + text = cpuinfo.read_text(encoding="utf-8", errors="ignore") + for pattern in (r"model name\s+:\s+(.+)", r"Hardware\s+:\s+(.+)"): + match = re.search(pattern, text) + if match: + return match.group(1).strip() + return platform.processor() or None + if sys.platform.startswith("win"): + return os.environ.get("PROCESSOR_IDENTIFIER") or platform.processor() or None + return platform.processor() or None + + +def _total_memory_bytes() -> int | None: + if sys.platform == "darwin": + raw = _run_cmd(["sysctl", "-n", "hw.memsize"]) + return int(raw) if raw and raw.isdigit() else None + + if sys.platform.startswith("linux"): + meminfo = Path("/proc/meminfo") + if meminfo.exists(): + text = meminfo.read_text(encoding="utf-8", errors="ignore") + match = re.search(r"MemTotal:\s+(\d+)\s+kB", text) + if match: + return int(match.group(1)) * 1024 + return None + + if sys.platform.startswith("win"): # pragma: no cover + try: + import ctypes + + class MEMORYSTATUSEX(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + status = MEMORYSTATUSEX() + status.dwLength = ctypes.sizeof(MEMORYSTATUSEX) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) + return int(status.ullTotalPhys) + except Exception: + return None + + return None + + +def _cargo_release_profile() -> dict[str, Any] | None: + cargo_toml = _read_toml(_ROOT / "Cargo.toml") + if not cargo_toml: + return None + profile = cargo_toml.get("profile", {}).get("release") + return profile if isinstance(profile, dict) else None + + +def git_info() -> dict[str, Any]: + """Best-effort git metadata for reproducible benchmark artifacts.""" + return { + "commit": _run_cmd(["git", "rev-parse", "HEAD"]), + "dirty": bool(_run_cmd(["git", "status", "--porcelain"]) or ""), + "branch": _run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]), + } + + +def runtime_info() -> dict[str, Any]: + return { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "python_version": sys.version.split()[0], + "python_implementation": platform.python_implementation(), + "python_executable": sys.executable, + "platform": platform.platform(), + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + "processor": platform.processor() or None, + "cpu_model": _cpu_model(), + "cpu_count_logical": os.cpu_count(), + "total_memory_bytes": _total_memory_bytes(), + } + + +def build_info() -> dict[str, Any]: + return { + "rustc": _run_cmd(["rustc", "-Vv"]), + "cargo": _run_cmd(["cargo", "-VV"]) or _run_cmd(["cargo", "-V"]), + "cargo_release_profile": _cargo_release_profile(), + "rustflags": os.environ.get("RUSTFLAGS"), + "cargo_build_rustflags": os.environ.get("CARGO_BUILD_RUSTFLAGS"), + "maturin_flags": os.environ.get("MATURIN_EXTRA_ARGS"), + } + + +def package_versions(*names: str) -> dict[str, str | None]: + versions: dict[str, str | None] = {} + for name in names: + try: + versions[name] = importlib_metadata.version(name) + except importlib_metadata.PackageNotFoundError: + versions[name] = None + return versions + + +def file_info(path: str | Path) -> dict[str, Any]: + file_path = Path(path) + data = file_path.read_bytes() + return { + "path": str(file_path), + "size_bytes": file_path.stat().st_size, + "sha256": hashlib.sha256(data).hexdigest(), + } + + +def benchmark_metadata( + suite: str, + *, + fixtures: list[str | Path] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "suite": suite, + "runtime": runtime_info(), + "git": git_info(), + "build": build_info(), + "packages": package_versions("numpy", "ferro-ta"), + } + if fixtures: + metadata["fixtures"] = [file_info(path) for path in fixtures] + if extra: + metadata.update(extra) + return metadata diff --git a/vendor/ferro-ta-main/benchmarks/profile_runtime_hotspots.py b/vendor/ferro-ta-main/benchmarks/profile_runtime_hotspots.py new file mode 100644 index 0000000..bdd6fcf --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/profile_runtime_hotspots.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import argparse +import json +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import numpy as np + +import ferro_ta as ft +from ferro_ta.analysis.features import feature_matrix +from ferro_ta.analysis.options import iv_percentile, iv_rank, iv_zscore +from ferro_ta.data.batch import compute_many + +try: + from benchmarks.metadata import benchmark_metadata +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata + + +def _time_min(fn: Callable[[], object], rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) * 1000.0 + + +def _naive_correl(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(window - 1, len(x)): + x_window = x[end + 1 - window : end + 1] + y_window = y[end + 1 - window : end + 1] + mean_x = float(np.sum(x_window)) / window + mean_y = float(np.sum(y_window)) / window + cov = float(np.sum((x_window - mean_x) * (y_window - mean_y))) + std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2))) + std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2))) + denom = std_x * std_y + out[end] = cov / denom if denom != 0.0 else np.nan + return out + + +def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(window, len(x)): + start = end - window + rx = np.array( + [ + x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + ry = np.array( + [ + y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + mean_x = float(np.sum(rx)) / window + mean_y = float(np.sum(ry)) / window + cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / window + var_x = float(np.sum((rx - mean_x) ** 2)) / window + out[end] = cov / var_x if var_x != 0.0 else np.nan + return out + + +def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray: + out = np.full(len(series), np.nan, dtype=np.float64) + xs = np.arange(timeperiod, dtype=np.float64) + sum_x = float(np.sum(xs)) + sum_x2 = float(np.sum(xs * xs)) + for end in range(timeperiod - 1, len(series)): + window = series[end + 1 - timeperiod : end + 1] + sum_y = float(np.sum(window)) + sum_xy = float(np.sum(xs * window)) + denom = timeperiod * sum_x2 - sum_x * sum_x + slope = (timeperiod * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0 + intercept = (sum_y - slope * sum_x) / timeperiod + out[end] = intercept + slope * x_value + return out + + +def _old_iv_rank(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + lower = float(np.nanmin(win)) + upper = float(np.nanmax(win)) + out[idx] = 0.0 if upper == lower else (iv[idx] - lower) / (upper - lower) + return out + + +def _old_iv_percentile(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + out[idx] = float(np.sum(win <= iv[idx])) / window + return out + + +def _old_iv_zscore(iv: np.ndarray, window: int) -> np.ndarray: + out = np.full(len(iv), np.nan, dtype=np.float64) + for idx in range(window - 1, len(iv)): + win = iv[idx - window + 1 : idx + 1] + mean = float(np.nanmean(win)) + std = float(np.nanstd(win, ddof=0)) + out[idx] = np.nan if std == 0.0 else (iv[idx] - mean) / std + return out + + +def build_hotspot_report( + *, + price_bars: int = 20_000, + iv_bars: int = 50_000, + window: int = 252, +) -> dict[str, Any]: + rng = np.random.default_rng(2026) + close = 100 + np.cumsum(rng.normal(0, 1, price_bars)).astype(np.float64) + high = close + rng.uniform(0.1, 2.0, price_bars) + low = close - rng.uniform(0.1, 2.0, price_bars) + iv = rng.uniform(10.0, 40.0, iv_bars).astype(np.float64) + ohlcv = { + "close": close, + "high": high, + "low": low, + "volume": np.full(price_bars, 1000.0), + } + + rows = [ + ( + "rust_kernel", + "CORREL", + lambda: ft.CORREL(high, low, timeperiod=30), + lambda: _naive_correl(high, low, 30), + ), + ( + "rust_kernel", + "BETA", + lambda: ft.BETA(high, low, timeperiod=5), + lambda: _naive_beta(high, low, 5), + ), + ( + "rust_kernel", + "LINEARREG", + lambda: ft.LINEARREG(close, timeperiod=14), + lambda: _naive_linearreg(close, 14, 13.0), + ), + ( + "rust_kernel", + "TSF", + lambda: ft.TSF(close, timeperiod=14), + lambda: _naive_linearreg(close, 14, 14.0), + ), + ( + "python_analysis", + "iv_rank", + lambda: iv_rank(iv, window), + lambda: _old_iv_rank(iv, window), + ), + ( + "python_analysis", + "iv_percentile", + lambda: iv_percentile(iv, window), + lambda: _old_iv_percentile(iv, window), + ), + ( + "python_analysis", + "iv_zscore", + lambda: iv_zscore(iv, window), + lambda: _old_iv_zscore(iv, window), + ), + ( + "ffi_grouping", + "compute_many_close", + lambda: compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, + ), + lambda: ( + ft.SMA(close, timeperiod=10), + ft.EMA(close, timeperiod=12), + ft.RSI(close, timeperiod=14), + ), + ), + ( + "ffi_grouping", + "feature_matrix", + lambda: feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ], + ), + lambda: { + "SMA": ft.SMA(close, timeperiod=10), + "ATR": ft.ATR(high, low, close, timeperiod=14), + "ADX": ft.ADX(high, low, close, timeperiod=14), + }, + ), + ] + + results: list[dict[str, Any]] = [] + for category, name, fast_fn, reference_fn in rows: + fast_ms = _time_min(fast_fn) + reference_ms = _time_min(reference_fn, rounds=1) + results.append( + { + "category": category, + "name": name, + "fast_ms": round(fast_ms, 4), + "reference_ms": round(reference_ms, 4), + "speedup_vs_reference": round(reference_ms / fast_ms, 4), + } + ) + + results.sort(key=lambda row: row["fast_ms"], reverse=True) + total_fast_ms = sum(float(row["fast_ms"]) for row in results) or 1.0 + for row in results: + row["share_of_suite_pct"] = round( + float(row["fast_ms"]) / total_fast_ms * 100.0, 2 + ) + + return { + "metadata": benchmark_metadata( + "runtime_hotspots", + extra={ + "dataset": { + "price_bars": price_bars, + "iv_bars": iv_bars, + "window": window, + } + }, + ), + "results": results, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Profile ferro-ta runtime hotspots.") + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument("--json", dest="json_path") + args = parser.parse_args() + + payload = build_hotspot_report( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ) + + print( + f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}" + ) + print("-" * 70) + for row in payload["results"]: + print( + f"{row['category']:<16} {row['name']:<18} {row['fast_ms']:10.2f} " + f"{row['reference_ms']:10.2f} {row['speedup_vs_reference']:10.2f}x" + ) + + if args.json_path: + path = Path(args.json_path) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nWrote JSON results to {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/results.json b/vendor/ferro-ta-main/benchmarks/results.json new file mode 100644 index 0000000..834428c --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/results.json @@ -0,0 +1,16097 @@ +{ + "machine_info": { + "node": "mac", + "processor": "arm", + "machine": "arm64", + "python_compiler": "Clang 20.1.4 ", + "python_implementation": "CPython", + "python_implementation_version": "3.13.5", + "python_version": "3.13.5", + "python_build": [ + "main", + "Jul 11 2025 22:26:07" + ], + "release": "25.3.0", + "system": "Darwin", + "cpu": { + "python_version": "3.13.5.final.0 (64 bit)", + "cpuinfo_version": [ + 9, + 0, + 0 + ], + "cpuinfo_version_string": "9.0.0", + "arch": "ARM_8", + "bits": 64, + "count": 14, + "arch_string_raw": "arm64", + "brand_raw": "Apple M3 Max" + } + }, + "commit_info": { + "id": "d40e68b5913e74fc5cd5d89106d7649a318ae98c", + "time": "2026-03-23T22:20:33+05:30", + "author_time": "2026-03-23T22:20:33+05:30", + "dirty": false, + "project": "ferro-ta", + "branch": "main" + }, + "benchmarks": [ + { + "group": null, + "name": "test_speed[Overlap/SMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/ferro_ta]", + "params": { + "indicator": "SMA", + "library": "ferro_ta" + }, + "param": "Overlap/SMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002488583995727822, + "max": 0.0002727418002905324, + "mean": 0.00025733753995154984, + "stddev": 7.2994548482285454e-06, + "rounds": 20, + "median": 0.00025416259959456513, + "iqr": 7.108400313882222e-06, + "q1": 0.0002522458002204075, + "q3": 0.00025935420053428975, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.0002488583995727822, + "hd15iqr": 0.0002705499995499849, + "ops": 3885.946839269058, + "total": 0.005146750799030997, + "data": [ + 0.00027131679962622, + 0.0002705499995499849, + 0.0002727418002905324, + 0.00025694179930724204, + 0.0002594418008811772, + 0.0002518918001442216, + 0.0002521834001527168, + 0.0002668333996552974, + 0.00025926660018740224, + 0.0002529334000428207, + 0.00025400839949725197, + 0.00025274999934481456, + 0.00025166660052491354, + 0.00025154999893857167, + 0.0002523082002880983, + 0.00025287500029662623, + 0.00025529999984428284, + 0.0002488583995727822, + 0.00025431679969187824, + 0.00025901660119416193 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/talib]", + "params": { + "indicator": "SMA", + "library": "talib" + }, + "param": "Overlap/SMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00031141660001594574, + "max": 0.0003478250000625849, + "mean": 0.0003291053999419091, + "stddev": 1.2718672114499328e-05, + "rounds": 20, + "median": 0.00032801660054246893, + "iqr": 2.5487598759355067e-05, + "q1": 0.0003152041012072004, + "q3": 0.0003406916999665555, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.00031141660001594574, + "hd15iqr": 0.0003478250000625849, + "ops": 3038.540237190005, + "total": 0.0065821079988381825, + "data": [ + 0.00033810000022640454, + 0.0003363665993674658, + 0.00034470819955458867, + 0.0003472165990388021, + 0.0003432833997067064, + 0.00034092499990947547, + 0.00033324999967589977, + 0.0003404584000236355, + 0.00032829160045366734, + 0.0003478250000625849, + 0.0003208916008588858, + 0.00031372499943245203, + 0.00032774160063127057, + 0.0003273249996709637, + 0.000313841798924841, + 0.00031444160122191535, + 0.00031141660001594574, + 0.00031323339935624973, + 0.00031596660119248554, + 0.0003230999995139427 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/pandas_ta]", + "params": { + "indicator": "SMA", + "library": "pandas_ta" + }, + "param": "Overlap/SMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003763165994314477, + "max": 0.00046844179887557403, + "mean": 0.0004044970797258429, + "stddev": 2.0394951574226647e-05, + "rounds": 20, + "median": 0.0004011416000139434, + "iqr": 2.1820899564772855e-05, + "q1": 0.00039067080069798976, + "q3": 0.0004124917002627626, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0003763165994314477, + "hd15iqr": 0.00046844179887557403, + "ops": 2472.205734285579, + "total": 0.00808994159451686, + "data": [ + 0.00039130820077843965, + 0.00041310840024380016, + 0.00042389159934828056, + 0.00040489999955752867, + 0.00039299159980146217, + 0.00039243339997483415, + 0.00038816679880255834, + 0.00038508339930558576, + 0.00038789159880252554, + 0.00040650000009918587, + 0.00039003340061753987, + 0.0004033831995911896, + 0.0004118750002817251, + 0.00040874159894883634, + 0.00039810839953133834, + 0.00043040839955210686, + 0.00046844179887557403, + 0.0003763165994314477, + 0.00039890000043669717, + 0.00041745820053620266 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/ta]", + "params": { + "indicator": "SMA", + "library": "ta" + }, + "param": "Overlap/SMA/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007623667988809757, + "max": 0.0008528915990609675, + "mean": 0.0007977570797083899, + "stddev": 2.307634920379365e-05, + "rounds": 20, + "median": 0.000794916698941961, + "iqr": 2.8224900597706415e-05, + "q1": 0.0007794958000886254, + "q3": 0.0008077207006863318, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0007623667988809757, + "hd15iqr": 0.0008528915990609675, + "ops": 1253.51441614976, + "total": 0.015955141594167797, + "data": [ + 0.0008070082010817714, + 0.0008126583998091519, + 0.00080654159974074, + 0.0008014082006411627, + 0.0008084332002908923, + 0.0008286249998491257, + 0.0008382833999348805, + 0.0007623667988809757, + 0.0008528915990609675, + 0.0007958167989272624, + 0.0007977834000485017, + 0.000779474999580998, + 0.0007795166005962528, + 0.0007935667992569507, + 0.0007940165989566595, + 0.0007855581992771476, + 0.0007918083996628411, + 0.0007750499993562698, + 0.0007703750001383014, + 0.0007739583990769461 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/tulipy]", + "params": { + "indicator": "SMA", + "library": "tulipy" + }, + "param": "Overlap/SMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003195166005752981, + "max": 0.0004660999999032356, + "mean": 0.0003386604299157625, + "stddev": 3.601720613971838e-05, + "rounds": 20, + "median": 0.00032367079984396696, + "iqr": 1.2750101450365048e-05, + "q1": 0.00032198749904637224, + "q3": 0.0003347376004967373, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0003195166005752981, + "hd15iqr": 0.0004089334004675038, + "ops": 2952.81028329391, + "total": 0.00677320859831525, + "data": [ + 0.00032366660016123203, + 0.0003232250004657544, + 0.0003231084003346041, + 0.00032139179966179655, + 0.00032056659983936697, + 0.00032161659910343585, + 0.00033194999996339903, + 0.0003355918001034297, + 0.0003204084001481533, + 0.0003230500005884096, + 0.0003195166005752981, + 0.000327741599176079, + 0.0003329999992274679, + 0.0004089334004675038, + 0.0004660999999032356, + 0.0003223583989893086, + 0.0003236749995267019, + 0.0003414416001760401, + 0.0003338834008900449, + 0.0003519833990139887 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/SMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/SMA/finta]", + "params": { + "indicator": "SMA", + "library": "finta" + }, + "param": "Overlap/SMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008491332002449781, + "max": 0.0009218667997629382, + "mean": 0.0008720020701730391, + "stddev": 2.31616081672388e-05, + "rounds": 20, + "median": 0.0008626875001937151, + "iqr": 2.4441698769805953e-05, + "q1": 0.0008554708008887246, + "q3": 0.0008799124996585305, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0008491332002449781, + "hd15iqr": 0.0009189249991322868, + "ops": 1146.7862682958553, + "total": 0.017440041403460782, + "data": [ + 0.0009063582008820958, + 0.0008491332002449781, + 0.0008630334006738849, + 0.0009026832005474717, + 0.0009218667997629382, + 0.0008623415997135453, + 0.0008782666001934559, + 0.0008573999992222525, + 0.0009189249991322868, + 0.0008552166007575579, + 0.0008557250010198913, + 0.0008519000009982846, + 0.0008589666002080775, + 0.0008722915998077951, + 0.0008503000004566275, + 0.0008500500000081957, + 0.0008566667995182798, + 0.0008699500001966953, + 0.0008774084009928629, + 0.0008815583991236053 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/ferro_ta]", + "params": { + "indicator": "EMA", + "library": "ferro_ta" + }, + "param": "Overlap/EMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003657499997643754, + "max": 0.0004116167998290621, + "mean": 0.00037906500001554376, + "stddev": 1.3700847541789766e-05, + "rounds": 20, + "median": 0.000372070799494395, + "iqr": 2.1737499628215996e-05, + "q1": 0.0003675416999612935, + "q3": 0.0003892791995895095, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0003657499997643754, + "hd15iqr": 0.0004116167998290621, + "ops": 2638.069987888606, + "total": 0.0075813000003108755, + "data": [ + 0.000399183199624531, + 0.00039443319983547556, + 0.0004116167998290621, + 0.000388308399124071, + 0.0003848834006930701, + 0.00036864180001430216, + 0.0003684083989355713, + 0.0003692749989568256, + 0.00036692499998025596, + 0.00036872500058962034, + 0.0003662415998405777, + 0.0003677417989820242, + 0.0003657499997643754, + 0.0003673416009405628, + 0.0003662084011011757, + 0.00039025000005494804, + 0.0003824082014034502, + 0.0003882250006427057, + 0.00039186659996630623, + 0.0003748666000319645 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/talib]", + "params": { + "indicator": "EMA", + "library": "talib" + }, + "param": "Overlap/EMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035595840017776936, + "max": 0.0003876499991747551, + "mean": 0.0003612466701451922, + "stddev": 7.761028101936687e-06, + "rounds": 20, + "median": 0.0003584249003324658, + "iqr": 2.9958995583001307e-06, + "q1": 0.00035732080068555665, + "q3": 0.0003603167002438568, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.00035595840017776936, + "hd15iqr": 0.00036749999999301507, + "ops": 2768.1916060238846, + "total": 0.007224933402903843, + "data": [ + 0.0003876499991747551, + 0.00036100840079598127, + 0.0003589334010030143, + 0.00035962499969173224, + 0.0003583333993447013, + 0.00035844160011038183, + 0.00035595840017776936, + 0.00035769160022027793, + 0.0003577581999707036, + 0.000356774999818299, + 0.00036749999999301507, + 0.000376158399740234, + 0.00035930000012740493, + 0.0003584082005545497, + 0.00035834160080412404, + 0.0003588749998016283, + 0.00036355839984025806, + 0.0003569500011508353, + 0.0003567834006389603, + 0.0003568833999452181 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/pandas_ta]", + "params": { + "indicator": "EMA", + "library": "pandas_ta" + }, + "param": "Overlap/EMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00042723320075310767, + "max": 0.0004673832008847967, + "mean": 0.0004417950101196766, + "stddev": 1.147674609880261e-05, + "rounds": 20, + "median": 0.0004438334006408695, + "iqr": 1.8295799964107584e-05, + "q1": 0.0004306291993998457, + "q3": 0.00044892499936395326, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00042723320075310767, + "hd15iqr": 0.0004673832008847967, + "ops": 2263.493197284218, + "total": 0.008835900202393531, + "data": [ + 0.00044064999965485183, + 0.00044757499999832363, + 0.0004453750007087365, + 0.000446324999211356, + 0.0004673832008847967, + 0.0004438500007381663, + 0.00045429160090861844, + 0.00045542500010924413, + 0.00045598340075230225, + 0.0004502749987295829, + 0.00044486679980764164, + 0.00044381680054357276, + 0.0004318666004110128, + 0.0004305999987991527, + 0.0004301167995436117, + 0.0004274999999324791, + 0.00043065840000053866, + 0.00042827500001294536, + 0.00043383340089349077, + 0.00042723320075310767 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/ta]", + "params": { + "indicator": "EMA", + "library": "ta" + }, + "param": "Overlap/EMA/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006342834007227793, + "max": 0.0006823666000855156, + "mean": 0.0006480024899792624, + "stddev": 1.2900239512939045e-05, + "rounds": 20, + "median": 0.0006415750009182374, + "iqr": 1.6129199502756797e-05, + "q1": 0.0006394750002073124, + "q3": 0.0006556041997100692, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0006342834007227793, + "hd15iqr": 0.0006823666000855156, + "ops": 1543.2039466885415, + "total": 0.01296004979958525, + "data": [ + 0.0006416500007617287, + 0.0006435666000470519, + 0.0006562333990586921, + 0.000640741600363981, + 0.0006401917999028228, + 0.0006404165993444622, + 0.0006399416000931524, + 0.0006374415999744088, + 0.0006528999991132877, + 0.0006549750003614462, + 0.0006415000010747462, + 0.0006367249996401369, + 0.0006390084003214724, + 0.0006372666000970639, + 0.0006342834007227793, + 0.0006477331990026869, + 0.0006598249994567596, + 0.0006619334002607502, + 0.0006823666000855156, + 0.0006713499999023043 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/tulipy]", + "params": { + "indicator": "EMA", + "library": "tulipy" + }, + "param": "Overlap/EMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003557334013748914, + "max": 0.0003751833995920606, + "mean": 0.00036206417986250016, + "stddev": 6.561072585034807e-06, + "rounds": 20, + "median": 0.000359241699334234, + "iqr": 1.2783400597982076e-05, + "q1": 0.00035694159960257825, + "q3": 0.00036972500020056033, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0003557334013748914, + "hd15iqr": 0.0003751833995920606, + "ops": 2761.941267925942, + "total": 0.007241283597250003, + "data": [ + 0.00037119180051377044, + 0.0003711165991262533, + 0.0003710083998157643, + 0.00036844160058535635, + 0.00036245819937903435, + 0.0003577417999622412, + 0.0003585415994166397, + 0.00035703319881577047, + 0.0003560333992936648, + 0.00035657500120578334, + 0.000356850000389386, + 0.0003560750003089197, + 0.00036051659990334886, + 0.000357933399209287, + 0.0003557334013748914, + 0.0003604249999625608, + 0.0003712249992531724, + 0.0003751833995920606, + 0.00035994179925182836, + 0.0003572583998902701 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/EMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/EMA/finta]", + "params": { + "indicator": "EMA", + "library": "finta" + }, + "param": "Overlap/EMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000689658199553378, + "max": 0.001083741600450594, + "mean": 0.0007726833099150099, + "stddev": 8.93923449144952e-05, + "rounds": 20, + "median": 0.0007445041999744717, + "iqr": 7.084590033628051e-05, + "q1": 0.0007233415999507997, + "q3": 0.0007941875002870802, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.000689658199553378, + "hd15iqr": 0.001083741600450594, + "ops": 1294.1912775493927, + "total": 0.015453666198300197, + "data": [ + 0.0008400999999139458, + 0.0008639000006951392, + 0.001083741600450594, + 0.0007684500000323169, + 0.0007024331993306987, + 0.0008199250005418435, + 0.0007044165991828777, + 0.0007341999997152015, + 0.000689658199553378, + 0.0007108415986294859, + 0.0007475250007701106, + 0.0007581334008136764, + 0.0007568500004708767, + 0.0008623831992736087, + 0.0007489583993447013, + 0.0007414833991788328, + 0.0007325418002437801, + 0.0007195915997726843, + 0.0007270916001289151, + 0.0007414416002575308 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/ferro_ta]", + "params": { + "indicator": "WMA", + "library": "ferro_ta" + }, + "param": "Overlap/WMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00025695820077089595, + "max": 0.00047544999979436396, + "mean": 0.0002729729200655129, + "stddev": 4.796588843959603e-05, + "rounds": 20, + "median": 0.000260033300583018, + "iqr": 8.429199078818816e-06, + "q1": 0.00025843750045169146, + "q3": 0.0002668666995305103, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.00025695820077089595, + "hd15iqr": 0.00047544999979436396, + "ops": 3663.367046665296, + "total": 0.005459458401310258, + "data": [ + 0.0002590750009403564, + 0.00026510000025155024, + 0.0002613415999803692, + 0.0002672917995369062, + 0.00026758340100059287, + 0.00027533319953363387, + 0.00025720840058056637, + 0.0002597915998194367, + 0.0002577999999630265, + 0.0002602750013465993, + 0.000257466800394468, + 0.00025939999904949217, + 0.00027409159956732766, + 0.000262941799883265, + 0.00047544999979436396, + 0.0002664415995241143, + 0.00025712500064400956, + 0.0002595833997474983, + 0.00025919999898178504, + 0.00025695820077089595 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/talib]", + "params": { + "indicator": "WMA", + "library": "talib" + }, + "param": "Overlap/WMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003557415999239311, + "max": 0.0003807000000961125, + "mean": 0.0003645608299848391, + "stddev": 7.5480632496527174e-06, + "rounds": 20, + "median": 0.000363025000115158, + "iqr": 1.1937499220948655e-05, + "q1": 0.0003578250005375594, + "q3": 0.00036976249975850806, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0003557415999239311, + "hd15iqr": 0.0003807000000961125, + "ops": 2743.026451968487, + "total": 0.0072912165996967815, + "data": [ + 0.00035924160038121046, + 0.0003632584004662931, + 0.00037172500015003607, + 0.00037037500005681067, + 0.0003557415999239311, + 0.00035777500015683473, + 0.000356774999818299, + 0.00035627500037662687, + 0.00036194999993313106, + 0.0003594334004446864, + 0.00035579160030465575, + 0.00036279159976402295, + 0.00035787500091828407, + 0.0003676584005006589, + 0.00036914999946020545, + 0.0003710249991854653, + 0.0003664999996544793, + 0.00036825839924858883, + 0.0003807000000961125, + 0.0003789165988564491 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/pandas_ta]", + "params": { + "indicator": "WMA", + "library": "pandas_ta" + }, + "param": "Overlap/WMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00042777499911608177, + "max": 0.0004674666008213535, + "mean": 0.0004393791499751387, + "stddev": 1.2557183278979184e-05, + "rounds": 20, + "median": 0.0004339832994446624, + "iqr": 1.8975099374074467e-05, + "q1": 0.0004301124004996382, + "q3": 0.00044908749987371266, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.00042777499911608177, + "hd15iqr": 0.0004674666008213535, + "ops": 2275.9386740508344, + "total": 0.008787582999502774, + "data": [ + 0.0004674666008213535, + 0.0004597833991283551, + 0.0004461000004084781, + 0.00045649160019820554, + 0.00042963320011040195, + 0.0004351750001660548, + 0.0004320250009186566, + 0.0004351749987108633, + 0.0004314584002713673, + 0.00042879999964497986, + 0.0004294418002245948, + 0.0004320416002883576, + 0.00042777499911608177, + 0.0004369250003946945, + 0.0004520749993389472, + 0.0004350999995949678, + 0.0004305916008888744, + 0.000432866599294357, + 0.00042929159972118216, + 0.00045936660026200114 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/tulipy]", + "params": { + "indicator": "WMA", + "library": "tulipy" + }, + "param": "Overlap/WMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033674160076770934, + "max": 0.0004601918000844307, + "mean": 0.00038051916992117187, + "stddev": 2.984033943215161e-05, + "rounds": 20, + "median": 0.0003779165999731049, + "iqr": 4.1299900476588e-05, + "q1": 0.00035841249919030813, + "q3": 0.00039971239966689613, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00033674160076770934, + "hd15iqr": 0.0004601918000844307, + "ops": 2627.9884932135205, + "total": 0.007610383398423437, + "data": [ + 0.00040290839970111845, + 0.0003571415989426896, + 0.0003442750006797723, + 0.00033674160076770934, + 0.0003419249987928197, + 0.00035652500082505867, + 0.0003596833994379267, + 0.0003919999988283962, + 0.0004601918000844307, + 0.0004175584006588906, + 0.0004001331995823421, + 0.00039929159975145013, + 0.00041231679933844133, + 0.0003711915996973403, + 0.00036294180026743563, + 0.0003753500001039356, + 0.0003856417999486439, + 0.00038048319984227417, + 0.0003711916011525318, + 0.0003828916000202298 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/WMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/WMA/finta]", + "params": { + "indicator": "WMA", + "library": "finta" + }, + "param": "Overlap/WMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.10562689179932931, + "max": 0.11277605819923338, + "mean": 0.11009632960987802, + "stddev": 0.0018945288742794161, + "rounds": 20, + "median": 0.11020489169968642, + "iqr": 0.002985625099245229, + "q1": 0.10849363750021439, + "q3": 0.11147926259945962, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.10562689179932931, + "hd15iqr": 0.11277605819923338, + "ops": 9.082954931771663, + "total": 2.20192659219756, + "data": [ + 0.10780974180088379, + 0.10791333340021084, + 0.10814943340083119, + 0.10914368320081849, + 0.10562689179932931, + 0.10826710840046871, + 0.11011473320104415, + 0.1115168584001367, + 0.11237966659973608, + 0.11277038339903811, + 0.11102183339971816, + 0.11152200839860597, + 0.1111434584003291, + 0.10872016659996006, + 0.11015570839954307, + 0.11016197499993723, + 0.1102478083994356, + 0.11144166679878253, + 0.11277605819923338, + 0.11104407499951777 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/ferro_ta]", + "params": { + "indicator": "DEMA", + "library": "ferro_ta" + }, + "param": "Overlap/DEMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004083334002643824, + "max": 0.0005255915995803662, + "mean": 0.000448509600100806, + "stddev": 3.4895833864869e-05, + "rounds": 20, + "median": 0.00043836669938173144, + "iqr": 3.9987399941310276e-05, + "q1": 0.00042388340007164514, + "q3": 0.0004638708000129554, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004083334002643824, + "hd15iqr": 0.0005255915995803662, + "ops": 2229.6066790437535, + "total": 0.00897019200201612, + "data": [ + 0.00048548340128036217, + 0.00040860000008251517, + 0.0004083334002643824, + 0.0004162999990512617, + 0.0004525249998550862, + 0.0005125250012497417, + 0.0004272665988537483, + 0.0004340750005212612, + 0.00042745000100694595, + 0.0005255915995803662, + 0.0004382083992823027, + 0.00044912500015925616, + 0.0004578000007313676, + 0.0005075250010122545, + 0.00044449179986258967, + 0.0004385249994811602, + 0.0004699415992945433, + 0.00041865840030368416, + 0.00042629180097719654, + 0.00042147499916609375 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/talib]", + "params": { + "indicator": "DEMA", + "library": "talib" + }, + "param": "Overlap/DEMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005306416001985781, + "max": 0.0006560168010764755, + "mean": 0.0005942396000318694, + "stddev": 4.0130681060291094e-05, + "rounds": 20, + "median": 0.000600125000346452, + "iqr": 6.35748998320196e-05, + "q1": 0.0005610000996966846, + "q3": 0.0006245749995287042, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0005306416001985781, + "hd15iqr": 0.0006560168010764755, + "ops": 1682.8228881857913, + "total": 0.01188479200063739, + "data": [ + 0.000627358398924116, + 0.0005872334004379809, + 0.0005994999999529682, + 0.0006217916001332924, + 0.0006346416004817002, + 0.0006188081999425777, + 0.0006460834003519267, + 0.0006082250009058043, + 0.0006007500007399358, + 0.0005985415991744958, + 0.0005899499999941326, + 0.0006056333993910811, + 0.0005445168004371226, + 0.0005338833987480029, + 0.0005330665997462347, + 0.0006362250001984649, + 0.0005344418008462526, + 0.0005306416001985781, + 0.0005774833989562467, + 0.0006560168010764755 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/pandas_ta]", + "params": { + "indicator": "DEMA", + "library": "pandas_ta" + }, + "param": "Overlap/DEMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006160666001960635, + "max": 0.0007623582001542673, + "mean": 0.0006757008001295617, + "stddev": 3.274947737574488e-05, + "rounds": 20, + "median": 0.0006722000005538575, + "iqr": 2.2699999681208196e-05, + "q1": 0.0006601708002563101, + "q3": 0.0006828707999375183, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0006532084007631056, + "hd15iqr": 0.0007371165993390605, + "ops": 1479.944969442475, + "total": 0.013514016002591233, + "data": [ + 0.0006534833999467082, + 0.0006699666002532468, + 0.0006774499997845851, + 0.0006842999995569698, + 0.000701191600819584, + 0.0007623582001542673, + 0.0006532084007631056, + 0.0006689581990940496, + 0.0006814416003180668, + 0.0006744334008544683, + 0.0006692499999189749, + 0.000660125000285916, + 0.0006779082003049552, + 0.0006974999996600673, + 0.0006242582006962038, + 0.0006160666001960635, + 0.000660216600226704, + 0.0007371165993390605, + 0.0006761918004485779, + 0.000668591599969659 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/tulipy]", + "params": { + "indicator": "DEMA", + "library": "tulipy" + }, + "param": "Overlap/DEMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00030955840047681705, + "max": 0.0003939749993151054, + "mean": 0.0003568879098020261, + "stddev": 1.8662767517296157e-05, + "rounds": 20, + "median": 0.00035432499935268426, + "iqr": 2.3145900195231694e-05, + "q1": 0.00034634580006240865, + "q3": 0.00036949170025764034, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.000342024999554269, + "hd15iqr": 0.0003939749993151054, + "ops": 2802.00021501071, + "total": 0.0071377581960405225, + "data": [ + 0.0003731834003701806, + 0.00035428339906502516, + 0.0003788249989156611, + 0.00037883320037508384, + 0.00035030839935643596, + 0.00035329999955138194, + 0.00036377499927766623, + 0.00030955840047681705, + 0.0003939749993151054, + 0.0003828749991953373, + 0.00034643340040929615, + 0.00035436659964034335, + 0.00035481660015648233, + 0.00034290820040041583, + 0.0003462581997155212, + 0.000342024999554269, + 0.0003658000001451001, + 0.00034458340087439865, + 0.00034667500003706666, + 0.0003549749992089346 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/DEMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/DEMA/finta]", + "params": { + "indicator": "DEMA", + "library": "finta" + }, + "param": "Overlap/DEMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001858624999294989, + "max": 0.0020374832005472855, + "mean": 0.0019142966698564123, + "stddev": 5.050497054266134e-05, + "rounds": 20, + "median": 0.0018938041997898837, + "iqr": 6.678350109723397e-05, + "q1": 0.001877595799305709, + "q3": 0.001944379300402943, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.001858624999294989, + "hd15iqr": 0.0020374832005472855, + "ops": 522.3850700607487, + "total": 0.03828593339712825, + "data": [ + 0.0018836749994079582, + 0.0018985167989740148, + 0.001858624999294989, + 0.0018733165998128243, + 0.0018890916006057523, + 0.0019450668012723326, + 0.0019436917995335535, + 0.00196179159975145, + 0.001881874998798594, + 0.001994991599349305, + 0.0019980249999207444, + 0.0018661834008526057, + 0.0018668584001716227, + 0.00190394160017604, + 0.001924824999878183, + 0.0020374832005472855, + 0.0019114417998935096, + 0.0018717749990173616, + 0.0018875581998145207, + 0.0018872000000556 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/ferro_ta]", + "params": { + "indicator": "TEMA", + "library": "ferro_ta" + }, + "param": "Overlap/TEMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004330915995524265, + "max": 0.0004908165996312164, + "mean": 0.00045130038975912614, + "stddev": 1.581528155914103e-05, + "rounds": 20, + "median": 0.00044890839999425227, + "iqr": 2.0104100258322433e-05, + "q1": 0.0004386249995150138, + "q3": 0.00045872909977333625, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004330915995524265, + "hd15iqr": 0.0004908165996312164, + "ops": 2215.8190480042194, + "total": 0.009026007795182523, + "data": [ + 0.0004908165996312164, + 0.00047767499927431345, + 0.0004514082000241615, + 0.00045110840001143514, + 0.00047804999921936543, + 0.0004338250000728294, + 0.00043775820086011663, + 0.00045989999925950543, + 0.00044329160009510816, + 0.0004521249997196719, + 0.00045839999947929757, + 0.00045905820006737487, + 0.0004436834002262913, + 0.00044442499929573385, + 0.0004522666000411846, + 0.0004393165989313275, + 0.0004379334000987001, + 0.0004351665993453935, + 0.00044670839997706935, + 0.0004330915995524265 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/talib]", + "params": { + "indicator": "TEMA", + "library": "talib" + }, + "param": "Overlap/TEMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007298084005014971, + "max": 0.0008072834010818042, + "mean": 0.0007689820702944417, + "stddev": 1.7571385956548363e-05, + "rounds": 20, + "median": 0.0007671542007301468, + "iqr": 1.4983299479354281e-05, + "q1": 0.0007594375005282927, + "q3": 0.000774420800007647, + "iqr_outliers": 3, + "stddev_outliers": 6, + "outliers": "6;3", + "ld15iqr": 0.0007507581991376356, + "hd15iqr": 0.0007973916013725102, + "ops": 1300.420437133342, + "total": 0.015379641405888832, + "data": [ + 0.0007675084008951672, + 0.0007949582010041923, + 0.0007298084005014971, + 0.0007507581991376356, + 0.0007578834003652446, + 0.00075875820039073, + 0.0007509332004701719, + 0.0007839084006263875, + 0.0007723081987933255, + 0.0007728500000666827, + 0.0007697499997448177, + 0.0007631168002262712, + 0.0007668000005651265, + 0.0007601168006658554, + 0.0007661249997909181, + 0.0007759915999486112, + 0.0008072834010818042, + 0.0007973916013725102, + 0.000772183200751897, + 0.0007612083994899876 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/pandas_ta]", + "params": { + "indicator": "TEMA", + "library": "pandas_ta" + }, + "param": "Overlap/TEMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008261583992862142, + "max": 0.0008528584003215656, + "mean": 0.0008346420999441761, + "stddev": 7.677758042930123e-06, + "rounds": 20, + "median": 0.0008312792000651825, + "iqr": 8.141699800035007e-06, + "q1": 0.0008299833003547974, + "q3": 0.0008381250001548324, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0008261583992862142, + "hd15iqr": 0.0008528584003215656, + "ops": 1198.1183312786204, + "total": 0.016692841998883524, + "data": [ + 0.0008404084001085721, + 0.0008477249997667968, + 0.0008299166001961567, + 0.000831291799840983, + 0.0008313831989653408, + 0.0008302083995658904, + 0.000831266600289382, + 0.0008303499998874031, + 0.0008528584003215656, + 0.0008459249991574324, + 0.000835041599930264, + 0.0008323667992954142, + 0.0008268999998108483, + 0.0008298499989905395, + 0.0008300500005134382, + 0.0008358416002010926, + 0.0008459584001684562, + 0.0008308084012242034, + 0.0008261583992862142, + 0.0008285334013635292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/tulipy]", + "params": { + "indicator": "TEMA", + "library": "tulipy" + }, + "param": "Overlap/TEMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033141659951070325, + "max": 0.0003504667998640798, + "mean": 0.00033672791010758374, + "stddev": 5.043303324796754e-06, + "rounds": 20, + "median": 0.00033457909958087837, + "iqr": 6.85420091031116e-06, + "q1": 0.00033303329983027654, + "q3": 0.0003398875007405877, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00033141659951070325, + "hd15iqr": 0.0003504667998640798, + "ops": 2969.75679764859, + "total": 0.006734558202151675, + "data": [ + 0.000334024999756366, + 0.00033510820067021995, + 0.00033141659951070325, + 0.000333933399815578, + 0.00033990000083576887, + 0.00034619999933056534, + 0.0003408000004128553, + 0.00033389999880455433, + 0.0003504667998640798, + 0.00034159180067945273, + 0.0003324834004160948, + 0.0003361666007549502, + 0.0003329749990371056, + 0.0003381666014320217, + 0.00033235820010304453, + 0.00033309160062344745, + 0.00033987500064540653, + 0.00033494160015834495, + 0.0003329418002977036, + 0.00033421659900341184 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TEMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TEMA/finta]", + "params": { + "indicator": "TEMA", + "library": "finta" + }, + "param": "Overlap/TEMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003263733199855778, + "max": 0.003538933400704991, + "mean": 0.0033302312298474136, + "stddev": 7.008348779448429e-05, + "rounds": 20, + "median": 0.003300212499743793, + "iqr": 0.00010047500036307637, + "q1": 0.0032764832998509515, + "q3": 0.003376958300214028, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.003263733199855778, + "hd15iqr": 0.003538933400704991, + "ops": 300.2794493779997, + "total": 0.06660462459694827, + "data": [ + 0.003385516599519178, + 0.003538933400704991, + 0.003415758399933111, + 0.003414074999454897, + 0.003317399999650661, + 0.003341191599611193, + 0.0032945416009170004, + 0.0032899832003749907, + 0.0032728666003094984, + 0.0032989415994961746, + 0.0032720000002882444, + 0.0033684000009088777, + 0.0032674833986675368, + 0.0032911165995756163, + 0.0032704333993024194, + 0.0033014833999914115, + 0.003280099999392405, + 0.0033872749991132878, + 0.003333391599880997, + 0.003263733199855778 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/ferro_ta]", + "params": { + "indicator": "T3", + "library": "ferro_ta" + }, + "param": "Overlap/T3/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004604000001563691, + "max": 0.00048169159999815746, + "mean": 0.0004666525000357069, + "stddev": 6.308517724311604e-06, + "rounds": 20, + "median": 0.0004655208002077415, + "iqr": 7.120799273252509e-06, + "q1": 0.0004616375001205597, + "q3": 0.0004687582993938122, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0004604000001563691, + "hd15iqr": 0.00048169159999815746, + "ops": 2142.922195688404, + "total": 0.009333050000714138, + "data": [ + 0.00048169159999815746, + 0.0004673750008805655, + 0.0004727917999844067, + 0.00047851679992163556, + 0.0004654584001400508, + 0.0004655832002754323, + 0.0004626000009011477, + 0.00046797499962849545, + 0.0004604000001563691, + 0.00046175000024959443, + 0.00046119160106172785, + 0.0004621168001904152, + 0.00046707500005140903, + 0.00047664999874541536, + 0.00046954159915912896, + 0.0004611915996065363, + 0.00046158339973771944, + 0.00046169160050339996, + 0.0004665165994083509, + 0.00046135000011418017 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/talib]", + "params": { + "indicator": "T3", + "library": "talib" + }, + "param": "Overlap/T3/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003904750003130175, + "max": 0.0004103583996766247, + "mean": 0.00039705916984530634, + "stddev": 5.785907270875115e-06, + "rounds": 20, + "median": 0.0003955374995712191, + "iqr": 5.92919896007509e-06, + "q1": 0.000392820800334448, + "q3": 0.0003987499992945231, + "iqr_outliers": 2, + "stddev_outliers": 6, + "outliers": "6;2", + "ld15iqr": 0.0003904750003130175, + "hd15iqr": 0.0004079999998793937, + "ops": 2518.516321861043, + "total": 0.007941183396906127, + "data": [ + 0.0003938084002584219, + 0.00039481679996242745, + 0.00039409160090144726, + 0.00039678339962847533, + 0.00040539159963373097, + 0.0004079999998793937, + 0.0003924499993445352, + 0.0004103583996766247, + 0.00039899999974295496, + 0.0003919668000889942, + 0.0003984999988460913, + 0.00039296659961109983, + 0.00039554999966640025, + 0.0003910499988705851, + 0.0003962415998103097, + 0.0003955249994760379, + 0.00039605819911230354, + 0.0003926750010577962, + 0.0003904750003130175, + 0.00040547500102547927 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/T3/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/T3/pandas_ta]", + "params": { + "indicator": "T3", + "library": "pandas_ta" + }, + "param": "Overlap/T3/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004631916002836078, + "max": 0.0004957249999279157, + "mean": 0.0004715829002088867, + "stddev": 7.986782228054758e-06, + "rounds": 20, + "median": 0.00046880410009180195, + "iqr": 7.112499588401988e-06, + "q1": 0.00046694580087205397, + "q3": 0.00047405830046045596, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004631916002836078, + "hd15iqr": 0.0004957249999279157, + "ops": 2120.5179398087844, + "total": 0.009431658004177734, + "data": [ + 0.0004957249999279157, + 0.00046735820069443434, + 0.0004682915998273529, + 0.0004681667996919714, + 0.00046653340104967355, + 0.00047220840060617774, + 0.00046932500117691234, + 0.0004751750006107613, + 0.0004676415992435068, + 0.00046435840049525724, + 0.00047542499960400163, + 0.00048456660006195306, + 0.00047294160031015054, + 0.00046741679980186743, + 0.00048243319906760007, + 0.0004711583998869173, + 0.0004652832009014674, + 0.000469316600356251, + 0.00046514160057995466, + 0.0004631916002836078 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/ferro_ta]", + "params": { + "indicator": "TRIMA", + "library": "ferro_ta" + }, + "param": "Overlap/TRIMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005690834004781209, + "max": 0.0005970833997707814, + "mean": 0.0005792178997944575, + "stddev": 7.400997792583969e-06, + "rounds": 20, + "median": 0.0005769540999608579, + "iqr": 1.025430028676064e-05, + "q1": 0.0005741415996453724, + "q3": 0.0005843958999321331, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005690834004781209, + "hd15iqr": 0.0005970833997707814, + "ops": 1726.4659817227027, + "total": 0.011584357995889149, + "data": [ + 0.0005916331996559165, + 0.0005785499990452081, + 0.0005717167994589544, + 0.0005742166002164594, + 0.0005773749988293275, + 0.0005724333997932263, + 0.0005740665990742854, + 0.0005737750005209818, + 0.0005750750002334826, + 0.0005759499996202067, + 0.0005765332010923885, + 0.0005890415995963849, + 0.0005818250006996095, + 0.0005970833997707814, + 0.0005774416000349447, + 0.0005869667991646565, + 0.0005873415997484699, + 0.00057785819954006, + 0.0005690834004781209, + 0.0005763915993156843 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/talib]", + "params": { + "indicator": "TRIMA", + "library": "talib" + }, + "param": "Overlap/TRIMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036660840123659, + "max": 0.0006999082004767842, + "mean": 0.00042776169000717344, + "stddev": 7.45471836633297e-05, + "rounds": 20, + "median": 0.00040273749982588924, + "iqr": 5.077079986222084e-05, + "q1": 0.00038874169986229394, + "q3": 0.0004395124997245148, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.00036660840123659, + "hd15iqr": 0.0006999082004767842, + "ops": 2337.7502552489686, + "total": 0.00855523380014347, + "data": [ + 0.00040434179973090065, + 0.0006999082004767842, + 0.0004100417994777672, + 0.00037741659907624124, + 0.00036660840123659, + 0.0004030249998322688, + 0.0004135499999392778, + 0.00038741680036764593, + 0.0003934668013243936, + 0.00040179999923566355, + 0.00040244999981950966, + 0.0004944582004100084, + 0.000453166599618271, + 0.0004686168002081104, + 0.00042585839983075855, + 0.0005048667997471056, + 0.00038970839959802107, + 0.0003781666004215367, + 0.00038777500012656676, + 0.00039259159966604785 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/pandas_ta]", + "params": { + "indicator": "TRIMA", + "library": "pandas_ta" + }, + "param": "Overlap/TRIMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004549165998469107, + "max": 0.00048071659984998404, + "mean": 0.00046667375005199574, + "stddev": 7.124904265168961e-06, + "rounds": 20, + "median": 0.0004655500000808388, + "iqr": 8.375100151170045e-06, + "q1": 0.0004632625001249835, + "q3": 0.00047163760027615356, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0004549165998469107, + "hd15iqr": 0.00048071659984998404, + "ops": 2142.8246176018733, + "total": 0.009333475001039915, + "data": [ + 0.00047868340043351055, + 0.00046737499942537395, + 0.00046685840061400086, + 0.00046342499990714714, + 0.0004732250003144145, + 0.0004730583998025395, + 0.00048071659984998404, + 0.00046830000064801425, + 0.0004583082001772709, + 0.00046310000034281983, + 0.00047021680074976757, + 0.0004656249991967343, + 0.0004644415996153839, + 0.0004549165998469107, + 0.0004654750009649433, + 0.0004553581995423883, + 0.0004586915994877927, + 0.00046445839980151503, + 0.0004652917996281758, + 0.0004759500006912276 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/tulipy]", + "params": { + "indicator": "TRIMA", + "library": "tulipy" + }, + "param": "Overlap/TRIMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003771749994484708, + "max": 0.00042373340111225846, + "mean": 0.00039389624987961724, + "stddev": 1.1549059302988846e-05, + "rounds": 20, + "median": 0.0003916209003364202, + "iqr": 1.656669919611884e-05, + "q1": 0.000384933299937984, + "q3": 0.00040149999913410286, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0003771749994484708, + "hd15iqr": 0.00042373340111225846, + "ops": 2538.7395800432737, + "total": 0.007877924997592345, + "data": [ + 0.00038641659921268, + 0.0003919668000889942, + 0.00038528320001205427, + 0.00039127500058384613, + 0.0003845250001177192, + 0.0003948333993321285, + 0.0003849665998131968, + 0.0004117583986953832, + 0.00039273339934879913, + 0.00042373340111225846, + 0.0003833081995253451, + 0.00040528340032324194, + 0.00040443320031045, + 0.0003771749994484708, + 0.00040279159875353796, + 0.0003827834007097408, + 0.0004002083995146677, + 0.00038490000006277116, + 0.00038943340041441843, + 0.00040011660021264104 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/TRIMA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/TRIMA/finta]", + "params": { + "indicator": "TRIMA", + "library": "finta" + }, + "param": "Overlap/TRIMA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0018327832003706135, + "max": 0.007367949999752455, + "mean": 0.0035420641499513293, + "stddev": 0.0011082252186320944, + "rounds": 20, + "median": 0.0033642248999967705, + "iqr": 0.0006917875005456155, + "q1": 0.0029140790997189466, + "q3": 0.003605866600264562, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.0028360915996017864, + "hd15iqr": 0.005188375001307577, + "ops": 282.32125609970694, + "total": 0.07084128299902659, + "data": [ + 0.007367949999752455, + 0.0028507000009994955, + 0.0032192668004427105, + 0.003614266599470284, + 0.003597466601058841, + 0.0032295333992806265, + 0.003498550000949763, + 0.004164749999472406, + 0.0033440166007494554, + 0.0029622916001244446, + 0.003783574998669792, + 0.002865866599313449, + 0.0028413833992090077, + 0.0033844331992440857, + 0.0028360915996017864, + 0.0034904999993159436, + 0.0035085499999695457, + 0.0018327832003706135, + 0.0032609333997243085, + 0.005188375001307577 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/ferro_ta]", + "params": { + "indicator": "KAMA", + "library": "ferro_ta" + }, + "param": "Overlap/KAMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010542999996687285, + "max": 0.005002533399965614, + "mean": 0.0019874978897860274, + "stddev": 0.0009545136153215923, + "rounds": 20, + "median": 0.0019252332000178284, + "iqr": 0.0009617250994779164, + "q1": 0.0012667208000493703, + "q3": 0.0022284458995272868, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0010542999996687285, + "hd15iqr": 0.0037077000000863337, + "ops": 503.14518829887123, + "total": 0.03974995779572055, + "data": [ + 0.0011940750002395362, + 0.0019299831998068838, + 0.0024959665999631396, + 0.002314674999797717, + 0.0014946583993150852, + 0.0010542999996687285, + 0.0015709499988588505, + 0.005002533399965614, + 0.001073308200284373, + 0.0037077000000863337, + 0.0022994499988271853, + 0.001920483200228773, + 0.0019610584000474772, + 0.002089574999990873, + 0.0013393665998592042, + 0.0020525915999314746, + 0.0018844665988581254, + 0.0021574418002273887, + 0.0011249415998463518, + 0.0010824331999174318 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/talib]", + "params": { + "indicator": "KAMA", + "library": "talib" + }, + "param": "Overlap/KAMA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003870084008667618, + "max": 0.0008882417998393067, + "mean": 0.00046930751006584614, + "stddev": 0.0001464840371167732, + "rounds": 20, + "median": 0.000412904100085143, + "iqr": 4.1591699846321684e-05, + "q1": 0.0003990416000306141, + "q3": 0.0004406332998769358, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.0003870084008667618, + "hd15iqr": 0.0005217750003794208, + "ops": 2130.799057231569, + "total": 0.009386150201316923, + "data": [ + 0.00040840000001480804, + 0.00039603339973837137, + 0.0004403500002808869, + 0.00043270000023767354, + 0.00039976679981919007, + 0.00039300000062212346, + 0.00039929159975145013, + 0.0005217750003794208, + 0.0008882417998393067, + 0.0005391750004491769, + 0.0008748334003030322, + 0.00044091659947298467, + 0.00041772499971557406, + 0.00040653340111020955, + 0.000398791600309778, + 0.0004124915998545475, + 0.0004185333993518725, + 0.0003870084008667618, + 0.0003972665988840163, + 0.00041331660031573847 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/pandas_ta]", + "params": { + "indicator": "KAMA", + "library": "pandas_ta" + }, + "param": "Overlap/KAMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.1391765584005043, + "max": 0.21343391659902408, + "mean": 0.15199162670003716, + "stddev": 0.02320810803367161, + "rounds": 20, + "median": 0.14112020839966133, + "iqr": 0.008807879200321611, + "q1": 0.1399587291998614, + "q3": 0.14876660840018302, + "iqr_outliers": 4, + "stddev_outliers": 3, + "outliers": "3;4", + "ld15iqr": 0.1391765584005043, + "hd15iqr": 0.1650585500014131, + "ops": 6.57930980614839, + "total": 3.0398325340007433, + "data": [ + 0.1399850249988958, + 0.1393613499996718, + 0.14321359180030413, + 0.14030661680008052, + 0.1396770999999717, + 0.14021284180053045, + 0.1391765584005043, + 0.14111357499932636, + 0.14112684179999632, + 0.15431962500006194, + 0.205352225000388, + 0.141088158400089, + 0.1417335499994806, + 0.14171715840057003, + 0.14241696660028538, + 0.21343391659902408, + 0.13975329179957044, + 0.1908531581997522, + 0.1650585500014131, + 0.139932433400827 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/KAMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/KAMA/tulipy]", + "params": { + "indicator": "KAMA", + "library": "tulipy" + }, + "param": "Overlap/KAMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000364641600754112, + "max": 0.00047417500027222557, + "mean": 0.00039751708005496766, + "stddev": 3.246935402453864e-05, + "rounds": 20, + "median": 0.0003838917000393849, + "iqr": 3.7316600355552455e-05, + "q1": 0.00037303749995771797, + "q3": 0.0004103541003132704, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.000364641600754112, + "hd15iqr": 0.00047417500027222557, + "ops": 2515.615177747136, + "total": 0.007950341601099354, + "data": [ + 0.0004436834002262913, + 0.00037986660026945174, + 0.00047417500027222557, + 0.0004120081997825764, + 0.00045134999963920565, + 0.0003846665989840403, + 0.000364641600754112, + 0.0004467833990929648, + 0.00040404180035693573, + 0.00040071660041576254, + 0.0003735749996849336, + 0.0003699084001709707, + 0.0003806916007306427, + 0.0004087000008439645, + 0.00038147499872138725, + 0.0003680666006403044, + 0.00036570840020431203, + 0.00037250000023050236, + 0.0003843833997962065, + 0.00038340000028256325 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/ferro_ta]", + "params": { + "indicator": "HULL_MA", + "library": "ferro_ta" + }, + "param": "Overlap/HULL_MA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005305333994328976, + "max": 0.0006424000006518326, + "mean": 0.0005622504198981914, + "stddev": 2.790051700449531e-05, + "rounds": 20, + "median": 0.0005513166994205676, + "iqr": 2.6862500089919238e-05, + "q1": 0.0005448832998808939, + "q3": 0.0005717457999708131, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0005305333994328976, + "hd15iqr": 0.0006424000006518326, + "ops": 1778.566924291623, + "total": 0.01124500839796383, + "data": [ + 0.0005305333994328976, + 0.0005535333999432624, + 0.0006424000006518326, + 0.0005426750009064563, + 0.0005490999988978729, + 0.0005476499994983896, + 0.0005440499997348524, + 0.0006093083997257054, + 0.0005672833998687565, + 0.0005462500004796312, + 0.0005762082000728697, + 0.0005562250007642433, + 0.0005415084000560455, + 0.0005457166000269354, + 0.000533658399945125, + 0.0005625250007142313, + 0.0005868915992323309, + 0.0005471665994264185, + 0.0005672749990480952, + 0.0005950499995378778 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/pandas_ta]", + "params": { + "indicator": "HULL_MA", + "library": "pandas_ta" + }, + "param": "Overlap/HULL_MA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009729582001455128, + "max": 0.001094641600502655, + "mean": 0.0010229258199979086, + "stddev": 3.451736167777335e-05, + "rounds": 20, + "median": 0.0010193750997132154, + "iqr": 4.168739906162955e-05, + "q1": 0.0010015417006798088, + "q3": 0.0010432290997414383, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0009729582001455128, + "hd15iqr": 0.001094641600502655, + "ops": 977.5879936260135, + "total": 0.02045851639995817, + "data": [ + 0.0010675249999621884, + 0.0009940168005414308, + 0.0010250167993945069, + 0.0010084915993502364, + 0.0009730749996379017, + 0.0010097916005179287, + 0.001094641600502655, + 0.0010664999994332903, + 0.0009729582001455128, + 0.001001758400525432, + 0.0010759331998997368, + 0.0010318333996110595, + 0.0010297249988070688, + 0.0010267834004480392, + 0.0010013250008341855, + 0.0010137334000319242, + 0.001044749999709893, + 0.0010033915998064913, + 0.0010417081997729839, + 0.0009755582010257058 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/tulipy]", + "params": { + "indicator": "HULL_MA", + "library": "tulipy" + }, + "param": "Overlap/HULL_MA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036898340040352194, + "max": 0.00041155840008286757, + "mean": 0.0003814953800610965, + "stddev": 1.1024062672493326e-05, + "rounds": 20, + "median": 0.00037945410076645203, + "iqr": 1.5237500338116672e-05, + "q1": 0.00037228329965728336, + "q3": 0.00038752079999540003, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.00036898340040352194, + "hd15iqr": 0.00041155840008286757, + "ops": 2621.2637223545144, + "total": 0.00762990760122193, + "data": [ + 0.00038786660006735475, + 0.0003796332006459124, + 0.00041155840008286757, + 0.0003865915990900248, + 0.00039283320074900985, + 0.00037537500029429796, + 0.0003958916000556201, + 0.000392866600304842, + 0.00038717499992344526, + 0.00037428320065373554, + 0.00037613319873344155, + 0.0003845084007480182, + 0.0003815332005615346, + 0.0003713418002007529, + 0.0003720165987033397, + 0.00037255000061122703, + 0.00036898340040352194, + 0.0003698831991641782, + 0.00036960839934181423, + 0.00037927500088699164 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/HULL_MA/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/HULL_MA/finta]", + "params": { + "indicator": "HULL_MA", + "library": "finta" + }, + "param": "Overlap/HULL_MA/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.3176775915999315, + "max": 0.40398270000005143, + "mean": 0.3312261416698311, + "stddev": 0.023181257965196444, + "rounds": 20, + "median": 0.3240056583999831, + "iqr": 0.008467429099982826, + "q1": 0.3202927166996233, + "q3": 0.3287601457996061, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.3176775915999315, + "hd15iqr": 0.3913448834005976, + "ops": 3.019085374598265, + "total": 6.624522833396623, + "data": [ + 0.40398270000005143, + 0.3213010249994113, + 0.3186954999997397, + 0.3189592665992677, + 0.32296742500038816, + 0.3913448834005976, + 0.3182757082002354, + 0.31928440839983524, + 0.32265284999884897, + 0.3176775915999315, + 0.3261065418002545, + 0.32268198340025267, + 0.32299688339990096, + 0.32848442499962405, + 0.331737200000498, + 0.3250144334000652, + 0.32986117499967804, + 0.3263368249987252, + 0.32903586659958817, + 0.32712614159972875 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/ferro_ta]", + "params": { + "indicator": "VWMA", + "library": "ferro_ta" + }, + "param": "Overlap/VWMA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00032898339995881545, + "max": 0.0003606749989558011, + "mean": 0.00033885874996485653, + "stddev": 9.885964372872836e-06, + "rounds": 20, + "median": 0.00033574580011190845, + "iqr": 1.1375000030966432e-05, + "q1": 0.00033083749949582855, + "q3": 0.000342212499526795, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00032898339995881545, + "hd15iqr": 0.0003606749989558011, + "ops": 2951.0821252327446, + "total": 0.006777174999297131, + "data": [ + 0.00033686660026432946, + 0.0003333582004415803, + 0.00033382500114385036, + 0.0003387668009963818, + 0.0003305831996840425, + 0.00033462499995948745, + 0.00034187499986728655, + 0.0003514331998303533, + 0.0003606749989558011, + 0.00035643339942907913, + 0.0003553168004145846, + 0.00034254999918630346, + 0.00033207499946001916, + 0.0003399250010261312, + 0.00033109179930761455, + 0.0003293082001619041, + 0.00032940840028459204, + 0.00032898339995881545, + 0.00032957499934127554, + 0.0003404999995836988 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/pandas_ta]", + "params": { + "indicator": "VWMA", + "library": "pandas_ta" + }, + "param": "Overlap/VWMA/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006855583997094072, + "max": 0.0008447000000160187, + "mean": 0.0007344595998438308, + "stddev": 3.551653002419854e-05, + "rounds": 20, + "median": 0.0007309625994821545, + "iqr": 3.340010007377712e-05, + "q1": 0.000713937499676831, + "q3": 0.0007473375997506082, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0006855583997094072, + "hd15iqr": 0.0008447000000160187, + "ops": 1361.5452779330972, + "total": 0.014689191996876617, + "data": [ + 0.0007830332004232332, + 0.0006974250005441718, + 0.0006941250001545995, + 0.0006855583997094072, + 0.0007137499997043051, + 0.0008447000000160187, + 0.0007303083999431692, + 0.0007169584001530893, + 0.0007399665992124937, + 0.0007186834001913667, + 0.0007610999993630685, + 0.0007083000004058704, + 0.0007384915996226482, + 0.000723041599849239, + 0.0007316167990211398, + 0.0007331083994358778, + 0.0007412667997414246, + 0.0007602249999763444, + 0.000714124999649357, + 0.0007534083997597918 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/VWMA/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/VWMA/tulipy]", + "params": { + "indicator": "VWMA", + "library": "tulipy" + }, + "param": "Overlap/VWMA/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004032418000861071, + "max": 0.00046014999970793723, + "mean": 0.000420225410052808, + "stddev": 1.4125305192513432e-05, + "rounds": 20, + "median": 0.00041793339987634677, + "iqr": 1.1191499652341008e-05, + "q1": 0.0004100251004274469, + "q3": 0.0004212166000797879, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0004032418000861071, + "hd15iqr": 0.00043978319881716744, + "ops": 2379.67523161994, + "total": 0.00840450820105616, + "data": [ + 0.0004196499998215586, + 0.00041659180133137854, + 0.0004202915995847434, + 0.0004182083997875452, + 0.0004467666003620252, + 0.00041530820017214863, + 0.00043978319881716744, + 0.0004176583999651484, + 0.0004032418000861071, + 0.0004078833997482434, + 0.00040820819995133204, + 0.0004100418009329587, + 0.0004221416005748324, + 0.00041000839992193504, + 0.00041250840004067866, + 0.0004080665996298194, + 0.00046014999970793723, + 0.0004202750002150424, + 0.00041993320046458394, + 0.00042779159994097424 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPOINT/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPOINT/ferro_ta]", + "params": { + "indicator": "MIDPOINT", + "library": "ferro_ta" + }, + "param": "Overlap/MIDPOINT/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0013064832004602068, + "max": 0.0015063250000821426, + "mean": 0.0013730866499827245, + "stddev": 5.3799846147194565e-05, + "rounds": 20, + "median": 0.001358662499842467, + "iqr": 7.263340012286811e-05, + "q1": 0.0013309540998307056, + "q3": 0.0014035874999535737, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0013064832004602068, + "hd15iqr": 0.0015063250000821426, + "ops": 728.286157332155, + "total": 0.027461732999654487, + "data": [ + 0.0014368584001204, + 0.0013654499998665415, + 0.0015063250000821426, + 0.0014643165995948948, + 0.001402758399490267, + 0.0013771832003840246, + 0.0014044166004168802, + 0.0014398084007552826, + 0.0013608499997644686, + 0.0013216915991506538, + 0.0013732749997870997, + 0.0013563583997893147, + 0.0013348749998840503, + 0.0013244583999039606, + 0.001322591600182932, + 0.001356474999920465, + 0.001332025000010617, + 0.0013456500004394912, + 0.0013298831996507942, + 0.0013064832004602068 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPOINT/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPOINT/talib]", + "params": { + "indicator": "MIDPOINT", + "library": "talib" + }, + "param": "Overlap/MIDPOINT/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004596699999819975, + "max": 0.004724758201336954, + "mean": 0.004642987910265219, + "stddev": 3.5744809807459475e-05, + "rounds": 20, + "median": 0.004627108400018187, + "iqr": 5.424589980975673e-05, + "q1": 0.004616320800414542, + "q3": 0.004670566700224299, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.004596699999819975, + "hd15iqr": 0.004724758201336954, + "ops": 215.3785491857715, + "total": 0.09285975820530439, + "data": [ + 0.004702741600340232, + 0.004669458400167059, + 0.004616591600643006, + 0.0046198082010960205, + 0.004660583200166002, + 0.004681733399047516, + 0.004679033400316257, + 0.004610891600896139, + 0.004724758201336954, + 0.004671675000281539, + 0.004596699999819975, + 0.00462952500092797, + 0.004657749999023508, + 0.004614341800333932, + 0.004622191601083614, + 0.004623975000868086, + 0.004637574999651406, + 0.004616050000186079, + 0.00459968340001069, + 0.004624691799108405 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPRICE/ferro_ta]", + "params": { + "indicator": "MIDPRICE", + "library": "ferro_ta" + }, + "param": "Overlap/MIDPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0011972168009378946, + "max": 0.0014547750004567205, + "mean": 0.0012470024900540012, + "stddev": 6.894870172849137e-05, + "rounds": 20, + "median": 0.001210433300002478, + "iqr": 9.76458999502937e-05, + "q1": 0.0012014457999612205, + "q3": 0.0012990916999115142, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0011972168009378946, + "hd15iqr": 0.0014547750004567205, + "ops": 801.9230177773705, + "total": 0.02494004980108002, + "data": [ + 0.0013083333993563428, + 0.0013131665997207164, + 0.0013024083993514069, + 0.001232624999829568, + 0.0012291915991227143, + 0.0012050834004185163, + 0.0012017000000923872, + 0.00120090840064222, + 0.0012140416001784615, + 0.0012068249998264946, + 0.0012011915998300538, + 0.0011977750007645227, + 0.0012050165998516605, + 0.0012055081999278628, + 0.0012168750006821937, + 0.001200466600130312, + 0.0011972168009378946, + 0.0013511665994883515, + 0.0014547750004567205, + 0.0012957750004716218 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Overlap/MIDPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Overlap/MIDPRICE/talib]", + "params": { + "indicator": "MIDPRICE", + "library": "talib" + }, + "param": "Overlap/MIDPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008010165998712182, + "max": 0.0008403250001720152, + "mean": 0.0008119945802172879, + "stddev": 1.1666465569998526e-05, + "rounds": 20, + "median": 0.0008089667004242073, + "iqr": 1.2987499212613308e-05, + "q1": 0.0008027958007005509, + "q3": 0.0008157832999131642, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0008010165998712182, + "hd15iqr": 0.0008369168004719541, + "ops": 1231.5353136130568, + "total": 0.016239891604345757, + "data": [ + 0.0008403250001720152, + 0.0008025750008528121, + 0.0008168665997800417, + 0.0008095750003121793, + 0.0008042834000661969, + 0.0008052584002143703, + 0.0008097915997495875, + 0.0008083584005362354, + 0.0008010165998712182, + 0.0008073416000115685, + 0.0008369168004719541, + 0.000830483200843446, + 0.0008147000000462868, + 0.0008023665999644436, + 0.0008022666006581858, + 0.0008030166005482897, + 0.0008015168001293205, + 0.0008134584000799805, + 0.0008108166002784856, + 0.0008189583997591399 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/ferro_ta]", + "params": { + "indicator": "RSI", + "library": "ferro_ta" + }, + "param": "Momentum/RSI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006271584003116004, + "max": 0.0006934584002010524, + "mean": 0.0006363537698780419, + "stddev": 1.4732478855902815e-05, + "rounds": 20, + "median": 0.0006313666999631096, + "iqr": 6.233199383131986e-06, + "q1": 0.0006297001003986225, + "q3": 0.0006359332997817545, + "iqr_outliers": 3, + "stddev_outliers": 1, + "outliers": "1;3", + "ld15iqr": 0.0006271584003116004, + "hd15iqr": 0.0006459000011091121, + "ops": 1571.452935985044, + "total": 0.01272707539756084, + "data": [ + 0.0006459000011091121, + 0.0006290249992161989, + 0.0006314583995845168, + 0.0006371666007908061, + 0.0006934584002010524, + 0.0006346999987727031, + 0.0006322332003037446, + 0.0006301749992417172, + 0.0006310750002739951, + 0.0006305417991825379, + 0.0006293668004218489, + 0.0006312750003417023, + 0.0006271584003116004, + 0.0006397667995770462, + 0.0006510166000225581, + 0.000630033400375396, + 0.000628366599266883, + 0.0006334749996312894, + 0.0006286833988269791, + 0.000632200000109151 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/talib]", + "params": { + "indicator": "RSI", + "library": "talib" + }, + "param": "Momentum/RSI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006214666005689651, + "max": 0.000653283199062571, + "mean": 0.0006308941401221091, + "stddev": 1.0335447950868837e-05, + "rounds": 20, + "median": 0.0006256709006265738, + "iqr": 1.6258299729088235e-05, + "q1": 0.0006228707999980543, + "q3": 0.0006391290997271426, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0006214666005689651, + "hd15iqr": 0.000653283199062571, + "ops": 1585.0519705357394, + "total": 0.012617882802442183, + "data": [ + 0.0006296666004345752, + 0.000646516599226743, + 0.000639558199327439, + 0.0006440415992983617, + 0.000625791800848674, + 0.0006233831998542882, + 0.000629158400988672, + 0.0006214666005689651, + 0.0006221832009032369, + 0.0006255500004044734, + 0.0006253833998925984, + 0.0006493082008091732, + 0.000653283199062571, + 0.0006387000001268461, + 0.0006294334001722745, + 0.0006228332000318915, + 0.0006221000003279187, + 0.0006222167998203076, + 0.0006244000003789551, + 0.000622908399964217 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/pandas_ta]", + "params": { + "indicator": "RSI", + "library": "pandas_ta" + }, + "param": "Momentum/RSI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006943999993382022, + "max": 0.0007160581997595727, + "mean": 0.0007010841596638784, + "stddev": 7.423360643277134e-06, + "rounds": 20, + "median": 0.0006981416998314671, + "iqr": 7.208299211924961e-06, + "q1": 0.0006956917000934481, + "q3": 0.000702899999305373, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0006943999993382022, + "hd15iqr": 0.0007153667989769019, + "ops": 1426.3622793580605, + "total": 0.014021683193277568, + "data": [ + 0.0007153667989769019, + 0.0007007749998592771, + 0.0006979083991609514, + 0.000696125000831671, + 0.0006962415995076298, + 0.0006947582005523145, + 0.000704274998861365, + 0.0007157500003813766, + 0.0006996249998337589, + 0.0006951833987841382, + 0.0006952583993552252, + 0.0006979415993555449, + 0.0006943999993382022, + 0.0006985415995586664, + 0.0006946166002308018, + 0.0007015249997493811, + 0.0007160581997595727, + 0.0007112249993951991, + 0.0006983418003073894, + 0.0006977665994782001 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/ta]", + "params": { + "indicator": "RSI", + "library": "ta" + }, + "param": "Momentum/RSI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0016815499999211169, + "max": 0.001762908199452795, + "mean": 0.0017002337299345527, + "stddev": 2.1557330871896647e-05, + "rounds": 20, + "median": 0.0016931416997977068, + "iqr": 1.9291698845336257e-05, + "q1": 0.0016850875006639398, + "q3": 0.001704379199509276, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0016815499999211169, + "hd15iqr": 0.0017354331997921691, + "ops": 588.154429825653, + "total": 0.034004674598691054, + "data": [ + 0.001694800000404939, + 0.0016845000005559995, + 0.0016850166008225641, + 0.0017326081986539065, + 0.0016873665997991338, + 0.0016815499999211169, + 0.0017043749990989453, + 0.001762908199452795, + 0.0016851584005053155, + 0.001686050000716932, + 0.0017142166005214676, + 0.0017037249999702908, + 0.0016914833991904742, + 0.0016867583995917811, + 0.0017043833999196068, + 0.0016815666007460096, + 0.0016826084000058472, + 0.0017013749995385297, + 0.0017354331997921691, + 0.0016987915994832292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/tulipy]", + "params": { + "indicator": "RSI", + "library": "tulipy" + }, + "param": "Momentum/RSI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035900840011890975, + "max": 0.00038547500007553027, + "mean": 0.0003663266600051429, + "stddev": 8.626350634918524e-06, + "rounds": 20, + "median": 0.0003619041002821177, + "iqr": 1.2058400898240507e-05, + "q1": 0.00036042919964529574, + "q3": 0.00037248760054353625, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00035900840011890975, + "hd15iqr": 0.00038547500007553027, + "ops": 2729.8040497133375, + "total": 0.007326533200102858, + "data": [ + 0.00037502499908441677, + 0.00037025839992566034, + 0.0003854249996948056, + 0.00038547500007553027, + 0.0003630250008427538, + 0.00036109999928157777, + 0.0003608165992773138, + 0.00036094160022912545, + 0.00036175819986965506, + 0.0003593583998735994, + 0.0003632166000897996, + 0.00036188320082146673, + 0.0003600418000132777, + 0.00035998319945065307, + 0.00036535840044962244, + 0.00037471680116141215, + 0.0003619249997427687, + 0.0003596499998820946, + 0.00035900840011890975, + 0.00037756660021841525 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/RSI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/RSI/finta]", + "params": { + "indicator": "RSI", + "library": "finta" + }, + "param": "Momentum/RSI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022185581998201086, + "max": 0.00235590820084326, + "mean": 0.0022495583201089177, + "stddev": 3.3812011696868017e-05, + "rounds": 20, + "median": 0.0022424374998081475, + "iqr": 3.134580038022293e-05, + "q1": 0.0022244542000407815, + "q3": 0.0022558000004210045, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0022185581998201086, + "hd15iqr": 0.00235590820084326, + "ops": 444.531706984855, + "total": 0.04499116640217835, + "data": [ + 0.00235590820084326, + 0.002278491600009147, + 0.002241941599640995, + 0.002283283400174696, + 0.0022353084001224487, + 0.002234791799855884, + 0.0022429333999752997, + 0.0022185581998201086, + 0.0022436166007537396, + 0.002252524999494199, + 0.002253175000078045, + 0.002247158200771082, + 0.0022265334002440794, + 0.002222374999837484, + 0.002258425000763964, + 0.0022192916003405116, + 0.00222231660009129, + 0.0023019833999569526, + 0.0022211999996216035, + 0.0022313499997835607 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/ferro_ta]", + "params": { + "indicator": "MACD", + "library": "ferro_ta" + }, + "param": "Momentum/MACD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008245917997555807, + "max": 0.0008499165996909142, + "mean": 0.0008328883400099585, + "stddev": 8.045669464210374e-06, + "rounds": 20, + "median": 0.0008296625004732051, + "iqr": 1.0253999789711088e-05, + "q1": 0.0008272043000033591, + "q3": 0.0008374582997930702, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0008245917997555807, + "hd15iqr": 0.0008499165996909142, + "ops": 1200.6411327454093, + "total": 0.01665776680019917, + "data": [ + 0.0008309666009154171, + 0.0008296084008179605, + 0.0008297166001284495, + 0.0008269918005680665, + 0.0008499165996909142, + 0.000833625000086613, + 0.000828466599341482, + 0.0008283083996502682, + 0.0008324084003106691, + 0.0008262082003057003, + 0.0008274167994386517, + 0.000842525000916794, + 0.0008412915994995274, + 0.0008461750010610558, + 0.0008312334000947885, + 0.0008258665999164805, + 0.0008258249989012256, + 0.0008245917997555807, + 0.0008284333991468884, + 0.0008481915996526368 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/talib]", + "params": { + "indicator": "MACD", + "library": "talib" + }, + "param": "Momentum/MACD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007829750000382773, + "max": 0.0008204582001781092, + "mean": 0.0007931349899445195, + "stddev": 1.1300551916390505e-05, + "rounds": 20, + "median": 0.0007887457999459003, + "iqr": 1.4149899652693356e-05, + "q1": 0.0007852333998016548, + "q3": 0.0007993832994543481, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0007829750000382773, + "hd15iqr": 0.0008204582001781092, + "ops": 1260.819422517157, + "total": 0.01586269979889039, + "data": [ + 0.0007856331998482346, + 0.0007851500005926937, + 0.0007853167990106158, + 0.0007884165999712423, + 0.0007921833996078931, + 0.0007998415996553377, + 0.0008068084003753029, + 0.0007899167991126888, + 0.0007829750000382773, + 0.0007839500001864507, + 0.0007838332006940618, + 0.0007858166005462408, + 0.0007989249992533587, + 0.0008195583999622613, + 0.0008002999995369465, + 0.0007926416001282632, + 0.0007890749999205582, + 0.0007870583998737857, + 0.0007848416003980674, + 0.0008204582001781092 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/pandas_ta]", + "params": { + "indicator": "MACD", + "library": "pandas_ta" + }, + "param": "Momentum/MACD/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010066333998111077, + "max": 0.0010455583993461913, + "mean": 0.0010202854198723798, + "stddev": 1.0313282247086423e-05, + "rounds": 20, + "median": 0.0010198374999163206, + "iqr": 1.6149900329764976e-05, + "q1": 0.0010111500996572431, + "q3": 0.001027299999987008, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0010066333998111077, + "hd15iqr": 0.0010455583993461913, + "ops": 980.117896936215, + "total": 0.020405708397447597, + "data": [ + 0.0010342166002374142, + 0.0010223831995972432, + 0.001021141599630937, + 0.0010298250010237098, + 0.0010266665995004587, + 0.001014358400425408, + 0.0010104250002768822, + 0.001014091599790845, + 0.0010118833990418353, + 0.0010208166000666096, + 0.0010276665998389944, + 0.0010321167996153236, + 0.0010066333998111077, + 0.0010269334001350217, + 0.001011216799088288, + 0.0010188583997660316, + 0.0010455583993461913, + 0.0010104831992066466, + 0.001009350000822451, + 0.0010110834002261982 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/ta]", + "params": { + "indicator": "MACD", + "library": "ta" + }, + "param": "Momentum/MACD/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0015758918001665735, + "max": 0.0016479750003782101, + "mean": 0.0015926996202324517, + "stddev": 1.6698245983907632e-05, + "rounds": 20, + "median": 0.0015861792002397125, + "iqr": 1.664159935899079e-05, + "q1": 0.0015822501009097323, + "q3": 0.001598891700268723, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0015758918001665735, + "hd15iqr": 0.0016479750003782101, + "ops": 627.8647820949765, + "total": 0.031853992404649034, + "data": [ + 0.0015864583998336456, + 0.0015824334011995233, + 0.0015913665993139148, + 0.0016479750003782101, + 0.0015859000006457791, + 0.0015838917999644764, + 0.0015981165997800417, + 0.0016069165998487734, + 0.0015832000004593282, + 0.0015820668006199412, + 0.0015955334005411715, + 0.0016115999998874031, + 0.0015854334007599391, + 0.0015802168010850437, + 0.0015986416008672677, + 0.0015991417996701785, + 0.0015762334005557932, + 0.0015758918001665735, + 0.0016043417999753729, + 0.0015786331990966574 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/tulipy]", + "params": { + "indicator": "MACD", + "library": "tulipy" + }, + "param": "Momentum/MACD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004089499998372048, + "max": 0.0004240333990310319, + "mean": 0.00041373039995960424, + "stddev": 4.815477685350209e-06, + "rounds": 20, + "median": 0.00041197910031769424, + "iqr": 4.983299731975421e-06, + "q1": 0.0004105667001567781, + "q3": 0.00041554999988875353, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004089499998372048, + "hd15iqr": 0.0004240333990310319, + "ops": 2417.032927958975, + "total": 0.008274607999192085, + "data": [ + 0.00041595819930080325, + 0.00041228340123780074, + 0.00041205820016330106, + 0.0004119000004720874, + 0.00041078340000240133, + 0.0004151418004767038, + 0.0004240333990310319, + 0.00042297499894630166, + 0.0004211749997921288, + 0.00042174999980488793, + 0.0004122166006709449, + 0.00041145839932141823, + 0.0004103500003111549, + 0.0004101832004380412, + 0.00041103319963440297, + 0.00040967499953694644, + 0.00041218319965992124, + 0.0004096584001672454, + 0.0004108416003873572, + 0.0004089499998372048 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MACD/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MACD/finta]", + "params": { + "indicator": "MACD", + "library": "finta" + }, + "param": "Momentum/MACD/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017074415998649783, + "max": 0.0017570666008396075, + "mean": 0.0017213978799554752, + "stddev": 1.4855512166017051e-05, + "rounds": 20, + "median": 0.0017131166001490782, + "iqr": 1.9791500380961398e-05, + "q1": 0.0017111333996581378, + "q3": 0.0017309249000390992, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0017074415998649783, + "hd15iqr": 0.0017570666008396075, + "ops": 580.9232203921765, + "total": 0.03442795759910951, + "data": [ + 0.0017111667999415658, + 0.0017366250001941807, + 0.0017131750006228685, + 0.00171109999937471, + 0.0017120999997132457, + 0.001748300000326708, + 0.001715608399535995, + 0.0017093250004108994, + 0.0017296416001045146, + 0.0017232332000276073, + 0.001713058199675288, + 0.0017106749990489333, + 0.0017322081999736837, + 0.0017123331999755464, + 0.0017124165999121033, + 0.0017176084002130665, + 0.0017448081998736598, + 0.0017100665994803422, + 0.0017074415998649783, + 0.0017570666008396075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/ferro_ta]", + "params": { + "indicator": "STOCH", + "library": "ferro_ta" + }, + "param": "Momentum/STOCH/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002266316600434948, + "max": 0.0023576166000566444, + "mean": 0.002291999560184195, + "stddev": 2.225686339044173e-05, + "rounds": 20, + "median": 0.0022855458002595695, + "iqr": 2.903329877881368e-05, + "q1": 0.0022757875005481763, + "q3": 0.00230482079932699, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.002266316600434948, + "hd15iqr": 0.0023576166000566444, + "ops": 436.3002582424736, + "total": 0.04583999120368389, + "data": [ + 0.0023576166000566444, + 0.002298591600265354, + 0.0023118166005588136, + 0.002308683199225925, + 0.0022823250008514153, + 0.0022958166009630077, + 0.0022764416004065423, + 0.0022751334006898107, + 0.0023123083999962548, + 0.002277250000042841, + 0.0022799668004154228, + 0.0023205250006867574, + 0.0022737415987649, + 0.0022920915987924674, + 0.002287225000327453, + 0.002266316600434948, + 0.002283866600191686, + 0.00226762500096811, + 0.002271691600617487, + 0.0023009583994280545 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/talib]", + "params": { + "indicator": "STOCH", + "library": "talib" + }, + "param": "Momentum/STOCH/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008924000008846633, + "max": 0.0009281749997171573, + "mean": 0.0009014741798455361, + "stddev": 9.781014492191172e-06, + "rounds": 20, + "median": 0.0008975500000815373, + "iqr": 1.4066699804970995e-05, + "q1": 0.0008938375001889653, + "q3": 0.0009079041999939363, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008924000008846633, + "hd15iqr": 0.0009281749997171573, + "ops": 1109.2941121966976, + "total": 0.01802948359691072, + "data": [ + 0.0009090084000490606, + 0.0009126083998125978, + 0.0009051415996509604, + 0.0008940833999076858, + 0.0008944415996666067, + 0.0008967916000983678, + 0.0008937665988923982, + 0.0008944250002969056, + 0.0009067999999388121, + 0.0009118499991018325, + 0.000901924999197945, + 0.0008937999999034218, + 0.0008936333993915469, + 0.0008924000008846633, + 0.0008983084000647068, + 0.0009156665997579694, + 0.0009281749997171573, + 0.0008991918002720922, + 0.0008938750004745088, + 0.0008935917998314835 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/pandas_ta]", + "params": { + "indicator": "STOCH", + "library": "pandas_ta" + }, + "param": "Momentum/STOCH/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012010416001430712, + "max": 0.001508349999494385, + "mean": 0.0012560820801445515, + "stddev": 9.674291831318799e-05, + "rounds": 20, + "median": 0.001210633300797781, + "iqr": 2.3974999203346756e-05, + "q1": 0.0012029666009766514, + "q3": 0.0012269416001799982, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0012010416001430712, + "hd15iqr": 0.0013852083997335286, + "ops": 796.1263167490763, + "total": 0.025121641602891032, + "data": [ + 0.0012280166003620252, + 0.0012069584001437761, + 0.0012017000000923872, + 0.0012010416001430712, + 0.001220774999819696, + 0.001205108399153687, + 0.0012029582008835859, + 0.0012011500002699904, + 0.001202975001069717, + 0.0012177499986137264, + 0.0012258665999979712, + 0.0012080666012479924, + 0.0012024750001728534, + 0.0012031834005028941, + 0.0012132000003475696, + 0.0012226165999891236, + 0.001508349999494385, + 0.0013852083997335286, + 0.0014482668004347943, + 0.0014159750004182569 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/ta]", + "params": { + "indicator": "STOCH", + "library": "ta" + }, + "param": "Momentum/STOCH/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032632249989546836, + "max": 0.0038337831996614114, + "mean": 0.003435649139864836, + "stddev": 0.00015559524437584808, + "rounds": 20, + "median": 0.0034009916002105457, + "iqr": 0.00020378340050228837, + "q1": 0.0033035499996913135, + "q3": 0.003507333400193602, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.0032632249989546836, + "hd15iqr": 0.0038337831996614114, + "ops": 291.06581006678164, + "total": 0.06871298279729672, + "data": [ + 0.003273491599247791, + 0.003310349999810569, + 0.003264858199690934, + 0.0032852668009581976, + 0.0032632249989546836, + 0.0032967499995720574, + 0.0034441581999999473, + 0.0038337831996614114, + 0.003594025000347756, + 0.003629208200436551, + 0.003525483400153462, + 0.0036791666003409772, + 0.003489183400233742, + 0.0033759500001906417, + 0.0034770165992085824, + 0.0034260332002304496, + 0.0034755499989842066, + 0.0033552665991010144, + 0.0033625334006501363, + 0.0033516833995236085 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/tulipy]", + "params": { + "indicator": "STOCH", + "library": "tulipy" + }, + "param": "Momentum/STOCH/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008966667999629862, + "max": 0.0010260584007482977, + "mean": 0.0009349141797429184, + "stddev": 3.282872273745214e-05, + "rounds": 20, + "median": 0.0009288583998568356, + "iqr": 4.016259917989369e-05, + "q1": 0.0009090624000236858, + "q3": 0.0009492249992035795, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0008966667999629862, + "hd15iqr": 0.0010260584007482977, + "ops": 1069.6168928307184, + "total": 0.018698283594858367, + "data": [ + 0.0009230668001691811, + 0.0009413250008947216, + 0.0009468749994994141, + 0.000937833399802912, + 0.0009532833995763212, + 0.0008966667999629862, + 0.0010260584007482977, + 0.0009515749989077449, + 0.0009331167995696888, + 0.0009246000001439825, + 0.000908558200171683, + 0.000913966799271293, + 0.0009061666001798585, + 0.0009160499990684912, + 0.0009374165994813666, + 0.0009011831993120722, + 0.0009095665998756885, + 0.0009051000000908971, + 0.00098769999895012, + 0.0009781749991816468 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/STOCH/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/STOCH/finta]", + "params": { + "indicator": "STOCH", + "library": "finta" + }, + "param": "Momentum/STOCH/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0033632334001595155, + "max": 0.0036877168007777073, + "mean": 0.003489002510032151, + "stddev": 0.00011377381099929499, + "rounds": 20, + "median": 0.0034526083996752276, + "iqr": 0.0002071709001029375, + "q1": 0.003392891599651193, + "q3": 0.0036000624997541307, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0033632334001595155, + "hd15iqr": 0.0036877168007777073, + "ops": 286.6148697585159, + "total": 0.06978005020064301, + "data": [ + 0.0035690333999809807, + 0.0035460500002955087, + 0.0036877168007777073, + 0.0036310915995272806, + 0.0036614166005165317, + 0.003523725000559352, + 0.0033972165998420677, + 0.0033632334001595155, + 0.0033794750008382833, + 0.0033666000002995134, + 0.003414750000229105, + 0.00342934179934673, + 0.0034004999994067474, + 0.0033905165997566654, + 0.003475875000003725, + 0.003366825000557583, + 0.003395266599545721, + 0.0036311833988293073, + 0.0036418917996343227, + 0.003508341600536369 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/ferro_ta]", + "params": { + "indicator": "CCI", + "library": "ferro_ta" + }, + "param": "Momentum/CCI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000883666799927596, + "max": 0.0010631166005623527, + "mean": 0.0009321975000784733, + "stddev": 5.2810083762242914e-05, + "rounds": 20, + "median": 0.0009106416997383349, + "iqr": 6.761670010746468e-05, + "q1": 0.0008935083002143073, + "q3": 0.000961125000321772, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.000883666799927596, + "hd15iqr": 0.0010631166005623527, + "ops": 1072.7340503657422, + "total": 0.018643950001569466, + "data": [ + 0.0009618081996450201, + 0.0009247750000213273, + 0.000884916799259372, + 0.0010631166005623527, + 0.0009058416006155312, + 0.0010151081994990818, + 0.0009604418009985238, + 0.0009248082002159208, + 0.000912600000447128, + 0.0009965749995899387, + 0.0008905668000807054, + 0.0009207499999320135, + 0.0008928833995014429, + 0.0008931666001444682, + 0.0008951666008215397, + 0.0008938500002841465, + 0.000883666799927596, + 0.0008953334006946533, + 0.0010198916002991608, + 0.0009086833990295418 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/talib]", + "params": { + "indicator": "CCI", + "library": "talib" + }, + "param": "Momentum/CCI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009949418003088796, + "max": 0.0010386332010966725, + "mean": 0.0010135208402061834, + "stddev": 1.3763974688096278e-05, + "rounds": 20, + "median": 0.0010083916997245977, + "iqr": 2.270420009153895e-05, + "q1": 0.0010024250004789792, + "q3": 0.0010251292005705182, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0009949418003088796, + "hd15iqr": 0.0010386332010966725, + "ops": 986.6595341015061, + "total": 0.02027041680412367, + "data": [ + 0.001032058399869129, + 0.0010386332010966725, + 0.0010162081991438754, + 0.00100571660004789, + 0.0010327250012778677, + 0.0009999750007409602, + 0.0010113666008692234, + 0.0010007500008214266, + 0.0010059918000479228, + 0.0010001667993492446, + 0.0010058583997306415, + 0.0010262668001814745, + 0.0010107915994012728, + 0.0010018000000854954, + 0.0010041999994427897, + 0.0009949418003088796, + 0.0010239916009595618, + 0.001003050000872463, + 0.001037116600491572, + 0.001018808399385307 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/pandas_ta]", + "params": { + "indicator": "CCI", + "library": "pandas_ta" + }, + "param": "Momentum/CCI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010963582011754625, + "max": 0.0013147999998182058, + "mean": 0.0011421566599165089, + "stddev": 5.5198956227109216e-05, + "rounds": 20, + "median": 0.0011159457004396244, + "iqr": 5.867500003660115e-05, + "q1": 0.001104158399539301, + "q3": 0.0011628333995759021, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0010963582011754625, + "hd15iqr": 0.0013147999998182058, + "ops": 875.5366361678435, + "total": 0.02284313319833018, + "data": [ + 0.0011700499992002733, + 0.0011390083993319422, + 0.001113766799971927, + 0.0011057083989726379, + 0.0011007584005710668, + 0.0011162331997184084, + 0.001114941798732616, + 0.0010985332002746873, + 0.0011007000008248723, + 0.0010963582011754625, + 0.0011026084001059643, + 0.001155616799951531, + 0.0012246500002220273, + 0.001194433199998457, + 0.0013147999998182058, + 0.0011482667992822825, + 0.001197083199804183, + 0.0011156582011608406, + 0.0011192499994649551, + 0.0011147081997478381 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/ta]", + "params": { + "indicator": "CCI", + "library": "ta" + }, + "param": "Momentum/CCI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.34554610820050585, + "max": 0.4289616668000235, + "mean": 0.36346130584999625, + "stddev": 0.017391130092050962, + "rounds": 20, + "median": 0.35773772500033374, + "iqr": 0.011663533300452389, + "q1": 0.3554399959000875, + "q3": 0.3671035292005399, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.34554610820050585, + "hd15iqr": 0.4289616668000235, + "ops": 2.7513245121413528, + "total": 7.269226116999926, + "data": [ + 0.3539229416011949, + 0.35507729179953457, + 0.34554610820050585, + 0.34720233339903644, + 0.35598323339945637, + 0.36447610000032, + 0.3663169084000401, + 0.3626409333999618, + 0.37233330000017306, + 0.35812142500071786, + 0.37245304999960355, + 0.37471672499959824, + 0.36789015000103975, + 0.4289616668000235, + 0.3524450749988318, + 0.35725394159962887, + 0.3645183583998005, + 0.35580270000064046, + 0.3573540249999496, + 0.3562098499998683 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/tulipy]", + "params": { + "indicator": "CCI", + "library": "tulipy" + }, + "param": "Momentum/CCI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006111416005296633, + "max": 0.0006556418011314236, + "mean": 0.0006218199901923072, + "stddev": 1.13391937559075e-05, + "rounds": 20, + "median": 0.0006174042006023228, + "iqr": 1.3887600653106217e-05, + "q1": 0.0006141290999948979, + "q3": 0.0006280167006480041, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0006111416005296633, + "hd15iqr": 0.0006556418011314236, + "ops": 1608.1824575802636, + "total": 0.012436399803846143, + "data": [ + 0.0006292583988397382, + 0.0006188834013300948, + 0.0006144915998447687, + 0.000614533199404832, + 0.0006202666001627222, + 0.0006131250003818423, + 0.0006228168000234291, + 0.0006119415993453003, + 0.0006111416005296633, + 0.000628066599892918, + 0.0006556418011314236, + 0.0006415832001948729, + 0.0006313082005362958, + 0.0006137666001450271, + 0.0006230665996554308, + 0.0006151417997898534, + 0.0006159249998745509, + 0.0006151250010589138, + 0.000612350000301376, + 0.0006279668014030904 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CCI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CCI/finta]", + "params": { + "indicator": "CCI", + "library": "finta" + }, + "param": "Momentum/CCI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.2993529668005067, + "max": 0.3176451167993946, + "mean": 0.31254243002011206, + "stddev": 0.004638903107757841, + "rounds": 20, + "median": 0.3136391374995583, + "iqr": 0.005252683300204841, + "q1": 0.31079825839988184, + "q3": 0.3160509417000867, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.30593349160044453, + "hd15iqr": 0.3176451167993946, + "ops": 3.199565575578491, + "total": 6.250848600402241, + "data": [ + 0.2993529668005067, + 0.3176451167993946, + 0.31680982499965465, + 0.31562652499997057, + 0.3072172500003944, + 0.31052993339981183, + 0.3169236000001547, + 0.3128914000000805, + 0.31121485000039684, + 0.30593349160044453, + 0.3153166416013846, + 0.31505346660123906, + 0.31409360819961873, + 0.3110665833999519, + 0.3164753584002028, + 0.3138149666003301, + 0.3121581918006996, + 0.31346330839878644, + 0.30776771679957166, + 0.31749379999964733 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/ferro_ta]", + "params": { + "indicator": "WILLR", + "library": "ferro_ta" + }, + "param": "Momentum/WILLR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012404334003804252, + "max": 0.0014558499999111519, + "mean": 0.0013053404100355692, + "stddev": 5.708898775847718e-05, + "rounds": 20, + "median": 0.0012832583000999876, + "iqr": 5.272509952192195e-05, + "q1": 0.0012689583003520966, + "q3": 0.0013216833998740186, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0012404334003804252, + "hd15iqr": 0.001435408399265725, + "ops": 766.0836915121251, + "total": 0.026106808200711384, + "data": [ + 0.0012898418004624545, + 0.0014558499999111519, + 0.0013338666001800447, + 0.0013162500006728805, + 0.0013211334007792175, + 0.001435408399265725, + 0.0013728331992751918, + 0.0012866916003986262, + 0.0012670249998336658, + 0.0012779081996995955, + 0.0012739415993564761, + 0.0012632500001927838, + 0.0012677000006078743, + 0.0012598333996720612, + 0.0012712416006252169, + 0.0012702166000963188, + 0.0013013250005315057, + 0.0012404334003804252, + 0.0013222333989688195, + 0.0012798249998013489 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/talib]", + "params": { + "indicator": "WILLR", + "library": "talib" + }, + "param": "Momentum/WILLR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007336750000831671, + "max": 0.0008699665995663963, + "mean": 0.0007845874800841557, + "stddev": 3.941710049435736e-05, + "rounds": 20, + "median": 0.0007887333005783149, + "iqr": 7.426669835695053e-05, + "q1": 0.0007435791005264037, + "q3": 0.0008178457988833542, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0007336750000831671, + "hd15iqr": 0.0008699665995663963, + "ops": 1274.555132963298, + "total": 0.015691749601683114, + "data": [ + 0.0007442332003847696, + 0.0007429250006680376, + 0.0007337750008446165, + 0.0007336750000831671, + 0.0008699665995663963, + 0.0008172249989002011, + 0.0007998250002856366, + 0.0007806166002410464, + 0.0008033165999222547, + 0.000819983400288038, + 0.0007869000008213333, + 0.0008184665988665074, + 0.000830800000403542, + 0.0007956418005051092, + 0.0007683332005399279, + 0.0007362999996985309, + 0.0008262917996034958, + 0.0007905666003352963, + 0.0007530000002589077, + 0.0007399081994662992 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/pandas_ta]", + "params": { + "indicator": "WILLR", + "library": "pandas_ta" + }, + "param": "Momentum/WILLR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008449583998299204, + "max": 0.001058283400197979, + "mean": 0.0009021850202407222, + "stddev": 6.125078230424613e-05, + "rounds": 20, + "median": 0.0008848375000525266, + "iqr": 8.282920025521885e-05, + "q1": 0.0008555917003832292, + "q3": 0.0009384209006384481, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008449583998299204, + "hd15iqr": 0.001058283400197979, + "ops": 1108.420088523725, + "total": 0.018043700404814445, + "data": [ + 0.0008526999998139217, + 0.0008584834009525366, + 0.0009915665999869817, + 0.0009652000007918105, + 0.0009549834008794278, + 0.000859966799907852, + 0.0008649082010379061, + 0.0008482418008497917, + 0.0008479750002152286, + 0.0008497168004396371, + 0.0008449583998299204, + 0.001004083400766831, + 0.0009218584003974683, + 0.0008902168003260158, + 0.0008806915997411124, + 0.000891958198917564, + 0.0008690082002431154, + 0.0008889834003639408, + 0.0008999165991554036, + 0.001058283400197979 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/ta]", + "params": { + "indicator": "WILLR", + "library": "ta" + }, + "param": "Momentum/WILLR/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032448749989271164, + "max": 0.0036128249994362704, + "mean": 0.003386439599998994, + "stddev": 0.00011034151962928716, + "rounds": 20, + "median": 0.003329825000400888, + "iqr": 0.00016185409986064787, + "q1": 0.003313791700202273, + "q3": 0.0034756458000629207, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0032448749989271164, + "hd15iqr": 0.0036128249994362704, + "ops": 295.29538929331477, + "total": 0.06772879199997987, + "data": [ + 0.003485900000669062, + 0.0033974834004766308, + 0.0036128249994362704, + 0.0032448749989271164, + 0.0034653915994567797, + 0.0033229831999051383, + 0.0035184583990485407, + 0.0033263500008615665, + 0.0033256334005272946, + 0.003307800000766292, + 0.003319783399638254, + 0.0032952416004263796, + 0.003322299999126699, + 0.003253525000764057, + 0.003389233400230296, + 0.003272641800867859, + 0.0035214000003179536, + 0.003582058398751542, + 0.0033332999999402093, + 0.0034316083998419344 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/tulipy]", + "params": { + "indicator": "WILLR", + "library": "tulipy" + }, + "param": "Momentum/WILLR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007638916009454988, + "max": 0.0008114333992125467, + "mean": 0.0007777199801057577, + "stddev": 1.256495018683506e-05, + "rounds": 20, + "median": 0.0007761332999507431, + "iqr": 1.4637400454375893e-05, + "q1": 0.0007677708992559929, + "q3": 0.0007824082997103688, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0007638916009454988, + "hd15iqr": 0.0008047916009672918, + "ops": 1285.809835905226, + "total": 0.015554399602115155, + "data": [ + 0.000776274999952875, + 0.0007822081999620423, + 0.000781325000571087, + 0.0007682083989493549, + 0.0007831750001059846, + 0.000783491600304842, + 0.0007747499999823049, + 0.0007650168001418934, + 0.0007671582003240474, + 0.0007805000001098961, + 0.0007825331995263696, + 0.0008047916009672918, + 0.0007759915999486112, + 0.0007708416000241413, + 0.0007638916009454988, + 0.0007642416007001884, + 0.0007673333995626308, + 0.0008114333992125467, + 0.0007822833998943679, + 0.0007689500009291806 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/WILLR/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/WILLR/finta]", + "params": { + "indicator": "WILLR", + "library": "finta" + }, + "param": "Momentum/WILLR/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00325145840033656, + "max": 0.0036330000002635643, + "mean": 0.003480253760062624, + "stddev": 0.00011052859324123222, + "rounds": 20, + "median": 0.0035015833003853914, + "iqr": 0.00018994589918293076, + "q1": 0.003380358299909858, + "q3": 0.003570304199092789, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00325145840033656, + "hd15iqr": 0.0036330000002635643, + "ops": 287.3353694708762, + "total": 0.06960507520125248, + "data": [ + 0.0035214249990531245, + 0.0033573834007256664, + 0.0034805000002961608, + 0.0035877416012226604, + 0.003316849999828264, + 0.003355750000628177, + 0.003502058199956082, + 0.0034541999993962236, + 0.0035719249994144776, + 0.00360144160076743, + 0.003414566599531099, + 0.0033775082003558053, + 0.003554608399281278, + 0.0036111168010393158, + 0.0036330000002635643, + 0.0035605418001068757, + 0.003501108400814701, + 0.0035686833987711, + 0.0033832083994639107, + 0.00325145840033656 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/ferro_ta]", + "params": { + "indicator": "AROON", + "library": "ferro_ta" + }, + "param": "Momentum/AROON/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001369483399321325, + "max": 0.0015028665991849266, + "mean": 0.0013932991301408037, + "stddev": 3.0431700467012695e-05, + "rounds": 20, + "median": 0.0013881916005630047, + "iqr": 2.8150000434834467e-05, + "q1": 0.0013733125000726432, + "q3": 0.0014014625005074777, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.001369483399321325, + "hd15iqr": 0.0015028665991849266, + "ops": 717.7209677141923, + "total": 0.027865982602816076, + "data": [ + 0.0014251332002459093, + 0.0014120831998297944, + 0.0015028665991849266, + 0.0014102082001045345, + 0.0013900666002882645, + 0.0013902833990869113, + 0.001391824999882374, + 0.0014082000008784235, + 0.0013863166008377449, + 0.00137772500020219, + 0.0013731415994698182, + 0.001394725000136532, + 0.001375075000396464, + 0.0013701415999094024, + 0.0013713332009501755, + 0.0013789916003588587, + 0.0013935000009951183, + 0.001369483399321325, + 0.001373483400675468, + 0.00137140000006184 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/talib]", + "params": { + "indicator": "AROON", + "library": "talib" + }, + "param": "Momentum/AROON/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005568667998886667, + "max": 0.0007276584001374431, + "mean": 0.0005909687402163399, + "stddev": 4.295326372886126e-05, + "rounds": 20, + "median": 0.0005837458003952634, + "iqr": 3.0374999187188288e-05, + "q1": 0.0005623208002361934, + "q3": 0.0005926957994233817, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0005568667998886667, + "hd15iqr": 0.0006822499999543652, + "ops": 1692.1368795816902, + "total": 0.011819374804326798, + "data": [ + 0.0005640000003040768, + 0.0005653332002111711, + 0.0005604584002867341, + 0.0005606416001683101, + 0.0005585666003753431, + 0.0005568667998886667, + 0.0005578168013016694, + 0.0005913750006584451, + 0.0007276584001374431, + 0.0006119334007962607, + 0.0005930581988650374, + 0.0006822499999543652, + 0.0006080500010284595, + 0.0005923333999817259, + 0.0005821750004542991, + 0.0005853166003362276, + 0.0005729166005039588, + 0.0005738581996411086, + 0.0005872749999980443, + 0.0005874915994354523 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/pandas_ta]", + "params": { + "indicator": "AROON", + "library": "pandas_ta" + }, + "param": "Momentum/AROON/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012466915999539197, + "max": 0.0012952249991940335, + "mean": 0.0012623816498671659, + "stddev": 1.4269437063281408e-05, + "rounds": 20, + "median": 0.001256683300016448, + "iqr": 1.9954099116148427e-05, + "q1": 0.0012507167004514486, + "q3": 0.001270670799567597, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0012466915999539197, + "hd15iqr": 0.0012952249991940335, + "ops": 792.1534665092961, + "total": 0.025247632997343318, + "data": [ + 0.001289225000073202, + 0.0012810168002033607, + 0.0012952249991940335, + 0.0012683166001806966, + 0.0012629999997443519, + 0.0012537249989691191, + 0.0012699250000878237, + 0.001258958199468907, + 0.001249449999886565, + 0.0012496415991336107, + 0.0012491332003264689, + 0.0012706999987130985, + 0.0012706416004220956, + 0.0012525165991974063, + 0.001254408400563989, + 0.0012501168006565423, + 0.0012723000007099472, + 0.001251316600246355, + 0.0012513249996118248, + 0.0012466915999539197 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/ta]", + "params": { + "indicator": "AROON", + "library": "ta" + }, + "param": "Momentum/AROON/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.1215228583998396, + "max": 0.13249444999964907, + "mean": 0.12504381875005494, + "stddev": 0.0026974968833491544, + "rounds": 20, + "median": 0.12505546250104088, + "iqr": 0.0035595833003753963, + "q1": 0.12305150009924545, + "q3": 0.12661108339962085, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.1215228583998396, + "hd15iqr": 0.13249444999964907, + "ops": 7.997196582734409, + "total": 2.5008763750010985, + "data": [ + 0.12615110839979024, + 0.12523628339986317, + 0.12612859159999062, + 0.12843612500000745, + 0.12799144160089782, + 0.1272988250013441, + 0.13249444999964907, + 0.12518555000133347, + 0.12317749179928797, + 0.12272819159989012, + 0.12292550839920295, + 0.12520366660028232, + 0.12707105839945143, + 0.12492537500074832, + 0.12351866660028463, + 0.12326716679963283, + 0.1215228583998396, + 0.1220750581996981, + 0.12227967499929945, + 0.12325928320060484 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROON/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROON/tulipy]", + "params": { + "indicator": "AROON", + "library": "tulipy" + }, + "param": "Momentum/AROON/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006820082009653561, + "max": 0.0007180583997978829, + "mean": 0.0006928087401320227, + "stddev": 1.089655980111001e-05, + "rounds": 20, + "median": 0.0006882041998323984, + "iqr": 1.889999912236813e-05, + "q1": 0.0006840375004685484, + "q3": 0.0007029374995909165, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0006820082009653561, + "hd15iqr": 0.0007180583997978829, + "ops": 1443.3998044098557, + "total": 0.013856174802640453, + "data": [ + 0.0007010583998635411, + 0.000692741600505542, + 0.0006898666004417464, + 0.0006880415996420198, + 0.0006859750006697141, + 0.0006854581995867192, + 0.0006836415996076539, + 0.0006821832008427009, + 0.0007052834000205622, + 0.0007180583997978829, + 0.0007068999999319203, + 0.0006883668000227771, + 0.000685808400157839, + 0.0006968333997065202, + 0.0006820082009653561, + 0.0006822499999543652, + 0.0006841000009444542, + 0.0007088084006682038, + 0.000704816599318292, + 0.0006839749999926426 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/ferro_ta]", + "params": { + "indicator": "AROONOSC", + "library": "ferro_ta" + }, + "param": "Momentum/AROONOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0014183084000251255, + "max": 0.0015243665999150834, + "mean": 0.0014498420999007066, + "stddev": 3.1066739053002475e-05, + "rounds": 20, + "median": 0.001447633399948245, + "iqr": 4.429160107974903e-05, + "q1": 0.0014223291997041087, + "q3": 0.0014666208007838577, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0014183084000251255, + "hd15iqr": 0.0015243665999150834, + "ops": 689.7302817103225, + "total": 0.028996841998014132, + "data": [ + 0.0014302999989013188, + 0.0014622834001784212, + 0.001422949999687262, + 0.0014186415995936842, + 0.001422591799928341, + 0.001435566799773369, + 0.0014201418001903222, + 0.001448050000180956, + 0.0014723665997735224, + 0.0014753165989532136, + 0.0014708500006236137, + 0.001449658199271653, + 0.0014623916009441017, + 0.0015137499998672866, + 0.0014220665994798764, + 0.0014186418004101143, + 0.0014183084000251255, + 0.001447216799715534, + 0.0015243665999150834, + 0.0014613834006013348 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/talib]", + "params": { + "indicator": "AROONOSC", + "library": "talib" + }, + "param": "Momentum/AROONOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005737583996960893, + "max": 0.0006250584003282711, + "mean": 0.0005881629399664234, + "stddev": 1.3842073137240903e-05, + "rounds": 20, + "median": 0.0005825209002068732, + "iqr": 1.7979300173465076e-05, + "q1": 0.0005788957998447586, + "q3": 0.0005968751000182237, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0005737583996960893, + "hd15iqr": 0.0006250584003282711, + "ops": 1700.2091292203606, + "total": 0.011763258799328468, + "data": [ + 0.0006250584003282711, + 0.00060208320064703, + 0.0006066000001737848, + 0.0005842833998030983, + 0.0005796334007754922, + 0.0005847749998793006, + 0.0005822000006446615, + 0.0005786581998108887, + 0.0005819083991809749, + 0.0005828417997690849, + 0.0005766415997641161, + 0.0005765915993833914, + 0.000611808399844449, + 0.0005967584002064541, + 0.0005969917998299934, + 0.0005793083997559734, + 0.0005791333998786286, + 0.0005862749996595085, + 0.0005779500002972782, + 0.0005737583996960893 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/AROONOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/AROONOSC/tulipy]", + "params": { + "indicator": "AROONOSC", + "library": "tulipy" + }, + "param": "Momentum/AROONOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007243999993079342, + "max": 0.000933350001287181, + "mean": 0.0007813604197872337, + "stddev": 5.968612377470288e-05, + "rounds": 20, + "median": 0.0007633625995367765, + "iqr": 7.222510030260312e-05, + "q1": 0.0007333707995712757, + "q3": 0.0008055958998738789, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0007243999993079342, + "hd15iqr": 0.000933350001287181, + "ops": 1279.8191137865704, + "total": 0.015627208395744673, + "data": [ + 0.000767791600083001, + 0.0007457165993400849, + 0.0007325500002480112, + 0.0007341915988945402, + 0.0007289916000445373, + 0.0007281081998371519, + 0.0007243999993079342, + 0.0007294167997315526, + 0.0007526833986048586, + 0.0007897083996795118, + 0.0009084416000405326, + 0.0008462915997370146, + 0.000933350001287181, + 0.0008380334009416401, + 0.0008020749999559484, + 0.0008091167997918092, + 0.0007728333992417901, + 0.0007654667992028408, + 0.0007567831999040209, + 0.0007612583998707123 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/ferro_ta]", + "params": { + "indicator": "ADX", + "library": "ferro_ta" + }, + "param": "Momentum/ADX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007825666005373932, + "max": 0.0008440833989880048, + "mean": 0.0007947512300597736, + "stddev": 1.5654096134965964e-05, + "rounds": 20, + "median": 0.0007883082995249424, + "iqr": 1.4933299098629504e-05, + "q1": 0.0007846500004234259, + "q3": 0.0007995832995220554, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0007825666005373932, + "hd15iqr": 0.0008440833989880048, + "ops": 1258.2553661789104, + "total": 0.015895024601195473, + "data": [ + 0.000794933398719877, + 0.0008440833989880048, + 0.0008218750008381903, + 0.0007845500003895722, + 0.000783416599733755, + 0.0007880749995820225, + 0.0007825666005373932, + 0.0007829581998521462, + 0.0007931250002002344, + 0.0008050165997701697, + 0.0007887999992817641, + 0.0007847500004572794, + 0.0007872250003856607, + 0.0007885415994678624, + 0.0007854416006011888, + 0.0007841334008844569, + 0.0008042332003242337, + 0.0007935250003356486, + 0.0008102500010863878, + 0.0007875249997596256 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/talib]", + "params": { + "indicator": "ADX", + "library": "talib" + }, + "param": "Momentum/ADX/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00070830819895491, + "max": 0.000744108400249388, + "mean": 0.0007191333299851976, + "stddev": 1.0426507118086933e-05, + "rounds": 20, + "median": 0.0007157499996537809, + "iqr": 1.3425000361166894e-05, + "q1": 0.0007111999999324325, + "q3": 0.0007246250002935994, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00070830819895491, + "hd15iqr": 0.000744108400249388, + "ops": 1390.5627208525902, + "total": 0.014382666599703952, + "data": [ + 0.0007408999998006038, + 0.0007277832002728247, + 0.000744108400249388, + 0.0007237500001792796, + 0.0007185084003140218, + 0.0007172166006057523, + 0.0007169834003434517, + 0.000712633399234619, + 0.00071451659896411, + 0.000713733400334604, + 0.0007202416003565304, + 0.0007331665998208337, + 0.0007126583994249813, + 0.0007092166008078494, + 0.0007111250000889413, + 0.0007106999997631647, + 0.0007112749997759237, + 0.0007103418000042438, + 0.00070830819895491, + 0.0007255000004079193 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/pandas_ta]", + "params": { + "indicator": "ADX", + "library": "pandas_ta" + }, + "param": "Momentum/ADX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.02613092499959748, + "max": 0.026615133399900515, + "mean": 0.026376356669861708, + "stddev": 0.00016099240529804596, + "rounds": 20, + "median": 0.02635677499929443, + "iqr": 0.00029911659948993544, + "q1": 0.02624082080074004, + "q3": 0.026539937400229974, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.02613092499959748, + "hd15iqr": 0.026615133399900515, + "ops": 37.912741798135656, + "total": 0.5275271333972341, + "data": [ + 0.026434666599379854, + 0.026203875000646804, + 0.026173216599272565, + 0.02629480000032345, + 0.026569558400660755, + 0.026292675000149757, + 0.02613092499959748, + 0.026277766600833273, + 0.02619797499937704, + 0.02660114159953082, + 0.026372308399004396, + 0.026544683199608697, + 0.026593358399986756, + 0.026484466799593064, + 0.0263784583992674, + 0.026615133399900515, + 0.026341241599584463, + 0.026308008399792016, + 0.026177683399873787, + 0.02653519160085125 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/ta]", + "params": { + "indicator": "ADX", + "library": "ta" + }, + "param": "Momentum/ADX/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.312413858200307, + "max": 0.3195473249987117, + "mean": 0.3156811116600875, + "stddev": 0.00223900625008408, + "rounds": 20, + "median": 0.31530326260035507, + "iqr": 0.003442158399411699, + "q1": 0.31386543750049894, + "q3": 0.31730759589991064, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.312413858200307, + "hd15iqr": 0.3195473249987117, + "ops": 3.1677536699654025, + "total": 6.313622233201749, + "data": [ + 0.31291124999988823, + 0.3138548000002629, + 0.31356883319967893, + 0.3161998666008003, + 0.3158537499999511, + 0.3156285168006434, + 0.31497800840006673, + 0.31471842500031927, + 0.312413858200307, + 0.3133946999994805, + 0.31729864999942947, + 0.31868933340010697, + 0.313876075000735, + 0.31721884160069747, + 0.3195473249987117, + 0.31899495839898007, + 0.3190076415994554, + 0.31731654180039187, + 0.31421771660097875, + 0.3139331416008645 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ADX/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ADX/tulipy]", + "params": { + "indicator": "ADX", + "library": "tulipy" + }, + "param": "Momentum/ADX/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006590749995666556, + "max": 0.0006869000004371628, + "mean": 0.0006677008199039847, + "stddev": 8.570670976447353e-06, + "rounds": 20, + "median": 0.0006642624000960495, + "iqr": 1.4604099123971493e-05, + "q1": 0.0006613209006900433, + "q3": 0.0006759249998140148, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0006590749995666556, + "hd15iqr": 0.0006869000004371628, + "ops": 1497.6767590966863, + "total": 0.013354016398079694, + "data": [ + 0.0006787082005757838, + 0.0006770334002794698, + 0.0006869000004371628, + 0.00067855840025004, + 0.0006648499998846092, + 0.0006646331996307709, + 0.0006647834001341834, + 0.0006638916005613282, + 0.0006620082000154071, + 0.0006624749992624856, + 0.0006596499995794147, + 0.00067481659934856, + 0.0006688833993393928, + 0.0006817165995016694, + 0.000660858400806319, + 0.0006607749994145707, + 0.0006617834005737677, + 0.0006590749995666556, + 0.0006621499996981584, + 0.0006604665992199443 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/ferro_ta]", + "params": { + "indicator": "MOM", + "library": "ferro_ta" + }, + "param": "Momentum/MOM/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001874665991635993, + "max": 0.0002098415992804803, + "mean": 0.00019470709004963283, + "stddev": 7.458290003689772e-06, + "rounds": 20, + "median": 0.0001907499994558748, + "iqr": 1.2633399455808098e-05, + "q1": 0.00018934580075438134, + "q3": 0.00020197920021018944, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0001874665991635993, + "hd15iqr": 0.0002098415992804803, + "ops": 5135.919805206322, + "total": 0.0038941418009926566, + "data": [ + 0.0002098415992804803, + 0.00020956680091330782, + 0.0002047834001132287, + 0.00020234179974067956, + 0.0002023667999310419, + 0.00020161660067969934, + 0.0001951750004081987, + 0.00019334179960424082, + 0.00019278319959994405, + 0.00018985839997185395, + 0.00018978340085595846, + 0.00019029160030186175, + 0.00019029999966733158, + 0.00018840840057237073, + 0.00018980000022565945, + 0.00018857499962905423, + 0.000191199999244418, + 0.0001874665991635993, + 0.00018890820065280421, + 0.00018773320043692366 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/talib]", + "params": { + "indicator": "MOM", + "library": "talib" + }, + "param": "Momentum/MOM/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001795834003132768, + "max": 0.00019547499978216364, + "mean": 0.00018325792014366014, + "stddev": 4.407628562215975e-06, + "rounds": 20, + "median": 0.00018156670048483645, + "iqr": 2.5040993932634646e-06, + "q1": 0.0001807042004656978, + "q3": 0.00018320829985896125, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.0001795834003132768, + "hd15iqr": 0.00018840840057237073, + "ops": 5456.790076063707, + "total": 0.003665158402873203, + "data": [ + 0.00018227500113425775, + 0.00018282499950146304, + 0.00018214160081697628, + 0.00018170840048696845, + 0.00018186660017818212, + 0.00018139180028811097, + 0.00018840840057237073, + 0.00019037499878322707, + 0.00019091659924015404, + 0.00019547499978216364, + 0.00018359160021645947, + 0.00018049180071102455, + 0.0001797915989300236, + 0.0001795834003132768, + 0.00018091660022037103, + 0.0001799000005121343, + 0.00018104159971699119, + 0.00018100000015692786, + 0.00018003340082941576, + 0.00018142500048270449 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/pandas_ta]", + "params": { + "indicator": "MOM", + "library": "pandas_ta" + }, + "param": "Momentum/MOM/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002525999996578321, + "max": 0.00027151679969392715, + "mean": 0.00025761585973668846, + "stddev": 4.711641605958088e-06, + "rounds": 20, + "median": 0.00025629169904277664, + "iqr": 2.57500068983062e-06, + "q1": 0.00025540419956087134, + "q3": 0.00025797920025070196, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.0002525999996578321, + "hd15iqr": 0.00026317500014556573, + "ops": 3881.7485888567157, + "total": 0.005152317194733769, + "data": [ + 0.0002566833994933404, + 0.0002562999987276271, + 0.0002564249996794388, + 0.0002553500002250075, + 0.0002570166005170904, + 0.00025619159860070797, + 0.00025574180035619064, + 0.0002562833993579261, + 0.00025545839889673516, + 0.0002525999996578321, + 0.00025495000008959325, + 0.00025594159960746766, + 0.00025894179998431355, + 0.00026740839966805653, + 0.00026317500014556573, + 0.00025364180037286134, + 0.0002592333999928087, + 0.00027151679969392715, + 0.00025268319877795874, + 0.0002567750008893199 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/tulipy]", + "params": { + "indicator": "MOM", + "library": "tulipy" + }, + "param": "Momentum/MOM/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018034999957308173, + "max": 0.0001893499997095205, + "mean": 0.0001825637299043592, + "stddev": 2.1280875392349773e-06, + "rounds": 20, + "median": 0.00018165829969802872, + "iqr": 1.8667997210286558e-06, + "q1": 0.0001812416005122941, + "q3": 0.00018310840023332274, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00018034999957308173, + "hd15iqr": 0.0001860000003944151, + "ops": 5477.539270937749, + "total": 0.0036512745980871843, + "data": [ + 0.00018394159997114912, + 0.00018308340077055618, + 0.00018254159949719905, + 0.00018272500019520522, + 0.000181458200677298, + 0.00018434159865137189, + 0.00018157500016968697, + 0.00018137500010197982, + 0.00018121660104952753, + 0.00018300000083399938, + 0.00018313339969608933, + 0.00018174159922637045, + 0.0001808834000257775, + 0.0001806333995773457, + 0.00018120819877367466, + 0.00018034999957308173, + 0.00018126659997506068, + 0.0001814499992178753, + 0.0001860000003944151, + 0.0001893499997095205 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MOM/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MOM/finta]", + "params": { + "indicator": "MOM", + "library": "finta" + }, + "param": "Momentum/MOM/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00034426680067554114, + "max": 0.0003692666010465473, + "mean": 0.0003492729098070413, + "stddev": 5.596595551468649e-06, + "rounds": 20, + "median": 0.0003479707993392367, + "iqr": 4.108200664632001e-06, + "q1": 0.0003458208993833978, + "q3": 0.0003499291000480298, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.00034426680067554114, + "hd15iqr": 0.0003692666010465473, + "ops": 2863.090643223542, + "total": 0.006985458196140826, + "data": [ + 0.00034853339893743397, + 0.00034980000054929405, + 0.00034759160043904556, + 0.00034595839970279484, + 0.0003492000003461726, + 0.00034545840026112273, + 0.00035462499945424495, + 0.00034426680067554114, + 0.00034824999893317, + 0.0003476915997453034, + 0.00034661660029087213, + 0.00035005819954676555, + 0.0003452999997534789, + 0.00035319159942446274, + 0.0003456833990640007, + 0.000354416600021068, + 0.0003692666010465473, + 0.00034870839881477876, + 0.000346041600278113, + 0.0003447999988566153 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/ferro_ta]", + "params": { + "indicator": "ROC", + "library": "ferro_ta" + }, + "param": "Momentum/ROC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005732165998779237, + "max": 0.0006108665998908691, + "mean": 0.0005849749800836434, + "stddev": 1.028423994690457e-05, + "rounds": 20, + "median": 0.0005829249996168074, + "iqr": 1.8108398944605185e-05, + "q1": 0.0005760666004789527, + "q3": 0.0005941749994235578, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005732165998779237, + "hd15iqr": 0.0006108665998908691, + "ops": 1709.4748220804481, + "total": 0.011699499601672868, + "data": [ + 0.0005828416004078462, + 0.000581975000386592, + 0.0005754249999881722, + 0.0005830083988257684, + 0.0005765499998233281, + 0.0005961084010777995, + 0.0005960833994322456, + 0.0005959415997494943, + 0.0005760915999417193, + 0.0005855000010342338, + 0.0005741665998357348, + 0.0005760416010161862, + 0.0005830166002851911, + 0.0005732165998779237, + 0.0005744750000303611, + 0.0005780584004241973, + 0.0005945749988313764, + 0.0006108665998908691, + 0.0005937750000157393, + 0.0005917832007980905 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/talib]", + "params": { + "indicator": "ROC", + "library": "talib" + }, + "param": "Momentum/ROC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020150819909758866, + "max": 0.00021743339893873782, + "mean": 0.00020540749974315987, + "stddev": 4.256377591251137e-06, + "rounds": 20, + "median": 0.00020329589970060624, + "iqr": 4.537498898571368e-06, + "q1": 0.00020265000057406723, + "q3": 0.0002071874994726386, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00020150819909758866, + "hd15iqr": 0.00021743339893873782, + "ops": 4868.371414142099, + "total": 0.004108149994863197, + "data": [ + 0.00020322500058682634, + 0.00020323339995229617, + 0.00020261659956304355, + 0.00020331679988885298, + 0.0002027833994361572, + 0.00020236660056980327, + 0.00020264160120859743, + 0.00020265839993953704, + 0.0002066999993985519, + 0.00020436659979168327, + 0.00020428339921636508, + 0.00020150819909758866, + 0.00020244999905116856, + 0.0002032749995123595, + 0.00020812500006286427, + 0.00021144999918760733, + 0.0002134331996785477, + 0.00021743339893873782, + 0.00020767499954672531, + 0.00020460840023588388 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/pandas_ta]", + "params": { + "indicator": "ROC", + "library": "pandas_ta" + }, + "param": "Momentum/ROC/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002709918000618927, + "max": 0.0002925667999079451, + "mean": 0.0002791746000002604, + "stddev": 6.394852125715163e-06, + "rounds": 20, + "median": 0.00027857509994646536, + "iqr": 8.070799231063596e-06, + "q1": 0.0002734250003413763, + "q3": 0.0002814957995724399, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0002709918000618927, + "hd15iqr": 0.0002925667999079451, + "ops": 3581.9877596280867, + "total": 0.005583492000005208, + "data": [ + 0.00029130819893907756, + 0.00027853339997818694, + 0.00027717500051949173, + 0.0002788249999866821, + 0.0002724168007262051, + 0.0002813415994751267, + 0.00027894160011783243, + 0.0002773416010313667, + 0.0002722165998420678, + 0.0002782668001600541, + 0.0002733000001171604, + 0.00027134179981658235, + 0.00028164999966975304, + 0.0002735500005655922, + 0.0002709918000618927, + 0.00027861679991474374, + 0.00028000000020256266, + 0.000288208200072404, + 0.0002925667999079451, + 0.0002868999989004806 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/ta]", + "params": { + "indicator": "ROC", + "library": "ta" + }, + "param": "Momentum/ROC/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003523168008541688, + "max": 0.0004730334010673687, + "mean": 0.000376715419915854, + "stddev": 2.8545009554807095e-05, + "rounds": 20, + "median": 0.0003659583002445288, + "iqr": 3.3991799864452376e-05, + "q1": 0.0003577498995582573, + "q3": 0.00039174169942270967, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0003523168008541688, + "hd15iqr": 0.0004730334010673687, + "ops": 2654.5236725997775, + "total": 0.00753430839831708, + "data": [ + 0.0003580499993404374, + 0.00035350839898455887, + 0.0003579415992135182, + 0.0003567166000721045, + 0.0003575581999029964, + 0.00035465000109979883, + 0.000358083400351461, + 0.0003625084005761892, + 0.0003523168008541688, + 0.0003683499991893768, + 0.0003829665991361253, + 0.00038340820028679443, + 0.00039680839981883766, + 0.0003955749998567626, + 0.0004730334010673687, + 0.0004074333992321044, + 0.00038840839988552034, + 0.00039507499895989894, + 0.00036749999999301507, + 0.00036441660049604254 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/tulipy]", + "params": { + "indicator": "ROC", + "library": "tulipy" + }, + "param": "Momentum/ROC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020316659938544035, + "max": 0.00022015839931555092, + "mean": 0.00021101706988702063, + "stddev": 4.937421930803606e-06, + "rounds": 20, + "median": 0.00021103740000398828, + "iqr": 8.84159962879496e-06, + "q1": 0.00020706249997601842, + "q3": 0.00021590409960481338, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00020316659938544035, + "hd15iqr": 0.00022015839931555092, + "ops": 4738.953111875755, + "total": 0.0042203413977404125, + "data": [ + 0.00021633340074913576, + 0.00021617500024149195, + 0.00021161659969948232, + 0.00020700000022770836, + 0.0002066332002868876, + 0.00021146660001249983, + 0.0002091999995172955, + 0.00020386679971124978, + 0.00020409160060808063, + 0.00021604159992421045, + 0.0002165333993616514, + 0.00021576659928541632, + 0.0002149499996448867, + 0.00022015839931555092, + 0.0002071249997243285, + 0.00020316659938544035, + 0.00020715839928016068, + 0.00021060819999547676, + 0.00021186660014791415, + 0.00021058340062154456 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ROC/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ROC/finta]", + "params": { + "indicator": "ROC", + "library": "finta" + }, + "param": "Momentum/ROC/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00046379179984796793, + "max": 0.0005776834004791453, + "mean": 0.0005007562698301626, + "stddev": 3.3027066650140045e-05, + "rounds": 20, + "median": 0.0004883915993559641, + "iqr": 4.884170048171662e-05, + "q1": 0.000477383299585199, + "q3": 0.0005262250000669156, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00046379179984796793, + "hd15iqr": 0.0005776834004791453, + "ops": 1996.9794893215449, + "total": 0.010015125396603253, + "data": [ + 0.00046756679948884995, + 0.00048085839953273537, + 0.0004818831992452033, + 0.00046825820027152076, + 0.00046379179984796793, + 0.00047448339901166035, + 0.000489674998971168, + 0.0004917584010399878, + 0.0005234582000412047, + 0.0005152166006155312, + 0.0005776834004791453, + 0.0005492500000400469, + 0.0005403167990152725, + 0.0005289918000926264, + 0.0004901667998638004, + 0.0005495083998539485, + 0.00048038340028142554, + 0.0004745749989524484, + 0.00048710819974076005, + 0.0004801916002179496 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/ferro_ta]", + "params": { + "indicator": "CMO", + "library": "ferro_ta" + }, + "param": "Momentum/CMO/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008705168002052233, + "max": 0.0009080666000954807, + "mean": 0.0008871371100394753, + "stddev": 1.1517701573121492e-05, + "rounds": 20, + "median": 0.0008849833000567741, + "iqr": 2.1204100630711807e-05, + "q1": 0.0008761001001403202, + "q3": 0.000897304200771032, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0008705168002052233, + "hd15iqr": 0.0009080666000954807, + "ops": 1127.2214730770336, + "total": 0.01774274220078951, + "data": [ + 0.0008838915993692354, + 0.0008749165994231589, + 0.0008705168002052233, + 0.0008884668000973761, + 0.0008991416005301289, + 0.0008740833989577367, + 0.0008836834007524885, + 0.0008729334003874101, + 0.0008786000005784444, + 0.0008761584002058953, + 0.0008911167999031023, + 0.000898391600640025, + 0.000894583399349358, + 0.0008839165995595977, + 0.000876041800074745, + 0.0009080666000954807, + 0.0009046749997651205, + 0.0008962168009020388, + 0.0009012915994389914, + 0.0008860500005539506 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/talib]", + "params": { + "indicator": "CMO", + "library": "talib" + }, + "param": "Momentum/CMO/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006336250007734634, + "max": 0.0007171749995904975, + "mean": 0.0006655862698971759, + "stddev": 2.3764854657654948e-05, + "rounds": 20, + "median": 0.0006584249000297858, + "iqr": 2.112919974024414e-05, + "q1": 0.0006508708996989298, + "q3": 0.000672000099439174, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0006336250007734634, + "hd15iqr": 0.0007157917993026785, + "ops": 1502.4348386190215, + "total": 0.013311725397943518, + "data": [ + 0.000650649999442976, + 0.0006519665999803692, + 0.0006336250007734634, + 0.0006534000000101514, + 0.0006666167988441885, + 0.0007171749995904975, + 0.0006606915994780138, + 0.0006585832001292147, + 0.0006454083995777182, + 0.0006610165990423411, + 0.00065606660064077, + 0.000645941800030414, + 0.0006975834010518156, + 0.0007157917993026785, + 0.000701100000878796, + 0.0006773834000341594, + 0.0006582665999303571, + 0.0006489499995950609, + 0.0006510917999548837, + 0.0006604167996556498 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/pandas_ta]", + "params": { + "indicator": "CMO", + "library": "pandas_ta" + }, + "param": "Momentum/CMO/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000703675000113435, + "max": 0.0008264999996754341, + "mean": 0.0007384612700116122, + "stddev": 3.1877089828718824e-05, + "rounds": 20, + "median": 0.0007330957996600773, + "iqr": 2.2483300563180797e-05, + "q1": 0.0007202000000688713, + "q3": 0.0007426833006320521, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.000703675000113435, + "hd15iqr": 0.0008202583994716406, + "ops": 1354.1671589415585, + "total": 0.014769225400232244, + "data": [ + 0.0007353584005613811, + 0.000737866600684356, + 0.0007315915994695388, + 0.0007345999998506159, + 0.0007223999986308627, + 0.0007124499999918044, + 0.000703675000113435, + 0.0007122831986634992, + 0.0008264999996754341, + 0.0008202583994716406, + 0.0007580834004329518, + 0.0007239168000523933, + 0.0007239918006234803, + 0.0007184168003732339, + 0.0007215000005089678, + 0.0007188999996287748, + 0.0007425500007229857, + 0.0007452917998307385, + 0.0007428166005411186, + 0.0007367750004050322 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/tulipy]", + "params": { + "indicator": "CMO", + "library": "tulipy" + }, + "param": "Momentum/CMO/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000310491600248497, + "max": 0.00033132499956991526, + "mean": 0.0003180750001774868, + "stddev": 7.010536234222882e-06, + "rounds": 20, + "median": 0.0003153666009893641, + "iqr": 1.1208400974283038e-05, + "q1": 0.0003120166991720907, + "q3": 0.00032322510014637373, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.000310491600248497, + "hd15iqr": 0.00033132499956991526, + "ops": 3143.912597475429, + "total": 0.006361500003549736, + "data": [ + 0.0003299916003015824, + 0.0003242000006139278, + 0.00031591660081176087, + 0.0003309000006993301, + 0.0003143166002701037, + 0.0003213666001101956, + 0.00031059160100994633, + 0.0003228583998861723, + 0.00033132499956991526, + 0.0003235918004065752, + 0.0003185584006132558, + 0.0003109999990556389, + 0.00031199999939417465, + 0.00031835000118007883, + 0.0003120333989500068, + 0.0003148166011669673, + 0.000310491600248497, + 0.0003108667995547876, + 0.0003148083997075446, + 0.00031351659999927505 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/CMO/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/CMO/finta]", + "params": { + "indicator": "CMO", + "library": "finta" + }, + "param": "Momentum/CMO/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022429917997214945, + "max": 0.002608574999612756, + "mean": 0.0023051533199031837, + "stddev": 8.114496390872666e-05, + "rounds": 20, + "median": 0.002277404199412558, + "iqr": 6.779989998904066e-05, + "q1": 0.0022596625000005587, + "q3": 0.0023274623999895994, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0022429917997214945, + "hd15iqr": 0.002608574999612756, + "ops": 433.8106239466969, + "total": 0.04610306639806368, + "data": [ + 0.002265158199588768, + 0.00230511679983465, + 0.0023457000002963468, + 0.0022598249997827224, + 0.00227561679930659, + 0.0023558750006486663, + 0.002608574999612756, + 0.0023205831996165214, + 0.0022590250009670854, + 0.002284058400255162, + 0.0022599415999138726, + 0.0022459581989096476, + 0.0022677000000840054, + 0.002259500000218395, + 0.002365916600683704, + 0.0023191665997728704, + 0.002248824998969212, + 0.0022791915995185263, + 0.0023343416003626773, + 0.0022429917997214945 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/ferro_ta]", + "params": { + "indicator": "PPO", + "library": "ferro_ta" + }, + "param": "Momentum/PPO/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00038585000002058225, + "max": 0.00041049160063266754, + "mean": 0.0003929562001576414, + "stddev": 7.182543838519843e-06, + "rounds": 20, + "median": 0.0003912249994755257, + "iqr": 9.208499977830862e-06, + "q1": 0.0003870124004606623, + "q3": 0.00039622090043849316, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00038585000002058225, + "hd15iqr": 0.00041049160063266754, + "ops": 2544.8128814326687, + "total": 0.007859124003152829, + "data": [ + 0.00039381660026265307, + 0.00039756659971317275, + 0.00041049160063266754, + 0.00039304160018218683, + 0.00038950820016907527, + 0.0003955584004870616, + 0.00039688340038992467, + 0.00039054159860825167, + 0.00038696660049026833, + 0.0003870582004310563, + 0.00038613320066360757, + 0.00038585000002058225, + 0.0003859916003420949, + 0.0003919084003427997, + 0.0004069415997946635, + 0.0004033416000311263, + 0.0003900082010659389, + 0.0003862500001559965, + 0.00039420839893864466, + 0.0003870582004310563 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/talib]", + "params": { + "indicator": "PPO", + "library": "talib" + }, + "param": "Momentum/PPO/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005083249998278916, + "max": 0.0005590333996224218, + "mean": 0.000522359559981851, + "stddev": 1.1521384078510251e-05, + "rounds": 20, + "median": 0.0005218582991801668, + "iqr": 1.0345799091737696e-05, + "q1": 0.0005146917006641161, + "q3": 0.0005250374997558538, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0005083249998278916, + "hd15iqr": 0.0005590333996224218, + "ops": 1914.390156915563, + "total": 0.010447191199637018, + "data": [ + 0.0005157581996172667, + 0.0005133083992404863, + 0.0005098915993585251, + 0.0005146416006027721, + 0.0005151418008608744, + 0.0005238832003669813, + 0.0005228665992035531, + 0.0005242168001132086, + 0.0005115666004712694, + 0.0005083249998278916, + 0.0005147418007254601, + 0.0005185166010051034, + 0.0005208499991567805, + 0.0005241165999905206, + 0.0005323749996023252, + 0.0005258581993984989, + 0.0005240915998001583, + 0.0005590333996224218, + 0.000533749999885913, + 0.0005342582007870078 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/pandas_ta]", + "params": { + "indicator": "PPO", + "library": "pandas_ta" + }, + "param": "Momentum/PPO/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010095416000694968, + "max": 0.0012498834010330028, + "mean": 0.0011132995702064364, + "stddev": 7.611714127019214e-05, + "rounds": 20, + "median": 0.001116179199743783, + "iqr": 0.00013546249974751836, + "q1": 0.0010420375001558568, + "q3": 0.0011774999999033752, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0010095416000694968, + "hd15iqr": 0.0012498834010330028, + "ops": 898.2308327080128, + "total": 0.022265991404128726, + "data": [ + 0.0010273416002746672, + 0.0010348916010116227, + 0.0012015082000289112, + 0.001174583200190682, + 0.0012498834010330028, + 0.0011318666001898237, + 0.0010399916005553677, + 0.0010458000004291534, + 0.001109174999874085, + 0.0010095416000694968, + 0.0011629084008745849, + 0.0010352582001360133, + 0.0011804167996160686, + 0.0012156249998952263, + 0.0011260166007559746, + 0.0012227750004967675, + 0.0010561168004642242, + 0.001044083399756346, + 0.001123183399613481, + 0.0010750249988632278 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/tulipy]", + "params": { + "indicator": "PPO", + "library": "tulipy" + }, + "param": "Momentum/PPO/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00036158319999231027, + "max": 0.0009160750007140451, + "mean": 0.00043860249024874065, + "stddev": 0.0001234664951004206, + "rounds": 20, + "median": 0.0003997541003627703, + "iqr": 3.0229200638132137e-05, + "q1": 0.0003903332995832898, + "q3": 0.00042056250022142193, + "iqr_outliers": 4, + "stddev_outliers": 1, + "outliers": "1;4", + "ld15iqr": 0.00036158319999231027, + "hd15iqr": 0.0005043500001193024, + "ops": 2279.968815117486, + "total": 0.008772049804974813, + "data": [ + 0.00036158319999231027, + 0.0003632084000855684, + 0.0009160750007140451, + 0.0005416082000010647, + 0.00040490840037818996, + 0.00042967500048689543, + 0.0003927334008039907, + 0.0004015168000478297, + 0.00040034160047071056, + 0.0003919581999070942, + 0.00041144999995594843, + 0.0003887083992594853, + 0.0005043500001193024, + 0.0003956750006182119, + 0.0003986416006227955, + 0.0005273581991787069, + 0.0003646916011348367, + 0.00039916660025482995, + 0.00040203340031439436, + 0.0003763668006286025 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PPO/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PPO/finta]", + "params": { + "indicator": "PPO", + "library": "finta" + }, + "param": "Momentum/PPO/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002286949999688659, + "max": 0.0025454249989707023, + "mean": 0.0023750178999034687, + "stddev": 8.154834891870603e-05, + "rounds": 20, + "median": 0.0023491292005928697, + "iqr": 0.00013560430015786568, + "q1": 0.0023027956995065324, + "q3": 0.002438399999664398, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.002286949999688659, + "hd15iqr": 0.0025454249989707023, + "ops": 421.04945821277573, + "total": 0.04750035799806938, + "data": [ + 0.0023470584012102334, + 0.002302483200037386, + 0.002351199999975506, + 0.002516641600232106, + 0.0024282918006065302, + 0.002316966599028092, + 0.0023197581991553306, + 0.002314766601193696, + 0.0024520250008208677, + 0.0024485081987222655, + 0.002363158400112297, + 0.0025454249989707023, + 0.002425741599290632, + 0.0024705749994609503, + 0.002296283400210086, + 0.0022917584006791002, + 0.0024233083997387437, + 0.0023031081989756787, + 0.002296349999960512, + 0.002286949999688659 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/ferro_ta]", + "params": { + "indicator": "TRIX", + "library": "ferro_ta" + }, + "param": "Momentum/TRIX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00047934160102158785, + "max": 0.0005070500003057532, + "mean": 0.00048665662994608284, + "stddev": 6.984821122236118e-06, + "rounds": 20, + "median": 0.0004840708003030159, + "iqr": 5.4083997383713505e-06, + "q1": 0.0004824124000151642, + "q3": 0.00048782079975353556, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00047934160102158785, + "hd15iqr": 0.0004997915995772928, + "ops": 2054.836898268068, + "total": 0.009733132598921657, + "data": [ + 0.0004884499998297542, + 0.00048719159967731686, + 0.0004997915995772928, + 0.0004943166000884958, + 0.00048555819957982747, + 0.0004824082003324293, + 0.00048436659999424593, + 0.0004821668000658974, + 0.0004824165996978991, + 0.0004828165998333134, + 0.0004844668001169339, + 0.0004811915991012938, + 0.00047934160102158785, + 0.00048111659998539835, + 0.00048255820001941174, + 0.0004934249998768791, + 0.0005070500003057532, + 0.00048707499954616653, + 0.0004836499996599741, + 0.0004837750006117858 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/talib]", + "params": { + "indicator": "TRIX", + "library": "talib" + }, + "param": "Momentum/TRIX/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007628831997863017, + "max": 0.0008056581995333545, + "mean": 0.0007738137599517358, + "stddev": 1.2858489258549825e-05, + "rounds": 20, + "median": 0.000767741600429872, + "iqr": 1.4145798922982154e-05, + "q1": 0.0007646292004210408, + "q3": 0.000778774999344023, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0007628831997863017, + "hd15iqr": 0.0008024084003409371, + "ops": 1292.3006177382679, + "total": 0.015476275199034717, + "data": [ + 0.0007699084002524614, + 0.0007673500003875233, + 0.0007901499993749894, + 0.0008024084003409371, + 0.0008056581995333545, + 0.0007715667990851216, + 0.00076730840082746, + 0.0007660667994059623, + 0.0007681332004722208, + 0.0007640333991730585, + 0.0007786165995639748, + 0.0007752666002488695, + 0.0007643083998118527, + 0.0007645083998795599, + 0.0007628831997863017, + 0.0007636584006831982, + 0.0007647500009625219, + 0.0007650750005268492, + 0.000778933399124071, + 0.0007856915995944292 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/pandas_ta]", + "params": { + "indicator": "TRIX", + "library": "pandas_ta" + }, + "param": "Momentum/TRIX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017738166003255173, + "max": 0.002085041800455656, + "mean": 0.0018278883500170197, + "stddev": 8.54246923148818e-05, + "rounds": 20, + "median": 0.0017988833002164028, + "iqr": 5.2466800843831065e-05, + "q1": 0.0017805749994295184, + "q3": 0.0018330418002733494, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0017738166003255173, + "hd15iqr": 0.0020403667993377896, + "ops": 547.0793661936129, + "total": 0.03655776700034039, + "data": [ + 0.001799366599880159, + 0.0018001166012254544, + 0.0017805249997763894, + 0.001779341600195039, + 0.0017993999994359911, + 0.0017834333993960172, + 0.0017826999988756142, + 0.0018119168002158404, + 0.0018541668003308586, + 0.001779983400774654, + 0.001779250000254251, + 0.0017984000005526468, + 0.0017806249990826473, + 0.0017738166003255173, + 0.002085041800455656, + 0.0020403667993377896, + 0.001873774999694433, + 0.0018566582002677023, + 0.0018088084005285054, + 0.001790074999735225 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/ta]", + "params": { + "indicator": "TRIX", + "library": "ta" + }, + "param": "Momentum/TRIX/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0018672165999305435, + "max": 0.0020900917996186765, + "mean": 0.001929315830129781, + "stddev": 6.519845812273049e-05, + "rounds": 20, + "median": 0.0018982333000167272, + "iqr": 8.687910012668016e-05, + "q1": 0.0018829209002433345, + "q3": 0.0019698000003700146, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0018672165999305435, + "hd15iqr": 0.0020900917996186765, + "ops": 518.3184548549173, + "total": 0.038586316602595615, + "data": [ + 0.001877233199775219, + 0.0018851000000722705, + 0.0018944081995869056, + 0.0018868915998609737, + 0.0018806499996571802, + 0.001971316599519923, + 0.001936691800074186, + 0.0018901418006862514, + 0.001921783400757704, + 0.0018807418004143984, + 0.0018979332002345473, + 0.0019059916012338363, + 0.0018985333997989073, + 0.0018672165999305435, + 0.0018728750001173466, + 0.0019792831997619944, + 0.0020900917996186765, + 0.0019682834012201057, + 0.002020541600359138, + 0.002060608399915509 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/tulipy]", + "params": { + "indicator": "TRIX", + "library": "tulipy" + }, + "param": "Momentum/TRIX/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000425041800190229, + "max": 0.0004840583991608582, + "mean": 0.0004373366798972711, + "stddev": 1.3017255071969323e-05, + "rounds": 20, + "median": 0.0004330625000875443, + "iqr": 1.0933299927273744e-05, + "q1": 0.0004303875000914559, + "q3": 0.00044132080001872963, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.000425041800190229, + "hd15iqr": 0.0004840583991608582, + "ops": 2286.5678685695802, + "total": 0.008746733597945422, + "data": [ + 0.0004325168003560975, + 0.00044010819983668624, + 0.000443000000086613, + 0.00043628319981507956, + 0.0004348417991423048, + 0.00042664999928092583, + 0.0004312831995775923, + 0.00042832500039367003, + 0.00043960000039078295, + 0.0004323418004787527, + 0.0004322749999118969, + 0.00042626659997040405, + 0.00043360819981899115, + 0.000425041800190229, + 0.00042949180060531945, + 0.0004320831998484209, + 0.00044384179927874355, + 0.00045258339960128067, + 0.000442533400200773, + 0.0004840583991608582 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TRIX/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TRIX/finta]", + "params": { + "indicator": "TRIX", + "library": "finta" + }, + "param": "Momentum/TRIX/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0017655999996350146, + "max": 0.0019041249994188546, + "mean": 0.0018084087598981568, + "stddev": 3.459924289492348e-05, + "rounds": 20, + "median": 0.0018037791996903252, + "iqr": 4.5558300189441027e-05, + "q1": 0.0017808666998462286, + "q3": 0.0018264250000356696, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0017655999996350146, + "hd15iqr": 0.0019041249994188546, + "ops": 552.9723269291819, + "total": 0.036168175197963136, + "data": [ + 0.0018652334008947946, + 0.0018038665992207825, + 0.0017757666006218641, + 0.0017888083995785565, + 0.0017987083992920816, + 0.0019041249994188546, + 0.001822041800187435, + 0.0018308081998839043, + 0.00178169999999227, + 0.0017737249989295378, + 0.0018089416000293568, + 0.00183942500007106, + 0.001803691800159868, + 0.001780033399700187, + 0.0017924833999131806, + 0.001768733399512712, + 0.0017655999996350146, + 0.0018163250002544372, + 0.0018145332011044956, + 0.001833624999562744 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/ferro_ta]", + "params": { + "indicator": "TSF", + "library": "ferro_ta" + }, + "param": "Momentum/TSF/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0014891582002746872, + "max": 0.0015583250002237036, + "mean": 0.0015040954000141936, + "stddev": 1.6717074571349935e-05, + "rounds": 20, + "median": 0.0014978499995777382, + "iqr": 1.6704100562492342e-05, + "q1": 0.001493691699579358, + "q3": 0.0015103958001418504, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0014891582002746872, + "hd15iqr": 0.0015583250002237036, + "ops": 664.8514449220197, + "total": 0.030081908000283875, + "data": [ + 0.0014940667999326252, + 0.0014891582002746872, + 0.0015299165999749676, + 0.0015121499993256294, + 0.0014896416003466583, + 0.0015015249999123625, + 0.0015203000002657063, + 0.0014956334009184502, + 0.001493316599226091, + 0.0014918084008968436, + 0.0015093166002770886, + 0.0015070333989569916, + 0.001496383199992124, + 0.0014903416013112292, + 0.0014989749994128942, + 0.0015114750000066123, + 0.0014971833996241912, + 0.0014968415998737328, + 0.0014985165995312854, + 0.0015583250002237036 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/talib]", + "params": { + "indicator": "TSF", + "library": "talib" + }, + "param": "Momentum/TSF/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006686417997116223, + "max": 0.0006941915999050252, + "mean": 0.0006773591600358486, + "stddev": 6.8613734264403555e-06, + "rounds": 20, + "median": 0.0006759917996532749, + "iqr": 6.358300015563102e-06, + "q1": 0.0006727666004735511, + "q3": 0.0006791249004891142, + "iqr_outliers": 3, + "stddev_outliers": 4, + "outliers": "4;3", + "ld15iqr": 0.0006686417997116223, + "hd15iqr": 0.0006902915993123315, + "ops": 1476.3216606490948, + "total": 0.013547183200716972, + "data": [ + 0.0006738418000168167, + 0.0006766581995179876, + 0.0006710665998980403, + 0.0006803166004829108, + 0.0006941915999050252, + 0.000676016800571233, + 0.0006761249998817221, + 0.0006715833995258435, + 0.0006686417997116223, + 0.0006779332004953175, + 0.0006723832004354336, + 0.0006731500005116686, + 0.0006748416009941139, + 0.0006902915993123315, + 0.0006807082012528553, + 0.0006903834000695497, + 0.0006762749995687045, + 0.0006750833999831229, + 0.0006759667987353169, + 0.0006717249998473562 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/TSF/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/TSF/tulipy]", + "params": { + "indicator": "TSF", + "library": "tulipy" + }, + "param": "Momentum/TSF/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003565749997505918, + "max": 0.0003744916000869125, + "mean": 0.0003630899700510781, + "stddev": 5.883771477622556e-06, + "rounds": 20, + "median": 0.0003602458004024811, + "iqr": 9.983299969462656e-06, + "q1": 0.0003583457997592632, + "q3": 0.00036832909972872585, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0003565749997505918, + "hd15iqr": 0.0003744916000869125, + "ops": 2754.138319654833, + "total": 0.0072617994010215625, + "data": [ + 0.0003679000001284294, + 0.0003727916002389975, + 0.0003744916000869125, + 0.00036035840021213516, + 0.00035883320088032634, + 0.0003586165999877267, + 0.0003601332005928271, + 0.0003687581993290223, + 0.0003598416005843319, + 0.00035950000019511206, + 0.0003580749995307997, + 0.0003565749997505918, + 0.00035723339969990776, + 0.00035706680064322426, + 0.0003659750000224449, + 0.00035670819925144316, + 0.00036362500104587526, + 0.0003643667994765565, + 0.0003693831997225061, + 0.00037156659964239226 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/ferro_ta]", + "params": { + "indicator": "ULTOSC", + "library": "ferro_ta" + }, + "param": "Momentum/ULTOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0020500166006968356, + "max": 0.002237850001256447, + "mean": 0.002097895009937929, + "stddev": 4.822756207424809e-05, + "rounds": 20, + "median": 0.002085870899463771, + "iqr": 5.284159997245288e-05, + "q1": 0.0020589416999428067, + "q3": 0.0021117832999152596, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0020500166006968356, + "hd15iqr": 0.002237850001256447, + "ops": 476.66827713632216, + "total": 0.04195790019875858, + "data": [ + 0.002072858400060795, + 0.002098116600245703, + 0.0020557417999953033, + 0.0021158415998797863, + 0.0020991832003346643, + 0.0020500166006968356, + 0.0020831333997193722, + 0.002059191800071858, + 0.0020578333991579712, + 0.0020728667994262652, + 0.0020586915998137556, + 0.002051883398962673, + 0.0020763166001415813, + 0.0020886083992081696, + 0.002107724999950733, + 0.002128916600486264, + 0.002237850001256447, + 0.0021587249997537584, + 0.0021051415998954324, + 0.0021792583997012117 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/talib]", + "params": { + "indicator": "ULTOSC", + "library": "talib" + }, + "param": "Momentum/ULTOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006138750002719461, + "max": 0.0006391831993823871, + "mean": 0.0006263729000056628, + "stddev": 7.535996214289021e-06, + "rounds": 20, + "median": 0.0006272749989875593, + "iqr": 1.326679994235753e-05, + "q1": 0.0006187958002556116, + "q3": 0.0006320626001979691, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0006138750002719461, + "hd15iqr": 0.0006391831993823871, + "ops": 1596.4930794275413, + "total": 0.012527458000113257, + "data": [ + 0.0006356167999911122, + 0.0006391831993823871, + 0.000633633199322503, + 0.0006314666010439396, + 0.0006256666005356237, + 0.0006303749993094243, + 0.0006249500002013519, + 0.0006193665991304443, + 0.0006312166005955078, + 0.0006266749987844378, + 0.0006334999998216517, + 0.0006314918005955406, + 0.0006326333998003975, + 0.0006278749991906807, + 0.0006222915995749645, + 0.0006138750002719461, + 0.0006175166010507383, + 0.0006177166011184454, + 0.0006141833990113809, + 0.0006182250013807789 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/ta]", + "params": { + "indicator": "ULTOSC", + "library": "ta" + }, + "param": "Momentum/ULTOSC/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.01351567499950761, + "max": 0.014012683399778325, + "mean": 0.013733944169871393, + "stddev": 0.00011490187969080973, + "rounds": 20, + "median": 0.013749662499321857, + "iqr": 0.00010492489964235643, + "q1": 0.013686337599938269, + "q3": 0.013791262499580625, + "iqr_outliers": 3, + "stddev_outliers": 5, + "outliers": "5;3", + "ld15iqr": 0.013560283400875051, + "hd15iqr": 0.014012683399778325, + "ops": 72.81229540700573, + "total": 0.27467888339742785, + "data": [ + 0.013526349999301602, + 0.01351567499950761, + 0.013560283400875051, + 0.013867466599913314, + 0.013809416598815006, + 0.01377034180040937, + 0.013804033200722187, + 0.013754499999049586, + 0.013730600000417325, + 0.013702108401048463, + 0.01376826679916121, + 0.014012683399778325, + 0.013797274998796637, + 0.013712225000199396, + 0.013670566798828077, + 0.013645016599912196, + 0.013759041600860656, + 0.0137429581998731, + 0.013785250000364613, + 0.013744824999594129 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/ULTOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/ULTOSC/tulipy]", + "params": { + "indicator": "ULTOSC", + "library": "tulipy" + }, + "param": "Momentum/ULTOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005771083990111947, + "max": 0.0005984749994240701, + "mean": 0.0005843562600057339, + "stddev": 5.6281566679210384e-06, + "rounds": 20, + "median": 0.0005823874998895917, + "iqr": 7.974899926921396e-06, + "q1": 0.0005805542001326102, + "q3": 0.0005885291000595316, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005771083990111947, + "hd15iqr": 0.0005984749994240701, + "ops": 1711.2848247577388, + "total": 0.011687125200114678, + "data": [ + 0.000581225000496488, + 0.0005818833989906125, + 0.0005798833997687324, + 0.0005816000004415401, + 0.0005827916000271216, + 0.0005830499998410232, + 0.0005984749994240701, + 0.0005880416007130407, + 0.0005813499999931082, + 0.0005827000000863336, + 0.0005796416007797234, + 0.0005796584009658545, + 0.0005834084004163742, + 0.0005793333999463357, + 0.0005771083990111947, + 0.0005820749996928498, + 0.00059027499955846, + 0.0005937584006460384, + 0.0005890165994060226, + 0.0005918499999097548 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/ferro_ta]", + "params": { + "indicator": "BOP", + "library": "ferro_ta" + }, + "param": "Momentum/BOP/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002463582000928, + "max": 0.0002629084003274329, + "mean": 0.0002501195499644382, + "stddev": 4.1155900247477305e-06, + "rounds": 20, + "median": 0.0002483999000105541, + "iqr": 2.7540998416952897e-06, + "q1": 0.00024779169980320147, + "q3": 0.00025054579964489676, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.0002463582000928, + "hd15iqr": 0.00025615839986130594, + "ops": 3998.0881148322046, + "total": 0.0050023909992887635, + "data": [ + 0.0002487000005203299, + 0.00025081659987336025, + 0.0002490750004653819, + 0.0002498000001651235, + 0.00024853319919202475, + 0.0002502749994164333, + 0.00024785839923424646, + 0.00024769159936113285, + 0.0002517332002753392, + 0.0002482081996276975, + 0.0002472499996656552, + 0.0002477250003721565, + 0.0002463582000928, + 0.0002573165998910554, + 0.00025615839986130594, + 0.0002629084003274329, + 0.00024826660082908345, + 0.0002480750001268461, + 0.0002481415998772718, + 0.000247500000114087 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/talib]", + "params": { + "indicator": "BOP", + "library": "talib" + }, + "param": "Momentum/BOP/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022670819889754058, + "max": 0.00025359160063089805, + "mean": 0.00023103166015062015, + "stddev": 6.338756129401035e-06, + "rounds": 20, + "median": 0.00022895000074640848, + "iqr": 1.4624994946643602e-06, + "q1": 0.00022830000016256237, + "q3": 0.00022976249965722673, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.00022670819889754058, + "hd15iqr": 0.00023579999979119747, + "ops": 4328.411090272451, + "total": 0.004620633203012403, + "data": [ + 0.00022932499996386468, + 0.00022944999946048484, + 0.0002293333993293345, + 0.00023177499970188363, + 0.00023007499985396861, + 0.0002285334005136974, + 0.0002280665998114273, + 0.0002288500007125549, + 0.00022891660046298057, + 0.00022721660061506555, + 0.00022864999918965624, + 0.00022760840074624865, + 0.00022862500045448542, + 0.00022798340069130063, + 0.00022898340102983639, + 0.00022670819889754058, + 0.00022899160103406757, + 0.00023579999979119747, + 0.00024215000012191014, + 0.00025359160063089805 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/pandas_ta]", + "params": { + "indicator": "BOP", + "library": "pandas_ta" + }, + "param": "Momentum/BOP/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035473320021992547, + "max": 0.0003798499994445592, + "mean": 0.00036205205011356154, + "stddev": 7.225315851873669e-06, + "rounds": 20, + "median": 0.00035880000068573277, + "iqr": 7.387699588434738e-06, + "q1": 0.00035757070072577334, + "q3": 0.0003649584003142081, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00035473320021992547, + "hd15iqr": 0.0003765665998798795, + "ops": 2762.033800627117, + "total": 0.007241041002271231, + "data": [ + 0.0003798499994445592, + 0.00037386659969342875, + 0.00036376680072862654, + 0.00035814159928122536, + 0.0003580332006094977, + 0.0003585415994166397, + 0.0003593916000681929, + 0.00035710820084204896, + 0.00035473320021992547, + 0.00035560819960664956, + 0.0003567084000678733, + 0.0003570082000805996, + 0.00036614999989978967, + 0.0003682500013383105, + 0.0003765665998798795, + 0.0003620999996201135, + 0.0003586500009987503, + 0.0003585668004234321, + 0.0003589500003727153, + 0.00035904999967897313 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/BOP/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/BOP/tulipy]", + "params": { + "indicator": "BOP", + "library": "tulipy" + }, + "param": "Momentum/BOP/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002244666000478901, + "max": 0.00023744179925415664, + "mean": 0.00022864414997457062, + "stddev": 4.089572663123374e-06, + "rounds": 20, + "median": 0.00022708750038873403, + "iqr": 4.887500108452503e-06, + "q1": 0.00022589159998460674, + "q3": 0.00023077910009305924, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002244666000478901, + "hd15iqr": 0.00023744179925415664, + "ops": 4373.608509604196, + "total": 0.004572882999491412, + "data": [ + 0.00022854999988339842, + 0.00022838320001028478, + 0.00022604999976465477, + 0.00022724160080542788, + 0.00022610839951084927, + 0.0002269333999720402, + 0.0002251915997476317, + 0.00022755839891033247, + 0.00022589160071220248, + 0.00022589159925701097, + 0.00022897500020917504, + 0.00022600820084335284, + 0.00023258319997694344, + 0.00023307500086957588, + 0.00023744179925415664, + 0.0002257334010209888, + 0.0002244666000478901, + 0.0002361999999266118, + 0.00023592499928781763, + 0.00022467499948106707 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/ferro_ta]", + "params": { + "indicator": "PLUS_DI", + "library": "ferro_ta" + }, + "param": "Momentum/PLUS_DI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000775624999369029, + "max": 0.0008040168002480641, + "mean": 0.0007839291703567141, + "stddev": 9.172885970592837e-06, + "rounds": 20, + "median": 0.0007797123995260336, + "iqr": 1.4162499428493967e-05, + "q1": 0.0007775708007102366, + "q3": 0.0007917333001387306, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.000775624999369029, + "hd15iqr": 0.0008040168002480641, + "ops": 1275.6254491014365, + "total": 0.015678583407134284, + "data": [ + 0.0007783749999362044, + 0.0007770166004775092, + 0.0007813418007572182, + 0.0007924250006908551, + 0.0008009500001207925, + 0.0007798000006005168, + 0.0007796331992722116, + 0.0007822584011591971, + 0.0007769750009174459, + 0.0007761750006466172, + 0.0007781250009429641, + 0.0007921331998659298, + 0.0007913334004115314, + 0.0007993584003997967, + 0.0007783416003803723, + 0.0007797915997798555, + 0.0007792084012180567, + 0.000775699999940116, + 0.000775624999369029, + 0.0008040168002480641 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/talib]", + "params": { + "indicator": "PLUS_DI", + "library": "talib" + }, + "param": "Momentum/PLUS_DI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005850750007084571, + "max": 0.0006401332007953897, + "mean": 0.0006037670901423553, + "stddev": 1.682999901580317e-05, + "rounds": 20, + "median": 0.0006078541999158915, + "iqr": 2.4129100347636246e-05, + "q1": 0.0005867542000487447, + "q3": 0.000610883300396381, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0005850750007084571, + "hd15iqr": 0.0006401332007953897, + "ops": 1656.267816392943, + "total": 0.012075341802847106, + "data": [ + 0.0006082250009058043, + 0.0006122250008047559, + 0.0006090499999118037, + 0.000607791799120605, + 0.000609541599988006, + 0.0006084749998990447, + 0.0006171333996462635, + 0.0006401332007953897, + 0.0006348334005451761, + 0.0006225167991942727, + 0.0006079166007111781, + 0.0005887999999686144, + 0.000586183401173912, + 0.0005858415999682621, + 0.0005872166002518497, + 0.0005850750007084571, + 0.0005864915990969166, + 0.0006043834000593051, + 0.0005870084001799114, + 0.000586499999917578 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/pandas_ta]", + "params": { + "indicator": "PLUS_DI", + "library": "pandas_ta" + }, + "param": "Momentum/PLUS_DI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.02599250820057932, + "max": 0.02711647499963874, + "mean": 0.026515360819830677, + "stddev": 0.0003152789796676263, + "rounds": 20, + "median": 0.02647980420078966, + "iqr": 0.0005425873998319702, + "q1": 0.026213699999789244, + "q3": 0.026756287399621215, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.02599250820057932, + "hd15iqr": 0.02711647499963874, + "ops": 37.71398800849454, + "total": 0.5303072163966135, + "data": [ + 0.02640910839982098, + 0.02649730839912081, + 0.02674495819956064, + 0.0264833418012131, + 0.02635302499984391, + 0.02711647499963874, + 0.026220625000132714, + 0.026148166799976023, + 0.026139141600287984, + 0.026693091599736363, + 0.02620677499944577, + 0.02599250820057932, + 0.02620020819886122, + 0.026786291800090112, + 0.026767616599681788, + 0.026728016599372496, + 0.026980816600553226, + 0.02692362499947194, + 0.026476266600366217, + 0.026439849998860156 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/PLUS_DI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/PLUS_DI/tulipy]", + "params": { + "indicator": "PLUS_DI", + "library": "tulipy" + }, + "param": "Momentum/PLUS_DI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006600249995244667, + "max": 0.0008075168007053435, + "mean": 0.0006787658499524695, + "stddev": 3.149416899891165e-05, + "rounds": 20, + "median": 0.0006740750999597367, + "iqr": 1.7104200378525937e-05, + "q1": 0.0006634833000134677, + "q3": 0.0006805875003919936, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0006600249995244667, + "hd15iqr": 0.0008075168007053435, + "ops": 1473.262097776464, + "total": 0.013575316999049392, + "data": [ + 0.0006753084002411924, + 0.0006728417996782809, + 0.000668333399516996, + 0.000687266601016745, + 0.0006717832002323121, + 0.0006819834001362324, + 0.0006627166003454477, + 0.00066474159975769, + 0.0006620665997616015, + 0.0006642499996814877, + 0.0006600249995244667, + 0.0006762168006389402, + 0.0006817000001319684, + 0.0006794750006520189, + 0.0006833084000390955, + 0.0006761333992471918, + 0.0006779749994166196, + 0.0006608749987208285, + 0.000660799999604933, + 0.0008075168007053435 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/ferro_ta]", + "params": { + "indicator": "MINUS_DI", + "library": "ferro_ta" + }, + "param": "Momentum/MINUS_DI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008105999993858859, + "max": 0.00098978340101894, + "mean": 0.0008675133200449636, + "stddev": 4.814281395855114e-05, + "rounds": 20, + "median": 0.0008576666004955769, + "iqr": 6.574580111191615e-05, + "q1": 0.0008262374991318211, + "q3": 0.0008919833002437373, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0008105999993858859, + "hd15iqr": 0.00098978340101894, + "ops": 1152.7200526997897, + "total": 0.01735026640089927, + "data": [ + 0.0008629083997220733, + 0.0008556500004488043, + 0.0008197999995900318, + 0.0008162832004018128, + 0.000856258200656157, + 0.0008257833993411623, + 0.0008549500009394251, + 0.0008669666000059806, + 0.0008176500006811694, + 0.0008105999993858859, + 0.0008741499987081625, + 0.0008913750003557652, + 0.0009344917998532765, + 0.0008590750003349967, + 0.0008986250002635642, + 0.0008925916001317092, + 0.0008438165998086334, + 0.0008266915989224799, + 0.00098978340101894, + 0.0009528166003292427 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/talib]", + "params": { + "indicator": "MINUS_DI", + "library": "talib" + }, + "param": "Momentum/MINUS_DI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005909750005230307, + "max": 0.0007287000000360422, + "mean": 0.0006368779300100868, + "stddev": 4.506923765296199e-05, + "rounds": 20, + "median": 0.000612462499702815, + "iqr": 6.759989919373759e-05, + "q1": 0.0006006042007356882, + "q3": 0.0006682040999294258, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005909750005230307, + "hd15iqr": 0.0007287000000360422, + "ops": 1570.1596065421234, + "total": 0.012737558600201737, + "data": [ + 0.0006030332006048411, + 0.0007074833993101493, + 0.0006419081997592002, + 0.0006065581997972913, + 0.0007178083993494511, + 0.0006053083998267539, + 0.0005996416002744809, + 0.0006792915999540127, + 0.0006525083997985348, + 0.0005909750005230307, + 0.0006183667996083386, + 0.0006854584004031494, + 0.0006005834002280608, + 0.0006006250012433156, + 0.0006430417997762561, + 0.0005940167990047485, + 0.0006064583998522721, + 0.0007287000000360422, + 0.0006571165999048389, + 0.0005986750009469688 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Momentum/MINUS_DI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Momentum/MINUS_DI/tulipy]", + "params": { + "indicator": "MINUS_DI", + "library": "tulipy" + }, + "param": "Momentum/MINUS_DI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005953999992925674, + "max": 0.0007718167995335534, + "mean": 0.0006637608398887096, + "stddev": 5.868423699438252e-05, + "rounds": 20, + "median": 0.0006484666999313049, + "iqr": 9.201240027323365e-05, + "q1": 0.0006140500998299103, + "q3": 0.0007060625001031439, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0005953999992925674, + "hd15iqr": 0.0007718167995335534, + "ops": 1506.566732933004, + "total": 0.013275216797774192, + "data": [ + 0.0007718167995335534, + 0.0006480083990027197, + 0.0006300584005657584, + 0.0007697249995544553, + 0.0007155915998737327, + 0.0006722749996697531, + 0.0006489250008598901, + 0.0006174750000354834, + 0.0006025916009093635, + 0.0006864583992864937, + 0.0006328250005026348, + 0.0006158584001241252, + 0.0006015581995598041, + 0.0007099499998730607, + 0.0006122417995356955, + 0.0006119081997894682, + 0.0005953999992925674, + 0.0006616581988055259, + 0.0007687168006668798, + 0.0007021750003332272 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/ferro_ta]", + "params": { + "indicator": "BBANDS", + "library": "ferro_ta" + }, + "param": "Volatility/BBANDS/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003509333997499198, + "max": 0.0004272334001143463, + "mean": 0.0003724342097848421, + "stddev": 2.004313476173514e-05, + "rounds": 20, + "median": 0.00037039579983684235, + "iqr": 2.4112500250339486e-05, + "q1": 0.00035690840013558045, + "q3": 0.00038102090038591994, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0003509333997499198, + "hd15iqr": 0.0004272334001143463, + "ops": 2685.037984501228, + "total": 0.007448684195696842, + "data": [ + 0.00035908339923480527, + 0.0004003915993962437, + 0.0003714667996973731, + 0.0003611916006775573, + 0.0003509333997499198, + 0.0004021917993668467, + 0.0003710999997565523, + 0.0003553749993443489, + 0.0003539249999448657, + 0.00035714999976335096, + 0.00037394999962998555, + 0.00035666680050780995, + 0.00036110839864704757, + 0.00035137499944539743, + 0.00037489179958356545, + 0.0003696915999171324, + 0.0003750834002858028, + 0.0004272334001143463, + 0.00038695840048603715, + 0.0003889168001478538 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/talib]", + "params": { + "indicator": "BBANDS", + "library": "talib" + }, + "param": "Volatility/BBANDS/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005582500001764856, + "max": 0.0006473249988630414, + "mean": 0.0006026891597866779, + "stddev": 1.965093990752825e-05, + "rounds": 20, + "median": 0.0006067124995752238, + "iqr": 2.283760040882048e-05, + "q1": 0.0005904040997847914, + "q3": 0.0006132417001936119, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005582500001764856, + "hd15iqr": 0.0006473249988630414, + "ops": 1659.2301085255133, + "total": 0.012053783195733558, + "data": [ + 0.0006110165995778516, + 0.0005986834003124386, + 0.0006230168000911362, + 0.0006473249988630414, + 0.0006073331998777576, + 0.000612350000301376, + 0.0006141334000858478, + 0.0005582500001764856, + 0.0006117331999121234, + 0.0005927165999310091, + 0.0005860249992110766, + 0.0006186749989865348, + 0.0005944749995251186, + 0.0005741916000260971, + 0.0005995500003336928, + 0.0005805249995319173, + 0.000617266799963545, + 0.0006060917992726899, + 0.0005880915996385738, + 0.0006123332001152449 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/pandas_ta]", + "params": { + "indicator": "BBANDS", + "library": "pandas_ta" + }, + "param": "Volatility/BBANDS/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010950084004434757, + "max": 0.0013554749995819293, + "mean": 0.0011527149999892572, + "stddev": 6.16621021602183e-05, + "rounds": 20, + "median": 0.0011341249992256053, + "iqr": 4.970010049873972e-05, + "q1": 0.0011139665999507996, + "q3": 0.0011636667004495393, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0010950084004434757, + "hd15iqr": 0.0013554749995819293, + "ops": 867.5171226272926, + "total": 0.023054299999785144, + "data": [ + 0.0011188331991434097, + 0.001225633401190862, + 0.001233108399901539, + 0.0013554749995819293, + 0.0011493999991216697, + 0.0011455582003691233, + 0.0011688000013236888, + 0.0011471999998320826, + 0.0011072334003983998, + 0.001106066799547989, + 0.00115853339957539, + 0.001121199999761302, + 0.0011268999995081685, + 0.0011239250001381152, + 0.0011127666002721526, + 0.0011030416004359721, + 0.0010950084004434757, + 0.0011991000006673857, + 0.001115166599629447, + 0.001141349998943042 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/ta]", + "params": { + "indicator": "BBANDS", + "library": "ta" + }, + "param": "Volatility/BBANDS/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0021664665997377596, + "max": 0.0024274084003991447, + "mean": 0.0022495016501488862, + "stddev": 7.91569041673182e-05, + "rounds": 20, + "median": 0.002221720899979118, + "iqr": 9.801669948501486e-05, + "q1": 0.002191391600354109, + "q3": 0.0022894082998391237, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0021664665997377596, + "hd15iqr": 0.0024274084003991447, + "ops": 444.5429057292817, + "total": 0.04499003300297773, + "data": [ + 0.002325100000598468, + 0.002275699999881908, + 0.0023031165997963398, + 0.0023731166002107784, + 0.0024274084003991447, + 0.002419683200423606, + 0.0022286084000370464, + 0.0022205250003025866, + 0.002195999999821652, + 0.00221564160019625, + 0.00222291679965565, + 0.0021910581999691203, + 0.0021917250007390974, + 0.0022365249998983925, + 0.0021900332008954137, + 0.0021868499999982303, + 0.0021825584000907837, + 0.0021664665997377596, + 0.002208325000538025, + 0.0022286749997874724 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/tulipy]", + "params": { + "indicator": "BBANDS", + "library": "tulipy" + }, + "param": "Volatility/BBANDS/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003947331992094405, + "max": 0.00043956659937975927, + "mean": 0.00040887208000640386, + "stddev": 1.063309060756816e-05, + "rounds": 20, + "median": 0.00040803749943734146, + "iqr": 1.0020799527410385e-05, + "q1": 0.0004019292005978059, + "q3": 0.0004119500001252163, + "iqr_outliers": 1, + "stddev_outliers": 7, + "outliers": "7;1", + "ld15iqr": 0.0003947331992094405, + "hd15iqr": 0.00043956659937975927, + "ops": 2445.7527155787643, + "total": 0.008177441600128076, + "data": [ + 0.0003978250009822659, + 0.0003974249993916601, + 0.0004060334002133459, + 0.0004103915998712182, + 0.0004133081994950771, + 0.0003954915999202058, + 0.00043956659937975927, + 0.0004114249997655861, + 0.0004079749996890314, + 0.0004105333995539695, + 0.0004124750004848465, + 0.00040731660119490697, + 0.0004074584008776583, + 0.0003947331992094405, + 0.00042448339954717087, + 0.00040786659956211225, + 0.0004080999991856515, + 0.0003957499997341074, + 0.00041061680094571783, + 0.0004186668011243455 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/BBANDS/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/BBANDS/finta]", + "params": { + "indicator": "BBANDS", + "library": "finta" + }, + "param": "Volatility/BBANDS/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002377016798709519, + "max": 0.0027651833996060306, + "mean": 0.0024591325299843448, + "stddev": 0.00010189434715016175, + "rounds": 20, + "median": 0.00242138760004309, + "iqr": 7.110000078682736e-05, + "q1": 0.0023949249996803703, + "q3": 0.0024660250004671976, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.002377016798709519, + "hd15iqr": 0.0026048584011732602, + "ops": 406.64746117053164, + "total": 0.04918265059968689, + "data": [ + 0.002414566800871398, + 0.0026503749992116354, + 0.002514399999927264, + 0.002428783399227541, + 0.002461591600149404, + 0.0024163918002159334, + 0.002401316798932385, + 0.0024263833998702466, + 0.0024704584007849916, + 0.002377016798709519, + 0.0024158583997632376, + 0.0023885332004283553, + 0.0023875500002759507, + 0.0024080999995931053, + 0.002385208400664851, + 0.0027651833996060306, + 0.0026048584011732602, + 0.002428958199743647, + 0.002453599999716971, + 0.002383516600821167 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/ferro_ta]", + "params": { + "indicator": "ATR", + "library": "ferro_ta" + }, + "param": "Volatility/ATR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006273081991821528, + "max": 0.0006722667996655219, + "mean": 0.0006353250001120614, + "stddev": 1.1564092958224678e-05, + "rounds": 20, + "median": 0.0006301957997493445, + "iqr": 1.1220799933653325e-05, + "q1": 0.0006283375005295966, + "q3": 0.0006395583004632499, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0006273081991821528, + "hd15iqr": 0.0006722667996655219, + "ops": 1573.997560026153, + "total": 0.012706500002241227, + "data": [ + 0.0006313499994575978, + 0.0006302415989921428, + 0.0006300500012002885, + 0.0006301500005065463, + 0.0006296084000496193, + 0.0006287416006671264, + 0.0006274583996855654, + 0.0006317250008578412, + 0.0006519415997900069, + 0.0006722667996655219, + 0.0006518334004795179, + 0.0006395083997631446, + 0.0006275666004512459, + 0.0006273081991821528, + 0.0006305250004515983, + 0.0006275749998167157, + 0.0006279334003920667, + 0.0006396082011633552, + 0.0006419665995053947, + 0.0006291418001637794 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/talib]", + "params": { + "indicator": "ATR", + "library": "talib" + }, + "param": "Volatility/ATR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006467084007454105, + "max": 0.0006665749999228865, + "mean": 0.0006528558399440953, + "stddev": 5.429730122700573e-06, + "rounds": 20, + "median": 0.0006508625003334601, + "iqr": 7.88339966675258e-06, + "q1": 0.000648716699652141, + "q3": 0.0006566000993188936, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0006467084007454105, + "hd15iqr": 0.0006665749999228865, + "ops": 1531.7317220990637, + "total": 0.013057116798881907, + "data": [ + 0.0006529916005092673, + 0.0006497834008769132, + 0.0006513415995868854, + 0.0006560833993717097, + 0.0006665749999228865, + 0.0006599250002182089, + 0.0006579084001714364, + 0.0006482749988208525, + 0.0006494083994766697, + 0.0006467084007454105, + 0.0006508250007755123, + 0.0006486750004114583, + 0.0006477582006482408, + 0.0006571167992660776, + 0.0006615749996853992, + 0.0006508999998914078, + 0.0006487583988928237, + 0.0006474832000094466, + 0.0006549749989062548, + 0.000650050000695046 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/pandas_ta]", + "params": { + "indicator": "ATR", + "library": "pandas_ta" + }, + "param": "Volatility/ATR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007892918001743965, + "max": 0.0008510750005370938, + "mean": 0.0008032075201481348, + "stddev": 1.691282437864719e-05, + "rounds": 20, + "median": 0.0007948541999212467, + "iqr": 1.1566699686227323e-05, + "q1": 0.0007941666000988335, + "q3": 0.0008057332997850608, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0007892918001743965, + "hd15iqr": 0.00084177500102669, + "ops": 1245.0082636372365, + "total": 0.016064150402962697, + "data": [ + 0.0008220668009016663, + 0.0008089249997283332, + 0.0007948665996082127, + 0.0007946833997266367, + 0.0007935583998914808, + 0.000794033199781552, + 0.0007933418004540726, + 0.0008025415998417884, + 0.0008142917999066412, + 0.0007946915997308679, + 0.0007948418002342805, + 0.000790650000271853, + 0.0007970500009832904, + 0.0007892918001743965, + 0.0007943000004161149, + 0.0007997331995284185, + 0.0008510750005370938, + 0.00084177500102669, + 0.0007980416005011648, + 0.0007943917997181415 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/ta]", + "params": { + "indicator": "ATR", + "library": "ta" + }, + "param": "Volatility/ATR/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.15432768339960604, + "max": 0.16315043340000557, + "mean": 0.1580097324601229, + "stddev": 0.0024508512755115714, + "rounds": 20, + "median": 0.15749782909988425, + "iqr": 0.004141237500152772, + "q1": 0.1558303748999606, + "q3": 0.15997161240011337, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.15432768339960604, + "hd15iqr": 0.16315043340000557, + "ops": 6.328724088260646, + "total": 3.160194649202458, + "data": [ + 0.15803537500032688, + 0.15750929999921937, + 0.16039933340071003, + 0.1596118415996898, + 0.15536931660026312, + 0.15484074160049205, + 0.15502613319986266, + 0.1558452415993088, + 0.1591044166008942, + 0.16042422500031533, + 0.15581550820061238, + 0.1571958832006203, + 0.15748635820054915, + 0.16033138320053694, + 0.15937074160028714, + 0.16174541679938556, + 0.15716737500042655, + 0.15432768339960604, + 0.16315043340000557, + 0.15743794159934624 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/tulipy]", + "params": { + "indicator": "ATR", + "library": "tulipy" + }, + "param": "Volatility/ATR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00035946680000051854, + "max": 0.00037548319960478693, + "mean": 0.0003645900000992697, + "stddev": 5.0215106339514e-06, + "rounds": 20, + "median": 0.00036251249985070895, + "iqr": 7.191499025793703e-06, + "q1": 0.00036071260110475127, + "q3": 0.000367904100130545, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.00035946680000051854, + "hd15iqr": 0.00037548319960478693, + "ops": 2742.806987925402, + "total": 0.007291800001985394, + "data": [ + 0.0003662834002170712, + 0.00036117499985266477, + 0.0003687832009745762, + 0.0003631415995187126, + 0.000360791600542143, + 0.00036056679964531214, + 0.00035987500014016404, + 0.00036702499928651375, + 0.00036331660085124894, + 0.00037109159893589094, + 0.0003718250009114854, + 0.00036109159991610796, + 0.00037548319960478693, + 0.0003731083997990936, + 0.00036188340018270535, + 0.00035946680000051854, + 0.0003655499996966682, + 0.0003606584010412917, + 0.00035991659970022736, + 0.0003607668011682108 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/ATR/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/ATR/finta]", + "params": { + "indicator": "ATR", + "library": "finta" + }, + "param": "Volatility/ATR/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.006690908399468754, + "max": 0.007669633199111558, + "mean": 0.0069351595700572945, + "stddev": 0.00022864816613486384, + "rounds": 20, + "median": 0.006886704199860105, + "iqr": 0.0001395292005327061, + "q1": 0.00679940420013736, + "q3": 0.006938933400670066, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.006690908399468754, + "hd15iqr": 0.007379100000252947, + "ops": 144.19278891830004, + "total": 0.1387031914011459, + "data": [ + 0.007669633199111558, + 0.007379100000252947, + 0.007139191600435879, + 0.006940716800454538, + 0.0068578999998862855, + 0.006931508400884923, + 0.006933166598901153, + 0.00681439159961883, + 0.006781408200913575, + 0.006794900000386406, + 0.006690908399468754, + 0.006860908400267362, + 0.006752366600267123, + 0.0068236249993788075, + 0.006921800000418444, + 0.006912499999452848, + 0.006937150000885595, + 0.006803908399888314, + 0.0067896166001446545, + 0.00696849160012789 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/ferro_ta]", + "params": { + "indicator": "NATR", + "library": "ferro_ta" + }, + "param": "Volatility/NATR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007020334000117145, + "max": 0.0007296499999938533, + "mean": 0.0007097550001344643, + "stddev": 7.67445096453596e-06, + "rounds": 20, + "median": 0.0007064582998282277, + "iqr": 1.2620801135199166e-05, + "q1": 0.000703599999542348, + "q3": 0.0007162208006775472, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0007020334000117145, + "hd15iqr": 0.0007296499999938533, + "ops": 1408.936886405236, + "total": 0.014195100002689287, + "data": [ + 0.0007107418001396582, + 0.0007296499999938533, + 0.0007159750006394461, + 0.0007111250000889413, + 0.0007063500001095235, + 0.000702350000210572, + 0.0007035165996057913, + 0.000703883399546612, + 0.0007020750010269694, + 0.0007147917989641428, + 0.0007172750003519468, + 0.0007195666010375134, + 0.0007065665995469317, + 0.0007020334000117145, + 0.0007047584003885277, + 0.0007020416000159457, + 0.0007036833994789049, + 0.0007054332003463059, + 0.0007164666007156484, + 0.0007168166004703381 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/talib]", + "params": { + "indicator": "NATR", + "library": "talib" + }, + "param": "Volatility/NATR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006496583999251015, + "max": 0.0006677249999484048, + "mean": 0.000654559559916379, + "stddev": 6.038357473321768e-06, + "rounds": 20, + "median": 0.0006515291999676265, + "iqr": 7.875100709497885e-06, + "q1": 0.000650412399409106, + "q3": 0.0006582875001186039, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0006496583999251015, + "hd15iqr": 0.0006677249999484048, + "ops": 1527.744855071327, + "total": 0.013091191198327579, + "data": [ + 0.000650741599383764, + 0.0006519500006106682, + 0.0006508915990707465, + 0.0006510416002129205, + 0.0006518334004795179, + 0.000666516600176692, + 0.0006602666006074287, + 0.0006677249999484048, + 0.0006523083997308276, + 0.0006500165996840224, + 0.0006498831993667409, + 0.0006508999998914078, + 0.0006496583999251015, + 0.0006539834008435719, + 0.0006576081999810412, + 0.0006656415993347764, + 0.0006589668002561667, + 0.0006499499999335967, + 0.000650083199434448, + 0.0006512249994557351 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/pandas_ta]", + "params": { + "indicator": "NATR", + "library": "pandas_ta" + }, + "param": "Volatility/NATR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007635834001121112, + "max": 0.0008178249991033226, + "mean": 0.0007758645799185615, + "stddev": 1.46322804257248e-05, + "rounds": 20, + "median": 0.0007686375000048428, + "iqr": 1.934159881784587e-05, + "q1": 0.0007660333008971065, + "q3": 0.0007853748997149524, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0007635834001121112, + "hd15iqr": 0.0008178249991033226, + "ops": 1288.884717620393, + "total": 0.01551729159837123, + "data": [ + 0.0007687083998462185, + 0.0007950250001158565, + 0.0008178249991033226, + 0.0007849831992643886, + 0.0007733167993137613, + 0.0007646833997569047, + 0.0007635834001121112, + 0.0007640832001925447, + 0.0007685666001634673, + 0.0007890749999205582, + 0.0007776500002364628, + 0.000766075000865385, + 0.0007659916009288281, + 0.0007673915999475867, + 0.0007669833998079411, + 0.0007672249994357117, + 0.0007636084003024735, + 0.0007857666001655162, + 0.0007970499995280988, + 0.000769699999364093 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/NATR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/NATR/tulipy]", + "params": { + "indicator": "NATR", + "library": "tulipy" + }, + "param": "Volatility/NATR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003569334003259428, + "max": 0.00037218320067040623, + "mean": 0.00036119998032518196, + "stddev": 4.274058487789185e-06, + "rounds": 20, + "median": 0.00035986660077469423, + "iqr": 4.937499761581399e-06, + "q1": 0.0003581666998798028, + "q3": 0.0003631041996413842, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.0003569334003259428, + "hd15iqr": 0.00037218320067040623, + "ops": 2768.5494309820215, + "total": 0.00722399960650364, + "data": [ + 0.00036006660084240136, + 0.0003610582003602758, + 0.0003634084001532756, + 0.000358083400351461, + 0.0003596666007069871, + 0.00036045000015292315, + 0.0003572000001440756, + 0.0003627999991294928, + 0.0003669167999760248, + 0.00037218320067040623, + 0.0003644666008767672, + 0.0003586083999834955, + 0.00035817500029224905, + 0.0003569334003259428, + 0.0003581583994673565, + 0.00035825000086333605, + 0.0003593416011426598, + 0.00036108320055063816, + 0.0003698582004290074, + 0.0003572916000848636 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/ferro_ta]", + "params": { + "indicator": "TRANGE", + "library": "ferro_ta" + }, + "param": "Volatility/TRANGE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000199366599554196, + "max": 0.00021681660000467674, + "mean": 0.00020486998990236317, + "stddev": 4.843823795716784e-06, + "rounds": 20, + "median": 0.0002023707995249424, + "iqr": 7.545799599029129e-06, + "q1": 0.00020136669991188683, + "q3": 0.00020891249951091596, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.000199366599554196, + "hd15iqr": 0.00021681660000467674, + "ops": 4881.144380768405, + "total": 0.0040973997980472635, + "data": [ + 0.00020106680021854119, + 0.00020243339968146757, + 0.00020138340041739867, + 0.000199366599554196, + 0.0002067331995931454, + 0.00020962499984307216, + 0.00020819999917875975, + 0.00021308339928509666, + 0.0002033334007137455, + 0.00020989159966120497, + 0.0002051916002528742, + 0.00021681660000467674, + 0.00020989159966120497, + 0.00020134999940637499, + 0.00020225000043865292, + 0.00020145839953329415, + 0.00020230819936841726, + 0.00020145840098848567, + 0.0002010331998462789, + 0.00020052500040037557 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/talib]", + "params": { + "indicator": "TRANGE", + "library": "talib" + }, + "param": "Volatility/TRANGE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001995915998122655, + "max": 0.0002186834011808969, + "mean": 0.0002054641500581056, + "stddev": 4.6589727700061155e-06, + "rounds": 20, + "median": 0.000204404099349631, + "iqr": 3.920900780940428e-06, + "q1": 0.0002027166003244929, + "q3": 0.00020663750110543332, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0001995915998122655, + "hd15iqr": 0.00021624179935315625, + "ops": 4867.029112948407, + "total": 0.004109283001162112, + "data": [ + 0.00020444999972824007, + 0.00020383340015541762, + 0.00020652499952120707, + 0.0002069665992166847, + 0.00020312500128056855, + 0.00020539160032058136, + 0.00021624179935315625, + 0.0002067000008537434, + 0.00020170839998172595, + 0.00020230819936841726, + 0.0001995915998122655, + 0.000203575000341516, + 0.00020199999999022112, + 0.0002186834011808969, + 0.00020657500135712326, + 0.00020800000056624411, + 0.00020435819897102192, + 0.00020484159904299304, + 0.00020338320027804002, + 0.0002010249998420477 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/pandas_ta]", + "params": { + "indicator": "TRANGE", + "library": "pandas_ta" + }, + "param": "Volatility/TRANGE/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003453582001384348, + "max": 0.000493208400439471, + "mean": 0.0003782029400463216, + "stddev": 4.097742184394216e-05, + "rounds": 20, + "median": 0.0003604208999604452, + "iqr": 1.6591599705861892e-05, + "q1": 0.00035568340026657095, + "q3": 0.00037227499997243284, + "iqr_outliers": 4, + "stddev_outliers": 3, + "outliers": "3;4", + "ld15iqr": 0.0003453582001384348, + "hd15iqr": 0.00040044159977696835, + "ops": 2644.083094323703, + "total": 0.007564058800926432, + "data": [ + 0.0003727916002389975, + 0.0004435668000951409, + 0.000493208400439471, + 0.000465266800893005, + 0.00036865000001853334, + 0.00035844160011038183, + 0.00035818339965771885, + 0.0003547833999618888, + 0.00036010839976370336, + 0.00036073340015718713, + 0.0003586999999242835, + 0.00036902499996358527, + 0.0003717583997058682, + 0.00035354999999981374, + 0.000347933400189504, + 0.00035658340057125314, + 0.0003453582001384348, + 0.00040044159977696835, + 0.0003716331993928179, + 0.0003533417999278754 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/tulipy]", + "params": { + "indicator": "TRANGE", + "library": "tulipy" + }, + "param": "Volatility/TRANGE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019571659940993414, + "max": 0.00020935000065946952, + "mean": 0.000199859580170596, + "stddev": 3.5775942323553067e-06, + "rounds": 20, + "median": 0.00019950409987359307, + "iqr": 4.383400664664805e-06, + "q1": 0.00019697499956237153, + "q3": 0.00020135840022703633, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.00019571659940993414, + "hd15iqr": 0.00020935000065946952, + "ops": 5003.512962182852, + "total": 0.00399719160341192, + "data": [ + 0.00020090000034542755, + 0.00019794179970631376, + 0.00020054159977007658, + 0.00020026660058647394, + 0.00020070820028195158, + 0.00019891660049324854, + 0.00019680840050568805, + 0.00019770840008277447, + 0.00020235820120433344, + 0.00020608339982572944, + 0.0002040500010480173, + 0.00020935000065946952, + 0.00020181680010864512, + 0.0002000915992539376, + 0.00019614160119090228, + 0.00019627499859780072, + 0.00019571659940993414, + 0.0001975668012164533, + 0.00019707499886862935, + 0.0001968750002561137 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/TRANGE/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/TRANGE/finta]", + "params": { + "indicator": "TRANGE", + "library": "finta" + }, + "param": "Volatility/TRANGE/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.006501883199962322, + "max": 0.0073248749991762455, + "mean": 0.006907176669847104, + "stddev": 0.00023645787493183987, + "rounds": 20, + "median": 0.006930641700455454, + "iqr": 0.00032172920036828076, + "q1": 0.006748254099511542, + "q3": 0.0070699832998798225, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.006501883199962322, + "hd15iqr": 0.0073248749991762455, + "ops": 144.77695414472956, + "total": 0.13814353339694208, + "data": [ + 0.007002500000817235, + 0.006790883399662562, + 0.007236674999876414, + 0.006558133399812505, + 0.0073248749991762455, + 0.007149550000031013, + 0.00706919999938691, + 0.00675068319978891, + 0.006745824999234174, + 0.007031566600198857, + 0.007070766600372735, + 0.0071403250010916965, + 0.007035483399522491, + 0.006501883199962322, + 0.006858783400093671, + 0.006686166799045168, + 0.006790066599205602, + 0.00676474159990903, + 0.007063491799635812, + 0.006571933400118723 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/ferro_ta]", + "params": { + "indicator": "STDDEV", + "library": "ferro_ta" + }, + "param": "Volatility/STDDEV/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005841249992954544, + "max": 0.0006848833989351987, + "mean": 0.0006335545798356179, + "stddev": 3.0441235815611137e-05, + "rounds": 20, + "median": 0.0006240541006263811, + "iqr": 5.135009996592988e-05, + "q1": 0.0006076498997572345, + "q3": 0.0006589999997231643, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0005841249992954544, + "hd15iqr": 0.0006848833989351987, + "ops": 1578.395976964542, + "total": 0.012671091596712359, + "data": [ + 0.000599016599880997, + 0.0006498250004369766, + 0.0006164749996969476, + 0.0006495167996035889, + 0.000621958400006406, + 0.0006164082005852833, + 0.0006224832002772018, + 0.0006737999996403232, + 0.0006365084002027289, + 0.0006825833988841623, + 0.0006848833989351987, + 0.0006517999994684942, + 0.0006661999999778345, + 0.0006038915991666727, + 0.0006256250009755604, + 0.0005841249992954544, + 0.000603074999526143, + 0.0006687000000965782, + 0.0006028083997080102, + 0.0006114082003477961 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/talib]", + "params": { + "indicator": "STDDEV", + "library": "talib" + }, + "param": "Volatility/STDDEV/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003510249996907078, + "max": 0.00043189179996261373, + "mean": 0.0003639195698633557, + "stddev": 1.749296585166202e-05, + "rounds": 20, + "median": 0.00036036659948877057, + "iqr": 1.1458299559308238e-05, + "q1": 0.0003544917002727743, + "q3": 0.00036594999983208254, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0003510249996907078, + "hd15iqr": 0.00043189179996261373, + "ops": 2747.8599196396044, + "total": 0.0072783913972671145, + "data": [ + 0.00035420000058365985, + 0.0003652250001323409, + 0.0003528749992256053, + 0.00035510000016074627, + 0.0003628749997005798, + 0.00035749160015257074, + 0.00036612499970942734, + 0.00036577499995473774, + 0.00036189160018693654, + 0.00036762499948963525, + 0.00035395000013522805, + 0.0003588415987906046, + 0.000356583199754823, + 0.0003684750001411885, + 0.0003510249996907078, + 0.0003629749990068376, + 0.000379033200442791, + 0.00043189179996261373, + 0.0003547833999618888, + 0.00035165000008419156 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/pandas_ta]", + "params": { + "indicator": "STDDEV", + "library": "pandas_ta" + }, + "param": "Volatility/STDDEV/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00041963320109061897, + "max": 0.0004996584000764414, + "mean": 0.0004415583202353446, + "stddev": 2.3531203734759713e-05, + "rounds": 20, + "median": 0.0004316624996135943, + "iqr": 1.9866700313286856e-05, + "q1": 0.0004270333003660198, + "q3": 0.0004469000006793067, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00041963320109061897, + "hd15iqr": 0.0004834250008570962, + "ops": 2264.706504606263, + "total": 0.008831166404706891, + "data": [ + 0.0004834250008570962, + 0.0004474000001209788, + 0.00043000000005122274, + 0.0004937000005156734, + 0.00045078340044710783, + 0.000436700000136625, + 0.0004361415994935669, + 0.0004264083996531554, + 0.0004299831998650916, + 0.00042597499996190893, + 0.00042761660006362945, + 0.00041963320109061897, + 0.0004312999997637235, + 0.0004264500006684102, + 0.00043202499946346504, + 0.00042770000000018625, + 0.00043931660038651896, + 0.00042055000085383654, + 0.0004996584000764414, + 0.00044640000123763457 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/tulipy]", + "params": { + "indicator": "STDDEV", + "library": "tulipy" + }, + "param": "Volatility/STDDEV/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039208340022014456, + "max": 0.0005356416004360653, + "mean": 0.000417036679937155, + "stddev": 3.938035639001007e-05, + "rounds": 20, + "median": 0.0004008749994682148, + "iqr": 1.4408300194190783e-05, + "q1": 0.00039727089970256204, + "q3": 0.0004116791998967528, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.00039208340022014456, + "hd15iqr": 0.00043356660025892777, + "ops": 2397.870614524109, + "total": 0.0083407335987431, + "data": [ + 0.0005172917997697368, + 0.0005356416004360653, + 0.0004361916013294831, + 0.00039707499963697045, + 0.00040743340068729597, + 0.00040913339908001947, + 0.00041160840046359224, + 0.00039937499968800694, + 0.0004117499993299134, + 0.00040967499953694644, + 0.00043356660025892777, + 0.00039870000036899, + 0.00039802499959478154, + 0.0003957417997298762, + 0.0003943415998946875, + 0.0004007583993370645, + 0.0003974667997681536, + 0.0004009915995993651, + 0.00039388320001307873, + 0.00039208340022014456 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/STDDEV/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/STDDEV/finta]", + "params": { + "indicator": "STDDEV", + "library": "finta" + }, + "param": "Volatility/STDDEV/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0015132583997910843, + "max": 0.0017106165993027388, + "mean": 0.0015450800000689924, + "stddev": 4.431755689699057e-05, + "rounds": 20, + "median": 0.0015321667007810902, + "iqr": 2.820009976858286e-05, + "q1": 0.0015199624001979827, + "q3": 0.0015481624999665656, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0015132583997910843, + "hd15iqr": 0.0017106165993027388, + "ops": 647.2156781236874, + "total": 0.03090160000137985, + "data": [ + 0.0017106165993027388, + 0.0015636084004654548, + 0.0015471499995328487, + 0.0015243416011799127, + 0.0015837418002774939, + 0.0015866000001551583, + 0.0015332918002968654, + 0.001531041601265315, + 0.0015164083990384825, + 0.0015377168005215936, + 0.0015437000009114854, + 0.001521208199847024, + 0.0015132583997910843, + 0.00153428339981474, + 0.0015187166005489416, + 0.0015168249999987892, + 0.0015226249990519137, + 0.0015296331999707035, + 0.0015491750004002825, + 0.0015176581990090198 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/ferro_ta]", + "params": { + "indicator": "VAR", + "library": "ferro_ta" + }, + "param": "Volatility/VAR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012193250004202127, + "max": 0.0012741084006847813, + "mean": 0.0012327412699960406, + "stddev": 1.4236889073907756e-05, + "rounds": 20, + "median": 0.001228416699450463, + "iqr": 2.129149870597766e-05, + "q1": 0.0012217376002809032, + "q3": 0.0012430290989868809, + "iqr_outliers": 0, + "stddev_outliers": 2, + "outliers": "2;0", + "ld15iqr": 0.0012193250004202127, + "hd15iqr": 0.0012741084006847813, + "ops": 811.2002285793611, + "total": 0.02465482539992081, + "data": [ + 0.0012741084006847813, + 0.0012509499996667729, + 0.0012232417997438461, + 0.0012290749989915639, + 0.0012315584011957982, + 0.0012438415986252948, + 0.0012400666004396045, + 0.0012224750011228026, + 0.0012201999998069368, + 0.0012296749991946854, + 0.0012457083998015151, + 0.0012277583999093622, + 0.0012243582008522936, + 0.001220516799367033, + 0.0012193250004202127, + 0.0012422165993484669, + 0.0012459584002499468, + 0.0012222667995956727, + 0.0012203165999380872, + 0.0012212084009661339 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/talib]", + "params": { + "indicator": "VAR", + "library": "talib" + }, + "param": "Volatility/VAR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003168331997585483, + "max": 0.0003314415997010656, + "mean": 0.00032112122986291067, + "stddev": 4.052501062634099e-06, + "rounds": 20, + "median": 0.0003200624996679835, + "iqr": 3.183401713613464e-06, + "q1": 0.0003187457994499709, + "q3": 0.00032192920116358437, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0003168331997585483, + "hd15iqr": 0.0003309915991849266, + "ops": 3114.088721031955, + "total": 0.006422424597258214, + "data": [ + 0.0003221083999960683, + 0.00032179180125240235, + 0.00032007499976316467, + 0.0003200917999492958, + 0.00032004999957280234, + 0.00032127500016940755, + 0.0003204917986295186, + 0.0003182665997883305, + 0.0003178915998432785, + 0.0003187915994203649, + 0.00031869999947957693, + 0.00031893340055830775, + 0.0003195416007656604, + 0.0003168331997585483, + 0.00031881659961072726, + 0.00031770819914527236, + 0.00032655819959472867, + 0.0003309915991849266, + 0.0003314415997010656, + 0.0003220666010747664 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/pandas_ta]", + "params": { + "indicator": "VAR", + "library": "pandas_ta" + }, + "param": "Volatility/VAR/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003898665992892347, + "max": 0.00042436680087121204, + "mean": 0.0003993503902165685, + "stddev": 1.0280732457295205e-05, + "rounds": 20, + "median": 0.0003947458004404325, + "iqr": 9.479200525674958e-06, + "q1": 0.00039287910040002315, + "q3": 0.0004023583009256981, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0003898665992892347, + "hd15iqr": 0.0004215666005620733, + "ops": 2504.066665510701, + "total": 0.00798700780433137, + "data": [ + 0.0004017166007542983, + 0.0003958250003051944, + 0.0003934416003176011, + 0.00039346659905277194, + 0.00039916660025482995, + 0.00039110840007197114, + 0.0003936666005756706, + 0.0003898665992892347, + 0.00039194159908220174, + 0.0003951750011765398, + 0.000403000001097098, + 0.00042436680087121204, + 0.0004215666005620733, + 0.0004157666000537574, + 0.00040855820116121323, + 0.0003943165997043252, + 0.00039576679992023853, + 0.00039187499933177603, + 0.0003923166004824452, + 0.00039410000026691706 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/VAR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/VAR/tulipy]", + "params": { + "indicator": "VAR", + "library": "tulipy" + }, + "param": "Volatility/VAR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003840334000415169, + "max": 0.0004060499995830469, + "mean": 0.00039079582995327656, + "stddev": 6.18639668412937e-06, + "rounds": 20, + "median": 0.00038919170037843287, + "iqr": 8.712500130059241e-06, + "q1": 0.00038629580012639053, + "q3": 0.00039500830025644977, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0003840334000415169, + "hd15iqr": 0.0004060499995830469, + "ops": 2558.880938211546, + "total": 0.00781591659906553, + "data": [ + 0.0003867916006129235, + 0.0003857999996398576, + 0.00039258340111700816, + 0.00039789159927750006, + 0.0004060499995830469, + 0.00038987500010989606, + 0.00039834999915910885, + 0.0003968334000092, + 0.0003878581992466934, + 0.0003931832005036995, + 0.00038679179997416215, + 0.0003904334007529542, + 0.0003840334000415169, + 0.0003893500004778616, + 0.0003875581998727284, + 0.00038427499966928733, + 0.000384624999423977, + 0.0003842831996735185, + 0.0003890334002790041, + 0.00040031679964158684 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/ferro_ta]", + "params": { + "indicator": "SAR", + "library": "ferro_ta" + }, + "param": "Volatility/SAR/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00046005000040167944, + "max": 0.00047868340043351055, + "mean": 0.0004667787501239218, + "stddev": 5.56152546586572e-06, + "rounds": 20, + "median": 0.0004655374999856576, + "iqr": 6.366799061652295e-06, + "q1": 0.0004623375010851305, + "q3": 0.00046870430014678277, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.00046005000040167944, + "hd15iqr": 0.00047868340043351055, + "ops": 2142.3425975036716, + "total": 0.009335575002478436, + "data": [ + 0.00046916680003050715, + 0.0004682418002630584, + 0.0004681581995100714, + 0.0004694000002928078, + 0.0004646417990443297, + 0.00046489160013152286, + 0.00046242500102380293, + 0.0004675500007579103, + 0.00046148340043146164, + 0.00047684160090284423, + 0.0004779499999131076, + 0.000460999998904299, + 0.00046374999947147445, + 0.00047868340043351055, + 0.0004651499999454245, + 0.0004608082002960145, + 0.00046005000040167944, + 0.00046720819955226034, + 0.00046592500002589076, + 0.0004622500011464581 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/talib]", + "params": { + "indicator": "SAR", + "library": "talib" + }, + "param": "Volatility/SAR/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00045893339993199336, + "max": 0.0006141831996501424, + "mean": 0.00047809244970267175, + "stddev": 3.4906825137420184e-05, + "rounds": 20, + "median": 0.00046863749957992695, + "iqr": 1.14542999654077e-05, + "q1": 0.0004622956999810413, + "q3": 0.000473749999946449, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.00045893339993199336, + "hd15iqr": 0.0005187749993638135, + "ops": 2091.6456652304496, + "total": 0.009561848994053435, + "data": [ + 0.0004903915993054398, + 0.00047010840062284843, + 0.0004639831997337751, + 0.0004681249993154779, + 0.0004629332004697062, + 0.00046117499878164383, + 0.00046305819996632635, + 0.0004616581994923763, + 0.0004604831992764957, + 0.00045893339993199336, + 0.0004841165995458141, + 0.0004602834000252187, + 0.00046320819965330886, + 0.00047279160062316803, + 0.000469149999844376, + 0.00047106659912969916, + 0.0005187749993638135, + 0.0006141831996501424, + 0.000472716600052081, + 0.00047470839926972986 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SAR/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SAR/tulipy]", + "params": { + "indicator": "SAR", + "library": "tulipy" + }, + "param": "Volatility/SAR/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043814179953187703, + "max": 0.0004706666004494764, + "mean": 0.00044794749017455614, + "stddev": 9.076453719409353e-06, + "rounds": 20, + "median": 0.0004435834009200335, + "iqr": 1.5037599951028824e-05, + "q1": 0.0004403833001560997, + "q3": 0.00045542090010712853, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00043814179953187703, + "hd15iqr": 0.0004706666004494764, + "ops": 2232.4045160077135, + "total": 0.008958949803491123, + "data": [ + 0.00045022500125924125, + 0.0004442418008693494, + 0.00044666659960057586, + 0.00045816659985575825, + 0.0004706666004494764, + 0.00045872500049881637, + 0.00045927500032121315, + 0.00045310840068850665, + 0.0004403666011057794, + 0.00044003319926559924, + 0.00043983340001432227, + 0.0004416081996168941, + 0.00043814179953187703, + 0.0004423584003234282, + 0.0004400416000862606, + 0.0004418331998749636, + 0.00044292500097071753, + 0.0004526000004261732, + 0.0004577333995257504, + 0.00044039999920642003 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/ferro_ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "ferro_ta" + }, + "param": "Volatility/KELTNER_CHANNELS/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0009138918001553975, + "max": 0.0019029249990126118, + "mean": 0.0010568024999520276, + "stddev": 0.0002039148879070644, + "rounds": 20, + "median": 0.0010171833004278597, + "iqr": 5.736670063924963e-05, + "q1": 0.000984566699480638, + "q3": 0.0010419334001198876, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0009138918001553975, + "hd15iqr": 0.0019029249990126118, + "ops": 946.2506003206786, + "total": 0.021136049999040552, + "data": [ + 0.0009138918001553975, + 0.0010429500005557201, + 0.0019029249990126118, + 0.0010791166001581586, + 0.0009910834007314407, + 0.001016566601174418, + 0.0009834333992330357, + 0.0009916750001139007, + 0.0010199165990343317, + 0.0010160499994526617, + 0.0010177999996813015, + 0.0011063416008255445, + 0.0010313833990949206, + 0.001025191599910613, + 0.0009644081990700215, + 0.001040916799684055, + 0.0010654500001692213, + 0.0009856999997282401, + 0.0009667083999374881, + 0.000974541601317469 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/pandas_ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "pandas_ta" + }, + "param": "Volatility/KELTNER_CHANNELS/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0010600499997963197, + "max": 0.0011597499993513337, + "mean": 0.0010898254199855728, + "stddev": 3.014568783125674e-05, + "rounds": 20, + "median": 0.0010822374999406748, + "iqr": 3.91583002055996e-05, + "q1": 0.001065566699980991, + "q3": 0.0011047250001865905, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0010600499997963197, + "hd15iqr": 0.0011597499993513337, + "ops": 917.5781567044362, + "total": 0.021796508399711458, + "data": [ + 0.0011390250001568347, + 0.0010625750001054257, + 0.0010896916006458922, + 0.0011597499993513337, + 0.001118549999955576, + 0.0010831749998033047, + 0.0010822083990206012, + 0.0010776084003737197, + 0.0010732333990745246, + 0.001090900000417605, + 0.0010822666008607484, + 0.0011434250001912006, + 0.0010872666010982358, + 0.0010607415999402293, + 0.0010772583991638385, + 0.0011192000005394221, + 0.0010685583998565561, + 0.0010600499997963197, + 0.0010602249996736646, + 0.0010607999996864238 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/KELTNER_CHANNELS/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/KELTNER_CHANNELS/ta]", + "params": { + "indicator": "KELTNER_CHANNELS", + "library": "ta" + }, + "param": "Volatility/KELTNER_CHANNELS/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002330608399643097, + "max": 0.0025369084003614263, + "mean": 0.002398291689969483, + "stddev": 5.8368881373361285e-05, + "rounds": 20, + "median": 0.002381170800072141, + "iqr": 8.370420036953822e-05, + "q1": 0.00234762089967262, + "q3": 0.0024313251000421584, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.002330608399643097, + "hd15iqr": 0.0025369084003614263, + "ops": 416.96345952511075, + "total": 0.04796583379938966, + "data": [ + 0.0023810165992472323, + 0.00238132500089705, + 0.002348383399657905, + 0.0024432249992969446, + 0.0023426750005455686, + 0.002349116599361878, + 0.002384758400148712, + 0.002330608399643097, + 0.0025066418005735614, + 0.0025369084003614263, + 0.002409024999360554, + 0.0024185084010241555, + 0.0024290834000566973, + 0.002477650000946596, + 0.0023468583996873347, + 0.0023457749994122423, + 0.0023804582000593656, + 0.002340674999868497, + 0.002379574999213219, + 0.00243356680002762 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/ferro_ta]", + "params": { + "indicator": "DONCHIAN", + "library": "ferro_ta" + }, + "param": "Volatility/DONCHIAN/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.001986816599674057, + "max": 0.002450374999898486, + "mean": 0.002223842889870866, + "stddev": 0.00015215121194322317, + "rounds": 20, + "median": 0.0022443540998210664, + "iqr": 0.0002815581996401303, + "q1": 0.002073179199942388, + "q3": 0.002354737399582518, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.001986816599674057, + "hd15iqr": 0.002450374999898486, + "ops": 449.6720539723325, + "total": 0.04447685779741732, + "data": [ + 0.002450374999898486, + 0.0024028749990975483, + 0.0023195082001620905, + 0.0023973999996087514, + 0.002389966599002946, + 0.002317924999806564, + 0.0023925582005176692, + 0.002309899999818299, + 0.002235241599555593, + 0.0022958250003284773, + 0.00225346660008654, + 0.002216449999832548, + 0.0021544581992202438, + 0.0020580500000505707, + 0.0020883083998342045, + 0.0021527750010136514, + 0.002022058400325477, + 0.002016516600269824, + 0.0020163833993137813, + 0.001986816599674057 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/pandas_ta]", + "params": { + "indicator": "DONCHIAN", + "library": "pandas_ta" + }, + "param": "Volatility/DONCHIAN/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0031616000007488763, + "max": 0.003889141599938739, + "mean": 0.0033899600100994578, + "stddev": 0.00023387357065888804, + "rounds": 20, + "median": 0.0032737334004195873, + "iqr": 0.00038460420037154117, + "q1": 0.0032013332995120434, + "q3": 0.0035859374998835846, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0031616000007488763, + "hd15iqr": 0.003889141599938739, + "ops": 294.9887305516212, + "total": 0.06779920020198915, + "data": [ + 0.003215100000670645, + 0.0031995415993151255, + 0.003216025000438094, + 0.003179416801140178, + 0.0032513084006495774, + 0.0032031249997089618, + 0.0032198583998251707, + 0.0031616000007488763, + 0.0031986834001145326, + 0.003182599999126978, + 0.003296158400189597, + 0.003412383400427643, + 0.0034024499997030943, + 0.0034140165997087026, + 0.0037042915995698423, + 0.0037159668005187995, + 0.003889141599938739, + 0.0035664833994815126, + 0.003765658200427424, + 0.0036053916002856566 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/DONCHIAN/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/DONCHIAN/ta]", + "params": { + "indicator": "DONCHIAN", + "library": "ta" + }, + "param": "Volatility/DONCHIAN/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0032218082007602787, + "max": 0.003995674999896437, + "mean": 0.003439732059996459, + "stddev": 0.00018333853696434637, + "rounds": 20, + "median": 0.0034203292001620863, + "iqr": 0.0002406750005320645, + "q1": 0.003288658299425151, + "q3": 0.0035293332999572157, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0032218082007602787, + "hd15iqr": 0.003995674999896437, + "ops": 290.7203184892923, + "total": 0.06879464119992917, + "data": [ + 0.0035007915997994133, + 0.0033796916002756918, + 0.0033533666006405837, + 0.003678808399126865, + 0.0035668249998707323, + 0.003223058200092055, + 0.003442850000283215, + 0.003995674999896437, + 0.0032568581998930314, + 0.00343307500006631, + 0.0035038750007515772, + 0.00348705840006005, + 0.0032857415993930773, + 0.0033763915998861194, + 0.003554791599162854, + 0.003562733400030993, + 0.003272083400224801, + 0.003407583400257863, + 0.003291574999457225, + 0.0032218082007602787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SUPERTREND/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SUPERTREND/ferro_ta]", + "params": { + "indicator": "SUPERTREND", + "library": "ferro_ta" + }, + "param": "Volatility/SUPERTREND/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012308165998547338, + "max": 0.001584591600112617, + "mean": 0.001340026260004379, + "stddev": 9.093827934437599e-05, + "rounds": 20, + "median": 0.0013218332998803815, + "iqr": 0.00012663750021602027, + "q1": 0.001269049999973504, + "q3": 0.0013956875001895242, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0012308165998547338, + "hd15iqr": 0.001584591600112617, + "ops": 746.254032362569, + "total": 0.026800525200087577, + "data": [ + 0.0012681415988481603, + 0.0013957834002212622, + 0.0013214916005381383, + 0.0012829666011384688, + 0.0012308165998547338, + 0.0012672500000917354, + 0.001331716800632421, + 0.0013106918006087654, + 0.0012695249999524095, + 0.0012696834004600533, + 0.0012685749999945984, + 0.0014656334009487183, + 0.0014512749999994411, + 0.001584591600112617, + 0.0013248499992187135, + 0.0013955916001577862, + 0.001322174999222625, + 0.0012329334000241942, + 0.0014253917994210496, + 0.001381441598641686 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/SUPERTREND/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/SUPERTREND/pandas_ta]", + "params": { + "indicator": "SUPERTREND", + "library": "pandas_ta" + }, + "param": "Volatility/SUPERTREND/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.6250263499998254, + "max": 0.6503970999998273, + "mean": 0.6371347046198934, + "stddev": 0.006190457908103897, + "rounds": 20, + "median": 0.6371011874995021, + "iqr": 0.008239416801370747, + "q1": 0.6332614957995247, + "q3": 0.6415009126008955, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.6250263499998254, + "hd15iqr": 0.6503970999998273, + "ops": 1.569526809242933, + "total": 12.742694092397869, + "data": [ + 0.6454465917995549, + 0.6366648915995029, + 0.641237091801304, + 0.6403032999995049, + 0.6325487084002817, + 0.6250263499998254, + 0.6351235833993997, + 0.6307996167990495, + 0.6339664915998583, + 0.6378470583993476, + 0.6424798584004747, + 0.6375374833995011, + 0.6354172499995911, + 0.6503970999998273, + 0.6440234584006248, + 0.6417647334004869, + 0.6385052666009869, + 0.6335530832002405, + 0.632969908398809, + 0.6270822667996981 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/CHOPPINESS_INDEX/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/CHOPPINESS_INDEX/ferro_ta]", + "params": { + "indicator": "CHOPPINESS_INDEX", + "library": "ferro_ta" + }, + "param": "Volatility/CHOPPINESS_INDEX/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0022348832004354335, + "max": 0.0026826916000572965, + "mean": 0.0023978708199138055, + "stddev": 0.0001602803375209451, + "rounds": 20, + "median": 0.0023250581994943786, + "iqr": 0.00031332090002251806, + "q1": 0.002258224999968661, + "q3": 0.002571545899991179, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0022348832004354335, + "hd15iqr": 0.0026826916000572965, + "ops": 417.03664421586575, + "total": 0.04795741639827611, + "data": [ + 0.0025867167991236784, + 0.002434483400429599, + 0.0022980081994319335, + 0.00228262500022538, + 0.0022583583995583467, + 0.002258091600378975, + 0.0022773082004277968, + 0.0022403000009944664, + 0.0022527081993757745, + 0.0022348832004354335, + 0.002267616799508687, + 0.002258074999554083, + 0.002588724999804981, + 0.0025899833999574184, + 0.0026826916000572965, + 0.0023521081995568236, + 0.0025563750008586795, + 0.0025106916000368074, + 0.002365441799338441, + 0.0026622249992215075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volatility/CHOPPINESS_INDEX/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volatility/CHOPPINESS_INDEX/pandas_ta]", + "params": { + "indicator": "CHOPPINESS_INDEX", + "library": "pandas_ta" + }, + "param": "Volatility/CHOPPINESS_INDEX/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004481349998968653, + "max": 0.005167391800205224, + "mean": 0.0047699337697849845, + "stddev": 0.00020711525826139535, + "rounds": 20, + "median": 0.0047310041001765064, + "iqr": 0.0003366667006048374, + "q1": 0.00461683749963413, + "q3": 0.004953504200238967, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.004481349998968653, + "hd15iqr": 0.005167391800205224, + "ops": 209.6465167576273, + "total": 0.09539867539569968, + "data": [ + 0.00484148340037791, + 0.004990125000767875, + 0.005032983400451485, + 0.005043058400042355, + 0.004916883399710059, + 0.004803466598968953, + 0.004649641799915116, + 0.004625499999383465, + 0.004705574999388773, + 0.004709233199537266, + 0.004752775000815746, + 0.005067899999266956, + 0.005167391800205224, + 0.004771008400712162, + 0.004485974999261089, + 0.004481349998968653, + 0.004570233199046925, + 0.004522674999316223, + 0.004653241799678654, + 0.004608174999884795 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/ferro_ta]", + "params": { + "indicator": "OBV", + "library": "ferro_ta" + }, + "param": "Volume/OBV/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043845000036526474, + "max": 0.0004696083997259848, + "mean": 0.00044876540981931613, + "stddev": 9.267561601188064e-06, + "rounds": 20, + "median": 0.00044586239964701236, + "iqr": 1.0299998393747956e-05, + "q1": 0.000442000000475673, + "q3": 0.00045229999886942096, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00043845000036526474, + "hd15iqr": 0.0004685584004619159, + "ops": 2228.3357364878557, + "total": 0.008975308196386322, + "data": [ + 0.0004696083997259848, + 0.0004685584004619159, + 0.0004643666004994884, + 0.000450758398801554, + 0.0004472665998036973, + 0.000444441799481865, + 0.00044370000105118377, + 0.00044464160018833356, + 0.00044190000044181944, + 0.00044251660001464187, + 0.00044133339979453013, + 0.00044210000050952657, + 0.0004414415991050191, + 0.00044786660000681875, + 0.0004538415989372879, + 0.00045544999884441497, + 0.00044708319910569116, + 0.0004497999994782731, + 0.00044018339976901186, + 0.00043845000036526474 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/talib]", + "params": { + "indicator": "OBV", + "library": "talib" + }, + "param": "Volume/OBV/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004390834001242183, + "max": 0.0004622834007022902, + "mean": 0.00044560711008671204, + "stddev": 5.417767963070005e-06, + "rounds": 20, + "median": 0.0004446083003131207, + "iqr": 5.28329983353617e-06, + "q1": 0.00044220420022611506, + "q3": 0.00044748750005965123, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.0004390834001242183, + "hd15iqr": 0.0004622834007022902, + "ops": 2244.12936275951, + "total": 0.008912142201734242, + "data": [ + 0.00045304999948712064, + 0.00044975000055273994, + 0.0004456499998923391, + 0.00044487499981187283, + 0.0004443416008143686, + 0.00045146680058678613, + 0.0004622834007022902, + 0.00044219160045031456, + 0.00044519179937196895, + 0.00044309999939287084, + 0.0004432918009115383, + 0.00044769999949494376, + 0.00044192499917699023, + 0.0004431916007888503, + 0.00044221680000191557, + 0.0004458666007849388, + 0.00043993339932058007, + 0.0004390834001242183, + 0.00043975839944323527, + 0.00044727500062435865 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/pandas_ta]", + "params": { + "indicator": "OBV", + "library": "pandas_ta" + }, + "param": "Volume/OBV/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005382999996072612, + "max": 0.0007260332000441849, + "mean": 0.0005835370201384649, + "stddev": 4.557214154853587e-05, + "rounds": 20, + "median": 0.0005757333005021792, + "iqr": 3.7145900569157805e-05, + "q1": 0.0005548749002628028, + "q3": 0.0005920208008319606, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.0005382999996072612, + "hd15iqr": 0.0006548418008605949, + "ops": 1713.6873334321008, + "total": 0.011670740402769298, + "data": [ + 0.0005933000007644296, + 0.0007260332000441849, + 0.0006322582004941069, + 0.0005796750003355556, + 0.0005802250001579523, + 0.0005655331988236867, + 0.00056124159891624, + 0.0005763000008300878, + 0.0005751666001742705, + 0.0005540915997698903, + 0.0005556582007557153, + 0.0005782332009403035, + 0.0006202415999723599, + 0.0005907416008994915, + 0.0005401332004112191, + 0.0005400499998359009, + 0.0005501331994310022, + 0.0005382999996072612, + 0.0005585831997450442, + 0.0006548418008605949 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/ta]", + "params": { + "indicator": "OBV", + "library": "ta" + }, + "param": "Volume/OBV/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004843750008149073, + "max": 0.0005456833998323419, + "mean": 0.0005027679199702107, + "stddev": 1.7131541777100625e-05, + "rounds": 20, + "median": 0.0004974083996785339, + "iqr": 2.0170798961771652e-05, + "q1": 0.0004906292007945013, + "q3": 0.0005107999997562729, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0004843750008149073, + "hd15iqr": 0.0005456833998323419, + "ops": 1988.989273737375, + "total": 0.010055358399404213, + "data": [ + 0.0004976167998393066, + 0.0004913668002700433, + 0.0005215500001213514, + 0.0004916834004689008, + 0.0004843750008149073, + 0.0005387665994931012, + 0.0005087999990792014, + 0.000509733401122503, + 0.000510333399870433, + 0.00048661659966455775, + 0.0004939917998854071, + 0.0004898916013189591, + 0.0005456833998323419, + 0.00048740819911472497, + 0.00048679999890737234, + 0.000491616599902045, + 0.0004985916006262414, + 0.0005112665996421129, + 0.0005120665999129414, + 0.0004971999995177611 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/tulipy]", + "params": { + "indicator": "OBV", + "library": "tulipy" + }, + "param": "Volume/OBV/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00043980839982395994, + "max": 0.0006727831991156563, + "mean": 0.0004905370897904504, + "stddev": 6.131506669180998e-05, + "rounds": 20, + "median": 0.0004603666988259647, + "iqr": 6.892090023029589e-05, + "q1": 0.00044799169991165404, + "q3": 0.0005169126001419499, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00043980839982395994, + "hd15iqr": 0.0006727831991156563, + "ops": 2038.581833694133, + "total": 0.009810741795809009, + "data": [ + 0.0006727831991156563, + 0.0005180918000405654, + 0.0004920999999740161, + 0.0005844499988597818, + 0.0004644499989808537, + 0.00045549999922513964, + 0.0004464917990844697, + 0.0005642499992973172, + 0.0005157334002433345, + 0.0005450334007036872, + 0.0005041166004957631, + 0.0004679165998823009, + 0.00045072499924572187, + 0.00044949160073883834, + 0.00044365840003592896, + 0.00043980839982395994, + 0.0004446332008228637, + 0.0004562833986710757, + 0.0004524834002950229, + 0.0004427416002727114 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/OBV/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/OBV/finta]", + "params": { + "indicator": "OBV", + "library": "finta" + }, + "param": "Volume/OBV/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.004183975000341888, + "max": 0.005119166799704544, + "mean": 0.004400687499946798, + "stddev": 0.000245053274824142, + "rounds": 20, + "median": 0.0043253999006992675, + "iqr": 0.0002921334002166983, + "q1": 0.004211833299632418, + "q3": 0.004503966699849116, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.004183975000341888, + "hd15iqr": 0.005119166799704544, + "ops": 227.2372214596218, + "total": 0.08801374999893596, + "data": [ + 0.00419231679989025, + 0.004199283399793785, + 0.004296791600063443, + 0.00420811660005711, + 0.004183975000341888, + 0.004230516598909162, + 0.005119166799704544, + 0.004815191600937396, + 0.004415608200361021, + 0.004716625000583008, + 0.004430375000811182, + 0.004368999999132939, + 0.004316216601000633, + 0.0045117499990738, + 0.004259108399855905, + 0.004191641799116042, + 0.004499266599304974, + 0.004334583200397901, + 0.004215549999207724, + 0.0045086668003932575 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/ferro_ta]", + "params": { + "indicator": "AD", + "library": "ferro_ta" + }, + "param": "Volume/AD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002599750005174428, + "max": 0.0002901168001699261, + "mean": 0.0002728962800028967, + "stddev": 8.28421679894293e-06, + "rounds": 20, + "median": 0.0002734749999945052, + "iqr": 1.5166700177360315e-05, + "q1": 0.0002649583999300376, + "q3": 0.0002801251001073979, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0002599750005174428, + "hd15iqr": 0.0002901168001699261, + "ops": 3664.395864939549, + "total": 0.005457925600057934, + "data": [ + 0.00028036680014338345, + 0.00027988340007141234, + 0.00028270000038901346, + 0.000273833199753426, + 0.0002653249990544282, + 0.00028239179955562576, + 0.0002731168002355844, + 0.0002770915991277434, + 0.0002901168001699261, + 0.0002689834000193514, + 0.0002634000004036352, + 0.00028065819933544847, + 0.0002698668002267368, + 0.0002715334005188197, + 0.0002739500007010065, + 0.0002625749999424443, + 0.00026459180080564695, + 0.00027535839908523487, + 0.0002599750005174428, + 0.0002622082000016235 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/talib]", + "params": { + "indicator": "AD", + "library": "talib" + }, + "param": "Volume/AD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002755334004177712, + "max": 0.00029946680006105454, + "mean": 0.000282004210021114, + "stddev": 7.022623873613387e-06, + "rounds": 20, + "median": 0.0002795209002215415, + "iqr": 1.1220700253033989e-05, + "q1": 0.00027659589977702126, + "q3": 0.00028781660003005525, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002755334004177712, + "hd15iqr": 0.00029946680006105454, + "ops": 3546.0463513120208, + "total": 0.0056400842004222795, + "data": [ + 0.00029946680006105454, + 0.00029063340043649076, + 0.0002903415996115655, + 0.00029091680044075475, + 0.0002810249992762692, + 0.0002928834001068026, + 0.00028529160044854507, + 0.0002788249999866821, + 0.0002766249992419034, + 0.00028005000058328733, + 0.00027988340007141234, + 0.00027684179949574175, + 0.00027739180077333, + 0.0002792168001178652, + 0.00027648319955915214, + 0.00027982500032521786, + 0.0002755334004177712, + 0.0002759249997325242, + 0.0002765668003121391, + 0.00027635839942377063 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/pandas_ta]", + "params": { + "indicator": "AD", + "library": "pandas_ta" + }, + "param": "Volume/AD/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00040585819951957094, + "max": 0.00042926680034724993, + "mean": 0.0004144779302441748, + "stddev": 6.256850847458707e-06, + "rounds": 20, + "median": 0.0004125208004552405, + "iqr": 8.53329984238369e-06, + "q1": 0.0004097792007087264, + "q3": 0.0004183125005511101, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00040585819951957094, + "hd15iqr": 0.00042926680034724993, + "ops": 2412.67369630727, + "total": 0.008289558604883496, + "data": [ + 0.00041919180075637995, + 0.00041897500050254167, + 0.00041665000026114285, + 0.0004116916010389104, + 0.00040909160015871747, + 0.0004120750003494322, + 0.0004129666005610488, + 0.00040585819951957094, + 0.00041296679992228744, + 0.0004100168007425964, + 0.00040954160067485645, + 0.000408758400590159, + 0.0004083749998244457, + 0.0004198249996989034, + 0.0004277665997506119, + 0.0004165665988693945, + 0.00042926680034724993, + 0.0004176500005996786, + 0.00041175840015057475, + 0.00041056680056499316 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/ta]", + "params": { + "indicator": "AD", + "library": "ta" + }, + "param": "Volume/AD/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005913917993893847, + "max": 0.0008743834012420848, + "mean": 0.0007945912598370341, + "stddev": 8.45128355074644e-05, + "rounds": 20, + "median": 0.0008259332993475254, + "iqr": 1.8700000509852543e-05, + "q1": 0.0008201207994716242, + "q3": 0.0008388207999814768, + "iqr_outliers": 5, + "stddev_outliers": 3, + "outliers": "3;5", + "ld15iqr": 0.0008197999995900318, + "hd15iqr": 0.0008743834012420848, + "ops": 1258.5086830744826, + "total": 0.015891825196740685, + "data": [ + 0.0007356166010140441, + 0.0008369000002858229, + 0.0008407415996771305, + 0.0008445667990599759, + 0.0008264250005595386, + 0.0008226749996538274, + 0.0008268083998700604, + 0.0008239249989856034, + 0.0008257499997853301, + 0.0008261165989097208, + 0.000848508399212733, + 0.0008743834012420848, + 0.000842450000345707, + 0.0008229750004829839, + 0.0008204415993532166, + 0.0008197999995900318, + 0.0008280833993921987, + 0.0006403416002285667, + 0.0005939249997027219, + 0.0005913917993893847 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/AD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/AD/tulipy]", + "params": { + "indicator": "AD", + "library": "tulipy" + }, + "param": "Volume/AD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000279741600388661, + "max": 0.00040370000060647727, + "mean": 0.0003273329001240199, + "stddev": 4.1325085055011836e-05, + "rounds": 20, + "median": 0.00031117079997784454, + "iqr": 7.648329992662186e-05, + "q1": 0.0002924250002251938, + "q3": 0.00036890830015181566, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.000279741600388661, + "hd15iqr": 0.00040370000060647727, + "ops": 3054.9938598323597, + "total": 0.006546658002480399, + "data": [ + 0.0003858334006508812, + 0.0003689165998366661, + 0.0002849833996151574, + 0.0002815165993524715, + 0.000279741600388661, + 0.00036890000046696514, + 0.00035764159983955326, + 0.00030391659965971484, + 0.00029158320103306323, + 0.0003301331991679035, + 0.00040370000060647727, + 0.00031614160106983034, + 0.00033469160116510465, + 0.00037171660078456626, + 0.00030187499942258, + 0.00029326679941732436, + 0.0003061999988858588, + 0.00029646680050063876, + 0.00028295000083744526, + 0.00038648339977953583 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/ferro_ta]", + "params": { + "indicator": "ADOSC", + "library": "ferro_ta" + }, + "param": "Volume/ADOSC/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004740250005852431, + "max": 0.0010638084000675007, + "mean": 0.0005542666900873883, + "stddev": 0.00012415681442168097, + "rounds": 20, + "median": 0.0005196916994464119, + "iqr": 5.027920051361436e-05, + "q1": 0.0005112124999868684, + "q3": 0.0005614917005004827, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0004740250005852431, + "hd15iqr": 0.0010638084000675007, + "ops": 1804.1856346127086, + "total": 0.011085333801747765, + "data": [ + 0.000510924999252893, + 0.0005764500005170703, + 0.000563358400540892, + 0.0004886499998974613, + 0.0005225415996392257, + 0.0005483583998284302, + 0.00048346659896196796, + 0.0010638084000675007, + 0.000597524999466259, + 0.0005197833990678192, + 0.0005131834011990577, + 0.0005142000009072945, + 0.0005671834005624987, + 0.0005068918006145395, + 0.0005147417992702686, + 0.0005195999998250045, + 0.0005115000007208437, + 0.0005596250004600734, + 0.0004740250005852431, + 0.0005295166003634222 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/talib]", + "params": { + "indicator": "ADOSC", + "library": "talib" + }, + "param": "Volume/ADOSC/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00038997499941615386, + "max": 0.0004927084009977989, + "mean": 0.00041430250013945624, + "stddev": 2.8144174045617044e-05, + "rounds": 20, + "median": 0.0004044375004014, + "iqr": 3.94540998968296e-05, + "q1": 0.0003929083999537397, + "q3": 0.0004323624998505693, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00038997499941615386, + "hd15iqr": 0.0004927084009977989, + "ops": 2413.6953063604374, + "total": 0.008286050002789124, + "data": [ + 0.00043894999980693684, + 0.0004469334002351388, + 0.00042694180010585117, + 0.0004927084009977989, + 0.0004377831995952874, + 0.0003927334008039907, + 0.0003914584012818523, + 0.00039225000073201955, + 0.00046066659997450187, + 0.0004109500005142763, + 0.00040874159894883634, + 0.0004031165997730568, + 0.0003987250005593523, + 0.0003930833991034888, + 0.0003914999993867241, + 0.00038997499941615386, + 0.0004163165998761542, + 0.00040575840102974325, + 0.0003933082000003196, + 0.00039415000064764173 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/pandas_ta]", + "params": { + "indicator": "ADOSC", + "library": "pandas_ta" + }, + "param": "Volume/ADOSC/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005246750006335787, + "max": 0.000554333400214091, + "mean": 0.0005358004499430535, + "stddev": 7.956827548318061e-06, + "rounds": 20, + "median": 0.00053412080014823, + "iqr": 1.1724900105036795e-05, + "q1": 0.0005300625998643227, + "q3": 0.0005417874999693595, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005246750006335787, + "hd15iqr": 0.000554333400214091, + "ops": 1866.3664804803411, + "total": 0.010716008998861071, + "data": [ + 0.000554333400214091, + 0.0005474249992403202, + 0.0005313999994541518, + 0.0005378000001655892, + 0.0005351750005502254, + 0.0005266667998512275, + 0.0005353667991585098, + 0.0005330665997462347, + 0.0005246750006335787, + 0.0005382499992265366, + 0.0005467918002977967, + 0.0005289417997119017, + 0.0005328750004991889, + 0.0005420000001322478, + 0.0005415749998064712, + 0.0005311834000167436, + 0.0005422583999461494, + 0.0005264666004222817, + 0.0005275083996821195, + 0.0005322500001057051 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/ADOSC/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/ADOSC/tulipy]", + "params": { + "indicator": "ADOSC", + "library": "tulipy" + }, + "param": "Volume/ADOSC/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003618333998019807, + "max": 0.00046870840014889836, + "mean": 0.0003758824898977764, + "stddev": 2.3535785707423034e-05, + "rounds": 20, + "median": 0.0003699665998283308, + "iqr": 1.4941801055101678e-05, + "q1": 0.0003635415996541269, + "q3": 0.0003784834007092286, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0003618333998019807, + "hd15iqr": 0.00046870840014889836, + "ops": 2660.40591641275, + "total": 0.0075176497979555276, + "data": [ + 0.0003707249998115003, + 0.0003701499997987412, + 0.000383000000147149, + 0.00036360819940455257, + 0.0003630081992014311, + 0.0003697831998579204, + 0.0003655249995063059, + 0.0003619416005676612, + 0.0003850166001939215, + 0.0003936833993066102, + 0.0003725333997863345, + 0.0003618333998019807, + 0.00036347499990370126, + 0.00036328339920146393, + 0.00046870840014889836, + 0.00038141680124681443, + 0.0003701581998029724, + 0.00037555000017164275, + 0.00036566659982781855, + 0.00036858340026810763 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/ferro_ta]", + "params": { + "indicator": "MFI", + "library": "ferro_ta" + }, + "param": "Volume/MFI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003435916005400941, + "max": 0.0004976500000339002, + "mean": 0.00038282624998828394, + "stddev": 4.5905469555997866e-05, + "rounds": 20, + "median": 0.000362887499795761, + "iqr": 4.127510037505995e-05, + "q1": 0.00035219580022385346, + "q3": 0.0003934709005989134, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.0003435916005400941, + "hd15iqr": 0.00048079160042107104, + "ops": 2612.151073837293, + "total": 0.00765652499976568, + "data": [ + 0.0003435916005400941, + 0.00044767500075977297, + 0.0004007417999673635, + 0.0004976500000339002, + 0.00048079160042107104, + 0.00037523339997278524, + 0.00035572499909903855, + 0.00037826659972779454, + 0.00037616679910570385, + 0.0003657166002085432, + 0.0003501500003039837, + 0.0003570250002667308, + 0.0003862000012304634, + 0.00034449180093361066, + 0.00034869160008383916, + 0.00035424160014372317, + 0.0003584249992854893, + 0.00034494159917812793, + 0.00043074159912066535, + 0.0003600583993829787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/talib]", + "params": { + "indicator": "MFI", + "library": "talib" + }, + "param": "Volume/MFI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007110500009730458, + "max": 0.0007499334009480662, + "mean": 0.0007252858501306036, + "stddev": 1.1353708232142423e-05, + "rounds": 20, + "median": 0.0007248082998557948, + "iqr": 1.66498000908178e-05, + "q1": 0.0007144333998439833, + "q3": 0.0007310831999348011, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0007110500009730458, + "hd15iqr": 0.0007499334009480662, + "ops": 1378.7667301380939, + "total": 0.014505717002612073, + "data": [ + 0.0007383415999356657, + 0.0007256333992700092, + 0.0007283166007255204, + 0.000724666599126067, + 0.0007298334006918594, + 0.0007413750005071052, + 0.0007499334009480662, + 0.0007430915997247211, + 0.0007313331996556371, + 0.0007198084000265226, + 0.0007134918007068336, + 0.0007150249992264434, + 0.0007249500005855225, + 0.0007138418004615232, + 0.0007211083997390233, + 0.000730833200213965, + 0.0007130668003810569, + 0.0007174083992140367, + 0.0007110500009730458, + 0.0007126084004994482 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/pandas_ta]", + "params": { + "indicator": "MFI", + "library": "pandas_ta" + }, + "param": "Volume/MFI/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008402917999774217, + "max": 0.000871641599223949, + "mean": 0.000851034579827683, + "stddev": 1.0225624151585104e-05, + "rounds": 20, + "median": 0.0008462374993541743, + "iqr": 1.676249885349543e-05, + "q1": 0.0008438458004093262, + "q3": 0.0008606082992628217, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008402917999774217, + "hd15iqr": 0.000871641599223949, + "ops": 1175.0403846133718, + "total": 0.01702069159655366, + "data": [ + 0.0008603665992268361, + 0.000871641599223949, + 0.0008611833996837959, + 0.0008471331995679066, + 0.0008478750009089708, + 0.0008402917999774217, + 0.0008456667987047694, + 0.0008525918005034327, + 0.0008608499992988072, + 0.0008468082000035792, + 0.000845024999580346, + 0.0008420999991358257, + 0.0008422081999015063, + 0.0008435166004346683, + 0.000845116599521134, + 0.0008679915990796871, + 0.0008695250013261102, + 0.0008448584005236626, + 0.0008441750003839843, + 0.0008417667995672673 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/ta]", + "params": { + "indicator": "MFI", + "library": "ta" + }, + "param": "Volume/MFI/ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.41174320819991406, + "max": 0.4314239750005072, + "mean": 0.4204762075199687, + "stddev": 0.005099123498887338, + "rounds": 20, + "median": 0.42081642500052113, + "iqr": 0.0069879126007436065, + "q1": 0.41601936249935534, + "q3": 0.42300727510009895, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.41174320819991406, + "hd15iqr": 0.4314239750005072, + "ops": 2.3782558492385313, + "total": 8.409524150399374, + "data": [ + 0.41483689159940695, + 0.4156232749999617, + 0.4234342918003676, + 0.4161888999995426, + 0.41699979179975344, + 0.4142426917998819, + 0.4201439416006906, + 0.41584982499916806, + 0.4225802583998302, + 0.42191900000034366, + 0.4180418499992811, + 0.4272655249995296, + 0.4314239750005072, + 0.4225033334005275, + 0.41174320819991406, + 0.42601544999924956, + 0.421724541799631, + 0.420297933400434, + 0.4213349166006083, + 0.4273545500007458 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/tulipy]", + "params": { + "indicator": "MFI", + "library": "tulipy" + }, + "param": "Volume/MFI/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006424584003980272, + "max": 0.0007229666007333435, + "mean": 0.0006612312400829978, + "stddev": 2.244172854967062e-05, + "rounds": 20, + "median": 0.0006554667997988872, + "iqr": 2.6879300276050387e-05, + "q1": 0.0006445915998483543, + "q3": 0.0006714709001244047, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0006424584003980272, + "hd15iqr": 0.0007229666007333435, + "ops": 1512.3302399845475, + "total": 0.013224624801659956, + "data": [ + 0.0007114416002877988, + 0.0007229666007333435, + 0.0006562668000697158, + 0.0006721084006130696, + 0.0006591166005819104, + 0.0006734084003255702, + 0.0006799166003474966, + 0.0006708333996357397, + 0.0006437081989133731, + 0.0006483332006609998, + 0.0006437165997340344, + 0.0006435666000470519, + 0.0006426416002796032, + 0.0006594749997020699, + 0.0006608334006159566, + 0.0006546667995280586, + 0.0006424584003980272, + 0.0006454665999626741, + 0.0006461833996581845, + 0.0006475165995652787 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/MFI/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/MFI/finta]", + "params": { + "indicator": "MFI", + "library": "finta" + }, + "param": "Volume/MFI/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.37874110840057257, + "max": 1.1485744834004437, + "mean": 0.6378114891901351, + "stddev": 0.25539338832654623, + "rounds": 20, + "median": 0.632249387599586, + "iqr": 0.4003730042000826, + "q1": 0.4001616667002963, + "q3": 0.8005346709003789, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.37874110840057257, + "hd15iqr": 1.1485744834004437, + "ops": 1.5678613774577126, + "total": 12.7562297838027, + "data": [ + 0.41436194160050943, + 0.3834217832001741, + 0.6196866417987621, + 0.4250675166011206, + 0.38740634999994655, + 1.0543201249995036, + 1.1485744834004437, + 0.8950173416000325, + 0.8131527668010676, + 1.0214627583991387, + 0.7335472916005529, + 0.7273777668000548, + 0.7161186584009556, + 0.78791657499969, + 0.64481213340041, + 0.42369262499996696, + 0.4114484666002681, + 0.3888748668003245, + 0.37874110840057257, + 0.3812285833992064 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/ferro_ta]", + "params": { + "indicator": "VWAP", + "library": "ferro_ta" + }, + "param": "Volume/VWAP/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00027074159879703075, + "max": 0.00028697500092675907, + "mean": 0.0002740724899922498, + "stddev": 4.748208113022087e-06, + "rounds": 20, + "median": 0.00027216249945922756, + "iqr": 2.966600004583532e-06, + "q1": 0.00027125000051455574, + "q3": 0.0002742166005191393, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00027074159879703075, + "hd15iqr": 0.00028217500075697897, + "ops": 3648.669737076779, + "total": 0.005481449799844995, + "data": [ + 0.00028217500075697897, + 0.00028394999972078947, + 0.00028697500092675907, + 0.0002749416002188809, + 0.00027261680079391224, + 0.00027324159891577436, + 0.0002712250003241934, + 0.00027684179949574175, + 0.0002734916008193977, + 0.00027137500001117587, + 0.0002709499996853992, + 0.0002721415992709808, + 0.00027170000103069467, + 0.00027081659936811775, + 0.0002722666002227925, + 0.0002707416002522223, + 0.000271799998881761, + 0.0002721833996474743, + 0.0002712750007049181, + 0.00027074159879703075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/pandas_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/pandas_ta]", + "params": { + "indicator": "VWAP", + "library": "pandas_ta" + }, + "param": "Volume/VWAP/pandas_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.010450675000902266, + "max": 0.010919149999972433, + "mean": 0.010568513329926645, + "stddev": 0.000122684078557069, + "rounds": 20, + "median": 0.010518804199818987, + "iqr": 7.465829912689514e-05, + "q1": 0.0105034208005236, + "q3": 0.010578079099650495, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.010450675000902266, + "hd15iqr": 0.010723483399488032, + "ops": 94.62068777150711, + "total": 0.21137026659853292, + "data": [ + 0.010450675000902266, + 0.010517958400305361, + 0.010575699999753852, + 0.010556683200411499, + 0.010589425000944175, + 0.010551850000047125, + 0.010501033200125676, + 0.010550091600453015, + 0.010475591600697953, + 0.01050743339874316, + 0.010505808400921524, + 0.010478174999298063, + 0.010519649999332614, + 0.010490774999198038, + 0.010516591600026003, + 0.010512166799162514, + 0.010919149999972433, + 0.010847566799202468, + 0.010723483399488032, + 0.010580458199547138 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Volume/VWAP/finta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Volume/VWAP/finta]", + "params": { + "indicator": "VWAP", + "library": "finta" + }, + "param": "Volume/VWAP/finta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008319583997945301, + "max": 0.0014169916001264937, + "mean": 0.0012304224901163252, + "stddev": 0.00019857965737331594, + "rounds": 20, + "median": 0.0013405958998191636, + "iqr": 0.00022344580065691844, + "q1": 0.0011289458001556341, + "q3": 0.0013523916008125526, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.0008319583997945301, + "hd15iqr": 0.0014169916001264937, + "ops": 812.7289675154257, + "total": 0.024608449802326505, + "data": [ + 0.0012882581999292598, + 0.0012997249999898487, + 0.0013530082011129706, + 0.0011194165999768302, + 0.0009344000005512499, + 0.0008319583997945301, + 0.0008502915996359661, + 0.000865683400479611, + 0.001138475000334438, + 0.0014169916001264937, + 0.0013356249997741542, + 0.0013435583998216317, + 0.0013376333998166956, + 0.0013709584003663623, + 0.001349058399500791, + 0.0013517750005121343, + 0.0013502250003512017, + 0.0013484832001267933, + 0.001361824999912642, + 0.0013611000002129003 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/ferro_ta]", + "params": { + "indicator": "AVGPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/AVGPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00020409999997355043, + "max": 0.0002294249992701225, + "mean": 0.00021019540981797035, + "stddev": 6.109191668370056e-06, + "rounds": 20, + "median": 0.00020864999969489874, + "iqr": 7.883299258537607e-06, + "q1": 0.0002054542004771065, + "q3": 0.0002133374997356441, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.00020409999997355043, + "hd15iqr": 0.0002294249992701225, + "ops": 4757.47781964412, + "total": 0.004203908196359407, + "data": [ + 0.00020409999997355043, + 0.00020978320098947735, + 0.0002068749992758967, + 0.00020944999996572733, + 0.00020517499942798167, + 0.00020513339986791835, + 0.0002078499994240701, + 0.00020630840008379893, + 0.00020545000006677583, + 0.00020539160032058136, + 0.0002057666002656333, + 0.00020545840088743718, + 0.0002113583992468193, + 0.00021322499960660935, + 0.0002148081999621354, + 0.0002294249992701225, + 0.00021746679994976147, + 0.0002160915988497436, + 0.00021134159906068816, + 0.00021344999986467884 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/talib]", + "params": { + "indicator": "AVGPRICE", + "library": "talib" + }, + "param": "Price Transform/AVGPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002022415996179916, + "max": 0.00021817499946337194, + "mean": 0.00020749084018461872, + "stddev": 4.903867580579643e-06, + "rounds": 20, + "median": 0.00020639170106733217, + "iqr": 5.1751005230471654e-06, + "q1": 0.00020343740034149959, + "q3": 0.00020861250086454675, + "iqr_outliers": 2, + "stddev_outliers": 5, + "outliers": "5;2", + "ld15iqr": 0.0002022415996179916, + "hd15iqr": 0.0002168500010157004, + "ops": 4819.489858493184, + "total": 0.0041498168036923746, + "data": [ + 0.00021434160007629543, + 0.00021817499946337194, + 0.00021520000009331853, + 0.0002168500010157004, + 0.00020893340115435421, + 0.0002031166004599072, + 0.00020294179994380103, + 0.00020678340079030021, + 0.00020794180018128826, + 0.00020410839933902026, + 0.00020333319989731535, + 0.0002082916005747393, + 0.00020354160078568385, + 0.00020271679968573154, + 0.000205275000189431, + 0.00020729999960167333, + 0.00020559999975375832, + 0.0002022415996179916, + 0.00020600000134436415, + 0.0002071249997243285 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/AVGPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/AVGPRICE/tulipy]", + "params": { + "indicator": "AVGPRICE", + "library": "tulipy" + }, + "param": "Price Transform/AVGPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002041334009845741, + "max": 0.0002198917994974181, + "mean": 0.00020753834025526884, + "stddev": 4.287165413955808e-06, + "rounds": 20, + "median": 0.0002061165992927272, + "iqr": 1.6042999050114358e-06, + "q1": 0.00020543740029097535, + "q3": 0.0002070417001959868, + "iqr_outliers": 3, + "stddev_outliers": 2, + "outliers": "2;3", + "ld15iqr": 0.0002041334009845741, + "hd15iqr": 0.00021114999981364235, + "ops": 4818.386803951578, + "total": 0.004150766805105377, + "data": [ + 0.00021848340111318976, + 0.0002069334004772827, + 0.00020609159982996062, + 0.0002062250001472421, + 0.00020889180013909935, + 0.0002065334003418684, + 0.00020544160070130603, + 0.00020614159875549377, + 0.00021114999981364235, + 0.00020578319963533432, + 0.0002041334009845741, + 0.0002049334012554027, + 0.0002198917994974181, + 0.00020714999991469085, + 0.0002067082008579746, + 0.0002042084001004696, + 0.00020598340051947162, + 0.00020543319988064468, + 0.00020575000089593232, + 0.00020490000024437905 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/ferro_ta]", + "params": { + "indicator": "MEDPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/MEDPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018776679935399442, + "max": 0.00020529999892460182, + "mean": 0.00019296959006169346, + "stddev": 5.201378546448543e-06, + "rounds": 20, + "median": 0.00019034169963560998, + "iqr": 7.329099753405877e-06, + "q1": 0.0001889751001726836, + "q3": 0.00019630419992608947, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.00018776679935399442, + "hd15iqr": 0.00020529999892460182, + "ops": 5182.163675013739, + "total": 0.003859391801233869, + "data": [ + 0.00019027499947696925, + 0.00019040839979425072, + 0.0001951833997736685, + 0.00019742500007851048, + 0.00019769160135183484, + 0.00020313320128479974, + 0.00019313340017106383, + 0.00018902500014519318, + 0.00020529999892460182, + 0.00019828340009553358, + 0.0001948666002135724, + 0.00018880820134654642, + 0.00018776679935399442, + 0.00018843319994630293, + 0.000194958399515599, + 0.00018895840039476753, + 0.00018899999995483085, + 0.00018899179995059966, + 0.00018924999894807116, + 0.00018850000051315874 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/talib]", + "params": { + "indicator": "MEDPRICE", + "library": "talib" + }, + "param": "Price Transform/MEDPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018684999959077685, + "max": 0.00020107500022277237, + "mean": 0.00019278788997326046, + "stddev": 5.246297939029735e-06, + "rounds": 20, + "median": 0.0001915749002364464, + "iqr": 9.129200043389595e-06, + "q1": 0.00018806239968398585, + "q3": 0.00019719159972737544, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.00018684999959077685, + "hd15iqr": 0.00020107500022277237, + "ops": 5187.047797134453, + "total": 0.0038557577994652093, + "data": [ + 0.00019628320005722345, + 0.00018854160007322207, + 0.00018720840016612784, + 0.00018713339959504084, + 0.00018859999981941656, + 0.0001881915988633409, + 0.00018814159993780778, + 0.00018684999959077685, + 0.0001875083995400928, + 0.00019473340071272106, + 0.00018798319943016394, + 0.00019141660013701766, + 0.00019592500029830262, + 0.0001963165996130556, + 0.00020000820077257231, + 0.00020107500022277237, + 0.00020063340052729474, + 0.00019806659984169527, + 0.0001994083999306895, + 0.00019173320033587514 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/MEDPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/MEDPRICE/tulipy]", + "params": { + "indicator": "MEDPRICE", + "library": "tulipy" + }, + "param": "Price Transform/MEDPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00018770000024233014, + "max": 0.00019395000126678496, + "mean": 0.0001897804102191003, + "stddev": 1.8283249687009444e-06, + "rounds": 20, + "median": 0.000189275000593625, + "iqr": 2.862599649233738e-06, + "q1": 0.00018822909987648018, + "q3": 0.00019109169952571392, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00018770000024233014, + "hd15iqr": 0.00019395000126678496, + "ops": 5269.2477524182095, + "total": 0.003795608204382006, + "data": [ + 0.00019040000042878092, + 0.000189650000538677, + 0.00019244159921072423, + 0.00019395000126678496, + 0.0001891166000859812, + 0.00018943340110126882, + 0.00018823319987859577, + 0.00019170000014128162, + 0.00018802499980665743, + 0.00018811659974744545, + 0.00018894160020863638, + 0.00019227500015404076, + 0.00019224179995944724, + 0.00018818340031430126, + 0.0001904833989101462, + 0.00018869160121539607, + 0.00018822499987436458, + 0.00018833340000128372, + 0.00018946660129586234, + 0.00018770000024233014 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/ferro_ta]", + "params": { + "indicator": "TYPPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/TYPPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000195141599397175, + "max": 0.0002100583995343186, + "mean": 0.00019906456975149923, + "stddev": 4.210469085446544e-06, + "rounds": 20, + "median": 0.00019695000009960494, + "iqr": 5.5208998674061095e-06, + "q1": 0.00019624159976956436, + "q3": 0.00020176249963697047, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.000195141599397175, + "hd15iqr": 0.0002100583995343186, + "ops": 5023.495648916041, + "total": 0.003981291395029984, + "data": [ + 0.0002100583995343186, + 0.00020638339919969438, + 0.00020408340060384944, + 0.00020418339991010726, + 0.00019615819910541178, + 0.0002015999998548068, + 0.00019906660018023103, + 0.00019580000080168247, + 0.0001977499996428378, + 0.00019650839967653156, + 0.00019714999943971635, + 0.00019633319898275657, + 0.00019549159915186465, + 0.000195141599397175, + 0.00020192499941913412, + 0.00019675000075949355, + 0.00019596659985836594, + 0.00019655840005725623, + 0.00019805819902103395, + 0.00019632500043371693 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/talib]", + "params": { + "indicator": "TYPPRICE", + "library": "talib" + }, + "param": "Price Transform/TYPPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019475820008665323, + "max": 0.00020779999904334544, + "mean": 0.0001997458201367408, + "stddev": 4.073174496253286e-06, + "rounds": 20, + "median": 0.0001985458002309315, + "iqr": 6.462499732151649e-06, + "q1": 0.00019632920084404758, + "q3": 0.00020279170057619923, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00019475820008665323, + "hd15iqr": 0.00020779999904334544, + "ops": 5006.3625827835895, + "total": 0.003994916402734816, + "data": [ + 0.0001994168007513508, + 0.00019671660120366142, + 0.00019475820008665323, + 0.00019633340125437825, + 0.0001972081998246722, + 0.00019868320086970925, + 0.00020206680055707694, + 0.00020351660059532152, + 0.00020409160060808063, + 0.00020779999904334544, + 0.00020701659959740937, + 0.00020165839960100128, + 0.00020558319956762717, + 0.00020019160001538694, + 0.00019515839958330616, + 0.0001953500002855435, + 0.00019840839959215373, + 0.0001983249996555969, + 0.00019632500043371693, + 0.0001963083996088244 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/TYPPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/TYPPRICE/tulipy]", + "params": { + "indicator": "TYPPRICE", + "library": "tulipy" + }, + "param": "Price Transform/TYPPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001964666007552296, + "max": 0.00026799179904628544, + "mean": 0.00020468501999857836, + "stddev": 1.6084123155496496e-05, + "rounds": 20, + "median": 0.00020071670005563646, + "iqr": 5.73760044062508e-06, + "q1": 0.00019772500018007123, + "q3": 0.0002034626006206963, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0001964666007552296, + "hd15iqr": 0.00022431679972214624, + "ops": 4885.555376778162, + "total": 0.004093700399971567, + "data": [ + 0.00020024179975735023, + 0.0002025417998083867, + 0.0002011916003539227, + 0.00019798319990513847, + 0.00019664160063257441, + 0.00019839999877149239, + 0.00019673339993460105, + 0.00019768339989241214, + 0.00019707500032382086, + 0.00019920820050174371, + 0.00020220839942339808, + 0.0001964666007552296, + 0.00019776660046773032, + 0.00020319180039223282, + 0.00026799179904628544, + 0.00022431679972214624, + 0.0002037334008491598, + 0.00020412500016391276, + 0.00020487500005401672, + 0.00020132499921601265 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/ferro_ta]", + "params": { + "indicator": "WCLPRICE", + "library": "ferro_ta" + }, + "param": "Price Transform/WCLPRICE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019629180023912341, + "max": 0.00020217500132275746, + "mean": 0.00019859625019307713, + "stddev": 1.53488442777797e-06, + "rounds": 20, + "median": 0.00019810829980997368, + "iqr": 2.3332991986535435e-06, + "q1": 0.00019762910014833323, + "q3": 0.00019996239934698677, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.00019629180023912341, + "hd15iqr": 0.00020217500132275746, + "ops": 5035.341800400514, + "total": 0.0039719250038615424, + "data": [ + 0.00020026679994771256, + 0.00019924160005757585, + 0.00020051660103490577, + 0.00020217500132275746, + 0.0001978168002096936, + 0.00019825000053970144, + 0.00019693320064106956, + 0.00019979159987997263, + 0.00019629180023912341, + 0.00019735840032808483, + 0.00019865840004058556, + 0.00020013319881400092, + 0.0001968418000615202, + 0.00019765820034081116, + 0.0001975999999558553, + 0.0001982415997190401, + 0.00019797499990090728, + 0.0001977500010980293, + 0.00019780000002356246, + 0.0002006249997066334 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/talib]", + "params": { + "indicator": "WCLPRICE", + "library": "talib" + }, + "param": "Price Transform/WCLPRICE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019606679998105392, + "max": 0.000209683200228028, + "mean": 0.0001995312598592136, + "stddev": 3.520072018498484e-06, + "rounds": 20, + "median": 0.0001986125993425958, + "iqr": 3.287398430984478e-06, + "q1": 0.00019692090063472278, + "q3": 0.00020020829906570726, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00019606679998105392, + "hd15iqr": 0.000209683200228028, + "ops": 5011.746032704779, + "total": 0.003990625197184272, + "data": [ + 0.00020467499998630956, + 0.00020426679984666407, + 0.00019669159955810757, + 0.00019794999971054495, + 0.000209683200228028, + 0.00020044159900862725, + 0.0001999000000068918, + 0.0001993333993596025, + 0.00019706680031958968, + 0.0002036916004726663, + 0.00019866679940605536, + 0.000197666599706281, + 0.00019664160063257441, + 0.00019855839927913622, + 0.00019720000127563254, + 0.00019606679998105392, + 0.0001967750009498559, + 0.00019997499912278728, + 0.00019890839903382584, + 0.00019646659930003807 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Price Transform/WCLPRICE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Price Transform/WCLPRICE/tulipy]", + "params": { + "indicator": "WCLPRICE", + "library": "tulipy" + }, + "param": "Price Transform/WCLPRICE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019716680108103902, + "max": 0.00020973319915356113, + "mean": 0.00020215749987983144, + "stddev": 4.075163194410026e-06, + "rounds": 20, + "median": 0.00020112499987590127, + "iqr": 6.829299672972389e-06, + "q1": 0.0001987666000786703, + "q3": 0.0002055958997516427, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.00019716680108103902, + "hd15iqr": 0.00020973319915356113, + "ops": 4946.638143993819, + "total": 0.004043149997596629, + "data": [ + 0.00019969160057371483, + 0.00019848319934681057, + 0.00020121660054428503, + 0.00020130000048084183, + 0.0002023584005655721, + 0.00020496679935604333, + 0.0002046167996013537, + 0.00020864999969489874, + 0.00020973319915356113, + 0.00020841679943259806, + 0.0002062250001472421, + 0.00020086659933440387, + 0.00019724999874597414, + 0.00019716680108103902, + 0.0002010333992075175, + 0.00019962500082328915, + 0.00020667499920818956, + 0.00019734160014195368, + 0.0001990415999898687, + 0.00019849160016747192 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/ferro_ta]", + "params": { + "indicator": "SQRT", + "library": "ferro_ta" + }, + "param": "Math/SQRT/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002010415992117487, + "max": 0.00021231659920886158, + "mean": 0.00020500874001299962, + "stddev": 4.042310367497842e-06, + "rounds": 20, + "median": 0.00020255830022506414, + "iqr": 7.095800538081665e-06, + "q1": 0.00020185419998597354, + "q3": 0.0002089500005240552, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0002010415992117487, + "hd15iqr": 0.00021231659920886158, + "ops": 4877.840817599239, + "total": 0.004100174800259993, + "data": [ + 0.00021100000012665986, + 0.00020244159968569876, + 0.00020215839904267341, + 0.00020226660126354545, + 0.00020351660059532152, + 0.00020140839915256948, + 0.00020266659994376822, + 0.00020235000120010226, + 0.00020745840010931714, + 0.00020120839908486233, + 0.0002010415992117487, + 0.0002011165997828357, + 0.00020589999912772327, + 0.00020924999989802018, + 0.00020921680115861818, + 0.00021217500034254044, + 0.00020868319988949225, + 0.00020155000092927366, + 0.00020245000050636008, + 0.00021231659920886158 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/talib]", + "params": { + "indicator": "SQRT", + "library": "talib" + }, + "param": "Math/SQRT/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019931659917347134, + "max": 0.00020545839943224563, + "mean": 0.00020095997984753922, + "stddev": 1.726110566591338e-06, + "rounds": 20, + "median": 0.00020037079957546666, + "iqr": 1.6790996596682763e-06, + "q1": 0.00019974579990957863, + "q3": 0.0002014248995692469, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00019931659917347134, + "hd15iqr": 0.0002043999993475154, + "ops": 4976.115148691109, + "total": 0.004019199596950784, + "data": [ + 0.0002013417994021438, + 0.00020095000072615222, + 0.00020375840103952213, + 0.00020016679918626322, + 0.0001999668005737476, + 0.000201441599347163, + 0.00020146659953752534, + 0.00019969999993918464, + 0.00020140819979133084, + 0.00019951660069637, + 0.00020545839943224563, + 0.00020029159932164476, + 0.00019931659917347134, + 0.00019979159987997263, + 0.0002010331998462789, + 0.00019940819911425933, + 0.0001998332008952275, + 0.00019949999987147748, + 0.0002043999993475154, + 0.00020044999982928857 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/SQRT/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/SQRT/tulipy]", + "params": { + "indicator": "SQRT", + "library": "tulipy" + }, + "param": "Math/SQRT/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00019796659908024594, + "max": 0.00020641660084947945, + "mean": 0.00020077665991266258, + "stddev": 2.3446554471980215e-06, + "rounds": 20, + "median": 0.00020004160032840448, + "iqr": 2.3084998247213445e-06, + "q1": 0.00019934579977416434, + "q3": 0.0002016542995988857, + "iqr_outliers": 2, + "stddev_outliers": 4, + "outliers": "4;2", + "ld15iqr": 0.00019796659908024594, + "hd15iqr": 0.0002060667990008369, + "ops": 4980.658610592476, + "total": 0.0040155331982532514, + "data": [ + 0.00020641660084947945, + 0.0002039915998466313, + 0.00019998320058220998, + 0.0002060667990008369, + 0.00020010000007459895, + 0.0001994083999306895, + 0.00019934159936383367, + 0.0002011917997151613, + 0.000199350000184495, + 0.0002002084002015181, + 0.00019880000036209822, + 0.00020034159970236943, + 0.0002027166003244929, + 0.00020211679948261007, + 0.0001989500000490807, + 0.00020030000014230608, + 0.0001997500003199093, + 0.00019796659908024594, + 0.00019912499992642553, + 0.00019940819911425933 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/ferro_ta]", + "params": { + "indicator": "LOG10", + "library": "ferro_ta" + }, + "param": "Math/LOG10/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004082500003278255, + "max": 0.0004459750009118579, + "mean": 0.00042151208006544036, + "stddev": 9.965537105237372e-06, + "rounds": 20, + "median": 0.00042056669990415686, + "iqr": 1.299170035053978e-05, + "q1": 0.0004145291997701861, + "q3": 0.00042752090012072587, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0004082500003278255, + "hd15iqr": 0.0004459750009118579, + "ops": 2372.411248201353, + "total": 0.008430241601308808, + "data": [ + 0.0004175832000328228, + 0.00043140819907421245, + 0.0004459750009118579, + 0.00043640000076266005, + 0.00042979179997928443, + 0.00041858339973259716, + 0.0004252500002621673, + 0.00042145000043092296, + 0.00041674160020193084, + 0.0004246666008839384, + 0.0004094165997230448, + 0.0004323500004829839, + 0.0004230417995131575, + 0.0004094165997230448, + 0.0004082500003278255, + 0.0004157250004936941, + 0.0004209334001643583, + 0.0004201999996439554, + 0.0004097249999176711, + 0.0004133333990466781 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/talib]", + "params": { + "indicator": "LOG10", + "library": "talib" + }, + "param": "Math/LOG10/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039428319869330155, + "max": 0.0004127334002987482, + "mean": 0.0003995754099742044, + "stddev": 5.7883533169754076e-06, + "rounds": 20, + "median": 0.00039756250043865293, + "iqr": 3.266500425525053e-06, + "q1": 0.00039626259967917576, + "q3": 0.0003995291001047008, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.00039428319869330155, + "hd15iqr": 0.00041217499965569006, + "ops": 2502.656507477669, + "total": 0.007991508199484087, + "data": [ + 0.0003990249999333173, + 0.00039928320038598033, + 0.00039710840064799413, + 0.0003960667992942035, + 0.0003970499994466081, + 0.0003986166004324332, + 0.000396458400064148, + 0.0003957333989092149, + 0.00039648320089327174, + 0.0004124165992834605, + 0.00041217499965569006, + 0.0004127334002987482, + 0.0003990166005678475, + 0.00039801660022931173, + 0.00039586659986525776, + 0.0004003418012871407, + 0.0003945750009734184, + 0.0003964833987993188, + 0.00039428319869330155, + 0.00039977499982342125 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/LOG10/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/LOG10/tulipy]", + "params": { + "indicator": "LOG10", + "library": "tulipy" + }, + "param": "Math/LOG10/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00039564999897265805, + "max": 0.0004278166001313366, + "mean": 0.00040361790976021437, + "stddev": 8.844495047246635e-06, + "rounds": 20, + "median": 0.00040007499992498194, + "iqr": 1.0679200204322125e-05, + "q1": 0.00039707499963697045, + "q3": 0.0004077541998412926, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00039564999897265805, + "hd15iqr": 0.0004278166001313366, + "ops": 2477.5907506039325, + "total": 0.008072358195204288, + "data": [ + 0.00039697500033071266, + 0.0004123749997233972, + 0.0004185415993561037, + 0.0004278166001313366, + 0.0004021499989903532, + 0.0004034415993373841, + 0.00039564999897265805, + 0.0004062416002852842, + 0.0003988416006905027, + 0.000401150000107009, + 0.00039624999917577954, + 0.0003960749992984347, + 0.0003994833998149261, + 0.0004002083995146677, + 0.00039699159970041364, + 0.0003978666005423293, + 0.00040926679939730094, + 0.0004159333999268711, + 0.00039715839957352725, + 0.00039994160033529624 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/ferro_ta]", + "params": { + "indicator": "ADD", + "library": "ferro_ta" + }, + "param": "Math/ADD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00017335820011794568, + "max": 0.00019092500006081536, + "mean": 0.0001815629100019578, + "stddev": 5.529575018618878e-06, + "rounds": 20, + "median": 0.00018430830023135057, + "iqr": 9.12910036277026e-06, + "q1": 0.00017660419980529695, + "q3": 0.0001857333001680672, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00017335820011794568, + "hd15iqr": 0.00019092500006081536, + "ops": 5507.732829294358, + "total": 0.003631258200039156, + "data": [ + 0.000177366599382367, + 0.0001774582007783465, + 0.00017509160097688435, + 0.00017642500024521722, + 0.00017550000047776847, + 0.0001772667994373478, + 0.00017433339962735772, + 0.00017335820011794568, + 0.0001841082004830241, + 0.00018654180021258072, + 0.0001848165993578732, + 0.000185608200263232, + 0.00018460839928593486, + 0.00018585840007290244, + 0.00019092500006081536, + 0.00017678339936537667, + 0.0001861500000813976, + 0.00018482500017853455, + 0.00018972499965457245, + 0.00018450839997967704 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/talib]", + "params": { + "indicator": "ADD", + "library": "talib" + }, + "param": "Math/ADD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001908915990497917, + "max": 0.00021565820061368867, + "mean": 0.00020578290997946169, + "stddev": 7.142006035921883e-06, + "rounds": 20, + "median": 0.00020805840031243859, + "iqr": 9.524999768473208e-06, + "q1": 0.00020128329997533002, + "q3": 0.00021080829974380323, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0001908915990497917, + "hd15iqr": 0.00021565820061368867, + "ops": 4859.490032966322, + "total": 0.0041156581995892335, + "data": [ + 0.0001923834002809599, + 0.00020789999980479478, + 0.0002130581997334957, + 0.00021011659991927446, + 0.00020821680082008242, + 0.00020395000028656797, + 0.00020925839926348998, + 0.00020605840109055862, + 0.00020554179936880246, + 0.000199274999613408, + 0.00020257500000298023, + 0.000211499999568332, + 0.00021003339934395627, + 0.0002129416010575369, + 0.00021565820061368867, + 0.00020988320029573514, + 0.00019999159994767978, + 0.00019442500051809476, + 0.0001908915990497917, + 0.0002119999990100041 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Math/ADD/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Math/ADD/tulipy]", + "params": { + "indicator": "ADD", + "library": "tulipy" + }, + "param": "Math/ADD/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0001863249999587424, + "max": 0.00020114159997319803, + "mean": 0.00019235584994021338, + "stddev": 5.219129504543149e-06, + "rounds": 20, + "median": 0.00019120839933748358, + "iqr": 1.0241800191579365e-05, + "q1": 0.00018709159994614313, + "q3": 0.0001973334001377225, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0001863249999587424, + "hd15iqr": 0.00020114159997319803, + "ops": 5198.698143627099, + "total": 0.0038471169988042674, + "data": [ + 0.00019796660053543745, + 0.0001915834000101313, + 0.0001882918004412204, + 0.0001870416002930142, + 0.00019564179965527728, + 0.0001968750002561137, + 0.00019335840042913332, + 0.0001999833999434486, + 0.0001936000000569038, + 0.0001866917998995632, + 0.00018672500009415672, + 0.00019083339866483585, + 0.00020019999938085674, + 0.00018873319932026788, + 0.00018714159959927202, + 0.0001863249999587424, + 0.00018642500072019175, + 0.00019779180001933128, + 0.00020114159997319803, + 0.00019076659955317155 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/ferro_ta]", + "params": { + "indicator": "LINEARREG", + "library": "ferro_ta" + }, + "param": "Statistics/LINEARREG/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00145559999946272, + "max": 0.001523283199639991, + "mean": 0.0014820245298324153, + "stddev": 1.8366629732714396e-05, + "rounds": 20, + "median": 0.0014822582998021971, + "iqr": 2.8616600320674652e-05, + "q1": 0.0014653582999017089, + "q3": 0.0014939749002223835, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.00145559999946272, + "hd15iqr": 0.001523283199639991, + "ops": 674.752664257911, + "total": 0.029640490596648306, + "data": [ + 0.0014880581991747021, + 0.0014979415995185264, + 0.0014665499998955055, + 0.0014641665999079122, + 0.0014938831998733803, + 0.0014937165993615053, + 0.0014734000011230818, + 0.0014848415987216868, + 0.0014620499990996906, + 0.001485933200456202, + 0.0014566833997378126, + 0.0014628665987402201, + 0.001476666599046439, + 0.00151004159997683, + 0.00145559999946272, + 0.001523283199639991, + 0.0014715916011482477, + 0.001499475000309758, + 0.0014940666005713865, + 0.0014796750008827075 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/talib]", + "params": { + "indicator": "LINEARREG", + "library": "talib" + }, + "param": "Statistics/LINEARREG/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006610750002437271, + "max": 0.0006917499995324761, + "mean": 0.000674682919998304, + "stddev": 8.57603031776785e-06, + "rounds": 20, + "median": 0.0006715875002555548, + "iqr": 9.525099449092544e-06, + "q1": 0.0006698124001559336, + "q3": 0.0006793374996050261, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0006610750002437271, + "hd15iqr": 0.0006917499995324761, + "ops": 1482.1777317299122, + "total": 0.01349365839996608, + "data": [ + 0.0006853500002762303, + 0.000690125000255648, + 0.000672241800930351, + 0.0006717000011121854, + 0.0006733249989338219, + 0.0006702668004436418, + 0.0006716666001011617, + 0.0006725582003127784, + 0.0006917499995324761, + 0.0006699999998090789, + 0.0006698331999359652, + 0.0006697916003759019, + 0.0006715084004099481, + 0.0006685999993351288, + 0.0006699749996187165, + 0.00066967499878956, + 0.0006693749994155951, + 0.000688541799900122, + 0.0006610750002437271, + 0.0006863000002340413 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG/tulipy]", + "params": { + "indicator": "LINEARREG", + "library": "tulipy" + }, + "param": "Statistics/LINEARREG/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033202500053448604, + "max": 0.0004885332004050724, + "mean": 0.0003456670999730704, + "stddev": 3.624581809021745e-05, + "rounds": 20, + "median": 0.000333616700663697, + "iqr": 3.6750003346242227e-06, + "q1": 0.00033264999947277827, + "q3": 0.0003363249998074025, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.00033202500053448604, + "hd15iqr": 0.0003443833993515, + "ops": 2892.9568364414954, + "total": 0.0069133419994614085, + "data": [ + 0.00033325839904136957, + 0.00033375000057276336, + 0.0003327083992189728, + 0.0003320668009109795, + 0.0003365750002558343, + 0.000334324999130331, + 0.00033202500053448604, + 0.0003325915997265838, + 0.0003330250008730218, + 0.00033384179987479, + 0.0003443833993515, + 0.00035099160013487565, + 0.00033252499997615814, + 0.0003324749995954335, + 0.00033538340067025275, + 0.0003334834007546306, + 0.00033322499948553743, + 0.0003360749993589707, + 0.00039209999958984555, + 0.0004885332004050724 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/ferro_ta]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "ferro_ta" + }, + "param": "Statistics/LINEARREG_SLOPE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0013671418011654169, + "max": 0.0017049165995558723, + "mean": 0.0014223262300947681, + "stddev": 7.078392021025672e-05, + "rounds": 20, + "median": 0.0014047750002646351, + "iqr": 2.2579100914299488e-05, + "q1": 0.0013929707994975616, + "q3": 0.001415549900411861, + "iqr_outliers": 3, + "stddev_outliers": 1, + "outliers": "1;3", + "ld15iqr": 0.0013671418011654169, + "hd15iqr": 0.0014621833994169719, + "ops": 703.0735838523987, + "total": 0.02844652460189536, + "data": [ + 0.0014043750008568168, + 0.0013671418011654169, + 0.0013705249992199242, + 0.00141436660051113, + 0.0017049165995558723, + 0.0014621833994169719, + 0.0014074083999730646, + 0.001392275000398513, + 0.0013926749990787358, + 0.0013932665999163874, + 0.001398941600928083, + 0.0014154832009808161, + 0.0014049916004296391, + 0.0014089749995036982, + 0.0013907665997976437, + 0.0014035332002094946, + 0.001466775000153575, + 0.001427749999857042, + 0.001415616599842906, + 0.0014045584000996314 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/talib]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "talib" + }, + "param": "Statistics/LINEARREG_SLOPE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006064916000468656, + "max": 0.0006430918001569808, + "mean": 0.0006272379300207831, + "stddev": 8.621342682502298e-06, + "rounds": 20, + "median": 0.0006251624996366444, + "iqr": 9.795799996936694e-06, + "q1": 0.0006221625008038245, + "q3": 0.0006319583008007612, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0006211917992914095, + "hd15iqr": 0.0006430918001569808, + "ops": 1594.2913400770674, + "total": 0.012544758600415661, + "data": [ + 0.0006425083993235603, + 0.0006398916011676192, + 0.0006225749995792285, + 0.0006211917992914095, + 0.0006430918001569808, + 0.0006064916000468656, + 0.0006218333990545943, + 0.0006296666004345752, + 0.0006228418002137915, + 0.00062704159936402, + 0.000632275000680238, + 0.000634258400532417, + 0.0006241499999305233, + 0.0006224416007171385, + 0.0006220000010216609, + 0.0006215499990503304, + 0.0006223250005859881, + 0.0006261749993427656, + 0.0006308083990006708, + 0.0006316416009212844 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/LINEARREG_SLOPE/tulipy]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/LINEARREG_SLOPE/tulipy]", + "params": { + "indicator": "LINEARREG_SLOPE", + "library": "tulipy" + }, + "param": "Statistics/LINEARREG_SLOPE/tulipy", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00033069160126615317, + "max": 0.0003595166010200046, + "mean": 0.0003377208101301221, + "stddev": 7.56983484703236e-06, + "rounds": 20, + "median": 0.00033351669990224766, + "iqr": 1.0154199844691913e-05, + "q1": 0.00033238739997614174, + "q3": 0.00034254159982083365, + "iqr_outliers": 1, + "stddev_outliers": 3, + "outliers": "3;1", + "ld15iqr": 0.00033069160126615317, + "hd15iqr": 0.0003595166010200046, + "ops": 2961.025705270294, + "total": 0.006754416202602443, + "data": [ + 0.0003419165994273499, + 0.0003472750002401881, + 0.0003388249999261461, + 0.00034316660021431743, + 0.00033338339999318124, + 0.00033292500011157246, + 0.0003351666004164144, + 0.000333649999811314, + 0.00033265819947700945, + 0.000332116600475274, + 0.00033202500053448604, + 0.0003328916005557403, + 0.00033117499988293274, + 0.00033153340045828373, + 0.0003407333992072381, + 0.0003477499994914979, + 0.00034374160022707654, + 0.00033069160126615317, + 0.0003595166010200046, + 0.0003332749998662621 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/CORREL/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/CORREL/ferro_ta]", + "params": { + "indicator": "CORREL", + "library": "ferro_ta" + }, + "param": "Statistics/CORREL/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0038626666006166487, + "max": 0.004232891600986477, + "mean": 0.00391570332023548, + "stddev": 8.747625483016705e-05, + "rounds": 20, + "median": 0.003883141699770931, + "iqr": 4.847509990213439e-05, + "q1": 0.003865191600198159, + "q3": 0.003913666700100293, + "iqr_outliers": 4, + "stddev_outliers": 2, + "outliers": "2;4", + "ld15iqr": 0.0038626666006166487, + "hd15iqr": 0.003992983199714218, + "ops": 255.38196288575378, + "total": 0.07831406640470959, + "data": [ + 0.003921508400526364, + 0.0038778582005761565, + 0.0038811167993117123, + 0.0038626666006166487, + 0.0038707665997208098, + 0.0038973084010649472, + 0.00388516660023015, + 0.0038654999996651897, + 0.0038648250003461724, + 0.0038721418008208276, + 0.003905824999674223, + 0.0038648832007311283, + 0.0038637916004518047, + 0.0039050584004144185, + 0.004232891600986477, + 0.00400355000019772, + 0.003992983199714218, + 0.0038626749999821188, + 0.003994975000387058, + 0.0038885749992914496 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/CORREL/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/CORREL/talib]", + "params": { + "indicator": "CORREL", + "library": "talib" + }, + "param": "Statistics/CORREL/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003729000003659166, + "max": 0.00041951660095946864, + "mean": 0.0003939791502489243, + "stddev": 1.1537300668324215e-05, + "rounds": 20, + "median": 0.00039747499977238476, + "iqr": 1.2825000158045452e-05, + "q1": 0.0003884625002683606, + "q3": 0.00040128750042640605, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0003729000003659166, + "hd15iqr": 0.00041951660095946864, + "ops": 2538.2053831228864, + "total": 0.007879583004978485, + "data": [ + 0.00040120000048773365, + 0.000374716600344982, + 0.00040137500036507845, + 0.00040050820098258555, + 0.000392699999792967, + 0.00037810000067111104, + 0.00040027499926509336, + 0.00040147499967133624, + 0.00039029999898048117, + 0.0003891250002197921, + 0.0003951749997213483, + 0.00041951660095946864, + 0.0004015166006865911, + 0.0003772834010305814, + 0.0003878000003169291, + 0.00039977499982342125, + 0.00040002500027185305, + 0.0003729000003659166, + 0.0003929500002413988, + 0.0004028666007798165 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/BETA/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/BETA/ferro_ta]", + "params": { + "indicator": "BETA", + "library": "ferro_ta" + }, + "param": "Statistics/BETA/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003995058400323615, + "max": 0.004397025000071153, + "mean": 0.00408184665015142, + "stddev": 8.605343629080731e-05, + "rounds": 20, + "median": 0.004060154200124089, + "iqr": 3.662080052890815e-05, + "q1": 0.004046108300099149, + "q3": 0.004082729100628057, + "iqr_outliers": 3, + "stddev_outliers": 3, + "outliers": "3;3", + "ld15iqr": 0.003995058400323615, + "hd15iqr": 0.00413845000002766, + "ops": 244.98715549808912, + "total": 0.08163693300302839, + "data": [ + 0.004045324999606237, + 0.004063358400890138, + 0.004075075000582728, + 0.004061349999392405, + 0.004057299999112729, + 0.004058958400855772, + 0.004046891600592062, + 0.004090383200673386, + 0.004074033399228938, + 0.004049316600139718, + 0.004047991600236856, + 0.004190500000549946, + 0.004397025000071153, + 0.0040616166006657295, + 0.004010858399851713, + 0.00413845000002766, + 0.0041143915994325654, + 0.003995058400323615, + 0.0040274582002894025, + 0.004031591600505635 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Statistics/BETA/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Statistics/BETA/talib]", + "params": { + "indicator": "BETA", + "library": "talib" + }, + "param": "Statistics/BETA/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004678833996877074, + "max": 0.0005030665997765027, + "mean": 0.00047983289005060215, + "stddev": 8.751089658950115e-06, + "rounds": 20, + "median": 0.00047663750010542574, + "iqr": 1.3012500130571414e-05, + "q1": 0.00047343750047730283, + "q3": 0.00048645000060787424, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0004678833996877074, + "hd15iqr": 0.0005030665997765027, + "ops": 2084.0588895324413, + "total": 0.009596657801012043, + "data": [ + 0.0004861000008531846, + 0.0004733331996249035, + 0.00048185000050580127, + 0.00047655840025981886, + 0.0004904415996861644, + 0.0005030665997765027, + 0.0004868000003625639, + 0.0004754915993544273, + 0.00048053320060716944, + 0.0004800331997103058, + 0.00047340000019175933, + 0.00047487499978160486, + 0.0004726332001155242, + 0.0004695334006100893, + 0.00047671659995103256, + 0.0004678833996877074, + 0.00047347500076284633, + 0.00048822499957168475, + 0.0004910083996946923, + 0.00047469999990426006 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_DCPERIOD/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_DCPERIOD/ferro_ta]", + "params": { + "indicator": "HT_DCPERIOD", + "library": "ferro_ta" + }, + "param": "Cycle/HT_DCPERIOD/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.007323950000863988, + "max": 0.009919116599485278, + "mean": 0.008005460389831569, + "stddev": 0.0006926875628735123, + "rounds": 20, + "median": 0.007776487499359063, + "iqr": 0.000840895799046849, + "q1": 0.007455120800295844, + "q3": 0.008296016599342693, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.007323950000863988, + "hd15iqr": 0.009919116599485278, + "ops": 124.91473960325716, + "total": 0.16010920779663138, + "data": [ + 0.0077805415989132595, + 0.007755433199054096, + 0.007772433399804868, + 0.007765550000476651, + 0.007814133400097489, + 0.007323950000863988, + 0.0073357916000531985, + 0.0073465415989630856, + 0.009468150000611786, + 0.009919116599485278, + 0.007482650000019931, + 0.007427591600571759, + 0.007363516600162256, + 0.0076766999991377816, + 0.00829995819949545, + 0.008374058399931527, + 0.008253083199087996, + 0.00837080839992268, + 0.008292074999189936, + 0.008287125000788365 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_DCPERIOD/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_DCPERIOD/talib]", + "params": { + "indicator": "HT_DCPERIOD", + "library": "talib" + }, + "param": "Cycle/HT_DCPERIOD/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.003796600000350736, + "max": 0.00395624160009902, + "mean": 0.0038471208400005707, + "stddev": 3.878078225296844e-05, + "rounds": 20, + "median": 0.003833662500255741, + "iqr": 5.0362599722575396e-05, + "q1": 0.0038217124005313964, + "q3": 0.0038720750002539718, + "iqr_outliers": 1, + "stddev_outliers": 6, + "outliers": "6;1", + "ld15iqr": 0.003796600000350736, + "hd15iqr": 0.00395624160009902, + "ops": 259.93464764674553, + "total": 0.07694241680001142, + "data": [ + 0.00381035000027623, + 0.0038889666000613944, + 0.003835241599881556, + 0.003829374999622814, + 0.0038867665993166157, + 0.0038201415998628365, + 0.003896633398835547, + 0.0038232832011999562, + 0.003796600000350736, + 0.0038469167993753217, + 0.003830466800718568, + 0.0038635000004433096, + 0.003859250000095926, + 0.003806791799433995, + 0.003827900000032969, + 0.003837333399860654, + 0.003880650000064634, + 0.0038320834006299264, + 0.0038139249998494053, + 0.00395624160009902 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_TRENDMODE/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_TRENDMODE/ferro_ta]", + "params": { + "indicator": "HT_TRENDMODE", + "library": "ferro_ta" + }, + "param": "Cycle/HT_TRENDMODE/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.007960100000491365, + "max": 0.011040958399826195, + "mean": 0.010046701689861947, + "stddev": 0.0010392377507822718, + "rounds": 20, + "median": 0.010464400099590421, + "iqr": 0.0013997708992974367, + "q1": 0.009448516699922038, + "q3": 0.010848287599219474, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.007960100000491365, + "hd15iqr": 0.011040958399826195, + "ops": 99.5351540107031, + "total": 0.20093403379723895, + "data": [ + 0.010260333398764487, + 0.010111258398683275, + 0.010663758400187361, + 0.010610716600785964, + 0.0096265084008337, + 0.01046013339946512, + 0.010258308199991007, + 0.009270524999010377, + 0.010789841799123678, + 0.01046866679971572, + 0.010965533398848492, + 0.010908058199856897, + 0.011040958399826195, + 0.008618766801373568, + 0.007966333199874498, + 0.007960100000491365, + 0.008388983200711663, + 0.01096806679997826, + 0.010690450000402052, + 0.010906733399315272 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Cycle/HT_TRENDMODE/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Cycle/HT_TRENDMODE/talib]", + "params": { + "indicator": "HT_TRENDMODE", + "library": "talib" + }, + "param": "Cycle/HT_TRENDMODE/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.021779850000166336, + "max": 0.02306985819886904, + "mean": 0.02224127873996622, + "stddev": 0.0002754805453360799, + "rounds": 20, + "median": 0.022193179199530275, + "iqr": 0.0001630750011827331, + "q1": 0.0221005832994706, + "q3": 0.022263658300653334, + "iqr_outliers": 4, + "stddev_outliers": 4, + "outliers": "4;4", + "ld15iqr": 0.022014825000951532, + "hd15iqr": 0.022551658199517988, + "ops": 44.961443615337686, + "total": 0.4448255747993244, + "data": [ + 0.022721391799859703, + 0.022551658199517988, + 0.02232100000110222, + 0.022269458400842268, + 0.022181408399774227, + 0.022194633400067686, + 0.022152916599588936, + 0.022014825000951532, + 0.022028575000877026, + 0.022016858399729243, + 0.0220776999994996, + 0.022219908398983534, + 0.022178116599388887, + 0.022191724998992867, + 0.022245625000505243, + 0.0221234665994416, + 0.021779850000166336, + 0.022257858200464397, + 0.02222874160070205, + 0.02306985819886904 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLENGULFING/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLENGULFING/ferro_ta]", + "params": { + "indicator": "CDLENGULFING", + "library": "ferro_ta" + }, + "param": "Pattern/CDLENGULFING/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00027645840018521997, + "max": 0.00044519160001073034, + "mean": 0.00031902997019642496, + "stddev": 4.123335110700092e-05, + "rounds": 20, + "median": 0.000308274900453398, + "iqr": 4.951250011799854e-05, + "q1": 0.0002861957997083664, + "q3": 0.00033570829982636495, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.00027645840018521997, + "hd15iqr": 0.00044519160001073034, + "ops": 3134.5017503662916, + "total": 0.0063805994039285, + "data": [ + 0.00034315000084461644, + 0.00035074160114163534, + 0.00030078340059844775, + 0.0002919749997090548, + 0.00044519160001073034, + 0.00033306659897789357, + 0.0003290831999038346, + 0.00030839160026516763, + 0.0002831416000844911, + 0.0003003834004630335, + 0.00037868320068810133, + 0.00033835000067483634, + 0.00033140820014523344, + 0.00028666659927694126, + 0.00028000000020256266, + 0.00030815820064162837, + 0.00033269999985350294, + 0.0002857250001397915, + 0.00027645840018521997, + 0.00027654180012177677 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLENGULFING/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLENGULFING/talib]", + "params": { + "indicator": "CDLENGULFING", + "library": "talib" + }, + "param": "Pattern/CDLENGULFING/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005443168003694155, + "max": 0.0007677750007132999, + "mean": 0.0006501204300730024, + "stddev": 6.981187568004996e-05, + "rounds": 20, + "median": 0.0006347791997541208, + "iqr": 9.977910012821673e-05, + "q1": 0.0005985791998682543, + "q3": 0.0006983582999964711, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005443168003694155, + "hd15iqr": 0.0007677750007132999, + "ops": 1538.1765496705118, + "total": 0.013002408601460047, + "data": [ + 0.0006777583999792114, + 0.0007677750007132999, + 0.0006669082009466365, + 0.0007590334003907629, + 0.0006067334004910662, + 0.0006003083995892666, + 0.0007622000004630536, + 0.000690349999058526, + 0.0007063666009344161, + 0.0005775331999757327, + 0.000599766599771101, + 0.0006019499996909872, + 0.0006586834002519027, + 0.0007503834000090137, + 0.0006416999996872619, + 0.0005973917999654077, + 0.0005713331993320026, + 0.0005443168003694155, + 0.0005940584000200033, + 0.0006278583998209797 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLDOJI/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLDOJI/ferro_ta]", + "params": { + "indicator": "CDLDOJI", + "library": "ferro_ta" + }, + "param": "Pattern/CDLDOJI/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00023774160072207451, + "max": 0.00047185819857986644, + "mean": 0.0003055166601552628, + "stddev": 7.150500420973441e-05, + "rounds": 20, + "median": 0.0002803500996378716, + "iqr": 9.031669978867287e-05, + "q1": 0.00025425410058232956, + "q3": 0.00034457080037100243, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00023774160072207451, + "hd15iqr": 0.00047185819857986644, + "ops": 3273.143924432149, + "total": 0.006110333203105256, + "data": [ + 0.0004619667990482412, + 0.00030373340123333035, + 0.00037227499997243284, + 0.000348658200528007, + 0.000283708400093019, + 0.0002506000004359521, + 0.00034048340021399783, + 0.00025790820072870704, + 0.00039388320001307873, + 0.0003045083998586051, + 0.00047185819857986644, + 0.0002621834006276913, + 0.0002602332009701058, + 0.00029782499914290386, + 0.0002662584010977298, + 0.0002426916005788371, + 0.00023831660073483362, + 0.00023774160072207451, + 0.00023850839934311808, + 0.0002769917991827242 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLDOJI/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLDOJI/talib]", + "params": { + "indicator": "CDLDOJI", + "library": "talib" + }, + "param": "Pattern/CDLDOJI/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002851249999366701, + "max": 0.0004393415991216898, + "mean": 0.000338947489799466, + "stddev": 4.36485691107468e-05, + "rounds": 20, + "median": 0.00032393339934060354, + "iqr": 6.102920015109707e-05, + "q1": 0.0003061165996768977, + "q3": 0.0003671457998279948, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0002851249999366701, + "hd15iqr": 0.0004393415991216898, + "ops": 2950.3095024885342, + "total": 0.00677894979598932, + "data": [ + 0.00031802500016056003, + 0.0003555750008672476, + 0.0002993749993038364, + 0.0003161168002407067, + 0.00041346660000272093, + 0.00032746679935371505, + 0.00036507500044535846, + 0.0004393415991216898, + 0.00031182499951682986, + 0.0003739166000741534, + 0.0003097166001680307, + 0.00034492499980842695, + 0.000320399999327492, + 0.0002861916000256315, + 0.00030251659918576477, + 0.0003692165992106311, + 0.0003476083991699852, + 0.0003996583996922709, + 0.0002934082003775984, + 0.0002851249999366701 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLHAMMER/ferro_ta]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLHAMMER/ferro_ta]", + "params": { + "indicator": "CDLHAMMER", + "library": "ferro_ta" + }, + "param": "Pattern/CDLHAMMER/ferro_ta", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0002776084002107382, + "max": 0.0005028999992646277, + "mean": 0.0003348600000026636, + "stddev": 5.698589436247308e-05, + "rounds": 20, + "median": 0.0003203500004019588, + "iqr": 6.823329968028705e-05, + "q1": 0.0002885458001401275, + "q3": 0.00035677909982041457, + "iqr_outliers": 1, + "stddev_outliers": 4, + "outliers": "4;1", + "ld15iqr": 0.0002776084002107382, + "hd15iqr": 0.0005028999992646277, + "ops": 2986.3226422745197, + "total": 0.006697200000053272, + "data": [ + 0.00030877500103088096, + 0.000311800000781659, + 0.00040400839934591205, + 0.0003831249996437691, + 0.0004137249998166226, + 0.0002900416002376005, + 0.00036117499985266477, + 0.00033629179961280897, + 0.00030974999972386283, + 0.0002828249998856336, + 0.0002796250002575107, + 0.0002776084002107382, + 0.00035124160058330745, + 0.0005028999992646277, + 0.00030152499966789036, + 0.00028470000106608497, + 0.0002870500000426546, + 0.00032974999921862034, + 0.0003289000000222586, + 0.00035238319978816437 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_speed[Pattern/CDLHAMMER/talib]", + "fullname": "benchmarks/test_speed.py::TestSpeed::test_speed[Pattern/CDLHAMMER/talib]", + "params": { + "indicator": "CDLHAMMER", + "library": "talib" + }, + "param": "Pattern/CDLHAMMER/talib", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012501666002208366, + "max": 0.0015625581989297643, + "mean": 0.0014000166499317857, + "stddev": 0.00010361022991808505, + "rounds": 20, + "median": 0.001375045900203986, + "iqr": 0.00018455009994795563, + "q1": 0.0013162623996322508, + "q3": 0.0015008124995802064, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0012501666002208366, + "hd15iqr": 0.0015625581989297643, + "ops": 714.2772195235849, + "total": 0.02800033299863571, + "data": [ + 0.0015434418004588225, + 0.0013960582000436261, + 0.001456891599809751, + 0.0013163915995392018, + 0.00139260839932831, + 0.0013214832011726684, + 0.0015625581989297643, + 0.0014761665996047668, + 0.0013477583997882903, + 0.0012501666002208366, + 0.0015336333992308937, + 0.0014753666007891297, + 0.0015254583995556459, + 0.001314125000499189, + 0.0013161331997253, + 0.0015333916002418847, + 0.0013304833992151543, + 0.0012883417992270551, + 0.0013574834010796621, + 0.0012623916001757607 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[SMA-libs0]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[SMA-libs0]", + "params": { + "indicator": "SMA", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "SMA-libs0", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022535840107593686, + "max": 0.0003908915998181328, + "mean": 0.00026595085982989987, + "stddev": 4.823923582782572e-05, + "rounds": 20, + "median": 0.0002444042002025526, + "iqr": 4.6020900481380566e-05, + "q1": 0.0002359374993829988, + "q3": 0.00028195839986437936, + "iqr_outliers": 2, + "stddev_outliers": 3, + "outliers": "3;2", + "ld15iqr": 0.00022535840107593686, + "hd15iqr": 0.0003756834004889242, + "ops": 3760.0931263752723, + "total": 0.005319017196597997, + "data": [ + 0.00023258339933818206, + 0.0003756834004889242, + 0.0002793749998090789, + 0.0002845417999196798, + 0.00024655839952174573, + 0.00029242499877000225, + 0.00024214180011767895, + 0.00025910840049618854, + 0.00022889999963808804, + 0.00022564160026377066, + 0.00022535840107593686, + 0.00033146679925266653, + 0.00023929159942781553, + 0.00026312499976484106, + 0.00024225000088335947, + 0.0002418249991023913, + 0.00023943340056575835, + 0.00022699159890180454, + 0.0002514249994419515, + 0.0003908915998181328 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[EMA-libs1]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[EMA-libs1]", + "params": { + "indicator": "EMA", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "EMA-libs1", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003487082009087317, + "max": 0.0005188084003748372, + "mean": 0.0004303175001405179, + "stddev": 6.0679005820778565e-05, + "rounds": 20, + "median": 0.00042030409967992456, + "iqr": 0.00011644580008578491, + "q1": 0.0003814707997662481, + "q3": 0.000497916599852033, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0003487082009087317, + "hd15iqr": 0.0005188084003748372, + "ops": 2323.865517143633, + "total": 0.008606350002810358, + "data": [ + 0.0004217249996145256, + 0.0003828583998256363, + 0.0003824333994998597, + 0.00043631680018734186, + 0.0003891250002197921, + 0.0005177166007342748, + 0.00040672500035725533, + 0.0003487082009087317, + 0.00045739159977529196, + 0.0004188831997453235, + 0.0005148415992152877, + 0.0003805082000326365, + 0.0004980500001693144, + 0.0005188084003748372, + 0.0003603668010327965, + 0.0003534168004989624, + 0.0004977831995347515, + 0.0005111000005854294, + 0.00036835840001003817, + 0.0004412334004882723 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[RSI-libs2]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[RSI-libs2]", + "params": { + "indicator": "RSI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "RSI-libs2", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0006004581999150104, + "max": 0.000859291800588835, + "mean": 0.0007025346101727336, + "stddev": 6.856376123163572e-05, + "rounds": 20, + "median": 0.0007091459003277123, + "iqr": 7.977930072229362e-05, + "q1": 0.0006539041001815348, + "q3": 0.0007336834009038284, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0006004581999150104, + "hd15iqr": 0.000859291800588835, + "ops": 1423.4174167648878, + "total": 0.014050692203454673, + "data": [ + 0.000859291800588835, + 0.0007221749998279847, + 0.0008353331999387592, + 0.0006742249999660999, + 0.000654758200107608, + 0.0007119666013750247, + 0.0007226750007248483, + 0.0007078167996951379, + 0.0007104750009602867, + 0.0006370834002154879, + 0.0006004581999150104, + 0.0007521334002376534, + 0.0007446918010828085, + 0.0007562333994428627, + 0.0007003417995292693, + 0.0006153084003017284, + 0.0006530500002554617, + 0.0007192333985585719, + 0.000670091800566297, + 0.0006033500001649372 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[MACD-libs3]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[MACD-libs3]", + "params": { + "indicator": "MACD", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "MACD-libs3", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005871916000614874, + "max": 0.0007956000001286157, + "mean": 0.0006621316400560318, + "stddev": 5.93817845656767e-05, + "rounds": 20, + "median": 0.0006457832998421509, + "iqr": 9.63751008384861e-05, + "q1": 0.0006131748996267561, + "q3": 0.0007095500004652422, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0005871916000614874, + "hd15iqr": 0.0007956000001286157, + "ops": 1510.273697108594, + "total": 0.013242632801120636, + "data": [ + 0.0007069416009471752, + 0.0006307831994490698, + 0.0005884418002096936, + 0.0005928583996137604, + 0.000744291600130964, + 0.0005976416010526009, + 0.0007121583999833092, + 0.0007316332004847937, + 0.0006800999995903112, + 0.0006217331989319064, + 0.0006911415999638848, + 0.0006528500001877546, + 0.0006321833992842585, + 0.0007195915997726843, + 0.0006334418008918874, + 0.0005871916000614874, + 0.0007956000001286157, + 0.0006807166006183252, + 0.0006046166003216058, + 0.0006387165994965471 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[BBANDS-libs4]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[BBANDS-libs4]", + "params": { + "indicator": "BBANDS", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "BBANDS-libs4", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003209165995940566, + "max": 0.00045758339983876796, + "mean": 0.0003743137299170485, + "stddev": 4.3629488101413486e-05, + "rounds": 20, + "median": 0.0003590501000871882, + "iqr": 6.953340052859861e-05, + "q1": 0.000340087399672484, + "q3": 0.0004096208002010826, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.0003209165995940566, + "hd15iqr": 0.00045758339983876796, + "ops": 2671.5557567754986, + "total": 0.00748627459834097, + "data": [ + 0.0003495249999104999, + 0.00034434999979566784, + 0.00045758339983876796, + 0.0004060666004079394, + 0.0003684333991259336, + 0.00034643340040929615, + 0.0003219416001229547, + 0.0004367665998870507, + 0.0004034083991427906, + 0.0003442082001129165, + 0.0004131749999942258, + 0.00033175819989992303, + 0.00038564999995287507, + 0.0003359665992320515, + 0.0004446332008228637, + 0.0003266500003519468, + 0.0003209165995940566, + 0.00034966680104844274, + 0.0004173665991402231, + 0.0003817749995505437 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[ATR-libs5]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[ATR-libs5]", + "params": { + "indicator": "ATR", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "ATR-libs5", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005948249992798083, + "max": 0.000859641600982286, + "mean": 0.0007126333399355645, + "stddev": 8.618998369969726e-05, + "rounds": 20, + "median": 0.0006980707003094722, + "iqr": 0.00016346669945050958, + "q1": 0.0006284541996137705, + "q3": 0.00079192089906428, + "iqr_outliers": 0, + "stddev_outliers": 10, + "outliers": "10;0", + "ld15iqr": 0.0005948249992798083, + "hd15iqr": 0.000859641600982286, + "ops": 1403.246163153718, + "total": 0.014252666798711289, + "data": [ + 0.0005948249992798083, + 0.0006146500003524124, + 0.0006242418006877415, + 0.0007484668007236905, + 0.0006880832006572746, + 0.0006186750004417263, + 0.0008385833993088454, + 0.0007426833995850757, + 0.0007771499993395991, + 0.0008208833998651244, + 0.0006248083998798392, + 0.000859641600982286, + 0.0006499249997432343, + 0.0008153081987984478, + 0.0006320999993477017, + 0.000806691798788961, + 0.0006608416006201878, + 0.0007080581999616697, + 0.0007617665993166156, + 0.0006652834010310471 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[CCI-libs6]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[CCI-libs6]", + "params": { + "indicator": "CCI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "CCI-libs6", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008333416000823491, + "max": 0.0011288665991742164, + "mean": 0.0009782278901548124, + "stddev": 8.724398235268927e-05, + "rounds": 20, + "median": 0.0009738291999383363, + "iqr": 0.00014463750048889792, + "q1": 0.0009124541000346653, + "q3": 0.0010570916005235632, + "iqr_outliers": 0, + "stddev_outliers": 9, + "outliers": "9;0", + "ld15iqr": 0.0008333416000823491, + "hd15iqr": 0.0011288665991742164, + "ops": 1022.2566848321427, + "total": 0.01956455780309625, + "data": [ + 0.0008689332011272199, + 0.0011221915992791764, + 0.0009622666009818203, + 0.0010609250006382354, + 0.0010683168002287857, + 0.0008855332009261474, + 0.0011288665991742164, + 0.001002225000411272, + 0.0009375581998028792, + 0.0008873500002664514, + 0.000942708199727349, + 0.001067049999255687, + 0.0010165833999053575, + 0.0009752500001923182, + 0.0009474667996983044, + 0.0008489750005537644, + 0.0009724083996843546, + 0.0008333416000823491, + 0.001053258200408891, + 0.0009833500007516704 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[WILLR-libs7]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[WILLR-libs7]", + "params": { + "indicator": "WILLR", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "WILLR-libs7", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0012216834002174437, + "max": 0.0017021584004396572, + "mean": 0.0013860970802488737, + "stddev": 0.0001148429215671508, + "rounds": 20, + "median": 0.0013733209008933045, + "iqr": 0.00014456269927904958, + "q1": 0.0013223582005593925, + "q3": 0.001466920899838442, + "iqr_outliers": 1, + "stddev_outliers": 5, + "outliers": "5;1", + "ld15iqr": 0.0012216834002174437, + "hd15iqr": 0.0017021584004396572, + "ops": 721.4501886263623, + "total": 0.027721941604977474, + "data": [ + 0.0014786749990889803, + 0.0017021584004396572, + 0.0014758750010514631, + 0.0013179832007153892, + 0.001525325000693556, + 0.0014134415992884896, + 0.0013376750008319504, + 0.0013269750008475967, + 0.0012216834002174437, + 0.001326733200403396, + 0.001361883401114028, + 0.0012386831993353553, + 0.001405541600252036, + 0.0014663334004580975, + 0.0014675083992187865, + 0.001384758400672581, + 0.001283883399446495, + 0.0012291750012082049, + 0.0014100915999733844, + 0.001347558399720583 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[OBV-libs8]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[OBV-libs8]", + "params": { + "indicator": "OBV", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "OBV-libs8", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0004056831996422261, + "max": 0.0006892416000482626, + "mean": 0.0005023220600560307, + "stddev": 8.58000897815503e-05, + "rounds": 20, + "median": 0.00047118750007939524, + "iqr": 0.000138708199665416, + "q1": 0.0004315042002417613, + "q3": 0.0005702123999071773, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0004056831996422261, + "hd15iqr": 0.0006892416000482626, + "ops": 1990.7546960777645, + "total": 0.010046441201120615, + "data": [ + 0.0006315749997156672, + 0.0005604831996606663, + 0.00048284179938491435, + 0.0005114750005304813, + 0.0006213333996129222, + 0.0004485750003368594, + 0.00042817500070668757, + 0.0005696666004951112, + 0.000434833399776835, + 0.0004056831996422261, + 0.00047114999906625597, + 0.0006892416000482626, + 0.0004401166006573476, + 0.000434941599087324, + 0.00042725820094347, + 0.00041915839974535627, + 0.0005707581993192434, + 0.0004712250010925345, + 0.0006021416003932246, + 0.0004258084009052254 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[ADX-libs9]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[ADX-libs9]", + "params": { + "indicator": "ADX", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "ADX-libs9", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007454249993315898, + "max": 0.001059275001171045, + "mean": 0.0008934458199655637, + "stddev": 8.756377436001077e-05, + "rounds": 20, + "median": 0.0008820791998005006, + "iqr": 9.906669947667979e-05, + "q1": 0.0008408416004385799, + "q3": 0.0009399082999152597, + "iqr_outliers": 0, + "stddev_outliers": 7, + "outliers": "7;0", + "ld15iqr": 0.0007454249993315898, + "hd15iqr": 0.001059275001171045, + "ops": 1119.2620499791954, + "total": 0.017868916399311274, + "data": [ + 0.0008991665992652998, + 0.0007454249993315898, + 0.00081320820027031, + 0.0008374666009331122, + 0.0008442165999440476, + 0.0008534417996997945, + 0.001049333199625835, + 0.0009284500003559515, + 0.0009965499993995763, + 0.0009513665994745679, + 0.0008534499997040257, + 0.001059275001171045, + 0.000770541800011415, + 0.0008853584004100412, + 0.000898391600640025, + 0.0007994250001502224, + 0.000910450000083074, + 0.0010221083997748793, + 0.0008787999991909601, + 0.0008724915998755023 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[MFI-libs10]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[MFI-libs10]", + "params": { + "indicator": "MFI", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "MFI-libs10", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003189583992934786, + "max": 0.000509766599861905, + "mean": 0.0003949437400297029, + "stddev": 6.526435858910924e-05, + "rounds": 20, + "median": 0.0003618791997723747, + "iqr": 0.0001144876005128026, + "q1": 0.00034380409997538666, + "q3": 0.00045829170048818926, + "iqr_outliers": 0, + "stddev_outliers": 8, + "outliers": "8;0", + "ld15iqr": 0.0003189583992934786, + "hd15iqr": 0.000509766599861905, + "ops": 2532.0062040350153, + "total": 0.007898874800594058, + "data": [ + 0.00035667499905684964, + 0.0003298832001746632, + 0.00032590000046184286, + 0.00036708340048789977, + 0.0003510167996864766, + 0.0004808249999769032, + 0.0003432249999605119, + 0.000509766599861905, + 0.0004797000001417473, + 0.00039522500010207294, + 0.0003510582013404928, + 0.00040963339997688306, + 0.00046789180050836875, + 0.0004486916004680097, + 0.00044824999931734053, + 0.00034438319999026136, + 0.00035058339999523015, + 0.0003189583992934786, + 0.00032823319925228133, + 0.0004918916005408391 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_head_to_head[STOCH-libs11]", + "fullname": "benchmarks/test_speed.py::test_head_to_head[STOCH-libs11]", + "params": { + "indicator": "STOCH", + "libs": [ + "ferro_ta", + "talib", + "tulipy", + "pandas_ta", + "ta", + "finta" + ] + }, + "param": "STOCH-libs11", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.002480933199694846, + "max": 0.0027984083993942478, + "mean": 0.0026606753800297155, + "stddev": 8.203168763829213e-05, + "rounds": 20, + "median": 0.0026674458007619247, + "iqr": 0.00010783750039990939, + "q1": 0.002617699899565196, + "q3": 0.0027255373999651054, + "iqr_outliers": 0, + "stddev_outliers": 6, + "outliers": "6;0", + "ld15iqr": 0.002480933199694846, + "hd15iqr": 0.0027984083993942478, + "ops": 375.84442187337845, + "total": 0.05321350760059431, + "data": [ + 0.0026757416009786537, + 0.0027535250002983956, + 0.002592483400076162, + 0.002480933199694846, + 0.002735758200287819, + 0.0026585334009723736, + 0.0027508582003065384, + 0.002659150000545196, + 0.0026185581999015996, + 0.0027153165996423923, + 0.002742383199802134, + 0.002616841599228792, + 0.0026932084001600742, + 0.0025111750001087785, + 0.0025728831999003885, + 0.0026971418003086, + 0.0026188415999058635, + 0.002690450000227429, + 0.0027984083993942478, + 0.002631316598854028 + ], + "iterations": 5 + } + }, + { + "group": null, + "name": "test_large_dataset[SMA]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[SMA]", + "params": { + "indicator": "SMA" + }, + "param": "SMA", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00022538900035821521, + "max": 0.00037056966781771433, + "mean": 0.0002529292335869589, + "stddev": 4.417250096630181e-05, + "rounds": 10, + "median": 0.00023604150070847635, + "iqr": 1.7777664955550193e-05, + "q1": 0.00023022233411514512, + "q3": 0.0002479999990706953, + "iqr_outliers": 2, + "stddev_outliers": 1, + "outliers": "1;2", + "ld15iqr": 0.00022538900035821521, + "hd15iqr": 0.00027806966681964695, + "ops": 3953.67504901798, + "total": 0.0025292923358695893, + "data": [ + 0.00023976366840846217, + 0.00023022233411514512, + 0.0002273613329937992, + 0.00023043066660951203, + 0.00022538900035821521, + 0.00027806966681964695, + 0.00037056966781771433, + 0.0002479999990706953, + 0.0002323193330084905, + 0.00024716666666790843 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[EMA]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[EMA]", + "params": { + "indicator": "EMA" + }, + "param": "EMA", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00034740299937160063, + "max": 0.0005852359997030968, + "mean": 0.0004130597662879154, + "stddev": 8.382708964817365e-05, + "rounds": 10, + "median": 0.0003792223336252694, + "iqr": 4.875000255803269e-05, + "q1": 0.0003642083320301026, + "q3": 0.0004129583345881353, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.00034740299937160063, + "hd15iqr": 0.0005503473318337152, + "ops": 2420.9571631408157, + "total": 0.004130597662879154, + "data": [ + 0.0003812223343023409, + 0.0005852359997030968, + 0.0004129583345881353, + 0.0003661250011646189, + 0.0003600416651655299, + 0.00038583333177181583, + 0.0003772223329481979, + 0.0005503473318337152, + 0.0003642083320301026, + 0.00034740299937160063 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[RSI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[RSI]", + "params": { + "indicator": "RSI" + }, + "param": "RSI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.000600069334420065, + "max": 0.0007878193331028646, + "mean": 0.0006891180331876967, + "stddev": 6.773662503176818e-05, + "rounds": 10, + "median": 0.0006752223331811062, + "iqr": 0.00010751399774259574, + "q1": 0.0006462360009512244, + "q3": 0.0007537499986938201, + "iqr_outliers": 0, + "stddev_outliers": 4, + "outliers": "4;0", + "ld15iqr": 0.000600069334420065, + "hd15iqr": 0.0007878193331028646, + "ops": 1451.1302154933271, + "total": 0.006891180331876967, + "data": [ + 0.0006517359991751922, + 0.0007878193331028646, + 0.0006110139996356642, + 0.000600069334420065, + 0.0006590416645243143, + 0.0007834026667599877, + 0.0007537499986938201, + 0.000691403001837898, + 0.0006462360009512244, + 0.0007067083327759368 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[MACD]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[MACD]", + "params": { + "indicator": "MACD" + }, + "param": "MACD", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005782500011264347, + "max": 0.0007826806662099747, + "mean": 0.0006408722331495179, + "stddev": 6.489620407795032e-05, + "rounds": 10, + "median": 0.0006145068327896297, + "iqr": 5.598633288173005e-05, + "q1": 0.0006021943336236291, + "q3": 0.0006581806665053591, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0005782500011264347, + "hd15iqr": 0.0007826806662099747, + "ops": 1560.3734227734847, + "total": 0.00640872233149518, + "data": [ + 0.0007826806662099747, + 0.0006159583329766368, + 0.0006130553326026226, + 0.0005782916656850526, + 0.0005782500011264347, + 0.000715736333707658, + 0.0006581806665053591, + 0.0006097083338924373, + 0.0006021943336236291, + 0.0006546666651653746 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[ATR]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[ATR]", + "params": { + "indicator": "ATR" + }, + "param": "ATR", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0005946386663708836, + "max": 0.0007365973336466899, + "mean": 0.0006466179996399054, + "stddev": 5.110334315120765e-05, + "rounds": 10, + "median": 0.0006296111666112363, + "iqr": 8.97640008285332e-05, + "q1": 0.0006081803318617555, + "q3": 0.0006979443326902887, + "iqr_outliers": 0, + "stddev_outliers": 5, + "outliers": "5;0", + "ld15iqr": 0.0005946386663708836, + "hd15iqr": 0.0007365973336466899, + "ops": 1546.5081401335708, + "total": 0.006466179996399053, + "data": [ + 0.000594930665101856, + 0.0005946386663708836, + 0.000629069332111006, + 0.0006414306650791938, + 0.0007141386668081395, + 0.0006081803318617555, + 0.0006979443326902887, + 0.0006301530011114664, + 0.0006190970016177744, + 0.0007365973336466899 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[BBANDS]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[BBANDS]", + "params": { + "indicator": "BBANDS" + }, + "param": "BBANDS", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003216110004965837, + "max": 0.0005164860000756258, + "mean": 0.0003732999665468621, + "stddev": 6.209120141730232e-05, + "rounds": 10, + "median": 0.00034969450037654803, + "iqr": 7.395866608324769e-05, + "q1": 0.00032977766628998023, + "q3": 0.0004037363323732279, + "iqr_outliers": 1, + "stddev_outliers": 2, + "outliers": "2;1", + "ld15iqr": 0.0003216110004965837, + "hd15iqr": 0.0005164860000756258, + "ops": 2678.8108481506265, + "total": 0.0037329996654686206, + "data": [ + 0.00034565266832942143, + 0.0004037363323732279, + 0.00032977766628998023, + 0.0003216110004965837, + 0.000323763665316316, + 0.00036163899979631725, + 0.0005164860000756258, + 0.0003407776675885543, + 0.0004358193327789195, + 0.0003537363324236746 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[OBV]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[OBV]", + "params": { + "indicator": "OBV" + }, + "param": "OBV", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.00044651366624748334, + "max": 0.0006777499996436139, + "mean": 0.0005403874665110683, + "stddev": 8.862267895374138e-05, + "rounds": 10, + "median": 0.0005186596669470115, + "iqr": 0.0001556806649508265, + "q1": 0.0004624583331557612, + "q3": 0.0006181389981065877, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.00044651366624748334, + "hd15iqr": 0.0006777499996436139, + "ops": 1850.5240442683694, + "total": 0.005403874665110682, + "data": [ + 0.00044651366624748334, + 0.0006777499996436139, + 0.0005513889991561882, + 0.0004859303347378348, + 0.00046744433348067105, + 0.0004624583331557612, + 0.0005663886671148551, + 0.0006181389981065877, + 0.0006656529997902302, + 0.000462208333677457 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[CCI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[CCI]", + "params": { + "indicator": "CCI" + }, + "param": "CCI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0008119026679196395, + "max": 0.0010690416650807795, + "mean": 0.0009010055332813256, + "stddev": 8.526608570891659e-05, + "rounds": 10, + "median": 0.0008731041658999553, + "iqr": 0.0001106110018251153, + "q1": 0.0008304999986042579, + "q3": 0.0009411110004293732, + "iqr_outliers": 0, + "stddev_outliers": 3, + "outliers": "3;0", + "ld15iqr": 0.0008119026679196395, + "hd15iqr": 0.0010690416650807795, + "ops": 1109.8710974150752, + "total": 0.009010055332813257, + "data": [ + 0.0010690416650807795, + 0.0008742083324856745, + 0.0009132220002356917, + 0.0008275556659403568, + 0.0008119026679196395, + 0.0008719999993142361, + 0.0010165833349068028, + 0.0008304999986042579, + 0.0008539306678964446, + 0.0009411110004293732 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[ADX]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[ADX]", + "params": { + "indicator": "ADX" + }, + "param": "ADX", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0007418056654084163, + "max": 0.0011887083334537845, + "mean": 0.0008643930666342688, + "stddev": 0.00013523304582108202, + "rounds": 10, + "median": 0.0008226111664650185, + "iqr": 9.762533348596969e-05, + "q1": 0.0007768053328618407, + "q3": 0.0008744306663478104, + "iqr_outliers": 1, + "stddev_outliers": 1, + "outliers": "1;1", + "ld15iqr": 0.0007418056654084163, + "hd15iqr": 0.0011887083334537845, + "ops": 1156.8810979636276, + "total": 0.008643930666342689, + "data": [ + 0.0008744306663478104, + 0.0009943749998152878, + 0.0007768053328618407, + 0.000863347333506681, + 0.0011887083334537845, + 0.000794291668474519, + 0.0007959723322225424, + 0.0008492500007074947, + 0.0007418056654084163, + 0.0007649443335443115 + ], + "iterations": 3 + } + }, + { + "group": null, + "name": "test_large_dataset[MFI]", + "fullname": "benchmarks/test_speed.py::test_large_dataset[MFI]", + "params": { + "indicator": "MFI" + }, + "param": "MFI", + "extra_info": {}, + "options": { + "disable_gc": false, + "timer": "perf_counter", + "min_rounds": 5, + "max_time": 1.0, + "min_time": 5e-06, + "warmup": false + }, + "stats": { + "min": 0.0003194029995938763, + "max": 0.0004913333323202096, + "mean": 0.0003576916332046191, + "stddev": 6.038320702172412e-05, + "rounds": 10, + "median": 0.00032843733424670063, + "iqr": 3.7721998523920774e-05, + "q1": 0.00032180566631723195, + "q3": 0.00035952766484115273, + "iqr_outliers": 2, + "stddev_outliers": 2, + "outliers": "2;2", + "ld15iqr": 0.0003194029995938763, + "hd15iqr": 0.000445291666740862, + "ops": 2795.7041964913547, + "total": 0.0035769163320461908, + "data": [ + 0.0004913333323202096, + 0.00032976366734753054, + 0.00032656933278000605, + 0.00032180566631723195, + 0.00035952766484115273, + 0.00033452766607903567, + 0.0003194029995938763, + 0.000445291666740862, + 0.0003271110011458707, + 0.00032158333488041535 + ], + "iterations": 3 + } + } + ], + "datetime": "2026-03-23T17:14:05.427766+00:00", + "version": "5.2.3" +} diff --git a/vendor/ferro-ta-main/benchmarks/run_perf_contract.py b/vendor/ferro-ta-main/benchmarks/run_perf_contract.py new file mode 100644 index 0000000..2a926fa --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/run_perf_contract.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path +from typing import Any + +import numpy as np + +try: + from benchmarks.bench_batch import run_batch_benchmark + from benchmarks.bench_simd import run_simd_benchmark + from benchmarks.bench_streaming import run_streaming_benchmark + from benchmarks.bench_vs_talib import run_comparison + from benchmarks.metadata import benchmark_metadata, file_info + from benchmarks.profile_runtime_hotspots import build_hotspot_report + from benchmarks.test_benchmark_suite import ( + FIXTURE_PATH, + INDICATOR_SUITE, + _run_indicator, + ) +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from bench_batch import run_batch_benchmark + from bench_simd import run_simd_benchmark + from bench_streaming import run_streaming_benchmark + from bench_vs_talib import run_comparison + from metadata import benchmark_metadata, file_info + from profile_runtime_hotspots import build_hotspot_report + from test_benchmark_suite import FIXTURE_PATH, INDICATOR_SUITE, _run_indicator + + +def _time_min(fn, rounds: int = 5) -> float: + fn() + samples: list[float] = [] + for _ in range(rounds): + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return min(samples) * 1000.0 + + +def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]: + if not FIXTURE_PATH.exists(): + raise FileNotFoundError( + f"Canonical fixture not found: {FIXTURE_PATH}. " + "Run benchmarks/fixtures/generate_canonical.py first." + ) + + fixture = np.load(FIXTURE_PATH) + ohlcv = {key: fixture[key] for key in fixture.files} + + rows: list[dict[str, Any]] = [] + for entry in INDICATOR_SUITE: + elapsed_ms = _time_min( + lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds + ) + rows.append( + { + "name": entry["name"], + "inputs": entry["inputs"], + "kwargs": entry["kwargs"], + "elapsed_ms": round(elapsed_ms, 4), + } + ) + + rows.sort(key=lambda row: float(row["elapsed_ms"]), reverse=True) + return { + "metadata": benchmark_metadata( + "indicator_latency", + fixtures=[FIXTURE_PATH], + extra={ + "dataset": { + "fixture": str(FIXTURE_PATH), + "bars": len(ohlcv["close"]), + "rounds": rounds, + } + }, + ), + "results": rows, + } + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate reproducible performance baseline artifacts." + ) + parser.add_argument( + "--output-dir", + default="benchmarks/artifacts/latest", + help="Directory where benchmark JSON artifacts are written", + ) + parser.add_argument("--indicator-rounds", type=int, default=5) + parser.add_argument("--batch-samples", type=int, default=100_000) + parser.add_argument("--batch-series", type=int, default=100) + parser.add_argument("--batch-seed", type=int, default=42) + parser.add_argument("--streaming-bars", type=int, default=100_000) + parser.add_argument("--streaming-seed", type=int, default=2026) + parser.add_argument("--price-bars", type=int, default=20_000) + parser.add_argument("--iv-bars", type=int, default=50_000) + parser.add_argument("--window", type=int, default=252) + parser.add_argument( + "--skip-simd", + action="store_true", + help="Skip portable-vs-SIMD comparison", + ) + parser.add_argument( + "--talib-sizes", + type=int, + nargs="+", + default=[10_000, 100_000], + help="Bar counts used for the TA-Lib comparison suite", + ) + parser.add_argument( + "--skip-talib", + action="store_true", + help="Skip the TA-Lib comparison artifact", + ) + args = parser.parse_args() + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + artifacts: dict[str, str] = {} + + indicator_path = output_dir / "indicator_latency.json" + _write_json( + indicator_path, + build_indicator_latency_report(rounds=args.indicator_rounds), + ) + artifacts["indicator_latency"] = str(indicator_path) + + batch_path = output_dir / "batch.json" + _write_json( + batch_path, + run_batch_benchmark( + n_samples=args.batch_samples, + n_series=args.batch_series, + seed=args.batch_seed, + ), + ) + artifacts["batch"] = str(batch_path) + + streaming_path = output_dir / "streaming.json" + _write_json( + streaming_path, + run_streaming_benchmark( + n_bars=args.streaming_bars, + seed=args.streaming_seed, + ), + ) + artifacts["streaming"] = str(streaming_path) + + hotspot_path = output_dir / "runtime_hotspots.json" + _write_json( + hotspot_path, + build_hotspot_report( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ), + ) + artifacts["runtime_hotspots"] = str(hotspot_path) + + if not args.skip_simd: + simd_path = output_dir / "simd.json" + _write_json( + simd_path, + run_simd_benchmark( + price_bars=args.price_bars, + iv_bars=args.iv_bars, + window=args.window, + ), + ) + artifacts["simd"] = str(simd_path) + + if not args.skip_talib: + talib_path = output_dir / "benchmark_vs_talib.json" + run_comparison(args.talib_sizes, str(talib_path)) + artifacts["benchmark_vs_talib"] = str(talib_path) + + wasm_path = output_dir / "wasm.json" + if wasm_path.exists(): + artifacts["wasm"] = str(wasm_path) + + manifest = { + "metadata": benchmark_metadata( + "perf_contract", + fixtures=[FIXTURE_PATH], + extra={"output_dir": str(output_dir)}, + ), + "artifacts": {name: file_info(path) for name, path in artifacts.items()}, + } + manifest_path = output_dir / "manifest.json" + _write_json(manifest_path, manifest) + + print(f"Generated performance contract artifacts in {output_dir}") + for name, path in artifacts.items(): + print(f" - {name}: {path}") + print(f" - manifest: {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/benchmarks/test_accuracy.py b/vendor/ferro-ta-main/benchmarks/test_accuracy.py new file mode 100644 index 0000000..c2d2e73 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/test_accuracy.py @@ -0,0 +1,200 @@ +""" +Cross-library accuracy tests. + +For each indicator we compare ferro_ta output against every available reference library. +Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed). +We only compare the overlapping (valid) suffix of each output array. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from benchmarks.data_generator import MEDIUM +from benchmarks.wrapper_registry import ( + BINARY_INDICATORS, + CUMULATIVE_INDICATORS, + INDICATOR_CATEGORIES, + INDICATOR_NAMES, + available_libraries, + execute_indicator, + is_supported, +) + +# Reference = ferro_ta; compare against each library that has a non-empty result. +REFERENCE_LIB = "ferro_ta" +COMPARISON_LIBS = [ + library for library in available_libraries() if library != REFERENCE_LIB +] + +# Per-indicator tolerances (rtol, atol) +_TOLERANCES: dict[str, tuple[float, float]] = { + "ATR": (1e-3, 0.05), # Wilder's smoothing seed differs + "NATR": (1e-3, 0.10), + "BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1 + "STDDEV": (1e-3, 0.20), + "VAR": (1e-3, 0.50), + "MACD": (1e-3, 1.00), # seed differences across libraries + "KAMA": (1e-3, 1e-3), + "STOCH": (1e-3, 0.10), # smoothing method differences + "SAR": (1e-3, 0.20), + "ADOSC": (1e-3, 0.20), + "ADX": (1e-3, 0.50), # Wilder's ADX + "PLUS_DI": (1e-3, 0.50), + "MINUS_DI": (1e-3, 0.50), + "PPO": (1e-2, 1e-3), + "CMO": (1e-3, 0.10), + "TRIX": (1e-3, 0.05), + "CCI": (1e-3, 0.10), + "SUPERTREND": (1e-2, 0.50), + "KELTNER_CHANNELS": (1e-2, 0.50), + "DONCHIAN": (1e-4, 1e-4), + "HT_DCPERIOD": (1e-2, 2.0), + "VWAP": (1e-3, 0.10), + "AROON": (1e-4, 1e-3), + "LINEARREG": (1e-4, 1e-4), + "LINEARREG_SLOPE": (1e-4, 1e-4), + "CORREL": (1e-4, 1e-3), + "BETA": (1e-3, 1e-3), + "TSF": (1e-4, 1e-4), + "EMA": (1e-3, 0.30), # ta library uses different EMA seed + "DEMA": (1e-3, 0.50), + "TEMA": (1e-3, 0.50), + "T3": (1e-3, 0.50), + "HULL_MA": (1e-3, 0.10), + "WMA": (1e-4, 1e-4), + "TRIMA": (1e-4, 1e-4), +} + +_DEFAULT_TOL = (1e-4, 1e-5) + +# Pairs that use correlation check (>=0.95) due to known algorithmic divergence +# Format: (indicator, library) or just indicator (applies to all libs) +_CORRELATION_PAIRS: set[tuple[str, str]] = { + ("PPO", "talib"), # different PPO formula normalization + ("PPO", "pandas_ta"), + ("PPO", "tulipy"), + ("STOCH", "ta"), + ("SUPERTREND", "pandas_ta"), + ("KELTNER_CHANNELS", "pandas_ta"), + ("KELTNER_CHANNELS", "ta"), + ("EMA", "finta"), # finta EMA uses different initialization + ("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed + ("RSI", "ta"), # ta uses SMA warmup vs Wilder + ("RSI", "finta"), # same +} + +# Pairs that are skipped because they are structurally incompatible +_SKIP_PAIRS: set[tuple[str, str]] = { + ("BBANDS", "finta"), # finta normalizes band differently + ("ATR", "finta"), # finta ATR uses simple TR not Wilder + ("STDDEV", "finta"), # finta uses population std + ("TRIMA", "finta"), # finta TRIMA uses different formula + ("PPO", "finta"), # finta PPO scaling incompatible + ("STOCH", "finta"), # finta STOCH formula differs + ("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start + ("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges + ("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90 + ("CMO", "pandas_ta"), + ("CMO", "finta"), + ("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70 +} + +MIN_OVERLAP = 30 # minimum points to make comparison meaningful + + +def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) -> None: + """Assert that ref and cmp agree on their overlapping suffix.""" + if (indicator, library) in _SKIP_PAIRS: + pytest.skip(f"Known structural incompatibility: {indicator} vs {library}") + if len(ref) < MIN_OVERLAP or len(cmp) < MIN_OVERLAP: + pytest.skip(f"Too few points to compare ({len(ref)} vs {len(cmp)})") + n = min(len(ref), len(cmp)) + r = ref[-n:] + c = cmp[-n:] + if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS: + # Use correlation check for structurally different algorithms + corr = np.corrcoef(r, c)[0, 1] if indicator not in BINARY_INDICATORS else None + if indicator in BINARY_INDICATORS: + agree = np.mean(r == c) + assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%" + else: + assert corr >= 0.90, ( + f"Correlation {corr:.4f} < 0.90 (structural divergence)" + ) + elif indicator in CUMULATIVE_INDICATORS: + dr, dc = np.diff(r), np.diff(c) + if len(dr) < 5 or len(dc) < 5: + return + corr = np.corrcoef(dr, dc)[0, 1] + assert corr >= 0.999, f"Cumulative corr {corr:.6f} < 0.999" + else: + rtol, atol = _TOLERANCES.get(indicator, _DEFAULT_TOL) + assert np.allclose(r, c, rtol=rtol, atol=atol), ( + f"max diff = {np.max(np.abs(r - c)):.6g}, " + f"mean diff = {np.mean(np.abs(r - c)):.6g}" + ) + + +# ── dynamically generate one test per (indicator, library) pair ───────────── + + +def pytest_generate_tests(metafunc): + if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: + params = [] + avail = available_libraries() + for ind in INDICATOR_NAMES: + for lib in COMPARISON_LIBS: + if lib in avail: + params.append(pytest.param(ind, lib, id=f"{ind}-{lib}")) + metafunc.parametrize("indicator,library", params) + + +class TestAccuracy: + """Compare ferro_ta vs every other library for all indicators.""" + + def test_accuracy(self, indicator, library): + """ferro_ta and {library} should agree on {indicator}.""" + if not is_supported(REFERENCE_LIB, indicator): + pytest.fail(f"{REFERENCE_LIB} does not implement {indicator}") + if not is_supported(library, indicator): + pytest.skip(f"{library} does not implement {indicator}") + + ref = execute_indicator(REFERENCE_LIB, indicator, MEDIUM) + cmp = execute_indicator(library, indicator, MEDIUM) + + if len(cmp) == 0: + pytest.fail( + f"{library} returned empty output for supported indicator {indicator}" + ) + if len(ref) == 0: + pytest.fail(f"{REFERENCE_LIB} returned empty for {indicator}") + + _compare(ref, cmp, indicator, library) + + +# ── quick smoke tests that always run (no skip) ────────────────────────────── + + +class TestSmoke: + """Sanity checks that ferro_ta returns non-empty finite arrays.""" + + @pytest.mark.parametrize("indicator", INDICATOR_NAMES) + def test_ferro_ta_returns_finite(self, indicator): + if not is_supported("ferro_ta", indicator): + pytest.fail(f"ferro_ta does not implement {indicator}") + + arr = execute_indicator("ferro_ta", indicator, MEDIUM) + assert len(arr) > 0, f"ferro_ta {indicator} returned empty array" + assert np.all(np.isfinite(arr)), ( + f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}" + ) + + @pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items()) + def test_category_coverage(self, category, indicators): + for ind in indicators: + if not is_supported("ferro_ta", ind): + pytest.fail(f"Category {category}: ferro_ta does not implement {ind}") + arr = execute_indicator("ferro_ta", ind, MEDIUM) + assert len(arr) > 0, f"Category {category}: {ind} returned empty" diff --git a/vendor/ferro-ta-main/benchmarks/test_benchmark_suite.py b/vendor/ferro-ta-main/benchmarks/test_benchmark_suite.py new file mode 100644 index 0000000..05c669a --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/test_benchmark_suite.py @@ -0,0 +1,370 @@ +""" +Benchmark suite +=========================== + +Numerical-regression and performance benchmarks that run against the canonical +OHLCV fixture in ``benchmarks/fixtures/canonical_ohlcv.npz``. + +Numerical regression checks +---------------------------- +For each (indicator, params) pair in ``INDICATOR_SUITE``, the test: +1. Loads the canonical dataset. +2. Runs the indicator. +3. Compares the last N non-NaN values to stored baselines (or tolerance-based). + +To regenerate baselines after an intentional indicator change:: + + pytest benchmarks/test_benchmark_suite.py --update-baselines + +Performance checks +------------------ +Each indicator is timed over the canonical dataset. If a ``baselines.npz`` +file exists in this directory, the run compares to that; otherwise timing is +reported only. + +Run locally:: + + pytest benchmarks/test_benchmark_suite.py -v + +""" + +from __future__ import annotations + +import pathlib +import time +from collections.abc import Callable +from typing import Any + +import numpy as np +import pytest + +FIXTURE_PATH = pathlib.Path(__file__).parent / "fixtures" / "canonical_ohlcv.npz" +BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz" + +# --------------------------------------------------------------------------- +# Load fixture +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def ohlcv() -> dict[str, np.ndarray]: + """Load canonical OHLCV fixture.""" + if not FIXTURE_PATH.exists(): + pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}") + data = np.load(FIXTURE_PATH) + return {k: data[k] for k in data.files} + + +# --------------------------------------------------------------------------- +# Indicator suite definition +# --------------------------------------------------------------------------- + +# Each entry: (name, callable, kwargs) +# The callable receives (close,) or (high, low, close,) based on 'inputs' key. +INDICATOR_SUITE: list[dict[str, Any]] = [ + { + "name": "SMA_20", + "inputs": "close", + "fn": None, + "fn_name": "SMA", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "EMA_20", + "inputs": "close", + "fn": None, + "fn_name": "EMA", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "RSI_14", + "inputs": "close", + "fn": None, + "fn_name": "RSI", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "ATR_14", + "inputs": "hlc", + "fn": None, + "fn_name": "ATR", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "ADX_14", + "inputs": "hlc", + "fn": None, + "fn_name": "ADX", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "STDDEV_20", + "inputs": "close", + "fn": None, + "fn_name": "STDDEV", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "MACD", + "inputs": "close", + "fn": None, + "fn_name": "MACD", + "kwargs": {}, + }, + { + "name": "BBANDS_20", + "inputs": "close", + "fn": None, + "fn_name": "BBANDS", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "STOCH", + "inputs": "hlc", + "fn": None, + "fn_name": "STOCH", + "kwargs": {}, + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "fn": None, + "fn_name": "LINEARREG", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "fn": None, + "fn_name": "LINEARREG_SLOPE", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "TSF_14", + "inputs": "close", + "fn": None, + "fn_name": "TSF", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "VAR_20", + "inputs": "close", + "fn": None, + "fn_name": "VAR", + "kwargs": {"timeperiod": 20}, + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "fn": None, + "fn_name": "CORREL", + "kwargs": {"timeperiod": 30}, + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "fn": None, + "fn_name": "BETA", + "kwargs": {"timeperiod": 5}, + }, + { + "name": "CCI_14", + "inputs": "hlc", + "fn": None, + "fn_name": "CCI", + "kwargs": {"timeperiod": 14}, + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "fn": None, + "fn_name": "WILLR", + "kwargs": {"timeperiod": 14}, + }, +] + + +def _load_fn(fn_name: str) -> Callable[..., Any]: + import ferro_ta as ft + + return getattr(ft, fn_name) + + +def _run_indicator(entry: dict[str, Any], data: dict[str, np.ndarray]) -> np.ndarray: + fn = _load_fn(entry["fn_name"]) + if entry["inputs"] == "close": + result = fn(data["close"], **entry["kwargs"]) + elif entry["inputs"] == "hlc": + result = fn(data["high"], data["low"], data["close"], **entry["kwargs"]) + else: # pair_hl + result = fn(data["high"], data["low"], **entry["kwargs"]) + if isinstance(result, tuple): + result = result[0] + return np.asarray(result, dtype=np.float64) + + +# --------------------------------------------------------------------------- +# Numerical regression tests +# --------------------------------------------------------------------------- + + +class TestNumericalRegression: + """Verify indicator outputs match stored baselines (or tolerance).""" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_output_shape( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Indicator output length must equal input length.""" + out = _run_indicator(entry, ohlcv) + assert len(out) == len(ohlcv["close"]), ( + f"{entry['name']}: expected len {len(ohlcv['close'])}, got {len(out)}" + ) + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_warmup_is_nan( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """First bar must be NaN (warm-up).""" + out = _run_indicator(entry, ohlcv) + assert np.isnan(out[0]), f"{entry['name']}: expected NaN at bar 0, got {out[0]}" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_no_inf(self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]) -> None: + """Output must not contain infinities.""" + out = _run_indicator(entry, ohlcv) + assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf" + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_last_values_stable( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Last 10 non-NaN values must be finite and stable (no sudden jumps).""" + out = _run_indicator(entry, ohlcv) + valid = out[~np.isnan(out)] + assert len(valid) >= 10, f"{entry['name']}: fewer than 10 valid output values" + last10 = valid[-10:] + assert np.all(np.isfinite(last10)), ( + f"{entry['name']}: non-finite in last 10 values" + ) + + @pytest.mark.skipif(not BASELINE_PATH.exists(), reason="No baselines.npz found") + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_regression_vs_baseline( + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] + ) -> None: + """Compare last 10 values to stored baselines.""" + baselines = np.load(BASELINE_PATH) + key = entry["name"] + if key not in baselines: + pytest.skip(f"No baseline stored for {key}") + out = _run_indicator(entry, ohlcv) + valid = out[~np.isnan(out)] + last10 = valid[-10:] + stored = baselines[key] + np.testing.assert_allclose( + last10, + stored, + rtol=1e-5, + atol=1e-8, + err_msg=f"Numerical regression for {key}", + ) + + +# --------------------------------------------------------------------------- +# Performance benchmarks +# --------------------------------------------------------------------------- + + +class TestPerformance: + """Timing benchmarks — record wall time and compare to baselines if present.""" + + PERF_THRESHOLD_FACTOR = 2.0 # fail if run is > 2× slower than baseline + + @pytest.mark.parametrize( + "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] + ) + def test_timing( + self, + entry: dict[str, Any], + ohlcv: dict[str, np.ndarray], + request: pytest.FixtureRequest, + ) -> None: + """Time the indicator on the canonical dataset.""" + # Warm-up run + _run_indicator(entry, ohlcv) + + # Timed run + t0 = time.perf_counter() + for _ in range(5): + _run_indicator(entry, ohlcv) + elapsed = (time.perf_counter() - t0) / 5.0 # average over 5 runs + + # Store timing in request node for reporting + request.node._ferro_ta_timing = elapsed # type: ignore[attr-defined] + + # Compare to baseline if available + if BASELINE_PATH.exists(): + baselines = np.load(BASELINE_PATH, allow_pickle=True) + key = f"timing_{entry['name']}" + if key in baselines: + baseline_time = float(baselines[key]) + if elapsed > baseline_time * self.PERF_THRESHOLD_FACTOR: + pytest.fail( + f"{entry['name']}: timing regression — " + f"current {elapsed * 1000:.2f}ms vs " + f"baseline {baseline_time * 1000:.2f}ms " + f"(>{self.PERF_THRESHOLD_FACTOR}×)" + ) + + +# --------------------------------------------------------------------------- +# Baseline update helper +# --------------------------------------------------------------------------- + + +def update_baselines(ohlcv_data: dict[str, np.ndarray]) -> None: + """Write current indicator outputs and timings to baselines.npz. + + Call this after intentional changes to update the stored baselines:: + + python -c " + import numpy as np + from benchmarks.test_benchmark_suite import update_baselines, FIXTURE_PATH + data = {k: v for k, v in np.load(FIXTURE_PATH).items()} + update_baselines(data) + " + """ + store: dict[str, np.ndarray] = {} + for entry in INDICATOR_SUITE: + out = _run_indicator(entry, ohlcv_data) + valid = out[~np.isnan(out)] + store[entry["name"]] = valid[-10:] + + # Timing + t0 = time.perf_counter() + for _ in range(5): + _run_indicator(entry, ohlcv_data) + store[f"timing_{entry['name']}"] = np.array([(time.perf_counter() - t0) / 5.0]) + + np.savez_compressed(BASELINE_PATH, **store) + print(f"Baselines written to {BASELINE_PATH}") + + +if __name__ == "__main__": + if not FIXTURE_PATH.exists(): + print(f"Fixture not found: {FIXTURE_PATH}") + print("Run: python benchmarks/fixtures/generate_canonical.py") + else: + data = {k: v for k, v in np.load(FIXTURE_PATH).items()} + update_baselines(data) diff --git a/vendor/ferro-ta-main/benchmarks/test_derivatives_speed.py b/vendor/ferro-ta-main/benchmarks/test_derivatives_speed.py new file mode 100644 index 0000000..a64ba0e --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/test_derivatives_speed.py @@ -0,0 +1,124 @@ +""" +Derivatives benchmark hooks. + +These are intentionally optional and skip when `py_vollib` is unavailable. +Run with: + + uv run pytest benchmarks/test_derivatives_speed.py --benchmark-only -v +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import numpy as np +import pytest + +# Ensure direct benchmark test runs can import local package from `python/`. +ROOT = Path(__file__).resolve().parents[1] +PYTHON_SRC = ROOT / "python" +if str(PYTHON_SRC) not in sys.path: + sys.path.insert(0, str(PYTHON_SRC)) + +HAS_FERRO_EXTENSION = True +try: + from ferro_ta.analysis.options import implied_volatility, option_price +except ModuleNotFoundError: + HAS_FERRO_EXTENSION = False + +pytestmark = pytest.mark.skipif( + not HAS_FERRO_EXTENSION, reason="ferro_ta extension is not built" +) + + +def _sample_chain(n: int = 1000) -> tuple[np.ndarray, ...]: + spot = np.linspace(90.0, 110.0, n) + strike = np.full(n, 100.0) + rate = np.full(n, 0.02) + time_to_expiry = np.full(n, 0.5) + volatility = np.full(n, 0.2) + return spot, strike, rate, time_to_expiry, volatility + + +def test_ferro_ta_option_price_speed(benchmark): + spot, strike, rate, time_to_expiry, volatility = _sample_chain() + + benchmark.pedantic( + lambda: option_price( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + model="bsm", + ), + iterations=5, + rounds=20, + warmup_rounds=2, + ) + + +def test_ferro_ta_implied_vol_speed(benchmark): + spot, strike, rate, time_to_expiry, volatility = _sample_chain() + prices = option_price( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + model="bsm", + ) + + benchmark.pedantic( + lambda: implied_volatility( + prices, + spot, + strike, + rate, + time_to_expiry, + option_type="call", + model="bsm", + ), + iterations=5, + rounds=20, + warmup_rounds=2, + ) + + +@pytest.mark.skipif( + importlib.util.find_spec("py_vollib") is None, + reason="py_vollib is optional", +) +def test_py_vollib_scalar_loop_baseline(benchmark): + from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm + from py_vollib.black_scholes_merton.implied_volatility import ( + implied_volatility as py_vollib_iv, + ) + + spot, strike, rate, time_to_expiry, volatility = _sample_chain(250) + prices = [ + py_vollib_bsm("c", float(s), float(k), float(t), float(r), float(vol), 0.0) + for s, k, r, t, vol in zip(spot, strike, rate, time_to_expiry, volatility) + ] + + benchmark.pedantic( + lambda: [ + py_vollib_iv( + float(price), + "c", + float(s), + float(k), + float(t), + float(r), + 0.0, + ) + for price, s, k, r, t in zip(prices, spot, strike, rate, time_to_expiry) + ], + iterations=3, + rounds=10, + warmup_rounds=1, + ) diff --git a/vendor/ferro-ta-main/benchmarks/test_speed.py b/vendor/ferro-ta-main/benchmarks/test_speed.py new file mode 100644 index 0000000..f6fbb8d --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/test_speed.py @@ -0,0 +1,102 @@ +""" +Cross-library speed benchmarks using pytest-benchmark. + +Run: pytest benchmarks/test_speed.py --benchmark-only -v + pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json + +Streaming benchmarks are in test_streaming_speed.py +""" + +from __future__ import annotations + +import pytest + +from benchmarks.data_generator import LARGE +from benchmarks.wrapper_registry import ( + INDICATOR_CATEGORIES, + available_libraries, + execute_indicator, + is_supported, +) + +BENCH_DATA = LARGE # 100k bars for main benchmarks +BENCH_LIBS = available_libraries() + + +def _make_bench(indicator: str, library: str): + """Return a benchmark function that runs indicator on library (uses BENCH_DATA).""" + + def _fn(): + execute_indicator(library, indicator, BENCH_DATA) + + _fn.__name__ = f"{library}_{indicator}" + return _fn + + +# ── Parametrize over all (indicator, library) combinations ─────────────────── + + +def pytest_generate_tests(metafunc): + if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: + params = [] + for cat, inds in INDICATOR_CATEGORIES.items(): + for ind in inds: + for lib in BENCH_LIBS: + params.append(pytest.param(ind, lib, id=f"{cat}/{ind}/{lib}")) + metafunc.parametrize("indicator,library", params) + + +class TestSpeed: + """One benchmark per (indicator, library) pair — all at 100k bars (LARGE dataset).""" + + def test_speed(self, benchmark, indicator, library): + if not is_supported(library, indicator): + pytest.skip(f"{library} does not implement {indicator}") + fn = _make_bench(indicator, library) + benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2) + + +# ── Standalone head-to-head for the most important indicators ───────────────── + + +@pytest.mark.parametrize( + "indicator,libs", + [ + ("SMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("EMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("RSI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("MACD", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("BBANDS", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("ATR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("CCI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("WILLR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("OBV", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("ADX", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("MFI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ("STOCH", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]), + ], +) +def test_head_to_head(benchmark, indicator, libs): + """Benchmark ferro_ta vs all peers — for README table generation.""" + if not is_supported("ferro_ta", indicator): + pytest.skip(f"ferro_ta does not implement {indicator}") + fn = _make_bench(indicator, "ferro_ta") + benchmark.pedantic(fn, iterations=5, rounds=20, warmup_rounds=2) + + +# ── Large dataset benchmarks (100k bars) ───────────────────────────────────── + + +@pytest.mark.parametrize( + "indicator", + ["SMA", "EMA", "RSI", "MACD", "ATR", "BBANDS", "OBV", "CCI", "ADX", "MFI"], +) +def test_large_dataset(benchmark, indicator): + """Scaling benchmark at 100k bars for ferro_ta.""" + if not is_supported("ferro_ta", indicator): + pytest.skip(f"ferro_ta does not implement {indicator}") + + def _fn(): + execute_indicator("ferro_ta", indicator, LARGE) + + benchmark.pedantic(_fn, iterations=3, rounds=10, warmup_rounds=1) diff --git a/vendor/ferro-ta-main/benchmarks/wrapper_registry.py b/vendor/ferro-ta-main/benchmarks/wrapper_registry.py new file mode 100644 index 0000000..77ecde9 --- /dev/null +++ b/vendor/ferro-ta-main/benchmarks/wrapper_registry.py @@ -0,0 +1,2822 @@ +""" +Cross-library wrapper registry — comprehensive indicator coverage. + +Unified interface: execute_indicator(library, indicator, data, df=None, **kwargs) +Supported libraries: ferro_ta, talib, pandas_ta, ta, tulipy, finta +50+ indicators across all categories. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def _try_import(name): + try: + import importlib + + return importlib.import_module(name) + except ImportError: + return None + + +_talib = _try_import("talib") +_pta = _try_import("pandas_ta") +_ta = _try_import("ta") +_tl = _try_import("tulipy") +_fi_m = _try_import("finta") +_fi = getattr(_fi_m, "TA", None) if _fi_m else None + + +def available_libraries(): + libs = ["ferro_ta"] + if _talib: + libs.append("talib") + if _pta: + libs.append("pandas_ta") + if _ta: + libs.append("ta") + if _tl: + libs.append("tulipy") + if _fi: + libs.append("finta") + return libs + + +def is_supported(library: str, indicator: str) -> bool: + """Return True if a wrapper exists for the given (library, indicator) pair.""" + if library not in available_libraries(): + return False + return (library, indicator) in REGISTRY + + +def _strip_nan(arr): + a = np.asarray(arr, dtype=np.float64).ravel() + return a[np.isfinite(a)] + + +def _c64(a): + return np.ascontiguousarray(a, dtype=np.float64) + + +def _empty(): + return np.array([], dtype=np.float64) + + +def _first_col(df, prefix): + col = next((c for c in df.columns if c.startswith(prefix)), None) + return _strip_nan(df[col].values) if col is not None else _empty() + + +# ============================================================ +# OVERLAP +# ============================================================ +def _sma_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.SMA(d["close"], timeperiod=timeperiod)) + + +def _sma_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.SMA(d["close"], timeperiod=timeperiod)) + + +def _sma_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.sma(df["close"], length=timeperiod).values) + + +def _sma_ta(d, df, timeperiod=20, **_): + from ta.trend import SMAIndicator + + return _strip_nan( + SMAIndicator(df["close"], window=timeperiod).sma_indicator().values + ) + + +def _sma_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.sma(_c64(d["close"]), period=timeperiod)) + + +def _sma_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.SMA(df, timeperiod).values) + + +def _ema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.EMA(d["close"], timeperiod=timeperiod)) + + +def _ema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.EMA(d["close"], timeperiod=timeperiod)) + + +def _ema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.ema(df["close"], length=timeperiod).values) + + +def _ema_ta(d, df, timeperiod=20, **_): + from ta.trend import EMAIndicator + + return _strip_nan( + EMAIndicator(df["close"], window=timeperiod).ema_indicator().values + ) + + +def _ema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.ema(_c64(d["close"]), period=timeperiod)) + + +def _ema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.EMA(df, timeperiod).values) + + +def _wma_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.WMA(d["close"], timeperiod=timeperiod)) + + +def _wma_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.WMA(d["close"], timeperiod=timeperiod)) + + +def _wma_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.wma(df["close"], length=timeperiod).values) + + +def _wma_ta(d, df, **_): + return _empty() + + +_wma_ta._stub = True + + +def _wma_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.wma(_c64(d["close"]), period=timeperiod)) + + +def _wma_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.WMA(df, timeperiod).values) + + +def _dema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.DEMA(d["close"], timeperiod=timeperiod)) + + +def _dema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.DEMA(d["close"], timeperiod=timeperiod)) + + +def _dema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.dema(df["close"], length=timeperiod).values) + + +def _dema_ta(d, df, **_): + return _empty() + + +_dema_ta._stub = True + + +def _dema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.dema(_c64(d["close"]), period=timeperiod)) + + +def _dema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.DEMA(df, timeperiod).values) + + +def _tema_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TEMA(d["close"], timeperiod=timeperiod)) + + +def _tema_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.TEMA(d["close"], timeperiod=timeperiod)) + + +def _tema_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.tema(df["close"], length=timeperiod).values) + + +def _tema_ta(d, df, **_): + return _empty() + + +_tema_ta._stub = True + + +def _tema_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.tema(_c64(d["close"]), period=timeperiod)) + + +def _tema_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.TEMA(df, timeperiod).values) + + +def _t3_ft(d, df, timeperiod=5, **_): + import ferro_ta + + return _strip_nan(ferro_ta.T3(d["close"], timeperiod=timeperiod)) + + +def _t3_tl(d, df, timeperiod=5, **_): + return _strip_nan(_talib.T3(d["close"], timeperiod=timeperiod)) + + +def _t3_pt(d, df, timeperiod=5, **_): + return _strip_nan(_pta.t3(df["close"], length=timeperiod).values) + + +def _t3_ta(d, df, **_): + return _empty() + + +_t3_ta._stub = True + + +def _t3_tu(d, df, **_): + return _empty() + + +_t3_tu._stub = True + + +def _t3_fi(d, df, **_): + return _empty() + + +_t3_fi._stub = True + + +def _trima_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRIMA(d["close"], timeperiod=timeperiod)) + + +def _trima_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.TRIMA(d["close"], timeperiod=timeperiod)) + + +def _trima_pt(d, df, timeperiod=20, **_): + return _strip_nan(_pta.trima(df["close"], length=timeperiod).values) + + +def _trima_ta(d, df, **_): + return _empty() + + +_trima_ta._stub = True + + +def _trima_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.trima(_c64(d["close"]), period=timeperiod)) + + +def _trima_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.TRIMA(df, timeperiod).values) + + +def _kama_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.KAMA(d["close"], timeperiod=timeperiod)) + + +def _kama_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.KAMA(d["close"], timeperiod=timeperiod)) + + +def _kama_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.kama(df["close"], length=timeperiod).values) + + +def _kama_ta(d, df, **_): + return _empty() + + +_kama_ta._stub = True + + +def _kama_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.kama(_c64(d["close"]), period=timeperiod)) + + +def _kama_fi(d, df, **_): + return _empty() + + +_kama_fi._stub = True + + +def _hma_ft(d, df, timeperiod=16, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HULL_MA(d["close"], timeperiod=timeperiod)) + + +def _hma_tl(d, df, **_): + return _empty() + + +_hma_tl._stub = True + + +def _hma_pt(d, df, timeperiod=16, **_): + return _strip_nan(_pta.hma(df["close"], length=timeperiod).values) + + +def _hma_ta(d, df, **_): + return _empty() + + +_hma_ta._stub = True + + +def _hma_tu(d, df, timeperiod=16, **_): + return _strip_nan(_tl.hma(_c64(d["close"]), period=timeperiod)) + + +def _hma_fi(d, df, timeperiod=16, **_): + return _strip_nan(_fi.HMA(df, timeperiod).values) + + +def _vwma_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VWMA(d["close"], d["volume"], timeperiod=timeperiod)) + + +def _vwma_tl(d, df, **_): + return _empty() + + +_vwma_tl._stub = True + + +def _vwma_pt(d, df, timeperiod=20, **_): + r = _pta.vwma(df["close"], df["volume"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _vwma_ta(d, df, **_): + return _empty() + + +_vwma_ta._stub = True + + +def _vwma_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.vwma(_c64(d["close"]), _c64(d["volume"]), period=timeperiod)) + + +def _vwma_fi(d, df, **_): + return _empty() + + +_vwma_fi._stub = True + + +def _midpoint_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MIDPOINT(d["close"], timeperiod=timeperiod)) + + +def _midpoint_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.MIDPOINT(d["close"], timeperiod=timeperiod)) + + +def _midpoint_pt(d, df, **_): + return _empty() + + +_midpoint_pt._stub = True + + +def _midpoint_ta(d, df, **_): + return _empty() + + +_midpoint_ta._stub = True + + +def _midpoint_tu(d, df, **_): + return _empty() + + +_midpoint_tu._stub = True + + +def _midpoint_fi(d, df, **_): + return _empty() + + +_midpoint_fi._stub = True + + +def _midprice_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MIDPRICE(d["high"], d["low"], timeperiod=timeperiod)) + + +def _midprice_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.MIDPRICE(d["high"], d["low"], timeperiod=timeperiod)) + + +def _midprice_pt(d, df, **_): + return _empty() + + +_midprice_pt._stub = True + + +def _midprice_ta(d, df, **_): + return _empty() + + +_midprice_ta._stub = True + + +def _midprice_tu(d, df, **_): + return _empty() + + +_midprice_tu._stub = True + + +def _midprice_fi(d, df, **_): + return _empty() + + +_midprice_fi._stub = True + + +# ============================================================ +# MOMENTUM +# ============================================================ +def _rsi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.RSI(d["close"], timeperiod=timeperiod)) + + +def _rsi_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.RSI(d["close"], timeperiod=timeperiod)) + + +def _rsi_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.rsi(df["close"], length=timeperiod).values) + + +def _rsi_ta(d, df, timeperiod=14, **_): + from ta.momentum import RSIIndicator + + return _strip_nan(RSIIndicator(df["close"], window=timeperiod).rsi().values) + + +def _rsi_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.rsi(_c64(d["close"]), period=timeperiod)) + + +def _rsi_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.RSI(df, timeperiod).values) + + +def _macd_ft(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + import ferro_ta + + m, s, h = ferro_ta.MACD( + d["close"], + fastperiod=fastperiod, + slowperiod=slowperiod, + signalperiod=signalperiod, + ) + return _strip_nan(m) + + +def _macd_tl(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + m, s, h = _talib.MACD( + d["close"], + fastperiod=fastperiod, + slowperiod=slowperiod, + signalperiod=signalperiod, + ) + return _strip_nan(m) + + +def _macd_pt(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + r = _pta.macd(df["close"], fast=fastperiod, slow=slowperiod, signal=signalperiod) + return _first_col(r, "MACD_") + + +def _macd_ta(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + from ta.trend import MACD + + return _strip_nan( + MACD( + df["close"], + window_fast=fastperiod, + window_slow=slowperiod, + window_sign=signalperiod, + ) + .macd() + .values + ) + + +def _macd_tu(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + m, s, h = _tl.macd( + _c64(d["close"]), + short_period=fastperiod, + long_period=slowperiod, + signal_period=signalperiod, + ) + return _strip_nan(m) + + +def _macd_fi(d, df, fastperiod=12, slowperiod=26, signalperiod=9, **_): + return _strip_nan(_fi.MACD(df, fastperiod, slowperiod, signalperiod)["MACD"].values) + + +def _stoch_ft(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + import ferro_ta + + k, dd = ferro_ta.STOCH( + d["high"], + d["low"], + d["close"], + fastk_period=fastk_period, + slowk_period=slowk_period, + slowd_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_tl(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + k, dd = _talib.STOCH( + d["high"], + d["low"], + d["close"], + fastk_period=fastk_period, + slowk_period=slowk_period, + slowd_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_pt(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + r = _pta.stoch(df["high"], df["low"], df["close"], k=fastk_period, d=slowd_period) + return _first_col(r, "STOCHk_") if r is not None else _empty() + + +def _stoch_ta(d, df, fastk_period=14, **_): + from ta.momentum import StochasticOscillator + + return _strip_nan( + StochasticOscillator(df["high"], df["low"], df["close"], window=fastk_period) + .stoch() + .values + ) + + +def _stoch_tu(d, df, fastk_period=14, slowk_period=3, slowd_period=3, **_): + k, dd = _tl.stoch( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + pct_k_period=fastk_period, + pct_k_slowing_period=slowk_period, + pct_d_period=slowd_period, + ) + return _strip_nan(k) + + +def _stoch_fi(d, df, fastk_period=14, **_): + return _strip_nan(_fi.STOCH(df, fastk_period).values) + + +def _cci_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CCI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _cci_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.CCI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _cci_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.cci(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _cci_ta(d, df, timeperiod=14, **_): + from ta.trend import CCIIndicator + + return _strip_nan( + CCIIndicator(df["high"], df["low"], df["close"], window=timeperiod).cci().values + ) + + +def _cci_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.cci(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _cci_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.CCI(df, timeperiod).values) + + +def _willr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.WILLR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _willr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.WILLR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _willr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.willr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _willr_ta(d, df, timeperiod=14, **_): + from ta.momentum import WilliamsRIndicator + + return _strip_nan( + WilliamsRIndicator(df["high"], df["low"], df["close"], lbp=timeperiod) + .williams_r() + .values + ) + + +def _willr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.willr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _willr_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.WILLIAMS(df, timeperiod).values) + + +def _aroon_ft(d, df, timeperiod=14, **_): + import ferro_ta + + dn, up = ferro_ta.AROON(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(up) + + +def _aroon_tl(d, df, timeperiod=14, **_): + dn, up = _talib.AROON(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(up) + + +def _aroon_pt(d, df, timeperiod=14, **_): + r = _pta.aroon(df["high"], df["low"], length=timeperiod) + return _first_col(r, "AROONU_") if r is not None else _empty() + + +def _aroon_ta(d, df, timeperiod=14, **_): + from ta.trend import AroonIndicator + + return _strip_nan( + AroonIndicator(df["high"], df["low"], window=timeperiod).aroon_up().values + ) + + +def _aroon_tu(d, df, timeperiod=14, **_): + dn, up = _tl.aroon(_c64(d["high"]), _c64(d["low"]), period=timeperiod) + return _strip_nan(up) + + +def _aroon_fi(d, df, **_): + return _empty() + + +_aroon_fi._stub = True + + +def _aroonosc_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AROONOSC(d["high"], d["low"], timeperiod=timeperiod)) + + +def _aroonosc_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.AROONOSC(d["high"], d["low"], timeperiod=timeperiod)) + + +def _aroonosc_pt(d, df, **_): + return _empty() + + +_aroonosc_pt._stub = True + + +def _aroonosc_ta(d, df, **_): + return _empty() + + +_aroonosc_ta._stub = True + + +def _aroonosc_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.aroonosc(_c64(d["high"]), _c64(d["low"]), period=timeperiod)) + + +def _aroonosc_fi(d, df, **_): + return _empty() + + +_aroonosc_fi._stub = True + + +def _adx_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ADX(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _adx_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.ADX(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _adx_pt(d, df, timeperiod=14, **_): + r = _pta.adx(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "ADX_") + + +def _adx_ta(d, df, timeperiod=14, **_): + from ta.trend import ADXIndicator + + return _strip_nan( + ADXIndicator(df["high"], df["low"], df["close"], window=timeperiod).adx().values + ) + + +def _adx_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.adx(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _adx_fi(d, df, **_): + return _empty() + + +_adx_fi._stub = True + + +def _mom_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MOM(d["close"], timeperiod=timeperiod)) + + +def _mom_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.MOM(d["close"], timeperiod=timeperiod)) + + +def _mom_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.mom(df["close"], length=timeperiod).values) + + +def _mom_ta(d, df, **_): + return _empty() + + +_mom_ta._stub = True + + +def _mom_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.mom(_c64(d["close"]), period=timeperiod)) + + +def _mom_fi(d, df, timeperiod=10, **_): + return _strip_nan(_fi.MOM(df, timeperiod).values) + + +def _roc_ft(d, df, timeperiod=10, **_): + import ferro_ta + + return _strip_nan(ferro_ta.ROC(d["close"], timeperiod=timeperiod)) + + +def _roc_tl(d, df, timeperiod=10, **_): + return _strip_nan(_talib.ROC(d["close"], timeperiod=timeperiod)) + + +def _roc_pt(d, df, timeperiod=10, **_): + return _strip_nan(_pta.roc(df["close"], length=timeperiod).values) + + +def _roc_ta(d, df, timeperiod=10, **_): + from ta.momentum import ROCIndicator + + return _strip_nan(ROCIndicator(df["close"], window=timeperiod).roc().values) + + +def _roc_tu(d, df, timeperiod=10, **_): + return _strip_nan(_tl.roc(_c64(d["close"]), period=timeperiod) * 100.0) + + +def _roc_fi(d, df, timeperiod=10, **_): + return _strip_nan(_fi.ROC(df, timeperiod).values) + + +def _cmo_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.CMO(d["close"], timeperiod=timeperiod)) + + +def _cmo_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.CMO(d["close"], timeperiod=timeperiod)) + + +def _cmo_pt(d, df, timeperiod=14, **_): + return _strip_nan(_pta.cmo(df["close"], length=timeperiod).values) + + +def _cmo_ta(d, df, **_): + return _empty() + + +_cmo_ta._stub = True + + +def _cmo_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.cmo(_c64(d["close"]), period=timeperiod)) + + +def _cmo_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.CMO(df, timeperiod).values) + + +def _ppo_ft(d, df, fastperiod=12, slowperiod=26, **_): + import ferro_ta + + ppo, sig, hist = ferro_ta.PPO( + d["close"], fastperiod=fastperiod, slowperiod=slowperiod + ) + return _strip_nan(ppo) + + +def _ppo_tl(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan( + _talib.PPO(d["close"], fastperiod=fastperiod, slowperiod=slowperiod) + ) + + +def _ppo_pt(d, df, fastperiod=12, slowperiod=26, **_): + r = _pta.ppo(df["close"], fast=fastperiod, slow=slowperiod) + return _strip_nan(r.iloc[:, 0].values) if r is not None else _empty() + + +def _ppo_ta(d, df, **_): + return _empty() + + +_ppo_ta._stub = True + + +def _ppo_tu(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan( + _tl.ppo(_c64(d["close"]), short_period=fastperiod, long_period=slowperiod) + ) + + +def _ppo_fi(d, df, fastperiod=12, slowperiod=26, **_): + return _strip_nan(_fi.PPO(df, fastperiod, slowperiod).values) + + +def _trix_ft(d, df, timeperiod=18, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRIX(d["close"], timeperiod=timeperiod)) + + +def _trix_tl(d, df, timeperiod=18, **_): + return _strip_nan(_talib.TRIX(d["close"], timeperiod=timeperiod)) + + +def _trix_pt(d, df, timeperiod=18, **_): + r = _pta.trix(df["close"], length=timeperiod) + return _strip_nan(r.iloc[:, 0].values) if r is not None else _empty() + + +def _trix_ta(d, df, timeperiod=18, **_): + from ta.trend import TRIXIndicator + + return _strip_nan(TRIXIndicator(df["close"], window=timeperiod).trix().values) + + +def _trix_tu(d, df, timeperiod=18, **_): + return _strip_nan(_tl.trix(_c64(d["close"]), period=timeperiod)) + + +def _trix_fi(d, df, timeperiod=18, **_): + return _strip_nan(_fi.TRIX(df, timeperiod).values) + + +def _tsf_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TSF(d["close"], timeperiod=timeperiod)) + + +def _tsf_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.TSF(d["close"], timeperiod=timeperiod)) + + +def _tsf_pt(d, df, **_): + return _empty() + + +_tsf_pt._stub = True + + +def _tsf_ta(d, df, **_): + return _empty() + + +_tsf_ta._stub = True + + +def _tsf_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.tsf(_c64(d["close"]), period=timeperiod)) + + +def _tsf_fi(d, df, **_): + return _empty() + + +_tsf_fi._stub = True + + +def _ultosc_ft(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ULTOSC( + d["high"], + d["low"], + d["close"], + timeperiod1=timeperiod1, + timeperiod2=timeperiod2, + timeperiod3=timeperiod3, + ) + ) + + +def _ultosc_tl(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + return _strip_nan( + _talib.ULTOSC( + d["high"], + d["low"], + d["close"], + timeperiod1=timeperiod1, + timeperiod2=timeperiod2, + timeperiod3=timeperiod3, + ) + ) + + +def _ultosc_pt(d, df, **_): + return _empty() + + +_ultosc_pt._stub = True + + +def _ultosc_ta(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + from ta.momentum import UltimateOscillator + + return _strip_nan( + UltimateOscillator( + df["high"], + df["low"], + df["close"], + window1=timeperiod1, + window2=timeperiod2, + window3=timeperiod3, + ) + .ultimate_oscillator() + .values + ) + + +def _ultosc_tu(d, df, timeperiod1=7, timeperiod2=14, timeperiod3=28, **_): + return _strip_nan( + _tl.ultosc( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + short_period=timeperiod1, + medium_period=timeperiod2, + long_period=timeperiod3, + ) + ) + + +def _ultosc_fi(d, df, **_): + return _empty() + + +_ultosc_fi._stub = True + + +def _bop_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.BOP(d["open"], d["high"], d["low"], d["close"])) + + +def _bop_tl(d, df, **_): + return _strip_nan(_talib.BOP(d["open"], d["high"], d["low"], d["close"])) + + +def _bop_pt(d, df, **_): + r = _pta.bop(df["open"], df["high"], df["low"], df["close"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _bop_ta(d, df, **_): + return _empty() + + +_bop_ta._stub = True + + +def _bop_tu(d, df, **_): + return _strip_nan( + _tl.bop(_c64(d["open"]), _c64(d["high"]), _c64(d["low"]), _c64(d["close"])) + ) + + +def _bop_fi(d, df, **_): + return _empty() + + +_bop_fi._stub = True + + +def _plusdi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.PLUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _plusdi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.PLUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _plusdi_pt(d, df, timeperiod=14, **_): + r = _pta.adx(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "DMP_") if r is not None else _empty() + + +def _plusdi_ta(d, df, **_): + return _empty() + + +_plusdi_ta._stub = True + + +def _plusdi_tu(d, df, timeperiod=14, **_): + pdi, mdi = _tl.di( + _c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod + ) + return _strip_nan(pdi) + + +def _plusdi_fi(d, df, **_): + return _empty() + + +_plusdi_fi._stub = True + + +def _minusdi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.MINUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _minusdi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.MINUS_DI(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _minusdi_pt(d, df, **_): + return _empty() + + +_minusdi_pt._stub = True + + +def _minusdi_ta(d, df, **_): + return _empty() + + +_minusdi_ta._stub = True + + +def _minusdi_tu(d, df, timeperiod=14, **_): + pdi, mdi = _tl.di( + _c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod + ) + return _strip_nan(mdi) + + +def _minusdi_fi(d, df, **_): + return _empty() + + +_minusdi_fi._stub = True + + +# ============================================================ +# VOLATILITY +# ============================================================ +def _bb_ft(d, df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, **_): + import ferro_ta + + u, m, l = ferro_ta.BBANDS( + d["close"], timeperiod=timeperiod, nbdevup=nbdevup, nbdevdn=nbdevdn + ) + return _strip_nan(u) + + +def _bb_tl(d, df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, **_): + u, m, l = _talib.BBANDS( + d["close"], timeperiod=timeperiod, nbdevup=nbdevup, nbdevdn=nbdevdn + ) + return _strip_nan(u) + + +def _bb_pt(d, df, timeperiod=20, nbdevup=2.0, **_): + r = _pta.bbands(df["close"], length=timeperiod, std=nbdevup) + return _first_col(r, "BBU_") + + +def _bb_ta(d, df, timeperiod=20, nbdevup=2.0, **_): + from ta.volatility import BollingerBands + + return _strip_nan( + BollingerBands(df["close"], window=timeperiod, window_dev=nbdevup) + .bollinger_hband() + .values + ) + + +def _bb_tu(d, df, timeperiod=20, nbdevup=2.0, **_): + lo, mi, up = _tl.bbands(_c64(d["close"]), period=timeperiod, stddev=nbdevup) + return _strip_nan(up) + + +def _bb_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.BBANDS(df, timeperiod)["BB_UPPER"].values) + + +def _atr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _atr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.ATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _atr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.atr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _atr_ta(d, df, timeperiod=14, **_): + from ta.volatility import AverageTrueRange + + return _strip_nan( + AverageTrueRange(df["high"], df["low"], df["close"], window=timeperiod) + .average_true_range() + .values + ) + + +def _atr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.atr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _atr_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.ATR(df, timeperiod).values) + + +def _natr_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.NATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _natr_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.NATR(d["high"], d["low"], d["close"], timeperiod=timeperiod) + ) + + +def _natr_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.natr(df["high"], df["low"], df["close"], length=timeperiod).values + ) + + +def _natr_ta(d, df, **_): + return _empty() + + +_natr_ta._stub = True + + +def _natr_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.natr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), period=timeperiod) + ) + + +def _natr_fi(d, df, **_): + return _empty() + + +_natr_fi._stub = True + + +def _trange_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TRANGE(d["high"], d["low"], d["close"])) + + +def _trange_tl(d, df, **_): + return _strip_nan(_talib.TRANGE(d["high"], d["low"], d["close"])) + + +def _trange_pt(d, df, **_): + r = _pta.true_range(df["high"], df["low"], df["close"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _trange_ta(d, df, **_): + return _empty() + + +_trange_ta._stub = True + + +def _trange_tu(d, df, **_): + return _strip_nan(_tl.tr(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _trange_fi(d, df, **_): + return _strip_nan(_fi.TR(df).values) + + +def _stddev_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.STDDEV(d["close"], timeperiod=timeperiod)) + + +def _stddev_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.STDDEV(d["close"], timeperiod=timeperiod)) + + +def _stddev_pt(d, df, timeperiod=20, **_): + r = _pta.stdev(df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _stddev_ta(d, df, **_): + return _empty() + + +_stddev_ta._stub = True + + +def _stddev_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.stddev(_c64(d["close"]), period=timeperiod)) + + +def _stddev_fi(d, df, timeperiod=20, **_): + return _strip_nan(_fi.MSD(df, timeperiod).values) + + +def _var_ft(d, df, timeperiod=20, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VAR(d["close"], timeperiod=timeperiod)) + + +def _var_tl(d, df, timeperiod=20, **_): + return _strip_nan(_talib.VAR(d["close"], timeperiod=timeperiod)) + + +def _var_pt(d, df, timeperiod=20, **_): + r = _pta.variance(df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _var_ta(d, df, **_): + return _empty() + + +_var_ta._stub = True + + +def _var_tu(d, df, timeperiod=20, **_): + return _strip_nan(_tl.var(_c64(d["close"]), period=timeperiod)) + + +def _var_fi(d, df, **_): + return _empty() + + +_var_fi._stub = True + + +def _sar_ft(d, df, acceleration=0.02, maximum=0.2, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.SAR(d["high"], d["low"], acceleration=acceleration, maximum=maximum) + ) + + +def _sar_tl(d, df, acceleration=0.02, maximum=0.2, **_): + return _strip_nan( + _talib.SAR(d["high"], d["low"], acceleration=acceleration, maximum=maximum) + ) + + +def _sar_pt(d, df, **_): + return _empty() + + +_sar_pt._stub = True + + +def _sar_ta(d, df, **_): + return _empty() + + +_sar_ta._stub = True + + +def _sar_tu(d, df, acceleration=0.02, maximum=0.2, **_): + return _strip_nan( + _tl.psar( + _c64(d["high"]), + _c64(d["low"]), + acceleration_factor_step=acceleration, + acceleration_factor_maximum=maximum, + ) + ) + + +def _sar_fi(d, df, **_): + return _empty() + + +_sar_fi._stub = True + + +def _kc_ft(d, df, timeperiod=20, **_): + import ferro_ta + + u, m, l = ferro_ta.KELTNER_CHANNELS( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + return _strip_nan(u) + + +def _kc_tl(d, df, **_): + return _empty() + + +_kc_tl._stub = True + + +def _kc_pt(d, df, timeperiod=20, **_): + r = _pta.kc(df["high"], df["low"], df["close"], length=timeperiod) + if r is None: + return _empty() + col = next( + (c for c in r.columns if "UCe" in c or "UB" in c or c.endswith("U")), None + ) + return _strip_nan(r[col].values) if col else _first_col(r, "KC") + + +def _kc_ta(d, df, timeperiod=20, **_): + from ta.volatility import KeltnerChannel + + return _strip_nan( + KeltnerChannel(df["high"], df["low"], df["close"], window=timeperiod) + .keltner_channel_hband() + .values + ) + + +def _kc_tu(d, df, **_): + return _empty() + + +_kc_tu._stub = True + + +def _kc_fi(d, df, **_): + return _empty() + + +_kc_fi._stub = True + + +def _donchian_ft(d, df, timeperiod=20, **_): + import ferro_ta + + u, m, l = ferro_ta.DONCHIAN(d["high"], d["low"], timeperiod=timeperiod) + return _strip_nan(u) + + +def _donchian_tl(d, df, **_): + return _empty() + + +_donchian_tl._stub = True + + +def _donchian_pt(d, df, timeperiod=20, **_): + r = _pta.donchian( + df["high"], df["low"], lower_length=timeperiod, upper_length=timeperiod + ) + return _first_col(r, "DCU_") if r is not None else _empty() + + +def _donchian_ta(d, df, timeperiod=20, **_): + from ta.volatility import DonchianChannel + + return _strip_nan( + DonchianChannel(df["high"], df["low"], df["close"], window=timeperiod) + .donchian_channel_hband() + .values + ) + + +def _donchian_tu(d, df, **_): + return _empty() + + +_donchian_tu._stub = True + + +def _donchian_fi(d, df, **_): + return _empty() + + +_donchian_fi._stub = True + + +def _supertrend_ft(d, df, timeperiod=7, **_): + import ferro_ta + + st, dir_ = ferro_ta.SUPERTREND( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + return _strip_nan(st) + + +def _supertrend_tl(d, df, **_): + return _empty() + + +_supertrend_tl._stub = True + + +def _supertrend_pt(d, df, timeperiod=7, **_): + r = _pta.supertrend(df["high"], df["low"], df["close"], length=timeperiod) + return _first_col(r, "SUPERT_") if r is not None else _empty() + + +def _supertrend_ta(d, df, **_): + return _empty() + + +_supertrend_ta._stub = True + + +def _supertrend_tu(d, df, **_): + return _empty() + + +_supertrend_tu._stub = True + + +def _supertrend_fi(d, df, **_): + return _empty() + + +_supertrend_fi._stub = True + + +def _chop_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CHOPPINESS_INDEX( + d["high"], d["low"], d["close"], timeperiod=timeperiod + ) + ) + + +def _chop_tl(d, df, **_): + return _empty() + + +_chop_tl._stub = True + + +def _chop_pt(d, df, timeperiod=14, **_): + r = _pta.chop(df["high"], df["low"], df["close"], length=timeperiod) + return _strip_nan(r.values) if r is not None else _empty() + + +def _chop_ta(d, df, **_): + return _empty() + + +_chop_ta._stub = True + + +def _chop_tu(d, df, **_): + return _empty() + + +_chop_tu._stub = True + + +def _chop_fi(d, df, **_): + return _empty() + + +_chop_fi._stub = True + + +# ============================================================ +# VOLUME +# ============================================================ +def _obv_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.OBV(d["close"], d["volume"])) + + +def _obv_tl(d, df, **_): + return _strip_nan(_talib.OBV(d["close"], d["volume"])) + + +def _obv_pt(d, df, **_): + return _strip_nan(_pta.obv(df["close"], df["volume"]).values) + + +def _obv_ta(d, df, **_): + from ta.volume import OnBalanceVolumeIndicator + + return _strip_nan( + OnBalanceVolumeIndicator(df["close"], df["volume"]).on_balance_volume().values + ) + + +def _obv_tu(d, df, **_): + return _strip_nan(_tl.obv(_c64(d["close"]), _c64(d["volume"]))) + + +def _obv_fi(d, df, **_): + return _strip_nan(_fi.OBV(df).values) + + +def _ad_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AD(d["high"], d["low"], d["close"], d["volume"])) + + +def _ad_tl(d, df, **_): + return _strip_nan(_talib.AD(d["high"], d["low"], d["close"], d["volume"])) + + +def _ad_pt(d, df, **_): + return _strip_nan(_pta.ad(df["high"], df["low"], df["close"], df["volume"]).values) + + +def _ad_ta(d, df, **_): + from ta.volume import AccDistIndexIndicator + + return _strip_nan( + AccDistIndexIndicator(df["high"], df["low"], df["close"], df["volume"]) + .acc_dist_index() + .values + ) + + +def _ad_tu(d, df, **_): + return _strip_nan( + _tl.ad(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]), _c64(d["volume"])) + ) + + +def _ad_fi(d, df, **_): + return _empty() + + +_ad_fi._stub = True + + +def _adosc_ft(d, df, fastperiod=3, slowperiod=10, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.ADOSC( + d["high"], + d["low"], + d["close"], + d["volume"], + fastperiod=fastperiod, + slowperiod=slowperiod, + ) + ) + + +def _adosc_tl(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _talib.ADOSC( + d["high"], + d["low"], + d["close"], + d["volume"], + fastperiod=fastperiod, + slowperiod=slowperiod, + ) + ) + + +def _adosc_pt(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _pta.adosc( + df["high"], + df["low"], + df["close"], + df["volume"], + fast=fastperiod, + slow=slowperiod, + ).values + ) + + +def _adosc_ta(d, df, **_): + return _empty() + + +_adosc_ta._stub = True + + +def _adosc_tu(d, df, fastperiod=3, slowperiod=10, **_): + return _strip_nan( + _tl.adosc( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + _c64(d["volume"]), + short_period=fastperiod, + long_period=slowperiod, + ) + ) + + +def _adosc_fi(d, df, **_): + return _empty() + + +_adosc_fi._stub = True + + +def _mfi_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.MFI( + d["high"], d["low"], d["close"], d["volume"], timeperiod=timeperiod + ) + ) + + +def _mfi_tl(d, df, timeperiod=14, **_): + return _strip_nan( + _talib.MFI(d["high"], d["low"], d["close"], d["volume"], timeperiod=timeperiod) + ) + + +def _mfi_pt(d, df, timeperiod=14, **_): + return _strip_nan( + _pta.mfi( + df["high"], df["low"], df["close"], df["volume"], length=timeperiod + ).values + ) + + +def _mfi_ta(d, df, timeperiod=14, **_): + from ta.volume import MFIIndicator + + return _strip_nan( + MFIIndicator( + df["high"], df["low"], df["close"], df["volume"], window=timeperiod + ) + .money_flow_index() + .values + ) + + +def _mfi_tu(d, df, timeperiod=14, **_): + return _strip_nan( + _tl.mfi( + _c64(d["high"]), + _c64(d["low"]), + _c64(d["close"]), + _c64(d["volume"]), + period=timeperiod, + ) + ) + + +def _mfi_fi(d, df, timeperiod=14, **_): + return _strip_nan(_fi.MFI(df, timeperiod).values) + + +def _vwap_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.VWAP(d["high"], d["low"], d["close"], d["volume"])) + + +def _vwap_tl(d, df, **_): + return _empty() + + +_vwap_tl._stub = True + + +def _vwap_pt(d, df, **_): + r = _pta.vwap(df["high"], df["low"], df["close"], df["volume"]) + return _strip_nan(r.values) if r is not None else _empty() + + +def _vwap_ta(d, df, **_): + return _empty() + + +_vwap_ta._stub = True + + +def _vwap_tu(d, df, **_): + return _empty() + + +_vwap_tu._stub = True + + +def _vwap_fi(d, df, **_): + return _strip_nan(_fi.VWAP(df).values) + + +# ============================================================ +# PRICE TRANSFORM +# ============================================================ +def _avgprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.AVGPRICE(d["open"], d["high"], d["low"], d["close"])) + + +def _avgprice_tl(d, df, **_): + return _strip_nan(_talib.AVGPRICE(d["open"], d["high"], d["low"], d["close"])) + + +def _avgprice_pt(d, df, **_): + return _empty() + + +_avgprice_pt._stub = True + + +def _avgprice_ta(d, df, **_): + return _empty() + + +_avgprice_ta._stub = True + + +def _avgprice_tu(d, df, **_): + return _strip_nan( + _tl.avgprice(_c64(d["open"]), _c64(d["high"]), _c64(d["low"]), _c64(d["close"])) + ) + + +def _avgprice_fi(d, df, **_): + return _empty() + + +_avgprice_fi._stub = True + + +def _medprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.MEDPRICE(d["high"], d["low"])) + + +def _medprice_tl(d, df, **_): + return _strip_nan(_talib.MEDPRICE(d["high"], d["low"])) + + +def _medprice_pt(d, df, **_): + return _empty() + + +_medprice_pt._stub = True + + +def _medprice_ta(d, df, **_): + return _empty() + + +_medprice_ta._stub = True + + +def _medprice_tu(d, df, **_): + return _strip_nan(_tl.medprice(_c64(d["high"]), _c64(d["low"]))) + + +def _medprice_fi(d, df, **_): + return _empty() + + +_medprice_fi._stub = True + + +def _typprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.TYPPRICE(d["high"], d["low"], d["close"])) + + +def _typprice_tl(d, df, **_): + return _strip_nan(_talib.TYPPRICE(d["high"], d["low"], d["close"])) + + +def _typprice_pt(d, df, **_): + return _empty() + + +_typprice_pt._stub = True + + +def _typprice_ta(d, df, **_): + return _empty() + + +_typprice_ta._stub = True + + +def _typprice_tu(d, df, **_): + return _strip_nan(_tl.typprice(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _typprice_fi(d, df, **_): + return _empty() + + +_typprice_fi._stub = True + + +def _wclprice_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.WCLPRICE(d["high"], d["low"], d["close"])) + + +def _wclprice_tl(d, df, **_): + return _strip_nan(_talib.WCLPRICE(d["high"], d["low"], d["close"])) + + +def _wclprice_pt(d, df, **_): + return _empty() + + +_wclprice_pt._stub = True + + +def _wclprice_ta(d, df, **_): + return _empty() + + +_wclprice_ta._stub = True + + +def _wclprice_tu(d, df, **_): + return _strip_nan(_tl.wcprice(_c64(d["high"]), _c64(d["low"]), _c64(d["close"]))) + + +def _wclprice_fi(d, df, **_): + return _empty() + + +_wclprice_fi._stub = True + + +# ============================================================ +# MATH +# ============================================================ +def _sqrt_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.SQRT(d["close"])) + + +def _sqrt_tl(d, df, **_): + return _strip_nan(_talib.SQRT(d["close"])) + + +def _sqrt_pt(d, df, **_): + return _empty() + + +_sqrt_pt._stub = True + + +def _sqrt_ta(d, df, **_): + return _empty() + + +_sqrt_ta._stub = True + + +def _sqrt_tu(d, df, **_): + return _strip_nan(_tl.sqrt(_c64(d["close"]))) + + +def _sqrt_fi(d, df, **_): + return _empty() + + +_sqrt_fi._stub = True + + +def _log10_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LOG10(d["close"])) + + +def _log10_tl(d, df, **_): + return _strip_nan(_talib.LOG10(d["close"])) + + +def _log10_pt(d, df, **_): + return _empty() + + +_log10_pt._stub = True + + +def _log10_ta(d, df, **_): + return _empty() + + +_log10_ta._stub = True + + +def _log10_tu(d, df, **_): + return _strip_nan(_tl.log10(_c64(d["close"]))) + + +def _log10_fi(d, df, **_): + return _empty() + + +_log10_fi._stub = True + + +def _add_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.ADD(d["high"], d["low"])) + + +def _add_tl(d, df, **_): + return _strip_nan(_talib.ADD(d["high"], d["low"])) + + +def _add_pt(d, df, **_): + return _empty() + + +_add_pt._stub = True + + +def _add_ta(d, df, **_): + return _empty() + + +_add_ta._stub = True + + +def _add_tu(d, df, **_): + return _strip_nan(_tl.add(_c64(d["high"]), _c64(d["low"]))) + + +def _add_fi(d, df, **_): + return _empty() + + +_add_fi._stub = True + + +# ============================================================ +# STATISTICS +# ============================================================ +def _linearreg_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LINEARREG(d["close"], timeperiod=timeperiod)) + + +def _linearreg_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.LINEARREG(d["close"], timeperiod=timeperiod)) + + +def _linearreg_pt(d, df, **_): + return _empty() + + +_linearreg_pt._stub = True + + +def _linearreg_ta(d, df, **_): + return _empty() + + +_linearreg_ta._stub = True + + +def _linearreg_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.linreg(_c64(d["close"]), period=timeperiod)) + + +def _linearreg_fi(d, df, **_): + return _empty() + + +_linearreg_fi._stub = True + + +def _linreg_slope_ft(d, df, timeperiod=14, **_): + import ferro_ta + + return _strip_nan(ferro_ta.LINEARREG_SLOPE(d["close"], timeperiod=timeperiod)) + + +def _linreg_slope_tl(d, df, timeperiod=14, **_): + return _strip_nan(_talib.LINEARREG_SLOPE(d["close"], timeperiod=timeperiod)) + + +def _linreg_slope_pt(d, df, **_): + return _empty() + + +_linreg_slope_pt._stub = True + + +def _linreg_slope_ta(d, df, **_): + return _empty() + + +_linreg_slope_ta._stub = True + + +def _linreg_slope_tu(d, df, timeperiod=14, **_): + return _strip_nan(_tl.linregslope(_c64(d["close"]), period=timeperiod)) + + +def _linreg_slope_fi(d, df, **_): + return _empty() + + +_linreg_slope_fi._stub = True + + +def _correl_ft(d, df, timeperiod=30, **_): + import ferro_ta + + return _strip_nan(ferro_ta.CORREL(d["high"], d["low"], timeperiod=timeperiod)) + + +def _correl_tl(d, df, timeperiod=30, **_): + return _strip_nan(_talib.CORREL(d["high"], d["low"], timeperiod=timeperiod)) + + +def _correl_pt(d, df, **_): + return _empty() + + +_correl_pt._stub = True + + +def _correl_ta(d, df, **_): + return _empty() + + +_correl_ta._stub = True + + +def _correl_tu(d, df, **_): + return _empty() + + +_correl_tu._stub = True + + +def _correl_fi(d, df, **_): + return _empty() + + +_correl_fi._stub = True + + +def _beta_ft(d, df, timeperiod=5, **_): + import ferro_ta + + return _strip_nan(ferro_ta.BETA(d["high"], d["low"], timeperiod=timeperiod)) + + +def _beta_tl(d, df, timeperiod=5, **_): + return _strip_nan(_talib.BETA(d["high"], d["low"], timeperiod=timeperiod)) + + +def _beta_pt(d, df, **_): + return _empty() + + +_beta_pt._stub = True + + +def _beta_ta(d, df, **_): + return _empty() + + +_beta_ta._stub = True + + +def _beta_tu(d, df, **_): + return _empty() + + +_beta_tu._stub = True + + +def _beta_fi(d, df, **_): + return _empty() + + +_beta_fi._stub = True + + +# ============================================================ +# CYCLE +# ============================================================ +def _ht_dcperiod_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HT_DCPERIOD(d["close"])) + + +def _ht_dcperiod_tl(d, df, **_): + return _strip_nan(_talib.HT_DCPERIOD(d["close"])) + + +def _ht_dcperiod_pt(d, df, **_): + return _empty() + + +_ht_dcperiod_pt._stub = True + + +def _ht_dcperiod_ta(d, df, **_): + return _empty() + + +_ht_dcperiod_ta._stub = True + + +def _ht_dcperiod_tu(d, df, **_): + return _empty() + + +_ht_dcperiod_tu._stub = True + + +def _ht_dcperiod_fi(d, df, **_): + return _empty() + + +_ht_dcperiod_fi._stub = True + + +def _ht_trendmode_ft(d, df, **_): + import ferro_ta + + return _strip_nan(ferro_ta.HT_TRENDMODE(d["close"]).astype(float)) + + +def _ht_trendmode_tl(d, df, **_): + return _strip_nan(_talib.HT_TRENDMODE(d["close"]).astype(float)) + + +def _ht_trendmode_pt(d, df, **_): + return _empty() + + +_ht_trendmode_pt._stub = True + + +def _ht_trendmode_ta(d, df, **_): + return _empty() + + +_ht_trendmode_ta._stub = True + + +def _ht_trendmode_tu(d, df, **_): + return _empty() + + +_ht_trendmode_tu._stub = True + + +def _ht_trendmode_fi(d, df, **_): + return _empty() + + +_ht_trendmode_fi._stub = True + + +# ============================================================ +# CANDLESTICK PATTERNS +# ============================================================ +def _cdlengulfing_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLENGULFING(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlengulfing_tl(d, df, **_): + return _strip_nan( + _talib.CDLENGULFING(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlengulfing_pt(d, df, **_): + return _empty() + + +_cdlengulfing_pt._stub = True + + +def _cdlengulfing_ta(d, df, **_): + return _empty() + + +_cdlengulfing_ta._stub = True + + +def _cdlengulfing_tu(d, df, **_): + return _empty() + + +_cdlengulfing_tu._stub = True + + +def _cdlengulfing_fi(d, df, **_): + return _empty() + + +_cdlengulfing_fi._stub = True + + +def _cdldoji_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLDOJI(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdldoji_tl(d, df, **_): + return _strip_nan( + _talib.CDLDOJI(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdldoji_pt(d, df, **_): + return _empty() + + +_cdldoji_pt._stub = True + + +def _cdldoji_ta(d, df, **_): + return _empty() + + +_cdldoji_ta._stub = True + + +def _cdldoji_tu(d, df, **_): + return _empty() + + +_cdldoji_tu._stub = True + + +def _cdldoji_fi(d, df, **_): + return _empty() + + +_cdldoji_fi._stub = True + + +def _cdlhammer_ft(d, df, **_): + import ferro_ta + + return _strip_nan( + ferro_ta.CDLHAMMER(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlhammer_tl(d, df, **_): + return _strip_nan( + _talib.CDLHAMMER(d["open"], d["high"], d["low"], d["close"]).astype(float) + ) + + +def _cdlhammer_pt(d, df, **_): + return _empty() + + +_cdlhammer_pt._stub = True + + +def _cdlhammer_ta(d, df, **_): + return _empty() + + +_cdlhammer_ta._stub = True + + +def _cdlhammer_tu(d, df, **_): + return _empty() + + +_cdlhammer_tu._stub = True + + +def _cdlhammer_fi(d, df, **_): + return _empty() + + +_cdlhammer_fi._stub = True + +# ============================================================ +# REGISTRY BUILD +# ============================================================ +REGISTRY: dict[tuple[str, Any], Any] = {} + + +def _reg(ind, ft, tl, pt, ta_, tu, fi): + """ + Register wrappers for a given indicator across all libraries. + + Wrappers marked ._stub = True (no-op return _empty()) are not registered, + so execute_indicator raises KeyError for unsupported (lib, ind). Speed + benchmarks then skip those pairs and the table shows N/A. + """ + for lib, fn in [ + ("ferro_ta", ft), + ("talib", tl), + ("pandas_ta", pt), + ("ta", ta_), + ("tulipy", tu), + ("finta", fi), + ]: + if getattr(fn, "_stub", False): + continue + REGISTRY[(lib, ind)] = fn + + +_reg("SMA", _sma_ft, _sma_tl, _sma_pt, _sma_ta, _sma_tu, _sma_fi) +_reg("EMA", _ema_ft, _ema_tl, _ema_pt, _ema_ta, _ema_tu, _ema_fi) +_reg("WMA", _wma_ft, _wma_tl, _wma_pt, _wma_ta, _wma_tu, _wma_fi) +_reg("DEMA", _dema_ft, _dema_tl, _dema_pt, _dema_ta, _dema_tu, _dema_fi) +_reg("TEMA", _tema_ft, _tema_tl, _tema_pt, _tema_ta, _tema_tu, _tema_fi) +_reg("T3", _t3_ft, _t3_tl, _t3_pt, _t3_ta, _t3_tu, _t3_fi) +_reg("TRIMA", _trima_ft, _trima_tl, _trima_pt, _trima_ta, _trima_tu, _trima_fi) +_reg("KAMA", _kama_ft, _kama_tl, _kama_pt, _kama_ta, _kama_tu, _kama_fi) +_reg("HULL_MA", _hma_ft, _hma_tl, _hma_pt, _hma_ta, _hma_tu, _hma_fi) +_reg("VWMA", _vwma_ft, _vwma_tl, _vwma_pt, _vwma_ta, _vwma_tu, _vwma_fi) +_reg( + "MIDPOINT", + _midpoint_ft, + _midpoint_tl, + _midpoint_pt, + _midpoint_ta, + _midpoint_tu, + _midpoint_fi, +) +_reg( + "MIDPRICE", + _midprice_ft, + _midprice_tl, + _midprice_pt, + _midprice_ta, + _midprice_tu, + _midprice_fi, +) +_reg("RSI", _rsi_ft, _rsi_tl, _rsi_pt, _rsi_ta, _rsi_tu, _rsi_fi) +_reg("MACD", _macd_ft, _macd_tl, _macd_pt, _macd_ta, _macd_tu, _macd_fi) +_reg("STOCH", _stoch_ft, _stoch_tl, _stoch_pt, _stoch_ta, _stoch_tu, _stoch_fi) +_reg("CCI", _cci_ft, _cci_tl, _cci_pt, _cci_ta, _cci_tu, _cci_fi) +_reg("WILLR", _willr_ft, _willr_tl, _willr_pt, _willr_ta, _willr_tu, _willr_fi) +_reg("AROON", _aroon_ft, _aroon_tl, _aroon_pt, _aroon_ta, _aroon_tu, _aroon_fi) +_reg( + "AROONOSC", + _aroonosc_ft, + _aroonosc_tl, + _aroonosc_pt, + _aroonosc_ta, + _aroonosc_tu, + _aroonosc_fi, +) +_reg("ADX", _adx_ft, _adx_tl, _adx_pt, _adx_ta, _adx_tu, _adx_fi) +_reg("MOM", _mom_ft, _mom_tl, _mom_pt, _mom_ta, _mom_tu, _mom_fi) +_reg("ROC", _roc_ft, _roc_tl, _roc_pt, _roc_ta, _roc_tu, _roc_fi) +_reg("CMO", _cmo_ft, _cmo_tl, _cmo_pt, _cmo_ta, _cmo_tu, _cmo_fi) +_reg("PPO", _ppo_ft, _ppo_tl, _ppo_pt, _ppo_ta, _ppo_tu, _ppo_fi) +_reg("TRIX", _trix_ft, _trix_tl, _trix_pt, _trix_ta, _trix_tu, _trix_fi) +_reg("TSF", _tsf_ft, _tsf_tl, _tsf_pt, _tsf_ta, _tsf_tu, _tsf_fi) +_reg("ULTOSC", _ultosc_ft, _ultosc_tl, _ultosc_pt, _ultosc_ta, _ultosc_tu, _ultosc_fi) +_reg("BOP", _bop_ft, _bop_tl, _bop_pt, _bop_ta, _bop_tu, _bop_fi) +_reg("PLUS_DI", _plusdi_ft, _plusdi_tl, _plusdi_pt, _plusdi_ta, _plusdi_tu, _plusdi_fi) +_reg( + "MINUS_DI", + _minusdi_ft, + _minusdi_tl, + _minusdi_pt, + _minusdi_ta, + _minusdi_tu, + _minusdi_fi, +) +_reg("BBANDS", _bb_ft, _bb_tl, _bb_pt, _bb_ta, _bb_tu, _bb_fi) +_reg("ATR", _atr_ft, _atr_tl, _atr_pt, _atr_ta, _atr_tu, _atr_fi) +_reg("NATR", _natr_ft, _natr_tl, _natr_pt, _natr_ta, _natr_tu, _natr_fi) +_reg("TRANGE", _trange_ft, _trange_tl, _trange_pt, _trange_ta, _trange_tu, _trange_fi) +_reg("STDDEV", _stddev_ft, _stddev_tl, _stddev_pt, _stddev_ta, _stddev_tu, _stddev_fi) +_reg("VAR", _var_ft, _var_tl, _var_pt, _var_ta, _var_tu, _var_fi) +_reg("SAR", _sar_ft, _sar_tl, _sar_pt, _sar_ta, _sar_tu, _sar_fi) +_reg("KELTNER_CHANNELS", _kc_ft, _kc_tl, _kc_pt, _kc_ta, _kc_tu, _kc_fi) +_reg( + "DONCHIAN", + _donchian_ft, + _donchian_tl, + _donchian_pt, + _donchian_ta, + _donchian_tu, + _donchian_fi, +) +_reg( + "SUPERTREND", + _supertrend_ft, + _supertrend_tl, + _supertrend_pt, + _supertrend_ta, + _supertrend_tu, + _supertrend_fi, +) +_reg("CHOPPINESS_INDEX", _chop_ft, _chop_tl, _chop_pt, _chop_ta, _chop_tu, _chop_fi) +_reg("OBV", _obv_ft, _obv_tl, _obv_pt, _obv_ta, _obv_tu, _obv_fi) +_reg("AD", _ad_ft, _ad_tl, _ad_pt, _ad_ta, _ad_tu, _ad_fi) +_reg("ADOSC", _adosc_ft, _adosc_tl, _adosc_pt, _adosc_ta, _adosc_tu, _adosc_fi) +_reg("MFI", _mfi_ft, _mfi_tl, _mfi_pt, _mfi_ta, _mfi_tu, _mfi_fi) +_reg("VWAP", _vwap_ft, _vwap_tl, _vwap_pt, _vwap_ta, _vwap_tu, _vwap_fi) +_reg( + "AVGPRICE", + _avgprice_ft, + _avgprice_tl, + _avgprice_pt, + _avgprice_ta, + _avgprice_tu, + _avgprice_fi, +) +_reg( + "MEDPRICE", + _medprice_ft, + _medprice_tl, + _medprice_pt, + _medprice_ta, + _medprice_tu, + _medprice_fi, +) +_reg( + "TYPPRICE", + _typprice_ft, + _typprice_tl, + _typprice_pt, + _typprice_ta, + _typprice_tu, + _typprice_fi, +) +_reg( + "WCLPRICE", + _wclprice_ft, + _wclprice_tl, + _wclprice_pt, + _wclprice_ta, + _wclprice_tu, + _wclprice_fi, +) +_reg("SQRT", _sqrt_ft, _sqrt_tl, _sqrt_pt, _sqrt_ta, _sqrt_tu, _sqrt_fi) +_reg("LOG10", _log10_ft, _log10_tl, _log10_pt, _log10_ta, _log10_tu, _log10_fi) +_reg("ADD", _add_ft, _add_tl, _add_pt, _add_ta, _add_tu, _add_fi) +_reg( + "LINEARREG", + _linearreg_ft, + _linearreg_tl, + _linearreg_pt, + _linearreg_ta, + _linearreg_tu, + _linearreg_fi, +) +_reg( + "LINEARREG_SLOPE", + _linreg_slope_ft, + _linreg_slope_tl, + _linreg_slope_pt, + _linreg_slope_ta, + _linreg_slope_tu, + _linreg_slope_fi, +) +_reg("CORREL", _correl_ft, _correl_tl, _correl_pt, _correl_ta, _correl_tu, _correl_fi) +_reg("BETA", _beta_ft, _beta_tl, _beta_pt, _beta_ta, _beta_tu, _beta_fi) +_reg( + "HT_DCPERIOD", + _ht_dcperiod_ft, + _ht_dcperiod_tl, + _ht_dcperiod_pt, + _ht_dcperiod_ta, + _ht_dcperiod_tu, + _ht_dcperiod_fi, +) +_reg( + "HT_TRENDMODE", + _ht_trendmode_ft, + _ht_trendmode_tl, + _ht_trendmode_pt, + _ht_trendmode_ta, + _ht_trendmode_tu, + _ht_trendmode_fi, +) +_reg( + "CDLENGULFING", + _cdlengulfing_ft, + _cdlengulfing_tl, + _cdlengulfing_pt, + _cdlengulfing_ta, + _cdlengulfing_tu, + _cdlengulfing_fi, +) +_reg( + "CDLDOJI", + _cdldoji_ft, + _cdldoji_tl, + _cdldoji_pt, + _cdldoji_ta, + _cdldoji_tu, + _cdldoji_fi, +) +_reg( + "CDLHAMMER", + _cdlhammer_ft, + _cdlhammer_tl, + _cdlhammer_pt, + _cdlhammer_ta, + _cdlhammer_tu, + _cdlhammer_fi, +) + +# ============================================================ +# METADATA +# ============================================================ +INDICATOR_DEFAULTS: dict[str, dict] = { + "SMA": {"timeperiod": 20}, + "EMA": {"timeperiod": 20}, + "WMA": {"timeperiod": 14}, + "DEMA": {"timeperiod": 20}, + "TEMA": {"timeperiod": 20}, + "T3": {"timeperiod": 5}, + "TRIMA": {"timeperiod": 20}, + "KAMA": {"timeperiod": 10}, + "HULL_MA": {"timeperiod": 16}, + "VWMA": {"timeperiod": 20}, + "MIDPOINT": {"timeperiod": 14}, + "MIDPRICE": {"timeperiod": 14}, + "RSI": {"timeperiod": 14}, + "MACD": {"fastperiod": 12, "slowperiod": 26, "signalperiod": 9}, + "STOCH": {"fastk_period": 14, "slowk_period": 3, "slowd_period": 3}, + "CCI": {"timeperiod": 14}, + "WILLR": {"timeperiod": 14}, + "AROON": {"timeperiod": 14}, + "AROONOSC": {"timeperiod": 14}, + "ADX": {"timeperiod": 14}, + "MOM": {"timeperiod": 10}, + "ROC": {"timeperiod": 10}, + "CMO": {"timeperiod": 14}, + "PPO": {"fastperiod": 12, "slowperiod": 26}, + "TRIX": {"timeperiod": 18}, + "TSF": {"timeperiod": 14}, + "ULTOSC": {"timeperiod1": 7, "timeperiod2": 14, "timeperiod3": 28}, + "BOP": {}, + "PLUS_DI": {"timeperiod": 14}, + "MINUS_DI": {"timeperiod": 14}, + "BBANDS": {"timeperiod": 20, "nbdevup": 2.0, "nbdevdn": 2.0}, + "ATR": {"timeperiod": 14}, + "NATR": {"timeperiod": 14}, + "TRANGE": {}, + "STDDEV": {"timeperiod": 20}, + "VAR": {"timeperiod": 20}, + "SAR": {"acceleration": 0.02, "maximum": 0.2}, + "KELTNER_CHANNELS": {"timeperiod": 20}, + "DONCHIAN": {"timeperiod": 20}, + "SUPERTREND": {"timeperiod": 7}, + "CHOPPINESS_INDEX": {"timeperiod": 14}, + "OBV": {}, + "AD": {}, + "ADOSC": {"fastperiod": 3, "slowperiod": 10}, + "MFI": {"timeperiod": 14}, + "VWAP": {}, + "AVGPRICE": {}, + "MEDPRICE": {}, + "TYPPRICE": {}, + "WCLPRICE": {}, + "SQRT": {}, + "LOG10": {}, + "ADD": {}, + "LINEARREG": {"timeperiod": 14}, + "LINEARREG_SLOPE": {"timeperiod": 14}, + "CORREL": {"timeperiod": 30}, + "BETA": {"timeperiod": 5}, + "HT_DCPERIOD": {}, + "HT_TRENDMODE": {}, + "CDLENGULFING": {}, + "CDLDOJI": {}, + "CDLHAMMER": {}, +} + +INDICATOR_NAMES = list(INDICATOR_DEFAULTS.keys()) +LIBRARY_NAMES = ["ferro_ta", "talib", "pandas_ta", "ta", "tulipy", "finta"] + +INDICATOR_CATEGORIES: dict[str, list[str]] = { + "Overlap": [ + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "T3", + "TRIMA", + "KAMA", + "HULL_MA", + "VWMA", + "MIDPOINT", + "MIDPRICE", + ], + "Momentum": [ + "RSI", + "MACD", + "STOCH", + "CCI", + "WILLR", + "AROON", + "AROONOSC", + "ADX", + "MOM", + "ROC", + "CMO", + "PPO", + "TRIX", + "TSF", + "ULTOSC", + "BOP", + "PLUS_DI", + "MINUS_DI", + ], + "Volatility": [ + "BBANDS", + "ATR", + "NATR", + "TRANGE", + "STDDEV", + "VAR", + "SAR", + "KELTNER_CHANNELS", + "DONCHIAN", + "SUPERTREND", + "CHOPPINESS_INDEX", + ], + "Volume": ["OBV", "AD", "ADOSC", "MFI", "VWAP"], + "Price Transform": ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"], + "Math": ["SQRT", "LOG10", "ADD"], + "Statistics": ["LINEARREG", "LINEARREG_SLOPE", "CORREL", "BETA"], + "Cycle": ["HT_DCPERIOD", "HT_TRENDMODE"], + "Pattern": ["CDLENGULFING", "CDLDOJI", "CDLHAMMER"], +} + +# Cumulative: compare first-differences not absolute values +CUMULATIVE_INDICATORS = {"OBV", "AD", "ADOSC"} +# Binary output: use agreement rate not allclose +BINARY_INDICATORS = {"CDLENGULFING", "CDLDOJI", "CDLHAMMER", "HT_TRENDMODE"} + + +def execute_indicator(library, indicator, data, df=None, **kwargs): + """Run indicator from library on data dict, return 1-D float64 array.""" + if library not in available_libraries(): + raise KeyError(f"Library not available in this environment: {library!r}") + + key = (library, indicator) + if key not in REGISTRY: + raise KeyError(f"No wrapper for {key!r}") + if df is None: + from benchmarks.data_generator import get_pandas_ohlcv + + df = get_pandas_ohlcv(data) + params = {**INDICATOR_DEFAULTS.get(indicator, {}), **kwargs} + return REGISTRY[key](data, df, **params) diff --git a/vendor/ferro-ta-main/conda/meta.yaml b/vendor/ferro-ta-main/conda/meta.yaml new file mode 100644 index 0000000..6d903f1 --- /dev/null +++ b/vendor/ferro-ta-main/conda/meta.yaml @@ -0,0 +1,53 @@ +{% set name = "ferro-ta" %} +{% set version = "1.2.0" %} + +package: + name: {{ name|lower }} + version: {{ version }} + +source: + # Build from PyPI wheel (simplest approach; no Rust toolchain required). + # Replace with url/sha256 of the specific wheel for your platform or + # use `pip_install: true` to let conda-build fetch it. + pip_install: true + packages: + - ferro-ta=={{ version }} + +build: + number: 0 + # Use noarch: python only if wheels are already compiled. For source builds + # remove noarch and add the maturin build steps below. + noarch: python + script: | + {{ PYTHON }} -m pip install ferro-ta=={{ version }} --no-deps --ignore-installed -vv + +requirements: + host: + - python + - pip + run: + - python >=3.10 + - numpy >=1.20 + +test: + imports: + - ferro_ta + commands: + - python -c "from ferro_ta import SMA, RSI; import numpy as np; print(SMA(np.array([1.0,2.0,3.0,4.0,5.0]), timeperiod=3))" + +about: + home: https://github.com/pratikbhadane24/ferro-ta + license: MIT + license_family: MIT + summary: Rust-powered Python technical analysis library with a TA-Lib-compatible API + description: | + ferro-ta is a Rust-powered Python technical analysis library with a + TA-Lib-compatible API and pre-compiled wheels for the supported platforms. + It provides 155+ indicators via a Rust core and PyO3 bindings, with + optional pandas and streaming APIs. + doc_url: https://github.com/pratikbhadane24/ferro-ta + dev_url: https://github.com/pratikbhadane24/ferro-ta + +extra: + recipe-maintainers: + - pratikbhadane24 diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/Cargo.toml b/vendor/ferro-ta-main/crates/ferro_ta_core/Cargo.toml new file mode 100644 index 0000000..710beed --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "ferro_ta_core" +version = "1.2.0" +edition = "2021" +description = "Pure Rust core indicator library — no PyO3, no numpy dependency" +license = "MIT" +readme = "README.md" +repository = "https://github.com/pratikbhadane24/ferro-ta" +homepage = "https://github.com/pratikbhadane24/ferro-ta#readme" +documentation = "https://docs.rs/ferro_ta_core" +keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"] +categories = ["finance", "mathematics"] + +[lib] +name = "ferro_ta_core" +crate-type = ["lib"] + +[dependencies] +multiversion = { version = "0.8", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", optional = true } + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } + +[[bench]] +name = "indicators" +harness = false + +[features] +# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add +# ferro_ta_core` and the published wheels get SIMD-accelerated reductions +# that adapt to the running CPU (baseline .. AVX-512 / NEON) WITHOUT pinning +# a target-cpu — one binary runs on any CPU of the target arch, with no +# illegal-instruction crashes on older chips. Disable with +# `--no-default-features` for a pure-scalar build. +default = ["simd"] +simd = ["dep:multiversion"] +serde = ["dep:serde", "dep:serde_json"] diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/README.md b/vendor/ferro-ta-main/crates/ferro_ta_core/README.md new file mode 100644 index 0000000..2add66e --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/README.md @@ -0,0 +1,87 @@ +# ferro_ta_core + +`ferro_ta_core` is the pure Rust indicator engine behind [`ferro-ta`](https://github.com/pratikbhadane24/ferro-ta). + +It provides allocation-friendly indicator functions over `&[f64]` slices without any +PyO3, NumPy, or Python runtime dependency, which makes it a good fit for: + +- Rust-native technical analysis workloads +- custom services and backtesting engines +- non-Python bindings (WASM, FFI) + +## Installation + +```toml +[dependencies] +ferro_ta_core = "1.2.0" +``` + +## Design + +- Pure functions over Rust slices +- No Python or NumPy dependency +- Shared core for the Python package and WASM bindings +- Output shape matches TA-Lib-style full-length series with `NaN` warm-up values where applicable + +## Modules + +| Module | Functions | Highlights | +|--------|-----------|------------| +| `overlap` | 20 | SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, MACDFIX, MACDEXT, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, MA, MAVP, Hull MA | +| `momentum` | 26 | RSI, MOM, STOCH, STOCHF, ADX, ADXR, DX, +DI, -DI, +DM, -DM, ROC, WILLR, AROON, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC | +| `volatility` | 3 | ATR, NATR, TRANGE | +| `volume` | 4 | OBV, MFI, AD, ADOSC | +| `pattern` | 61 | All TA-Lib candlestick patterns (CDL2CROWS through CDLXSIDEGAP3METHODS) | +| `statistic` | 9 | STDDEV, VAR, LINEARREG, LINEARREG_SLOPE/INTERCEPT/ANGLE, TSF, BETA, CORREL | +| `math` | 24 | Rolling SUM/MAX/MIN/MAXINDEX/MININDEX, element-wise ADD/SUB/MULT/DIV, 15 transforms (trig, exp, log, sqrt, ceil, floor) | +| `price_transform` | 4 | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE | +| `cycle` | 7 | Hilbert Transform: TRENDLINE, DCPERIOD, DCPHASE, PHASOR, SINE, TRENDMODE | +| `extended` | 10 | VWAP, VWMA, Supertrend, Donchian, Keltner, Ichimoku, Pivot Points, Hull MA, Chandelier Exit, Choppiness Index | +| `streaming` | 9 | Stateful bar-by-bar: SMA, EMA, RSI, ATR, BBands, MACD, Stoch, VWAP, Supertrend | +| `batch` | 8 | Vectorized multi-column: batch_sma/ema/rsi/atr/stoch/adx, run_close/hlc_indicators | +| `backtest` | 19 | Signal generators, close-only and OHLCV engines, walk-forward, Monte Carlo, performance metrics | +| `options` | 18 | Black-Scholes/Black-76 pricing, Greeks, implied volatility, IV rank/percentile/zscore, smile metrics, chain analytics | +| `futures` | 14 | Basis, annualized basis, carry, roll (weighted/back-adjusted/ratio), curve analysis, synthetic forward/spot | +| `portfolio` | 10 | Beta, correlation matrix, drawdown, relative strength, spread, ratio, z-score, portfolio volatility | +| `signals` | 4 | Rank values, compose rank, top/bottom N indices | +| `alerts` | 3 | Threshold crossings, cross detection, alert bar collection | +| `regime` | 4 | ADX regime, combined regime, CUSUM breaks, variance breaks | +| `aggregation` | 3 | Tick bars, volume bars, time bars from trade data | +| `resampling` | 2 | Volume bars, OHLCV aggregation by label | +| `chunked` | 4 | Trim overlap, stitch chunks, make chunk ranges, forward fill NaN | +| `crypto` | 3 | Funding cumulative PnL, continuous bar labels, session boundaries | + +## Example + +```rust +use ferro_ta_core::overlap; + +fn main() { + let close = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let sma = overlap::sma(&close, 3); + + assert!(sma[0].is_nan()); + assert!(sma[1].is_nan()); + assert!((sma[2] - 2.0).abs() < 1e-10); +} +``` + +## Relationship To `ferro-ta` + +The published Python package (`ferro-ta` on PyPI) wraps this crate with PyO3 bindings and adds NumPy conversion, pandas/polars wrappers, and higher-level Python tooling. The WASM package (`ferro-ta-wasm` on npm) also wraps this crate with full feature parity. + +If you only need Rust indicator functions, use `ferro_ta_core` directly. + +## Development + +From the repository root: + +```bash +cargo build -p ferro_ta_core +cargo test -p ferro_ta_core +cargo bench -p ferro_ta_core --no-run +``` + +## License + +MIT diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs new file mode 100644 index 0000000..9c74666 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/benches/indicators.rs @@ -0,0 +1,212 @@ +//! Criterion benchmarks for ferro_ta_core — pure Rust indicator throughput. +//! +//! Run from repo root: cargo bench -p ferro_ta_core +//! Or: cd crates/ferro_ta_core && cargo bench +//! +//! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators. +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use ferro_ta_core::{futures, momentum, options, overlap, volatility}; +use std::hint::black_box; + +fn synthetic_close(n: usize) -> Vec { + let mut v = Vec::with_capacity(n); + let mut price = 100.0_f64; + for i in 0..n { + price += ((i as f64 * 0.1).sin()) * 0.5; + v.push(price); + } + v +} + +fn synthetic_high_low_close(n: usize) -> (Vec, Vec, Vec) { + let close = synthetic_close(n); + let high: Vec = close.iter().map(|&c| c + 0.5).collect(); + let low: Vec = close.iter().map(|&c| c - 0.5).collect(); + (high, low, close) +} + +fn bench_sma(c: &mut Criterion) { + let mut group = c.benchmark_group("SMA"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::sma(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_ema(c: &mut Criterion) { + let mut group = c.benchmark_group("EMA"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::ema(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_rsi(c: &mut Criterion) { + let mut group = c.benchmark_group("RSI"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| momentum::rsi(black_box(close), 14)) + }); + } + group.finish(); +} + +fn bench_atr(c: &mut Criterion) { + let mut group = c.benchmark_group("ATR"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let (high, low, close) = synthetic_high_low_close(size); + group.bench_with_input( + BenchmarkId::from_parameter(size), + &(high.clone(), low.clone(), close), + |b, (high, low, close)| { + b.iter(|| volatility::atr(black_box(high), black_box(low), black_box(close), 14)) + }, + ); + } + group.finish(); +} + +fn bench_bbands(c: &mut Criterion) { + let mut group = c.benchmark_group("BBANDS"); + for size in [1_000_usize, 10_000, 100_000, 1_000_000] { + let close = synthetic_close(size); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| overlap::bbands(black_box(close), 20, 2.0, 2.0)) + }); + } + group.finish(); +} + +fn bench_bsm_price(c: &mut Criterion) { + let mut group = c.benchmark_group("BSM_PRICE"); + for size in [1_000_usize, 10_000, 100_000] { + let close = synthetic_close(size); + let strikes: Vec = close.iter().map(|_| 100.0).collect(); + let vols: Vec = close.iter().map(|_| 0.2).collect(); + group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| { + b.iter(|| { + close + .iter() + .zip(strikes.iter()) + .zip(vols.iter()) + .map(|((&spot, &strike), &vol)| { + options::pricing::black_scholes_price( + black_box(spot), + black_box(strike), + black_box(0.02), + black_box(0.0), + black_box(0.5), + black_box(vol), + options::OptionKind::Call, + ) + }) + .collect::>() + }) + }); + } + group.finish(); +} + +fn bench_implied_volatility(c: &mut Criterion) { + let mut group = c.benchmark_group("IMPLIED_VOL"); + for size in [1_000_usize, 10_000] { + let prices: Vec = (0..size) + .map(|i| { + let spot = 90.0 + (i % 20) as f64; + options::pricing::black_scholes_price( + spot, + 100.0, + 0.02, + 0.0, + 0.5, + 0.2, + options::OptionKind::Call, + ) + }) + .collect(); + group.bench_with_input(BenchmarkId::from_parameter(size), &prices, |b, prices| { + b.iter(|| { + prices + .iter() + .enumerate() + .map(|(i, &price)| { + options::iv::implied_volatility( + options::OptionContract { + model: options::PricingModel::BlackScholes, + underlying: black_box(90.0 + (i % 20) as f64), + strike: black_box(100.0), + rate: black_box(0.02), + carry: black_box(0.0), + time_to_expiry: black_box(0.5), + kind: options::OptionKind::Call, + }, + black_box(price), + options::IvSolverConfig { + initial_guess: black_box(0.25), + tolerance: black_box(1e-8), + max_iterations: black_box(100), + }, + ) + }) + .collect::>() + }) + }); + } + group.finish(); +} + +fn bench_smile_metrics(c: &mut Criterion) { + let mut group = c.benchmark_group("SMILE_METRICS"); + let strikes: Vec = (0..41).map(|i| 80.0 + i as f64).collect(); + let vols: Vec = strikes + .iter() + .map(|&k| 0.18 + ((k - 100.0).abs() / 100.0) * 0.15) + .collect(); + group.bench_function("single_chain", |b| { + b.iter(|| { + options::surface::smile_metrics( + black_box(&strikes), + black_box(&vols), + black_box(100.0), + black_box(0.02), + black_box(0.0), + black_box(0.5), + options::PricingModel::BlackScholes, + ) + }) + }); + group.finish(); +} + +fn bench_curve_summary(c: &mut Criterion) { + let mut group = c.benchmark_group("FUTURES_CURVE"); + let tenors = vec![0.1, 0.25, 0.5, 0.75, 1.0]; + let prices = vec![101.0, 101.8, 102.7, 103.4, 104.1]; + group.bench_function("curve_summary", |b| { + b.iter(|| { + futures::curve::curve_summary(black_box(100.0), black_box(&tenors), black_box(&prices)) + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_sma, + bench_ema, + bench_rsi, + bench_atr, + bench_bbands, + bench_bsm_price, + bench_implied_volatility, + bench_smile_metrics, + bench_curve_summary +); +criterion_main!(benches); diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs new file mode 100644 index 0000000..43c7464 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/aggregation.rs @@ -0,0 +1,340 @@ +//! Tick / Trade Aggregation Pipeline — pure Rust, no PyO3. +//! +//! Aggregates raw tick/trade data into OHLCV bars: +//! - **tick bars** — fixed number of ticks per bar +//! - **volume bars** — fixed volume threshold per bar +//! - **time bars** — label-based grouping (labels from Python timestamps) + +/// OHLCV 5-tuple return type alias. +type Ohlcv5 = (Vec, Vec, Vec, Vec, Vec); + +/// OHLCV 5-tuple plus labels return type alias. +type Ohlcv5AndLabels = (Vec, Vec, Vec, Vec, Vec, Vec); + +// --------------------------------------------------------------------------- +// aggregate_tick_bars +// --------------------------------------------------------------------------- + +/// Aggregate tick/trade data into tick bars (every N ticks become one bar). +/// +/// Returns `(open, high, low, close, volume)` where volume = sum of sizes. +/// +/// # Panics +/// Panics if `ticks_per_bar == 0`, arrays are empty, or lengths differ. +pub fn aggregate_tick_bars(price: &[f64], size: &[f64], ticks_per_bar: usize) -> Ohlcv5 { + assert!(ticks_per_bar >= 1, "ticks_per_bar must be >= 1"); + let n = price.len(); + assert!( + n > 0 && size.len() == n, + "price and size must be non-empty and equal length" + ); + + let n_bars = n.div_ceil(ticks_per_bar); + let mut out_open = Vec::with_capacity(n_bars); + let mut out_high = Vec::with_capacity(n_bars); + let mut out_low = Vec::with_capacity(n_bars); + let mut out_close = Vec::with_capacity(n_bars); + let mut out_vol = Vec::with_capacity(n_bars); + + let mut i = 0; + while i < n { + let end = (i + ticks_per_bar).min(n); + let bar_p = &price[i..end]; + let bar_s = &size[i..end]; + let bar_open = bar_p[0]; + let bar_high = bar_p.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let bar_low = bar_p.iter().cloned().fold(f64::INFINITY, f64::min); + let bar_close = *bar_p.last().expect("slice cannot be empty"); + let bar_vol: f64 = bar_s.iter().sum(); + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + i = end; + } + + (out_open, out_high, out_low, out_close, out_vol) +} + +// --------------------------------------------------------------------------- +// aggregate_volume_bars_ticks +// --------------------------------------------------------------------------- + +/// Aggregate tick data into volume bars (fixed volume threshold). +/// +/// Accumulates ticks until cumulative size >= `volume_threshold`, then emits +/// a bar. Any remaining partial bar is also emitted. +/// +/// Returns `(open, high, low, close, volume)`. +/// +/// # Panics +/// Panics if `volume_threshold <= 0`, arrays are empty, or lengths differ. +pub fn aggregate_volume_bars_ticks(price: &[f64], size: &[f64], volume_threshold: f64) -> Ohlcv5 { + assert!(volume_threshold > 0.0, "volume_threshold must be > 0"); + let n = price.len(); + assert!( + n > 0 && size.len() == n, + "price and size must be non-empty and equal length" + ); + + let mut out_open: Vec = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = Vec::new(); + + let mut bar_open = price[0]; + let mut bar_high = price[0]; + let mut bar_low = price[0]; + let mut bar_close = price[0]; + let mut bar_vol = size[0]; + + for i in 1..n { + bar_high = bar_high.max(price[i]); + bar_low = bar_low.min(price[i]); + bar_close = price[i]; + bar_vol += size[i]; + + if bar_vol >= volume_threshold { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + if i + 1 < n { + bar_open = price[i + 1]; + bar_high = price[i + 1]; + bar_low = price[i + 1]; + bar_close = price[i + 1]; + bar_vol = size[i + 1]; + } else { + bar_vol = 0.0; + } + } + } + // Push remaining partial bar + if bar_vol > 0.0 { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + } + + (out_open, out_high, out_low, out_close, out_vol) +} + +// --------------------------------------------------------------------------- +// aggregate_time_bars +// --------------------------------------------------------------------------- + +/// Aggregate tick data into time bars using pre-computed integer bucket labels. +/// +/// Each tick is assigned a `label` (e.g. unix_ts // period_secs). Ticks with +/// the same label are accumulated into one bar. Labels must be non-decreasing. +/// +/// Returns `(open, high, low, close, volume, unique_labels)`. +/// +/// # Panics +/// Panics if arrays are empty or have unequal lengths. +pub fn aggregate_time_bars(price: &[f64], size: &[f64], labels: &[i64]) -> Ohlcv5AndLabels { + let n = price.len(); + assert!( + n > 0 && size.len() == n && labels.len() == n, + "price, size, and labels must be non-empty and equal length" + ); + + let mut out_open: Vec = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = Vec::new(); + let mut out_labels: Vec = Vec::new(); + + let mut cur_label = labels[0]; + let mut bar_open = price[0]; + let mut bar_high = price[0]; + let mut bar_low = price[0]; + let mut bar_close = price[0]; + let mut bar_vol = size[0]; + + for i in 1..n { + if labels[i] != cur_label { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + out_labels.push(cur_label); + cur_label = labels[i]; + bar_open = price[i]; + bar_high = price[i]; + bar_low = price[i]; + bar_close = price[i]; + bar_vol = size[i]; + } else { + bar_high = bar_high.max(price[i]); + bar_low = bar_low.min(price[i]); + bar_close = price[i]; + bar_vol += size[i]; + } + } + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + out_labels.push(cur_label); + + (out_open, out_high, out_low, out_close, out_vol, out_labels) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- aggregate_tick_bars ------------------------------------------------- + + #[test] + fn test_tick_bars_exact_division() { + let price = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0]; + let size = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + let (o, h, l, c, v) = aggregate_tick_bars(&price, &size, 3); + assert_eq!(o.len(), 2); + // Bar 0: ticks 0..3 + assert!((o[0] - 10.0).abs() < 1e-10); + assert!((h[0] - 12.0).abs() < 1e-10); + assert!((l[0] - 10.0).abs() < 1e-10); + assert!((c[0] - 12.0).abs() < 1e-10); + assert!((v[0] - 6.0).abs() < 1e-10); + // Bar 1: ticks 3..6 + assert!((o[1] - 13.0).abs() < 1e-10); + assert!((h[1] - 15.0).abs() < 1e-10); + assert!((l[1] - 13.0).abs() < 1e-10); + assert!((c[1] - 15.0).abs() < 1e-10); + assert!((v[1] - 15.0).abs() < 1e-10); + } + + #[test] + fn test_tick_bars_partial_last_bar() { + let price = [10.0, 11.0, 12.0, 13.0, 14.0]; + let size = [1.0, 2.0, 3.0, 4.0, 5.0]; + let (o, _h, _l, c, v) = aggregate_tick_bars(&price, &size, 3); + assert_eq!(o.len(), 2); + // Partial bar: ticks 3..5 + assert!((o[1] - 13.0).abs() < 1e-10); + assert!((c[1] - 14.0).abs() < 1e-10); + assert!((v[1] - 9.0).abs() < 1e-10); + } + + #[test] + fn test_tick_bars_single_tick() { + let (o, h, l, c, v) = aggregate_tick_bars(&[42.0], &[100.0], 5); + assert_eq!(o.len(), 1); + assert!((o[0] - 42.0).abs() < 1e-10); + assert!((h[0] - 42.0).abs() < 1e-10); + assert!((l[0] - 42.0).abs() < 1e-10); + assert!((c[0] - 42.0).abs() < 1e-10); + assert!((v[0] - 100.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "ticks_per_bar must be >= 1")] + fn test_tick_bars_zero_ticks() { + aggregate_tick_bars(&[1.0], &[1.0], 0); + } + + // -- aggregate_volume_bars_ticks ----------------------------------------- + + #[test] + fn test_volume_bars_ticks_basic() { + let price = [10.0, 11.0, 12.0, 13.0, 14.0]; + let size = [30.0, 40.0, 50.0, 20.0, 60.0]; + // threshold=70: bar0 = ticks 0+1 (vol=70), bar1 = tick2 (vol=50) + tick3 (vol=70), + // then tick4 as partial + let (o, h, l, c, v) = aggregate_volume_bars_ticks(&price, &size, 70.0); + // First bar: 30+40=70 >= 70 + assert!((o[0] - 10.0).abs() < 1e-10); + assert!((c[0] - 11.0).abs() < 1e-10); + assert!((v[0] - 70.0).abs() < 1e-10); + assert!((h[0] - 11.0).abs() < 1e-10); + assert!((l[0] - 10.0).abs() < 1e-10); + assert!(v.len() >= 2); + } + + #[test] + fn test_volume_bars_ticks_single() { + let (o, _h, _l, _c, v) = aggregate_volume_bars_ticks(&[5.0], &[10.0], 100.0); + assert_eq!(o.len(), 1); + assert!((v[0] - 10.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "volume_threshold must be > 0")] + fn test_volume_bars_ticks_zero_threshold() { + aggregate_volume_bars_ticks(&[1.0], &[1.0], 0.0); + } + + // -- aggregate_time_bars ------------------------------------------------- + + #[test] + fn test_time_bars_basic() { + let price = [10.0, 11.0, 12.0, 13.0, 14.0]; + let size = [1.0, 2.0, 3.0, 4.0, 5.0]; + let labels: [i64; 5] = [0, 0, 1, 1, 1]; + let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels); + assert_eq!(o.len(), 2); + assert_eq!(out_lbl, vec![0, 1]); + // Group 0: ticks 0,1 + assert!((o[0] - 10.0).abs() < 1e-10); + assert!((h[0] - 11.0).abs() < 1e-10); + assert!((l[0] - 10.0).abs() < 1e-10); + assert!((c[0] - 11.0).abs() < 1e-10); + assert!((v[0] - 3.0).abs() < 1e-10); + // Group 1: ticks 2,3,4 + assert!((o[1] - 12.0).abs() < 1e-10); + assert!((h[1] - 14.0).abs() < 1e-10); + assert!((l[1] - 12.0).abs() < 1e-10); + assert!((c[1] - 14.0).abs() < 1e-10); + assert!((v[1] - 12.0).abs() < 1e-10); + } + + #[test] + fn test_time_bars_all_same_label() { + let price = [5.0, 6.0, 4.0]; + let size = [10.0, 20.0, 30.0]; + let labels: [i64; 3] = [42, 42, 42]; + let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels); + assert_eq!(o.len(), 1); + assert_eq!(out_lbl, vec![42]); + assert!((o[0] - 5.0).abs() < 1e-10); + assert!((h[0] - 6.0).abs() < 1e-10); + assert!((l[0] - 4.0).abs() < 1e-10); + assert!((c[0] - 4.0).abs() < 1e-10); + assert!((v[0] - 60.0).abs() < 1e-10); + } + + #[test] + fn test_time_bars_each_tick_own_label() { + let price = [10.0, 20.0, 30.0]; + let size = [1.0, 2.0, 3.0]; + let labels: [i64; 3] = [0, 1, 2]; + let (o, _h, _l, _c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels); + assert_eq!(o.len(), 3); + assert_eq!(out_lbl, vec![0, 1, 2]); + assert!((v[0] - 1.0).abs() < 1e-10); + assert!((v[1] - 2.0).abs() < 1e-10); + assert!((v[2] - 3.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "price, size, and labels must be non-empty and equal length")] + fn test_time_bars_empty() { + aggregate_time_bars(&[], &[], &[]); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs new file mode 100644 index 0000000..62d3475 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/alerts.rs @@ -0,0 +1,126 @@ +//! Alerts — condition evaluation helpers. +//! +//! - `check_threshold` — fires when a series crosses above/below a level +//! - `check_cross` — fires when *fast* crosses above or below *slow* +//! - `collect_alert_bars` — returns indices of bars where a mask is non-zero + +/// Fire an alert when `series` crosses a threshold level. +/// +/// `direction`: `1` = cross above, `-1` = cross below. +/// +/// Returns a `Vec` with `1` at crossing bars, `0` elsewhere. +/// Element 0 is always 0. +pub fn check_threshold(series: &[f64], level: f64, direction: i32) -> Vec { + let n = series.len(); + let mut out = vec![0i8; n]; + if n < 2 { + return out; + } + for i in 1..n { + let prev = series[i - 1]; + let curr = series[i]; + if prev.is_nan() || curr.is_nan() { + continue; + } + if (direction == 1 && prev <= level && curr > level) + || (direction == -1 && prev >= level && curr < level) + { + out[i] = 1; + } + } + out +} + +/// Detect cross-over / cross-under events between two series. +/// +/// Returns `Vec`: `1` = bullish cross (fast above slow), `-1` = bearish, `0` = none. +/// Element 0 is always 0. +pub fn check_cross(fast: &[f64], slow: &[f64]) -> Vec { + let n = fast.len(); + let mut out = vec![0i8; n]; + if n < 2 { + return out; + } + for i in 1..n { + let fp = fast[i - 1]; + let fc = fast[i]; + let sp = slow[i - 1]; + let sc = slow[i]; + if fp.is_nan() || fc.is_nan() || sp.is_nan() || sc.is_nan() { + continue; + } + if fp <= sp && fc > sc { + out[i] = 1; + } else if fp >= sp && fc < sc { + out[i] = -1; + } + } + out +} + +/// Collect bar indices where `mask` is non-zero. +pub fn collect_alert_bars(mask: &[i8]) -> Vec { + mask.iter() + .enumerate() + .filter(|(_, &v)| v != 0) + .map(|(i, _)| i as i64) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_check_threshold_cross_above() { + let series = vec![10.0, 20.0, 30.0, 40.0, 50.0]; + let result = check_threshold(&series, 25.0, 1); + assert_eq!(result, vec![0, 0, 1, 0, 0]); + } + + #[test] + fn test_check_threshold_cross_below() { + let series = vec![50.0, 40.0, 30.0, 20.0, 10.0]; + let result = check_threshold(&series, 25.0, -1); + assert_eq!(result, vec![0, 0, 0, 1, 0]); + } + + #[test] + fn test_check_cross_bullish() { + let fast = vec![1.0, 2.0, 5.0]; + let slow = vec![3.0, 3.0, 3.0]; + let result = check_cross(&fast, &slow); + assert_eq!(result, vec![0, 0, 1]); + } + + #[test] + fn test_check_cross_bearish() { + let fast = vec![5.0, 4.0, 1.0]; + let slow = vec![3.0, 3.0, 3.0]; + let result = check_cross(&fast, &slow); + assert_eq!(result, vec![0, 0, -1]); + } + + #[test] + fn test_collect_alert_bars() { + let mask = vec![0i8, 1, 0, -1, 0, 1]; + let result = collect_alert_bars(&mask); + assert_eq!(result, vec![1, 3, 5]); + } + + #[test] + fn test_empty() { + assert_eq!(check_threshold(&[], 0.0, 1), Vec::::new()); + assert_eq!(check_cross(&[], &[]), Vec::::new()); + assert_eq!(collect_alert_bars(&[]), Vec::::new()); + } + + #[test] + fn test_nan_handling() { + let series = vec![10.0, f64::NAN, 30.0, 40.0]; + let result = check_threshold(&series, 25.0, 1); + // NaN bars are skipped + assert_eq!(result[1], 0); + assert_eq!(result[2], 0); // prev is NaN + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs new file mode 100644 index 0000000..d7536b9 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/attribution.rs @@ -0,0 +1,333 @@ +//! Performance attribution and trade analysis — pure Rust, no PyO3. +//! +//! Functions +//! --------- +//! - `trade_stats` — win rate, avg win/loss, profit factor, avg hold +//! - `monthly_contribution` — group bar returns by month index and sum +//! - `signal_attribution` — group bar returns by signal label and sum +//! - `extract_trades` — extract trade pnl and hold durations from positions + +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// trade_stats +// --------------------------------------------------------------------------- + +/// Compute trade-level statistics from trade PnL and hold durations. +/// +/// Returns `(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)`. +/// +/// - **win_rate** : fraction of trades with PnL > 0 +/// - **avg_win** : mean PnL of winning trades (0 if none) +/// - **avg_loss** : mean PnL of losing trades (negative; 0 if none) +/// - **profit_factor** : gross profit / |gross loss| (inf if no losses) +/// - **avg_hold_bars** : mean hold duration across all trades +/// +/// # Panics +/// Panics if `pnl` is empty or `pnl.len() != hold_bars.len()`. +pub fn trade_stats(pnl: &[f64], hold_bars: &[f64]) -> (f64, f64, f64, f64, f64) { + let n = pnl.len(); + assert!(n > 0, "pnl must be non-empty"); + assert_eq!( + n, + hold_bars.len(), + "pnl and hold_bars must have equal length" + ); + + let mut wins: Vec = Vec::new(); + let mut losses: Vec = Vec::new(); + for &v in pnl.iter() { + if v > 0.0 { + wins.push(v); + } else if v < 0.0 { + losses.push(v); + } + } + + let win_rate = wins.len() as f64 / n as f64; + let avg_win = if wins.is_empty() { + 0.0 + } else { + wins.iter().sum::() / wins.len() as f64 + }; + let avg_loss = if losses.is_empty() { + 0.0 + } else { + losses.iter().sum::() / losses.len() as f64 + }; + + let gross_profit: f64 = wins.iter().sum(); + let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum(); + let profit_factor = if gross_loss == 0.0 { + f64::INFINITY + } else { + gross_profit / gross_loss + }; + + let avg_hold = hold_bars.iter().sum::() / n as f64; + + (win_rate, avg_win, avg_loss, profit_factor, avg_hold) +} + +// --------------------------------------------------------------------------- +// monthly_contribution +// --------------------------------------------------------------------------- + +/// Group per-bar returns by month index and sum each month's contribution. +/// +/// Returns `(months, contributions)` where `months` is sorted unique month +/// indices and `contributions` is the corresponding total return per month. +/// NaN returns are skipped. +/// +/// # Panics +/// Panics if `bar_returns.len() != month_index.len()`. +pub fn monthly_contribution(bar_returns: &[f64], month_index: &[i64]) -> (Vec, Vec) { + let n = bar_returns.len(); + assert_eq!( + n, + month_index.len(), + "bar_returns and month_index must have equal length" + ); + + let mut map: HashMap = HashMap::new(); + for i in 0..n { + if !bar_returns[i].is_nan() { + *map.entry(month_index[i]).or_insert(0.0) += bar_returns[i]; + } + } + + let mut months: Vec = map.keys().copied().collect(); + months.sort_unstable(); + let contributions: Vec = months.iter().map(|m| map[m]).collect(); + + (months, contributions) +} + +// --------------------------------------------------------------------------- +// signal_attribution +// --------------------------------------------------------------------------- + +/// Attribute per-bar returns to each signal label. +/// +/// Returns `(labels, contributions)` where `labels` is sorted unique signal +/// labels and `contributions` is the corresponding total return per label. +/// NaN returns are skipped. +/// +/// # Panics +/// Panics if `bar_returns.len() != signal_labels.len()`. +pub fn signal_attribution(bar_returns: &[f64], signal_labels: &[i64]) -> (Vec, Vec) { + let n = bar_returns.len(); + assert_eq!( + n, + signal_labels.len(), + "bar_returns and signal_labels must have equal length" + ); + + let mut map: HashMap = HashMap::new(); + for i in 0..n { + if !bar_returns[i].is_nan() { + *map.entry(signal_labels[i]).or_insert(0.0) += bar_returns[i]; + } + } + + let mut labels: Vec = map.keys().copied().collect(); + labels.sort_unstable(); + let contributions: Vec = labels.iter().map(|l| map[l]).collect(); + + (labels, contributions) +} + +// --------------------------------------------------------------------------- +// extract_trades +// --------------------------------------------------------------------------- + +/// Extract trade-level PnL and hold durations from positions and strategy returns. +/// +/// A trade is a maximal contiguous run of non-zero position values with the +/// same sign/magnitude. Returns `(pnl, hold_durations)`. +/// +/// # Panics +/// Panics if `positions.len() != strategy_returns.len()`. +pub fn extract_trades(positions: &[f64], strategy_returns: &[f64]) -> (Vec, Vec) { + let n = positions.len(); + assert_eq!( + n, + strategy_returns.len(), + "positions and strategy_returns must have equal length" + ); + + let mut pnl = Vec::::new(); + let mut hold = Vec::::new(); + + let mut i = 0usize; + while i < n { + if positions[i] == 0.0 { + i += 1; + continue; + } + let mut j = i + 1; + while j < n && positions[j] == positions[i] { + j += 1; + } + let mut trade_pnl = 0.0_f64; + for v in strategy_returns.iter().take(j).skip(i) { + trade_pnl += *v; + } + pnl.push(trade_pnl); + hold.push((j - i) as f64); + i = j; + } + + (pnl, hold) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- trade_stats --------------------------------------------------------- + + #[test] + fn test_trade_stats_basic() { + let pnl = [100.0, -50.0, 200.0, -30.0, 150.0]; + let hold = [5.0, 3.0, 7.0, 2.0, 6.0]; + let (wr, aw, al, pf, ah) = trade_stats(&pnl, &hold); + + // 3 wins out of 5 + assert!((wr - 0.6).abs() < 1e-10); + // avg win = (100+200+150)/3 + assert!((aw - 150.0).abs() < 1e-10); + // avg loss = (-50 + -30)/2 = -40 + assert!((al - (-40.0)).abs() < 1e-10); + // profit_factor = 450 / 80 + assert!((pf - 5.625).abs() < 1e-10); + // avg hold = (5+3+7+2+6)/5 = 4.6 + assert!((ah - 4.6).abs() < 1e-10); + } + + #[test] + fn test_trade_stats_all_wins() { + let pnl = [10.0, 20.0]; + let hold = [1.0, 2.0]; + let (wr, _aw, al, pf, _ah) = trade_stats(&pnl, &hold); + assert!((wr - 1.0).abs() < 1e-10); + assert!((al - 0.0).abs() < 1e-10); + assert!(pf.is_infinite()); + } + + #[test] + fn test_trade_stats_all_losses() { + let pnl = [-10.0, -20.0]; + let hold = [1.0, 2.0]; + let (wr, aw, _al, pf, _ah) = trade_stats(&pnl, &hold); + assert!((wr - 0.0).abs() < 1e-10); + assert!((aw - 0.0).abs() < 1e-10); + assert!((pf - 0.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "pnl must be non-empty")] + fn test_trade_stats_empty() { + trade_stats(&[], &[]); + } + + // -- monthly_contribution ------------------------------------------------ + + #[test] + fn test_monthly_contribution_basic() { + let returns = [0.01, 0.02, -0.01, 0.03, -0.02]; + let months = [0, 0, 1, 1, 2]; + let (m, c) = monthly_contribution(&returns, &months); + assert_eq!(m, vec![0, 1, 2]); + assert!((c[0] - 0.03).abs() < 1e-10); + assert!((c[1] - 0.02).abs() < 1e-10); + assert!((c[2] - (-0.02)).abs() < 1e-10); + } + + #[test] + fn test_monthly_contribution_nan_skipped() { + let returns = [0.01, f64::NAN, 0.03]; + let months = [0, 0, 1]; + let (m, c) = monthly_contribution(&returns, &months); + assert_eq!(m, vec![0, 1]); + assert!((c[0] - 0.01).abs() < 1e-10); + assert!((c[1] - 0.03).abs() < 1e-10); + } + + #[test] + fn test_monthly_contribution_empty() { + let (m, c) = monthly_contribution(&[], &[]); + assert!(m.is_empty()); + assert!(c.is_empty()); + } + + // -- signal_attribution -------------------------------------------------- + + #[test] + fn test_signal_attribution_basic() { + let returns = [0.05, -0.02, 0.03, 0.01]; + let labels = [1, -1, 2, 1]; + let (l, c) = signal_attribution(&returns, &labels); + assert_eq!(l, vec![-1, 1, 2]); + assert!((c[0] - (-0.02)).abs() < 1e-10); + assert!((c[1] - 0.06).abs() < 1e-10); // 0.05 + 0.01 + assert!((c[2] - 0.03).abs() < 1e-10); + } + + #[test] + fn test_signal_attribution_nan_skipped() { + let returns = [0.05, f64::NAN]; + let labels = [1, 2]; + let (l, c) = signal_attribution(&returns, &labels); + assert_eq!(l, vec![1]); + assert!((c[0] - 0.05).abs() < 1e-10); + } + + // -- extract_trades ------------------------------------------------------ + + #[test] + fn test_extract_trades_basic() { + // positions: flat, long, long, flat, short, short + let positions = [0.0, 1.0, 1.0, 0.0, -1.0, -1.0]; + let strat_ret = [0.0, 0.01, 0.02, 0.0, -0.01, 0.03]; + let (pnl, hold) = extract_trades(&positions, &strat_ret); + assert_eq!(pnl.len(), 2); + assert_eq!(hold.len(), 2); + // First trade: bars 1..3 => 0.01 + 0.02 = 0.03 + assert!((pnl[0] - 0.03).abs() < 1e-10); + assert!((hold[0] - 2.0).abs() < 1e-10); + // Second trade: bars 4..6 => -0.01 + 0.03 = 0.02 + assert!((pnl[1] - 0.02).abs() < 1e-10); + assert!((hold[1] - 2.0).abs() < 1e-10); + } + + #[test] + fn test_extract_trades_all_flat() { + let positions = [0.0, 0.0, 0.0]; + let strat_ret = [0.01, 0.02, 0.03]; + let (pnl, hold) = extract_trades(&positions, &strat_ret); + assert!(pnl.is_empty()); + assert!(hold.is_empty()); + } + + #[test] + fn test_extract_trades_empty() { + let (pnl, hold) = extract_trades(&[], &[]); + assert!(pnl.is_empty()); + assert!(hold.is_empty()); + } + + #[test] + fn test_extract_trades_single_bar_trade() { + let positions = [0.0, 1.0, 0.0]; + let strat_ret = [0.0, 0.05, 0.0]; + let (pnl, hold) = extract_trades(&positions, &strat_ret); + assert_eq!(pnl.len(), 1); + assert!((pnl[0] - 0.05).abs() < 1e-10); + assert!((hold[0] - 1.0).abs() < 1e-10); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs new file mode 100644 index 0000000..7b9c33f --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/backtest.rs @@ -0,0 +1,2123 @@ +//! Pure Rust backtest engine — no PyO3, no numpy dependency. +//! +//! This module contains all backtest logic as pure functions operating on +//! `&[f64]` slices. The PyO3 binding crate provides thin wrappers that +//! convert NumPy arrays to slices and call into this module. + +use crate::commission::CommissionModel; + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +/// Replace NaN → 0, +Inf → f64::MAX, −Inf → −f64::MAX (mirrors numpy nan_to_num defaults). +#[inline] +pub fn nan_to_num(v: f64) -> f64 { + if v.is_nan() { + 0.0 + } else if v.is_infinite() { + if v.is_sign_positive() { + f64::MAX + } else { + -f64::MAX + } + } else { + v + } +} + +/// Kelly criterion formula: f = win_rate − (1 − win_rate) × (|avg_loss| / avg_win), clamped to [0, 1]. +#[inline] +pub fn kelly_formula(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + if avg_win <= 0.0 { + return 0.0; + } + let f = win_rate - (1.0 - win_rate) * (avg_loss.abs() / avg_win); + f.clamp(0.0, 1.0) +} + +/// Deterministic LCG (Knuth MMIX). +#[inline] +pub fn lcg_next(state: &mut u64) -> u64 { + *state = state + .wrapping_mul(6_364_136_223_846_793_005_u64) + .wrapping_add(1_442_695_040_888_963_407_u64); + *state +} + +#[inline] +pub fn lcg_index(state: &mut u64, n: usize) -> usize { + ((lcg_next(state) >> 11) as usize) % n +} + +/// Compute commission cost as a fraction of `initial_capital` for a single execution. +#[inline] +pub fn commission_fraction( + cm: &CommissionModel, + fill_price: f64, + position_size: f64, + is_buy: bool, + initial_capital: f64, +) -> f64 { + if fill_price <= 0.0 || position_size <= 0.0 || initial_capital <= 0.0 { + return 0.0; + } + let trade_value = position_size * fill_price * initial_capital; + let num_lots = if cm.lot_size > 0.0 { + (position_size * initial_capital / (cm.lot_size * fill_price)).ceil() + } else { + 1.0 + }; + cm.cost_fraction(trade_value, num_lots, is_buy, initial_capital) +} + +/// Resolve a CommissionModel from an optional reference or backward-compat scalar. +pub fn resolve_commission_model( + commission: Option<&CommissionModel>, + commission_per_trade: f64, +) -> CommissionModel { + match commission { + Some(c) => c.clone(), + None if commission_per_trade > 0.0 => CommissionModel { + flat_per_order: commission_per_trade, + ..Default::default() + }, + None => CommissionModel::default(), + } +} + +// --------------------------------------------------------------------------- +// Structs +// --------------------------------------------------------------------------- + +/// Configuration for the OHLCV-aware backtester. +#[derive(Clone, Debug)] +pub struct BacktestConfig { + pub fill_mode: String, + pub stop_loss_pct: f64, + pub take_profit_pct: f64, + pub trailing_stop_pct: f64, + pub slippage_bps: f64, + pub initial_capital: f64, + pub commission_per_trade: f64, + pub max_hold_bars: usize, + pub slippage_pct_range: f64, + pub breakeven_pct: f64, + pub periods_per_year: f64, + pub margin_ratio: f64, + pub margin_call_pct: f64, + pub daily_loss_limit: f64, + pub total_loss_limit: f64, + pub commission: Option, +} + +impl Default for BacktestConfig { + fn default() -> Self { + Self { + fill_mode: "market_open".to_string(), + stop_loss_pct: 0.0, + take_profit_pct: 0.0, + trailing_stop_pct: 0.0, + slippage_bps: 0.0, + initial_capital: 100_000.0, + commission_per_trade: 0.0, + max_hold_bars: 0, + slippage_pct_range: 0.0, + breakeven_pct: 0.0, + periods_per_year: 252.0, + margin_ratio: 0.0, + margin_call_pct: 0.5, + daily_loss_limit: 0.0, + total_loss_limit: 0.0, + commission: None, + } + } +} + +/// Result of the OHLCV backtest engine. +#[derive(Clone, Debug)] +pub struct OhlcvBacktestResult { + pub positions: Vec, + pub fill_prices: Vec, + pub bar_returns: Vec, + pub strategy_returns: Vec, + pub equity: Vec, +} + +/// A single completed trade record. +#[derive(Clone, Debug)] +pub struct TradeRecord { + pub entry_bar: i64, + pub exit_bar: i64, + pub direction: f64, + pub entry_price: f64, + pub exit_price: f64, + pub pnl_pct: f64, + pub duration_bars: i64, + pub mae: f64, + pub mfe: f64, +} + +/// Comprehensive performance metrics. +#[derive(Clone, Debug)] +pub struct BacktestMetrics { + pub total_return: f64, + pub cagr: f64, + pub annualized_vol: f64, + pub sharpe: f64, + pub sortino: f64, + pub calmar: f64, + pub max_drawdown: f64, + pub avg_drawdown: f64, + pub max_drawdown_duration_bars: usize, + pub avg_drawdown_duration_bars: f64, + pub ulcer_index: f64, + pub omega_ratio: f64, + pub win_rate: f64, + pub profit_factor: f64, + pub r_expectancy: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub tail_ratio: f64, + pub skewness: f64, + pub kurtosis: f64, + pub best_bar: f64, + pub worst_bar: f64, + pub n_trades: usize, + pub n_position_changes: usize, + // Optional benchmark metrics + pub benchmark_total_return: Option, + pub benchmark_cagr: Option, + pub benchmark_annualized_vol: Option, + pub benchmark_sharpe: Option, + pub alpha: Option, + pub beta: Option, + pub tracking_error: Option, + pub information_ratio: Option, +} + +/// Mutable state bundle for the OHLCV backtest loop. +pub struct OhlcvState { + pub current_pos: f64, + pub entry_price: f64, + pub trail_high: f64, + pub trail_low: f64, + pub breakeven_activated: bool, + pub breakeven_stop: f64, + pub bars_in_trade: usize, + pub margin_entry_price: f64, + pub initial_margin_required: f64, +} + +impl OhlcvState { + pub fn new() -> Self { + Self { + current_pos: 0.0, + entry_price: f64::NAN, + trail_high: f64::NAN, + trail_low: f64::NAN, + breakeven_activated: false, + breakeven_stop: f64::NAN, + bars_in_trade: 0, + margin_entry_price: f64::NAN, + initial_margin_required: 0.0, + } + } + + #[inline] + pub fn close_position(&mut self) { + self.current_pos = 0.0; + self.entry_price = f64::NAN; + self.trail_high = f64::NAN; + self.trail_low = f64::NAN; + self.breakeven_activated = false; + self.breakeven_stop = f64::NAN; + self.bars_in_trade = 0; + self.margin_entry_price = f64::NAN; + self.initial_margin_required = 0.0; + } +} + +impl Default for OhlcvState { + fn default() -> Self { + Self::new() + } +} + +/// Stateful streaming backtester — feed one bar at a time. +#[derive(Clone, Debug)] +pub struct StreamingBacktest { + pub commission_per_trade: f64, + pub slippage_bps: f64, + pub position: f64, + pub entry_price: f64, + pub equity: f64, + pub prev_close: f64, + pub total_commission: f64, + pub n_trades: usize, + pub sum_wins: f64, + pub n_wins: usize, + pub sum_losses: f64, + pub n_losses: usize, +} + +/// Result of a single `StreamingBacktest::on_bar` call. +#[derive(Clone, Debug)] +pub struct StreamingBarResult { + pub position: f64, + pub bar_return: f64, + pub equity: f64, + pub n_trades: usize, +} + +/// Summary statistics from StreamingBacktest. +#[derive(Clone, Debug)] +pub struct StreamingSummary { + pub equity: f64, + pub n_trades: usize, + pub total_commission: f64, + pub win_rate: f64, + pub avg_win: f64, + pub avg_loss: f64, + pub kelly_fraction: f64, +} + +// --------------------------------------------------------------------------- +// Signal generators +// --------------------------------------------------------------------------- + +/// RSI threshold strategy: +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise. +pub fn rsi_threshold_signals( + close: &[f64], + timeperiod: usize, + oversold: f64, + overbought: f64, +) -> Vec { + let rsi = crate::momentum::rsi(close, timeperiod); + rsi.iter() + .map(|&v| { + if v.is_nan() { + f64::NAN + } else if v <= oversold { + 1.0 + } else if v >= overbought { + -1.0 + } else { + 0.0 + } + }) + .collect() +} + +/// SMA crossover strategy: +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN. +/// +/// Returns `Err` if `fast >= slow`. +pub fn sma_crossover_signals(close: &[f64], fast: usize, slow: usize) -> Result, String> { + if fast >= slow { + return Err(format!("fast ({fast}) must be less than slow ({slow})")); + } + let sma_fast = crate::overlap::sma(close, fast); + let sma_slow = crate::overlap::sma(close, slow); + Ok(sma_fast + .iter() + .zip(sma_slow.iter()) + .map(|(&f, &s)| { + if f.is_nan() || s.is_nan() { + f64::NAN + } else if f > s { + 1.0 + } else { + -1.0 + } + }) + .collect()) +} + +/// MACD crossover strategy: +1 when MACD line > signal line, -1 otherwise. +/// +/// Returns `Err` if `fastperiod >= slowperiod`. +pub fn macd_crossover_signals( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Result, String> { + if fastperiod >= slowperiod { + return Err(format!( + "fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})" + )); + } + let (macd_line, signal_line, _) = + crate::overlap::macd(close, fastperiod, slowperiod, signalperiod); + Ok(macd_line + .iter() + .zip(signal_line.iter()) + .map(|(&m, &s)| { + if m.is_nan() || s.is_nan() { + f64::NAN + } else if m > s { + 1.0 + } else { + -1.0 + } + }) + .collect()) +} + +// --------------------------------------------------------------------------- +// Core backtest (close-only) +// --------------------------------------------------------------------------- + +/// Backtest result from the simple close-only engine. +#[derive(Clone, Debug)] +pub struct BacktestCoreResult { + pub positions: Vec, + pub bar_returns: Vec, + pub strategy_returns: Vec, + pub equity: Vec, +} + +/// Backtest core loop over close prices and strategy signals. +/// +/// Uses the full `CommissionModel` if provided, otherwise falls back to +/// `commission_per_trade` as a flat per-order fee. +pub fn backtest_core( + close: &[f64], + signals: &[f64], + commission: Option<&CommissionModel>, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, +) -> Result { + let n = close.len(); + if n != signals.len() { + return Err(format!( + "close length ({}) != signals length ({})", + n, + signals.len() + )); + } + + let mut positions = vec![0.0_f64; n]; + if n > 1 { + for i in 1..n { + positions[i] = nan_to_num(signals[i - 1]); + } + } + + let mut bar_returns = vec![0.0_f64; n]; + for i in 1..n { + bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1]; + } + + let mut strategy_returns = vec![0.0_f64; n]; + for i in 0..n { + strategy_returns[i] = positions[i] * bar_returns[i]; + } + + let mut position_changed = vec![false; n]; + for i in 1..n { + position_changed[i] = (positions[i] - positions[i - 1]).abs() > 1e-12; + } + + if slippage_bps > 0.0 { + let slip = slippage_bps / 10_000.0; + for i in 0..n { + if position_changed[i] { + strategy_returns[i] -= slip; + } + } + } + + let cm = resolve_commission_model(commission, commission_per_trade); + + let mut equity = vec![1.0_f64; n]; + let mut cum = 1.0_f64; + for i in 0..n { + cum *= 1.0 + strategy_returns[i]; + if position_changed[i] { + let prev_pos = if i > 0 { positions[i - 1] } else { 0.0 }; + let cost = commission_fraction( + &cm, + if close[i] != 0.0 { close[i] } else { 1.0 }, + (positions[i] - prev_pos).abs(), + positions[i] > prev_pos, + initial_capital, + ); + cum -= cost; + } + equity[i] = cum; + } + + Ok(BacktestCoreResult { + positions, + bar_returns, + strategy_returns, + equity, + }) +} + +/// Core single-asset loop reused by multi-asset backtest. +pub fn single_asset_backtest( + close: &[f64], + signals: &[f64], + commission_per_trade: f64, + slippage_bps: f64, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let slip = slippage_bps / 10_000.0; + + let mut positions = vec![0.0_f64; n]; + for i in 1..n { + positions[i] = nan_to_num(signals[i - 1]); + } + + let mut bar_returns = vec![0.0_f64; n]; + for i in 1..n { + if close[i - 1] != 0.0 { + bar_returns[i] = (close[i] - close[i - 1]) / close[i - 1]; + } + } + + let mut strategy_returns = vec![0.0_f64; n]; + for i in 0..n { + strategy_returns[i] = positions[i] * bar_returns[i]; + } + + let mut position_changed = vec![false; n]; + for i in 1..n { + position_changed[i] = (positions[i] - positions[i - 1]).abs() > 1e-12; + } + + if slip > 0.0 { + for i in 0..n { + if position_changed[i] { + strategy_returns[i] -= slip; + } + } + } + + let mut equity = vec![1.0_f64; n]; + if commission_per_trade <= 0.0 { + let mut g = 1.0_f64; + for i in 0..n { + g *= 1.0 + strategy_returns[i]; + equity[i] = g; + } + } else { + let mut gross = vec![1.0_f64; n]; + let mut g = 1.0_f64; + for i in 0..n { + g *= 1.0 + strategy_returns[i]; + gross[i] = g; + } + let has_zero = gross.contains(&0.0); + if has_zero { + equity[0] = 1.0; + for i in 1..n { + equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]); + if position_changed[i] { + equity[i] -= commission_per_trade; + } + } + } else { + let mut disc = 0.0_f64; + for i in 0..n { + if position_changed[i] { + disc += commission_per_trade / gross[i]; + } + equity[i] = gross[i] * (1.0 - disc); + } + } + } + + (positions, strategy_returns, equity) +} + +// --------------------------------------------------------------------------- +// OHLCV-aware backtest engine +// --------------------------------------------------------------------------- + +/// Full OHLCV backtest with stop loss, take profit, trailing stops, breakeven, +/// margin calls, circuit breakers, limit orders, and short borrow costs. +pub fn backtest_ohlcv_core( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + signals: &[f64], + config: &BacktestConfig, + limit_prices: Option<&[f64]>, +) -> Result { + let n = close.len(); + if n < 2 { + return Err("arrays must have at least 2 elements".to_string()); + } + if open.len() != n || high.len() != n || low.len() != n || signals.len() != n { + return Err(format!( + "all arrays must have equal length (close={}), got open={}, high={}, low={}, signals={}", + n, + open.len(), + high.len(), + low.len(), + signals.len() + )); + } + + let use_open_fill = config.fill_mode != "market_close"; + let cm = resolve_commission_model(config.commission.as_ref(), config.commission_per_trade); + + let stop_loss_pct = config.stop_loss_pct; + let take_profit_pct = config.take_profit_pct; + let trailing_stop_pct = config.trailing_stop_pct; + let slippage_bps = config.slippage_bps; + let initial_capital = config.initial_capital; + let max_hold_bars = config.max_hold_bars; + let slippage_pct_range = config.slippage_pct_range; + let breakeven_pct = config.breakeven_pct; + let periods_per_year = config.periods_per_year; + let margin_ratio = config.margin_ratio; + let margin_call_pct = config.margin_call_pct; + let daily_loss_limit = config.daily_loss_limit; + let total_loss_limit = config.total_loss_limit; + + let mut positions = vec![0.0_f64; n]; + let mut fill_prices = vec![f64::NAN; n]; + let mut bar_returns = vec![0.0_f64; n]; + let mut strategy_returns = vec![0.0_f64; n]; + + let mut st = OhlcvState::new(); + let default_slip = slippage_bps / 10_000.0; + + let mut circuit_broken: bool = false; + let mut running_equity: f64 = 1.0; + + for i in 1..n { + let pos_start = st.current_pos; + let desired_pos = nan_to_num(signals[i - 1]); + + // --- Margin call check (at bar open) --- + if margin_ratio > 0.0 + && st.current_pos != 0.0 + && !st.margin_entry_price.is_nan() + && st.initial_margin_required > 0.0 + { + let position_pnl = + st.current_pos * (open[i] - st.margin_entry_price) / st.margin_entry_price; + let margin_equity = st.initial_margin_required + position_pnl; + if margin_equity <= margin_call_pct * st.initial_margin_required { + let mc_fill = open[i]; + let mc_ret = if close[i - 1] != 0.0 { + st.current_pos * (mc_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + mc_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + strategy_returns[i] = mc_ret - comm; + fill_prices[i] = mc_fill; + st.close_position(); + positions[i] = 0.0; + continue; + } + } + + let slip: f64 = if slippage_pct_range > 0.0 && close[i] > 0.0 { + slippage_pct_range * (high[i] - low[i]) / close[i] + } else { + default_slip + }; + + if trailing_stop_pct > 0.0 { + if st.current_pos > 0.0 && !st.trail_high.is_nan() { + st.trail_high = st.trail_high.max(high[i]); + } + if st.current_pos < 0.0 && !st.trail_low.is_nan() { + st.trail_low = st.trail_low.min(low[i]); + } + } + + let close_ret = if close[i - 1] != 0.0 { + (close[i] - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + bar_returns[i] = close_ret; + + let mut forced_close = false; + + // --- Circuit breaker check --- + if i > 1 { + running_equity *= 1.0 + strategy_returns[i - 1]; + } + if !circuit_broken { + if daily_loss_limit > 0.0 && i > 1 && strategy_returns[i - 1] < -daily_loss_limit { + circuit_broken = true; + } + if total_loss_limit > 0.0 && running_equity < 1.0 - total_loss_limit { + circuit_broken = true; + } + } + if circuit_broken && st.current_pos != 0.0 { + let base_fill = if use_open_fill { open[i] } else { close[i] }; + let is_buy = st.current_pos < 0.0; + let close_r = if close[i - 1] != 0.0 { + st.current_pos * (base_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + base_fill, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = close_r - comm; + fill_prices[i] = base_fill; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + if circuit_broken { + positions[i] = 0.0; + continue; + } + + // ---- Intrabar trailing stop check ---- + if trailing_stop_pct > 0.0 && st.current_pos != 0.0 && !st.entry_price.is_nan() { + if st.current_pos > 0.0 && !st.trail_high.is_nan() { + let trail_stop = st.trail_high * (1.0 - trailing_stop_pct); + if low[i] <= trail_stop { + let stop_ret = if close[i - 1] != 0.0 { + (trail_stop - close[i - 1]) / close[i - 1] + } else { + -trailing_stop_pct + }; + let comm = commission_fraction( + &cm, + trail_stop, + st.current_pos.abs(), + false, + initial_capital, + ); + strategy_returns[i] = st.current_pos * stop_ret - slip - comm; + fill_prices[i] = trail_stop; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } else if st.current_pos < 0.0 && !st.trail_low.is_nan() { + let trail_stop = st.trail_low * (1.0 + trailing_stop_pct); + if high[i] >= trail_stop { + let stop_ret = if close[i - 1] != 0.0 { + (trail_stop - close[i - 1]) / close[i - 1] + } else { + trailing_stop_pct + }; + let comm = commission_fraction( + &cm, + trail_stop, + st.current_pos.abs(), + true, + initial_capital, + ); + strategy_returns[i] = st.current_pos * stop_ret - slip - comm; + fill_prices[i] = trail_stop; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } + } + + // ---- Breakeven stop activation ---- + if breakeven_pct > 0.0 + && st.current_pos != 0.0 + && !st.entry_price.is_nan() + && !st.breakeven_activated + { + let condition_met = if st.current_pos > 0.0 { + high[i] >= st.entry_price * (1.0 + breakeven_pct) + } else { + low[i] <= st.entry_price * (1.0 - breakeven_pct) + }; + if condition_met { + st.breakeven_activated = true; + st.breakeven_stop = st.entry_price; + } + } + + // ---- Intrabar SL/TP combined bracket check ---- + { + let has_stop = st.breakeven_activated || stop_loss_pct > 0.0; + let stop_long = if st.breakeven_activated { + st.breakeven_stop + } else { + st.entry_price * (1.0 - stop_loss_pct) + }; + let stop_short = if st.breakeven_activated { + st.breakeven_stop + } else { + st.entry_price * (1.0 + stop_loss_pct) + }; + let has_tp = take_profit_pct > 0.0; + let tp_long = st.entry_price * (1.0 + take_profit_pct); + let tp_short = st.entry_price * (1.0 - take_profit_pct); + + if !forced_close && st.current_pos != 0.0 && !st.entry_price.is_nan() { + let (exit_price, did_exit) = if st.current_pos > 0.0 { + let sl_hit = has_stop && low[i] <= stop_long; + let tp_hit = has_tp && high[i] >= tp_long; + match (sl_hit, tp_hit) { + (true, true) => { + if (open[i] - stop_long).abs() < (tp_long - open[i]).abs() { + (stop_long, true) + } else { + (tp_long, true) + } + } + (true, false) => (stop_long, true), + (false, true) => (tp_long, true), + _ => (0.0, false), + } + } else { + let sl_hit = has_stop && high[i] >= stop_short; + let tp_hit = has_tp && low[i] <= tp_short; + match (sl_hit, tp_hit) { + (true, true) => { + if (stop_short - open[i]).abs() < (open[i] - tp_short).abs() { + (stop_short, true) + } else { + (tp_short, true) + } + } + (true, false) => (stop_short, true), + (false, true) => (tp_short, true), + _ => (0.0, false), + } + }; + + if did_exit { + let exit_ret = if close[i - 1] != 0.0 { + (exit_price - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let is_buy = st.current_pos < 0.0; + let comm = commission_fraction( + &cm, + exit_price, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = st.current_pos * exit_ret - slip - comm; + fill_prices[i] = exit_price; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + } + } + + // ---- Time-based exit check ---- + if !forced_close + && max_hold_bars > 0 + && st.current_pos != 0.0 + && st.bars_in_trade >= max_hold_bars + { + let base_fill = if use_open_fill { open[i] } else { close[i] }; + let is_buy = st.current_pos < 0.0; + let actual_fill = if is_buy { + base_fill * (1.0 + slip) + } else { + base_fill * (1.0 - slip) + }; + let exit_ret = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + is_buy, + initial_capital, + ); + strategy_returns[i] = exit_ret - comm; + fill_prices[i] = actual_fill; + st.close_position(); + positions[i] = 0.0; + forced_close = true; + } + + if !forced_close { + // ---- Limit order check ---- + let raw_change = (desired_pos - st.current_pos).abs() > 1e-12; + let (effective_desired_pos, limit_override_price): (f64, Option) = if raw_change { + match limit_prices { + Some(lp) => { + let lp_val = lp[i - 1]; + if lp_val.is_nan() { + (desired_pos, None) + } else { + let is_buy = desired_pos > st.current_pos; + if (is_buy && low[i] <= lp_val) || (!is_buy && high[i] >= lp_val) { + (desired_pos, Some(lp_val)) + } else { + (st.current_pos, None) + } + } + } + None => (desired_pos, None), + } + } else { + (desired_pos, None) + }; + + let pos_changed = (effective_desired_pos - st.current_pos).abs() > 1e-12; + let base_fill_raw = if use_open_fill { open[i] } else { close[i] }; + let base_fill = limit_override_price.unwrap_or(base_fill_raw); + + let actual_fill = if effective_desired_pos > st.current_pos { + base_fill * (1.0 + slip) + } else if effective_desired_pos < st.current_pos { + base_fill * (1.0 - slip) + } else { + base_fill + }; + + if pos_changed { + fill_prices[i] = actual_fill; + if effective_desired_pos != 0.0 { + st.entry_price = actual_fill; + if trailing_stop_pct > 0.0 { + if effective_desired_pos > 0.0 { + st.trail_high = actual_fill; + st.trail_low = f64::NAN; + } else { + st.trail_low = actual_fill; + st.trail_high = f64::NAN; + } + } + } else { + st.entry_price = f64::NAN; + st.trail_high = f64::NAN; + st.trail_low = f64::NAN; + st.breakeven_activated = false; + st.breakeven_stop = f64::NAN; + } + if effective_desired_pos != 0.0 + && st.current_pos != 0.0 + && (effective_desired_pos.signum() != st.current_pos.signum()) + { + st.breakeven_activated = false; + st.breakeven_stop = f64::NAN; + } + } + + strategy_returns[i] = if pos_changed && use_open_fill && actual_fill != 0.0 { + if effective_desired_pos != 0.0 && st.current_pos == 0.0 { + let r = effective_desired_pos * (close[i] - actual_fill) / actual_fill; + let comm = commission_fraction( + &cm, + actual_fill, + effective_desired_pos.abs(), + effective_desired_pos > 0.0, + initial_capital, + ); + r - comm + } else if effective_desired_pos == 0.0 { + let r = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + r - comm + } else { + let exit_r = if close[i - 1] != 0.0 { + st.current_pos * (actual_fill - close[i - 1]) / close[i - 1] + } else { + 0.0 + }; + let entry_r = effective_desired_pos * (close[i] - actual_fill) / actual_fill; + let exit_comm = commission_fraction( + &cm, + actual_fill, + st.current_pos.abs(), + st.current_pos < 0.0, + initial_capital, + ); + let entry_comm = commission_fraction( + &cm, + actual_fill, + effective_desired_pos.abs(), + effective_desired_pos > 0.0, + initial_capital, + ); + exit_r + entry_r - exit_comm - entry_comm + } + } else { + let r = st.current_pos * close_ret; + if pos_changed { + let comm = commission_fraction( + &cm, + if close[i] != 0.0 { close[i] } else { 1.0 }, + (effective_desired_pos - st.current_pos).abs(), + effective_desired_pos > st.current_pos, + initial_capital, + ); + r - comm + } else { + r + } + }; + + if pos_changed && margin_ratio > 0.0 { + if effective_desired_pos != 0.0 { + if st.current_pos == 0.0 + || (st.current_pos.signum() != effective_desired_pos.signum()) + { + st.initial_margin_required = effective_desired_pos.abs() * margin_ratio; + st.margin_entry_price = actual_fill; + } + } else { + st.initial_margin_required = 0.0; + st.margin_entry_price = f64::NAN; + } + } + + st.current_pos = effective_desired_pos; + positions[i] = st.current_pos; + } + + // --- Short borrow cost accrual --- + if st.current_pos < 0.0 && cm.short_borrow_rate_annual > 0.0 { + let fill_price_for_borrow = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + close[i] + }; + let trade_value = st.current_pos.abs() * fill_price_for_borrow * initial_capital; + let borrow_cost_fraction = + cm.short_borrow_cost(trade_value, periods_per_year) / initial_capital; + strategy_returns[i] -= borrow_cost_fraction; + } + + // Update bars_in_trade counter + if st.current_pos == 0.0 { + st.bars_in_trade = 0; + } else if pos_start == 0.0 || (pos_start.signum() != st.current_pos.signum()) { + st.bars_in_trade = 1; + } else { + st.bars_in_trade += 1; + } + } + + // Build equity curve + let mut equity = vec![1.0_f64; n]; + let mut cum = 1.0_f64; + for i in 0..n { + cum *= 1.0 + strategy_returns[i]; + equity[i] = cum; + } + + Ok(OhlcvBacktestResult { + positions, + fill_prices, + bar_returns, + strategy_returns, + equity, + }) +} + +// --------------------------------------------------------------------------- +// Performance metrics +// --------------------------------------------------------------------------- + +/// Compute all industry-standard performance metrics from strategy returns and equity. +pub fn compute_performance_metrics( + strategy_returns: &[f64], + equity: &[f64], + periods_per_year: f64, + risk_free_rate: f64, + benchmark_returns: Option<&[f64]>, +) -> Result { + let r = strategy_returns; + let eq = equity; + let n = r.len(); + + if n < 2 { + return Err("strategy_returns must have at least 2 elements".to_string()); + } + if eq.len() != n { + return Err("equity and strategy_returns must have equal length".to_string()); + } + + // --- Pass 1: drawdown / equity stats --- + let mut peak = eq[0]; + let mut max_dd = 0.0_f64; + let mut dd_sum = 0.0_f64; + let mut dd_count = 0_usize; + let mut ulcer_sum = 0.0_f64; + let mut current_dd_len = 0_usize; + let mut max_dd_len = 0_usize; + let mut dd_len_sum = 0_usize; + let mut dd_len_count = 0_usize; + + for &eq_val in eq.iter().take(n) { + if eq_val > peak { + if current_dd_len > 0 { + dd_len_sum += current_dd_len; + dd_len_count += 1; + current_dd_len = 0; + } + peak = eq_val; + } + let dd = if peak != 0.0 { + (eq_val - peak) / peak + } else { + 0.0 + }; + if dd < 0.0 { + dd_sum += dd; + dd_count += 1; + ulcer_sum += dd * dd; + current_dd_len += 1; + if dd < max_dd { + max_dd = dd; + } + if current_dd_len > max_dd_len { + max_dd_len = current_dd_len; + } + } + } + if current_dd_len > 0 { + dd_len_sum += current_dd_len; + dd_len_count += 1; + } + + let avg_dd = if dd_count > 0 { + dd_sum / dd_count as f64 + } else { + 0.0 + }; + let ulcer_index = (ulcer_sum / n as f64).sqrt(); + let avg_dd_duration = if dd_len_count > 0 { + dd_len_sum as f64 / dd_len_count as f64 + } else { + 0.0 + }; + + // --- Pass 2: statistical moments --- + let rf_per_bar = risk_free_rate / periods_per_year; + + let valid_r: Vec = r.iter().copied().filter(|v| v.is_finite()).collect(); + let n_valid = valid_r.len(); + if n_valid == 0 { + return Err("No finite values in strategy_returns".to_string()); + } + + let mean_r: f64 = valid_r.iter().sum::() / n_valid as f64; + let variance: f64 = valid_r.iter().map(|&v| (v - mean_r).powi(2)).sum::() / n_valid as f64; + let std_r = variance.sqrt(); + + let downside_sq_sum: f64 = valid_r + .iter() + .filter(|&&v| v < rf_per_bar) + .map(|&v| (v - rf_per_bar).powi(2)) + .sum(); + let downside_std = (downside_sq_sum / n_valid as f64).sqrt(); + + let skewness = if std_r > 0.0 { + valid_r + .iter() + .map(|&v| ((v - mean_r) / std_r).powi(3)) + .sum::() + / n_valid as f64 + } else { + 0.0 + }; + let kurtosis = if std_r > 0.0 { + valid_r + .iter() + .map(|&v| ((v - mean_r) / std_r).powi(4)) + .sum::() + / n_valid as f64 + - 3.0 + } else { + 0.0 + }; + + let total_return = if eq[0] != 0.0 { + eq[n - 1] / eq[0] - 1.0 + } else { + 0.0 + }; + let cagr = if eq[0] != 0.0 && eq[n - 1] > 0.0 { + (eq[n - 1] / eq[0]).powf(periods_per_year / n as f64) - 1.0 + } else { + 0.0 + }; + let annual_vol = std_r * periods_per_year.sqrt(); + let sharpe = if annual_vol > 0.0 { + (cagr - risk_free_rate) / annual_vol + } else { + 0.0 + }; + let sortino = if downside_std > 0.0 { + (cagr - risk_free_rate) / (downside_std * periods_per_year.sqrt()) + } else { + 0.0 + }; + let calmar = if max_dd < 0.0 { + cagr / max_dd.abs() + } else { + 0.0 + }; + + // Win / loss analysis — single pass with running counters + let mut n_active = 0_usize; + let mut n_wins = 0_usize; + let mut n_losses = 0_usize; + let mut win_sum = 0.0_f64; + let mut loss_sum = 0.0_f64; + for &v in &valid_r { + if v != 0.0 { + n_active += 1; + if v > 0.0 { + n_wins += 1; + win_sum += v; + } else { + n_losses += 1; + loss_sum += v.abs(); + } + } + } + let win_rate = if n_active > 0 { + n_wins as f64 / n_active as f64 + } else { + 0.0 + }; + let avg_win = if n_wins > 0 { + win_sum / n_wins as f64 + } else { + 0.0 + }; + let avg_loss = if n_losses > 0 { + -(loss_sum / n_losses as f64) + } else { + 0.0 + }; + let profit_factor = if loss_sum > 0.0 { + win_sum / loss_sum + } else { + f64::INFINITY + }; + let loss_rate = 1.0 - win_rate; + let r_expectancy = win_rate * avg_win - loss_rate * avg_loss.abs(); + + // Omega ratio + let omega_numer: f64 = valid_r + .iter() + .filter(|&&v| v > rf_per_bar) + .map(|&v| v - rf_per_bar) + .sum(); + let omega_denom: f64 = valid_r + .iter() + .filter(|&&v| v <= rf_per_bar) + .map(|&v| rf_per_bar - v) + .sum(); + let omega_ratio = if omega_denom > 0.0 { + omega_numer / omega_denom + } else { + f64::INFINITY + }; + + // Tail ratio — use select_nth_unstable for O(n) percentile lookup + let mut pct_r = valid_r.clone(); + let idx_5 = ((n_valid as f64 * 0.05) as usize).min(n_valid.saturating_sub(1)); + let idx_95 = ((n_valid as f64 * 0.95) as usize).min(n_valid.saturating_sub(1)); + // Find 5th percentile (also partitions so all elements below idx_5 are <=) + pct_r.select_nth_unstable_by(idx_5, |a, b| { + a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) + }); + let p5 = pct_r[idx_5]; + let worst_bar = pct_r[..=idx_5] + .iter() + .copied() + .fold(f64::INFINITY, f64::min); + // Find 95th percentile in the remaining upper partition + pct_r[idx_5..].select_nth_unstable_by(idx_95 - idx_5, |a, b| { + a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal) + }); + let p95 = pct_r[idx_95]; + let best_bar = pct_r[idx_95..] + .iter() + .copied() + .fold(f64::NEG_INFINITY, f64::max); + let tail_ratio = if p5.abs() > 0.0 { + p95.abs() / p5.abs() + } else { + f64::INFINITY + }; + + // Position changes + let mut n_pos_changes = 0_usize; + for i in 1..n { + let prev_active = r[i - 1].is_finite() && r[i - 1] != 0.0; + let cur_active = r[i].is_finite() && r[i] != 0.0; + if prev_active != cur_active { + n_pos_changes += 1; + } + } + + // Benchmark metrics + let ( + benchmark_total_return, + benchmark_cagr, + benchmark_annualized_vol, + benchmark_sharpe, + alpha, + beta, + tracking_error, + information_ratio, + ) = if let Some(br) = benchmark_returns { + if br.len() == n { + let mut s_sum = 0.0_f64; + let mut b_sum = 0.0_f64; + let mut nb = 0_usize; + for i in 0..n { + if r[i].is_finite() && br[i].is_finite() { + s_sum += r[i]; + b_sum += br[i]; + nb += 1; + } + } + if nb > 1 { + let s_mean = s_sum / nb as f64; + let b_mean = b_sum / nb as f64; + + let mut b_var_sum = 0.0_f64; + let mut cov_sum = 0.0_f64; + let mut ex_sum = 0.0_f64; + let mut ex_sq_sum = 0.0_f64; + for i in 0..n { + if r[i].is_finite() && br[i].is_finite() { + let sd = r[i] - s_mean; + let bd = br[i] - b_mean; + b_var_sum += bd * bd; + cov_sum += sd * bd; + let ex = r[i] - br[i]; + ex_sum += ex; + ex_sq_sum += ex * ex; + } + } + let b_var = b_var_sum / nb as f64; + let b_std = b_var.sqrt(); + + let mut b_eq = 1.0_f64; + for &ret in br { + b_eq *= 1.0 + if ret.is_finite() { ret } else { 0.0 }; + } + let bench_total_return = b_eq - 1.0; + let bench_cagr = if b_eq > 0.0 { + b_eq.powf(periods_per_year / n as f64) - 1.0 + } else { + 0.0 + }; + let bench_ann_vol = b_std * periods_per_year.sqrt(); + let bench_sharpe = if bench_ann_vol > 0.0 { + (bench_cagr - risk_free_rate) / bench_ann_vol + } else { + 0.0 + }; + + let cov_val = cov_sum / nb as f64; + let beta_val = if b_var > 0.0 { cov_val / b_var } else { 0.0 }; + let alpha_val = cagr - bench_cagr; + + let ex_mean = ex_sum / nb as f64; + let ex_var = ex_sq_sum / nb as f64 - ex_mean * ex_mean; + let te = ex_var.max(0.0).sqrt() * periods_per_year.sqrt(); + let ir = if te > 0.0 { alpha_val / te } else { 0.0 }; + + ( + Some(bench_total_return), + Some(bench_cagr), + Some(bench_ann_vol), + Some(bench_sharpe), + Some(alpha_val), + Some(beta_val), + Some(te), + Some(ir), + ) + } else { + (None, None, None, None, None, None, None, None) + } + } else { + (None, None, None, None, None, None, None, None) + } + } else { + (None, None, None, None, None, None, None, None) + }; + + Ok(BacktestMetrics { + total_return, + cagr, + annualized_vol: annual_vol, + sharpe, + sortino, + calmar, + max_drawdown: max_dd, + avg_drawdown: avg_dd, + max_drawdown_duration_bars: max_dd_len, + avg_drawdown_duration_bars: avg_dd_duration, + ulcer_index, + omega_ratio, + win_rate, + profit_factor, + r_expectancy, + avg_win, + avg_loss, + tail_ratio, + skewness, + kurtosis, + best_bar, + worst_bar, + n_trades: n_active, + n_position_changes: n_pos_changes, + benchmark_total_return, + benchmark_cagr, + benchmark_annualized_vol, + benchmark_sharpe, + alpha, + beta, + tracking_error, + information_ratio, + }) +} + +// --------------------------------------------------------------------------- +// Trade extraction +// --------------------------------------------------------------------------- + +/// Extract trade records from positions and price arrays. +pub fn extract_trades_ohlcv( + positions: &[f64], + fill_prices: &[f64], + high: &[f64], + low: &[f64], +) -> Result, String> { + let n = positions.len(); + if fill_prices.len() != n || high.len() != n || low.len() != n { + return Err(format!( + "all arrays must have equal length (positions={}), got fill_prices={}, high={}, low={}", + n, + fill_prices.len(), + high.len(), + low.len() + )); + } + + let mut trades: Vec = Vec::new(); + + let mut in_trade = false; + let mut trade_entry_bar = 0_i64; + let mut trade_dir = 0.0_f64; + let mut trade_entry_price = 0.0_f64; + let mut trade_mae = 0.0_f64; + let mut trade_mfe = 0.0_f64; + + for i in 0..n { + let cur_pos = positions[i]; + + if !in_trade { + if cur_pos != 0.0 { + in_trade = true; + trade_entry_bar = i as i64; + trade_dir = cur_pos.signum(); + trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + high[i] + }; + trade_mae = 0.0; + trade_mfe = 0.0; + } + } else { + if trade_entry_price > 0.0 { + let unreal_high = trade_dir * (high[i] - trade_entry_price) / trade_entry_price; + let unreal_low = trade_dir * (low[i] - trade_entry_price) / trade_entry_price; + let bar_best = unreal_high.max(unreal_low); + let bar_worst = unreal_high.min(unreal_low); + if bar_best > trade_mfe { + trade_mfe = bar_best; + } + if bar_worst < trade_mae { + trade_mae = bar_worst; + } + } + + let pos_closed = cur_pos == 0.0 || cur_pos.signum() != trade_dir; + + if pos_closed { + let exit_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + low[i] + }; + let pnl = if trade_entry_price > 0.0 { + trade_dir * (exit_price - trade_entry_price) / trade_entry_price + } else { + 0.0 + }; + + trades.push(TradeRecord { + entry_bar: trade_entry_bar, + exit_bar: i as i64, + direction: trade_dir, + entry_price: trade_entry_price, + exit_price, + pnl_pct: pnl, + duration_bars: i as i64 - trade_entry_bar, + mae: trade_mae, + mfe: trade_mfe, + }); + + if cur_pos != 0.0 { + in_trade = true; + trade_entry_bar = i as i64; + trade_dir = cur_pos.signum(); + trade_entry_price = if fill_prices[i].is_finite() && fill_prices[i] > 0.0 { + fill_prices[i] + } else { + high[i] + }; + trade_mae = 0.0; + trade_mfe = 0.0; + } else { + in_trade = false; + } + } + } + } + + // Close any open trade at last bar + if in_trade { + let last = n - 1; + let exit_price = if fill_prices[last].is_finite() && fill_prices[last] > 0.0 { + fill_prices[last] + } else { + high[last] + }; + let pnl = if trade_entry_price > 0.0 { + trade_dir * (exit_price - trade_entry_price) / trade_entry_price + } else { + 0.0 + }; + trades.push(TradeRecord { + entry_bar: trade_entry_bar, + exit_bar: last as i64, + direction: trade_dir, + entry_price: trade_entry_price, + exit_price, + pnl_pct: pnl, + duration_bars: last as i64 - trade_entry_bar, + mae: trade_mae, + mfe: trade_mfe, + }); + } + + Ok(trades) +} + +// --------------------------------------------------------------------------- +// Multi-asset backtest +// --------------------------------------------------------------------------- + +/// Multi-asset result: per-asset strategy returns (n_bars x n_assets), portfolio returns, portfolio equity. +#[derive(Clone, Debug)] +pub struct MultiAssetBacktestResult { + /// Shape: (n_assets, n_bars) — row-major per asset. + pub asset_returns: Vec>, + pub portfolio_returns: Vec, + pub portfolio_equity: Vec, +} + +/// Backtest N assets, then combine into a portfolio. +/// +/// `close_2d`: row-major (n_assets, n_bars) +/// `weights_2d`: row-major (n_assets, n_bars) +/// +/// Callers must transpose from (n_bars, n_assets) if needed. +#[allow(clippy::too_many_arguments)] +pub fn backtest_multi_asset_core( + close_2d: &[Vec], + weights_2d: &[Vec], + n_bars: usize, + n_assets: usize, + commission_per_trade: f64, + slippage_bps: f64, + max_asset_weight: f64, + max_gross_exposure: f64, + max_net_exposure: f64, +) -> Result { + if n_bars < 2 { + return Err("n_bars must be at least 2".to_string()); + } + if close_2d.len() != n_assets || weights_2d.len() != n_assets { + return Err("close_2d and weights_2d must have n_assets rows".to_string()); + } + + // Apply portfolio constraints per bar + let mut constrained: Vec> = weights_2d.to_vec(); + #[allow(clippy::needless_range_loop)] + if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 { + for i in 0..n_bars { + // 1. Clamp per-asset weight + if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 { + for j in 0..n_assets { + let w = constrained[j][i]; + if w.abs() > max_asset_weight { + constrained[j][i] = w.signum() * max_asset_weight; + } + } + } + // 2. Normalize so sum(abs) <= max_gross_exposure + if max_gross_exposure > 0.0 { + let gross: f64 = (0..n_assets).map(|j| constrained[j][i].abs()).sum(); + if gross > max_gross_exposure { + let scale = max_gross_exposure / gross; + for j in 0..n_assets { + constrained[j][i] *= scale; + } + } + } + // 3. Clamp net exposure + if max_net_exposure > 0.0 { + let net: f64 = (0..n_assets).map(|j| constrained[j][i]).sum(); + if net.abs() > max_net_exposure { + let excess = net - net.signum() * max_net_exposure; + let adj_per_asset = excess / n_assets as f64; + for j in 0..n_assets { + constrained[j][i] -= adj_per_asset; + } + } + } + } + } + + // Per-asset backtests + let asset_strategy_returns: Vec> = (0..n_assets) + .map(|j| { + let (_, strat_rets, _) = single_asset_backtest( + &close_2d[j], + &constrained[j], + commission_per_trade, + slippage_bps, + ); + strat_rets + }) + .collect(); + + // Portfolio return = sum of per-asset strategy returns + let mut portfolio_returns = vec![0.0_f64; n_bars]; + #[allow(clippy::needless_range_loop)] + for i in 0..n_bars { + let mut s = 0.0_f64; + for j in 0..n_assets { + s += asset_strategy_returns[j][i]; + } + portfolio_returns[i] = s; + } + + // Portfolio equity + let mut portfolio_equity = vec![1.0_f64; n_bars]; + let mut cum = 1.0_f64; + for i in 0..n_bars { + cum *= 1.0 + portfolio_returns[i]; + portfolio_equity[i] = cum; + } + + Ok(MultiAssetBacktestResult { + asset_returns: asset_strategy_returns, + portfolio_returns, + portfolio_equity, + }) +} + +// --------------------------------------------------------------------------- +// Monte Carlo bootstrap +// --------------------------------------------------------------------------- + +/// Bootstrap Monte Carlo simulation over strategy returns. +/// +/// Returns `n_sims` equity curves, each of length `n_bars`. +pub fn monte_carlo_bootstrap( + strategy_returns: &[f64], + n_sims: usize, + seed: u64, + block_size: usize, +) -> Result>, String> { + let n = strategy_returns.len(); + if n < 2 { + return Err("strategy_returns must have at least 2 elements".to_string()); + } + if n_sims == 0 { + return Err("n_sims must be >= 1".to_string()); + } + let bsize = block_size.max(1).min(n); + + let mut result: Vec> = Vec::with_capacity(n_sims); + + for sim_idx in 0..n_sims { + let mut state = seed + .wrapping_mul(6_364_136_223_846_793_005_u64) + .wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64)); + lcg_next(&mut state); + lcg_next(&mut state); + + let mut row = vec![0.0_f64; n]; + + if bsize == 1 { + for dst in row.iter_mut() { + *dst = strategy_returns[lcg_index(&mut state, n)]; + } + } else { + let mut filled = 0_usize; + while filled < n { + let start = lcg_index(&mut state, n); + let take = bsize.min(n - filled); + for k in 0..take { + row[filled + k] = strategy_returns[(start + k) % n]; + } + filled += take; + } + } + + // Convert to equity curve in-place + let mut cum = 1.0_f64; + for elem in row.iter_mut() { + cum *= 1.0 + *elem; + *elem = cum; + } + + result.push(row); + } + + Ok(result) +} + +// --------------------------------------------------------------------------- +// Walk-forward indices +// --------------------------------------------------------------------------- + +/// Generate train/test fold index boundaries for walk-forward analysis. +/// +/// Returns a vector of (train_start, train_end, test_start, test_end) tuples. +pub fn walk_forward_indices( + n_bars: usize, + train_bars: usize, + test_bars: usize, + anchored: bool, + step_bars: usize, +) -> Result, String> { + if train_bars == 0 { + return Err("train_bars must be >= 1".to_string()); + } + if test_bars == 0 { + return Err("test_bars must be >= 1".to_string()); + } + if train_bars + test_bars > n_bars { + return Err("train_bars + test_bars must be <= n_bars".to_string()); + } + + let step = if step_bars == 0 { test_bars } else { step_bars }; + let mut folds: Vec<[i64; 4]> = Vec::new(); + + let mut offset = 0_usize; + loop { + let train_start = if anchored { 0 } else { offset }; + let train_end = offset + train_bars; + let test_start = train_end; + let test_end = test_start + test_bars; + + if test_end > n_bars { + break; + } + + folds.push([ + train_start as i64, + train_end as i64, + test_start as i64, + test_end as i64, + ]); + + offset += step; + } + + if folds.is_empty() { + return Err( + "No complete folds fit within n_bars with the given train/test sizes".to_string(), + ); + } + + Ok(folds) +} + +// --------------------------------------------------------------------------- +// Kelly criterion +// --------------------------------------------------------------------------- + +/// Compute the Kelly fraction: f = win_rate - (1 - win_rate) * (|avg_loss| / avg_win), clamped to [0, 1]. +pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result { + if !(0.0..=1.0).contains(&win_rate) { + return Err("win_rate must be in [0, 1]".to_string()); + } + if avg_win <= 0.0 { + return Err("avg_win must be > 0".to_string()); + } + Ok(kelly_formula(win_rate, avg_win, avg_loss)) +} + +/// Half-Kelly fraction (conservative position sizing). +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> Result { + Ok(kelly_fraction(win_rate, avg_win, avg_loss)? / 2.0) +} + +// --------------------------------------------------------------------------- +// StreamingBacktest +// --------------------------------------------------------------------------- + +impl StreamingBacktest { + pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self { + StreamingBacktest { + commission_per_trade, + slippage_bps, + position: 0.0, + entry_price: f64::NAN, + equity: 1.0, + prev_close: f64::NAN, + total_commission: 0.0, + n_trades: 0, + sum_wins: 0.0, + n_wins: 0, + sum_losses: 0.0, + n_losses: 0, + } + } + + /// Process one bar. Returns position, bar_return, equity, n_trades. + pub fn on_bar(&mut self, close: f64, signal: f64) -> StreamingBarResult { + let slip = self.slippage_bps / 10_000.0; + let mut bar_return = 0.0_f64; + + if self.position != 0.0 && !self.prev_close.is_nan() { + let price_ret = (close - self.prev_close) / self.prev_close; + bar_return = self.position * price_ret; + self.equity *= 1.0 + bar_return; + } + + let new_pos = if signal.is_nan() { 0.0 } else { signal }; + if (new_pos - self.position).abs() > 1e-12 { + let direction = if new_pos > self.position { 1.0 } else { -1.0 }; + let slippage_cost = direction * slip; + self.equity *= 1.0 - slippage_cost.abs(); + self.equity -= self.commission_per_trade; + self.total_commission += self.commission_per_trade; + + if self.position != 0.0 && !self.entry_price.is_nan() { + let trade_ret = self.position * (close - self.entry_price) / self.entry_price; + if trade_ret >= 0.0 { + self.sum_wins += trade_ret; + self.n_wins += 1; + } else { + self.sum_losses += trade_ret.abs(); + self.n_losses += 1; + } + self.n_trades += 1; + } + + self.position = new_pos; + self.entry_price = if new_pos != 0.0 { close } else { f64::NAN }; + } + + self.prev_close = close; + + StreamingBarResult { + position: self.position, + bar_return, + equity: self.equity, + n_trades: self.n_trades, + } + } + + /// Summary statistics. + pub fn summary(&self) -> StreamingSummary { + let win_rate = if self.n_trades > 0 { + self.n_wins as f64 / self.n_trades as f64 + } else { + 0.0 + }; + let avg_win = if self.n_wins > 0 { + self.sum_wins / self.n_wins as f64 + } else { + 0.0 + }; + let avg_loss = if self.n_losses > 0 { + self.sum_losses / self.n_losses as f64 + } else { + 0.0 + }; + let kf = kelly_formula(win_rate, avg_win, avg_loss); + + StreamingSummary { + equity: self.equity, + n_trades: self.n_trades, + total_commission: self.total_commission, + win_rate, + avg_win, + avg_loss, + kelly_fraction: kf, + } + } + + /// Reset all state. + pub fn reset(&mut self) { + self.position = 0.0; + self.entry_price = f64::NAN; + self.equity = 1.0; + self.prev_close = f64::NAN; + self.total_commission = 0.0; + self.n_trades = 0; + self.sum_wins = 0.0; + self.n_wins = 0; + self.sum_losses = 0.0; + self.n_losses = 0; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nan_to_num() { + assert_eq!(nan_to_num(f64::NAN), 0.0); + assert_eq!(nan_to_num(f64::INFINITY), f64::MAX); + assert_eq!(nan_to_num(f64::NEG_INFINITY), -f64::MAX); + assert_eq!(nan_to_num(42.0), 42.0); + } + + #[test] + fn test_kelly_formula_basic() { + let f = kelly_formula(0.6, 1.0, 0.5); + assert!((f - 0.4).abs() < 1e-10); + } + + #[test] + fn test_kelly_fraction_validation() { + assert!(kelly_fraction(1.5, 1.0, 0.5).is_err()); + assert!(kelly_fraction(0.5, -1.0, 0.5).is_err()); + assert!(kelly_fraction(0.6, 1.0, 0.5).is_ok()); + } + + #[test] + fn test_half_kelly() { + let full = kelly_fraction(0.6, 1.0, 0.5).unwrap(); + let half = half_kelly_fraction(0.6, 1.0, 0.5).unwrap(); + assert!((half - full / 2.0).abs() < 1e-12); + } + + #[test] + fn test_backtest_core_flat_signal() { + let close = vec![100.0, 101.0, 102.0, 103.0, 104.0]; + let signals = vec![0.0, 0.0, 0.0, 0.0, 0.0]; + let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap(); + // With zero signals, equity should remain at 1.0 + for &e in &result.equity { + assert!((e - 1.0).abs() < 1e-10); + } + } + + #[test] + fn test_backtest_core_long_signal() { + let close = vec![100.0, 110.0, 120.0]; + let signals = vec![1.0, 1.0, 1.0]; + let result = backtest_core(&close, &signals, None, 0.0, 100_000.0, 0.0).unwrap(); + // Position is lagged: pos[0]=0, pos[1]=1, pos[2]=1 + // bar_returns[1] = 0.1, bar_returns[2] ≈ 0.0909 + // strategy_returns[1] = 1*0.1 = 0.1, strategy_returns[2] = 1*0.0909 + assert!((result.equity[2] - 1.1 * (1.0 + 10.0 / 110.0)).abs() < 1e-10); + } + + #[test] + fn test_walk_forward_indices_basic() { + let folds = walk_forward_indices(100, 50, 25, false, 0).unwrap(); + assert_eq!(folds.len(), 2); + assert_eq!(folds[0], [0, 50, 50, 75]); + assert_eq!(folds[1], [25, 75, 75, 100]); + } + + #[test] + fn test_walk_forward_anchored() { + let folds = walk_forward_indices(100, 50, 25, true, 0).unwrap(); + assert!(folds.len() >= 2); + // Anchored: train always starts at 0 + for fold in &folds { + assert_eq!(fold[0], 0); + } + } + + #[test] + fn test_monte_carlo_basic() { + let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015]; + let result = monte_carlo_bootstrap(&returns, 10, 42, 1).unwrap(); + assert_eq!(result.len(), 10); + for curve in &result { + assert_eq!(curve.len(), 5); + // Equity curves should be positive + assert!(curve.last().unwrap() > &0.0); + } + } + + #[test] + fn test_extract_trades_empty() { + let positions = vec![0.0, 0.0, 0.0]; + let fill_prices = vec![f64::NAN, f64::NAN, f64::NAN]; + let high = vec![100.0, 101.0, 102.0]; + let low = vec![99.0, 100.0, 101.0]; + let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap(); + assert!(trades.is_empty()); + } + + #[test] + fn test_extract_trades_single_roundtrip() { + let positions = vec![0.0, 1.0, 1.0, 0.0]; + let fill_prices = vec![f64::NAN, 100.0, f64::NAN, 110.0]; + let high = vec![100.0, 105.0, 115.0, 112.0]; + let low = vec![98.0, 99.0, 100.0, 108.0]; + let trades = extract_trades_ohlcv(&positions, &fill_prices, &high, &low).unwrap(); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].entry_bar, 1); + assert_eq!(trades[0].exit_bar, 3); + assert!((trades[0].entry_price - 100.0).abs() < 1e-10); + assert!((trades[0].exit_price - 110.0).abs() < 1e-10); + assert!(trades[0].pnl_pct > 0.0); + } + + #[test] + fn test_ohlcv_backtest_basic() { + let n = 10; + let open: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + let high: Vec = open.iter().map(|&v| v + 2.0).collect(); + let low: Vec = open.iter().map(|&v| v - 2.0).collect(); + let close: Vec = open.iter().map(|&v| v + 1.0).collect(); + let signals: Vec = vec![0.0, 1.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 0.0]; + + let config = BacktestConfig::default(); + let result = + backtest_ohlcv_core(&open, &high, &low, &close, &signals, &config, None).unwrap(); + assert_eq!(result.equity.len(), n); + // Equity should be positive + assert!(*result.equity.last().unwrap() > 0.0); + } + + #[test] + fn test_streaming_backtest() { + let mut engine = StreamingBacktest::new(0.0, 0.0); + let closes = vec![100.0, 105.0, 103.0, 110.0]; + let signals = vec![1.0, 1.0, -1.0, 0.0]; + + for (&c, &s) in closes.iter().zip(signals.iter()) { + let _r = engine.on_bar(c, s); + } + assert!(engine.equity > 0.0); + let summary = engine.summary(); + assert!(summary.n_trades > 0); + } + + #[test] + fn test_compute_performance_metrics_basic() { + let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015, 0.005, -0.003, 0.008]; + let mut equity = vec![1.0_f64; returns.len()]; + let mut cum = 1.0; + for (i, &r) in returns.iter().enumerate() { + cum *= 1.0 + r; + equity[i] = cum; + } + let metrics = compute_performance_metrics(&returns, &equity, 252.0, 0.0, None).unwrap(); + assert!(metrics.total_return > 0.0); + assert!(metrics.sharpe != 0.0); + assert!(metrics.n_trades > 0); + } + + #[test] + fn test_multi_asset_basic() { + let n_bars = 5; + let close1 = vec![100.0, 101.0, 102.0, 103.0, 104.0]; + let close2 = vec![200.0, 198.0, 201.0, 203.0, 205.0]; + let weights1 = vec![0.0, 0.5, 0.5, 0.5, 0.0]; + let weights2 = vec![0.0, 0.5, 0.5, 0.5, 0.0]; + + let result = backtest_multi_asset_core( + &[close1, close2], + &[weights1, weights2], + n_bars, + 2, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + ) + .unwrap(); + + assert_eq!(result.portfolio_returns.len(), n_bars); + assert_eq!(result.portfolio_equity.len(), n_bars); + assert_eq!(result.asset_returns.len(), 2); + } + + #[test] + fn test_sma_crossover_signals() { + let close: Vec = (1..=40).map(|i| i as f64).collect(); + let signals = sma_crossover_signals(&close, 5, 10).unwrap(); + assert_eq!(signals.len(), close.len()); + // First 9 bars should be NaN (slow SMA warm-up) + for i in 0..9 { + assert!(signals[i].is_nan(), "bar {} should be NaN", i); + } + } + + #[test] + fn test_sma_crossover_invalid() { + let close = vec![1.0; 20]; + assert!(sma_crossover_signals(&close, 10, 5).is_err()); + } + + #[test] + fn test_rsi_threshold_signals() { + let close: Vec = (1..=30).map(|i| 100.0 + (i as f64).sin() * 10.0).collect(); + let signals = rsi_threshold_signals(&close, 14, 30.0, 70.0); + assert_eq!(signals.len(), close.len()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/batch.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/batch.rs new file mode 100644 index 0000000..24f55c5 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/batch.rs @@ -0,0 +1,641 @@ +//! Pure-Rust batch operations — apply indicators across multiple series +//! (columns) sequentially. The PyO3 wrapper can add Rayon parallelism on top. +//! +//! Input convention: `data[j]` is column *j* (one time-series). All columns +//! must have the same length. + +use crate::{momentum, overlap, statistic, volatility}; + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +/// Validate that every column in `data` has the same length. Returns `Ok(n)` +/// where `n` is the common length, or `Err` with a message. +fn validate_columns(data: &[Vec]) -> Result { + if data.is_empty() { + return Ok(0); + } + let n = data[0].len(); + for (idx, col) in data.iter().enumerate() { + if col.len() != n { + return Err(format!( + "column 0 has length {n}, but column {idx} has length {}", + col.len() + )); + } + } + Ok(n) +} + +fn validate_hlc_columns( + high: &[Vec], + low: &[Vec], + close: &[Vec], +) -> Result<(usize, usize), String> { + let n_series = high.len(); + if low.len() != n_series || close.len() != n_series { + return Err(format!( + "high has {} columns, low has {}, close has {} — must be equal", + n_series, + low.len(), + close.len() + )); + } + if n_series == 0 { + return Ok((0, 0)); + } + let n = high[0].len(); + for (idx, (h, (l, c))) in high.iter().zip(low.iter().zip(close.iter())).enumerate() { + if h.len() != n || l.len() != n || c.len() != n { + return Err(format!( + "column {idx}: high len={}, low len={}, close len={} — must all be {n}", + h.len(), + l.len(), + c.len() + )); + } + } + Ok((n, n_series)) +} + +// --------------------------------------------------------------------------- +// rolling linear regression (self-contained so core has no PyO3 dep) +// --------------------------------------------------------------------------- + +fn linreg(window: &[f64]) -> (f64, f64) { + let n = window.len() as f64; + let sum_x: f64 = (0..window.len()).map(|i| i as f64).sum(); + let sum_y: f64 = window.iter().sum(); + let sum_xy: f64 = window.iter().enumerate().map(|(i, &y)| i as f64 * y).sum(); + let sum_x2: f64 = (0..window.len()).map(|i| (i as f64).powi(2)).sum(); + let denom = n * sum_x2 - sum_x * sum_x; + let slope = if denom != 0.0 { + (n * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / n; + (slope, intercept) +} + +fn rolling_linreg_apply(prices: &[f64], timeperiod: usize, mut map: F) -> Vec +where + F: FnMut(f64, f64) -> f64, +{ + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + + if prices.iter().any(|value| !value.is_finite()) { + for end in (timeperiod - 1)..n { + let window = &prices[(end + 1 - timeperiod)..=end]; + let (slope, intercept) = linreg(window); + result[end] = map(slope, intercept); + } + return result; + } + + let period = timeperiod as f64; + let last_x = (timeperiod - 1) as f64; + let sum_x = last_x * period / 2.0; + let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0; + let denom = period * sum_x2 - sum_x * sum_x; + + let mut sum_y = prices[..timeperiod].iter().sum::(); + let mut sum_xy = prices[..timeperiod] + .iter() + .enumerate() + .map(|(idx, &value)| idx as f64 * value) + .sum::(); + + for end in (timeperiod - 1)..n { + let slope = if denom != 0.0 { + (period * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / period; + result[end] = map(slope, intercept); + + if end + 1 < n { + let outgoing = prices[end + 1 - timeperiod]; + let incoming = prices[end + 1]; + let prev_sum_y = sum_y; + + sum_y = prev_sum_y - outgoing + incoming; + sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming; + } + } + + result +} + +// --------------------------------------------------------------------------- +// CCI / WILLR helpers (no external dep) +// --------------------------------------------------------------------------- + +fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let typical_price: Vec = high + .iter() + .zip(low.iter()) + .zip(close.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect(); + + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for end in (timeperiod - 1)..n { + let window = &typical_price[(end + 1 - timeperiod)..=end]; + let mean = window.iter().sum::() / timeperiod as f64; + let mad = window + .iter() + .map(|&value| (value - mean).abs()) + .sum::() + / timeperiod as f64; + result[end] = if mad != 0.0 { + (typical_price[end] - mean) / (0.015 * mad) + } else { + 0.0 + }; + } + result +} + +fn compute_willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + + // Use simple sliding-window max/min + for end in (timeperiod - 1)..n { + let start = end + 1 - timeperiod; + let mut highest = f64::NEG_INFINITY; + let mut lowest = f64::INFINITY; + for i in start..=end { + if high[i] > highest { + highest = high[i]; + } + if low[i] < lowest { + lowest = low[i]; + } + } + let range = highest - lowest; + result[end] = if range != 0.0 { + -100.0 * (highest - close[end]) / range + } else { + -50.0 + }; + } + + result +} + +// --------------------------------------------------------------------------- +// batch_sma +// --------------------------------------------------------------------------- + +/// Apply SMA to each column. Returns one output column per input column. +pub fn batch_sma(data: &[Vec], timeperiod: usize) -> Result>, String> { + if timeperiod == 0 { + return Err("timeperiod must be >= 1".into()); + } + validate_columns(data)?; + Ok(data + .iter() + .map(|col| overlap::sma(col, timeperiod)) + .collect()) +} + +// --------------------------------------------------------------------------- +// batch_ema +// --------------------------------------------------------------------------- + +/// Apply EMA to each column. +pub fn batch_ema(data: &[Vec], timeperiod: usize) -> Result>, String> { + if timeperiod == 0 { + return Err("timeperiod must be >= 1".into()); + } + validate_columns(data)?; + Ok(data + .iter() + .map(|col| overlap::ema(col, timeperiod)) + .collect()) +} + +// --------------------------------------------------------------------------- +// batch_rsi +// --------------------------------------------------------------------------- + +/// Apply RSI to each column. +pub fn batch_rsi(data: &[Vec], timeperiod: usize) -> Result>, String> { + if timeperiod == 0 { + return Err("timeperiod must be >= 1".into()); + } + validate_columns(data)?; + Ok(data + .iter() + .map(|col| momentum::rsi(col, timeperiod)) + .collect()) +} + +// --------------------------------------------------------------------------- +// batch_atr +// --------------------------------------------------------------------------- + +/// Apply ATR to each set of (high, low, close) columns. +pub fn batch_atr( + high: &[Vec], + low: &[Vec], + close: &[Vec], + timeperiod: usize, +) -> Result>, String> { + if timeperiod == 0 { + return Err("timeperiod must be >= 1".into()); + } + validate_hlc_columns(high, low, close)?; + Ok((0..high.len()) + .map(|i| volatility::atr(&high[i], &low[i], &close[i], timeperiod)) + .collect()) +} + +// --------------------------------------------------------------------------- +// batch_stoch +// --------------------------------------------------------------------------- + +/// Apply Stochastic to each set of (high, low, close) columns. +/// Returns `(slowk_columns, slowd_columns)`. +#[allow(clippy::type_complexity)] +pub fn batch_stoch( + high: &[Vec], + low: &[Vec], + close: &[Vec], + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> Result<(Vec>, Vec>), String> { + validate_hlc_columns(high, low, close)?; + let mut all_k = Vec::with_capacity(high.len()); + let mut all_d = Vec::with_capacity(high.len()); + for i in 0..high.len() { + let (k, d) = momentum::stoch( + &high[i], + &low[i], + &close[i], + fastk_period, + slowk_period, + slowd_period, + ); + all_k.push(k); + all_d.push(d); + } + Ok((all_k, all_d)) +} + +// --------------------------------------------------------------------------- +// batch_adx +// --------------------------------------------------------------------------- + +/// Apply ADX to each set of (high, low, close) columns. +pub fn batch_adx( + high: &[Vec], + low: &[Vec], + close: &[Vec], + timeperiod: usize, +) -> Result>, String> { + if timeperiod == 0 { + return Err("timeperiod must be >= 1".into()); + } + validate_hlc_columns(high, low, close)?; + Ok((0..high.len()) + .map(|i| momentum::adx(&high[i], &low[i], &close[i], timeperiod)) + .collect()) +} + +// --------------------------------------------------------------------------- +// run_close_indicators +// --------------------------------------------------------------------------- + +fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> Result<(), String> { + if names.len() != timeperiods.len() { + return Err(format!( + "names length ({}) must equal timeperiods length ({})", + names.len(), + timeperiods.len() + )); + } + for (name, &tp) in names.iter().zip(timeperiods.iter()) { + if tp == 0 { + return Err(format!("{name}: timeperiod must be >= 1")); + } + } + Ok(()) +} + +fn compute_close_indicator( + name: &str, + close: &[f64], + timeperiod: usize, +) -> Result, String> { + match name { + "SMA" => Ok(overlap::sma(close, timeperiod)), + "EMA" => Ok(overlap::ema(close, timeperiod)), + "RSI" => Ok(momentum::rsi(close, timeperiod)), + "STDDEV" => Ok(statistic::stddev(close, timeperiod, 1.0)), + "VAR" => Ok(statistic::stddev(close, timeperiod, 1.0) + .into_iter() + .map(|v| if v.is_nan() { v } else { v * v }) + .collect()), + "LINEARREG" => { + let last_x = (timeperiod - 1) as f64; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope, intercept| intercept + slope * last_x, + )) + } + "LINEARREG_SLOPE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| slope)), + "LINEARREG_INTERCEPT" => Ok(rolling_linreg_apply(close, timeperiod, |_, intercept| { + intercept + })), + "LINEARREG_ANGLE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| { + slope.atan() * 180.0 / std::f64::consts::PI + })), + "TSF" => { + let forecast_x = timeperiod as f64; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope, intercept| intercept + slope * forecast_x, + )) + } + _ => Err(format!( + "unsupported close indicator for grouped execution: {name}" + )), + } +} + +/// Run multiple close-only indicators on the same series. +/// Returns `Vec, String>>` — one result per (name, timeperiod) pair. +pub fn run_close_indicators( + close: &[f64], + names: &[String], + timeperiods: &[usize], +) -> Result>, String> { + validate_indicator_requests(names, timeperiods)?; + let mut results = Vec::with_capacity(names.len()); + for (name, &tp) in names.iter().zip(timeperiods.iter()) { + results.push(compute_close_indicator(name, close, tp)?); + } + Ok(results) +} + +// --------------------------------------------------------------------------- +// run_hlc_indicators +// --------------------------------------------------------------------------- + +fn compute_hlc_indicator( + name: &str, + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod: usize, +) -> Result, String> { + match name { + "ATR" => Ok(volatility::atr(high, low, close, timeperiod)), + "NATR" => { + let atr_vals = volatility::atr(high, low, close, timeperiod); + Ok(atr_vals + .into_iter() + .zip(close.iter()) + .map(|(a, &c)| { + if a.is_nan() || c == 0.0 { + f64::NAN + } else { + (a / c) * 100.0 + } + }) + .collect()) + } + "ADX" => Ok(momentum::adx(high, low, close, timeperiod)), + "ADXR" => Ok(momentum::adxr(high, low, close, timeperiod)), + "CCI" => Ok(compute_cci(high, low, close, timeperiod)), + "WILLR" => Ok(compute_willr(high, low, close, timeperiod)), + _ => Err(format!( + "unsupported HLC indicator for grouped execution: {name}" + )), + } +} + +/// Run multiple HLC indicators on the same series. +pub fn run_hlc_indicators( + high: &[f64], + low: &[f64], + close: &[f64], + names: &[String], + timeperiods: &[usize], +) -> Result>, String> { + validate_indicator_requests(names, timeperiods)?; + if high.len() != low.len() || high.len() != close.len() { + return Err("high, low, and close must have equal length".into()); + } + let mut results = Vec::with_capacity(names.len()); + for (name, &tp) in names.iter().zip(timeperiods.iter()) { + results.push(compute_hlc_indicator(name, high, low, close, tp)?); + } + Ok(results) +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn close_data() -> Vec { + vec![ + 44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, + 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64, + ] + } + + fn hlc_data() -> (Vec, Vec, Vec) { + let close = close_data(); + let high: Vec = close.iter().map(|c| c + 0.5).collect(); + let low: Vec = close.iter().map(|c| c - 0.5).collect(); + (high, low, close) + } + + #[test] + fn test_batch_sma_basic() { + let col1 = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let col2 = vec![10.0, 20.0, 30.0, 40.0, 50.0]; + let data = vec![col1, col2]; + let result = batch_sma(&data, 3).unwrap(); + assert_eq!(result.len(), 2); + assert!(result[0][0].is_nan()); + assert!(result[0][1].is_nan()); + assert!((result[0][2] - 2.0).abs() < 1e-10); + assert!((result[1][2] - 20.0).abs() < 1e-10); + } + + #[test] + fn test_batch_sma_zero_period() { + let data = vec![vec![1.0, 2.0]]; + assert!(batch_sma(&data, 0).is_err()); + } + + #[test] + fn test_batch_ema_basic() { + let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]]; + let result = batch_ema(&data, 3).unwrap(); + assert_eq!(result.len(), 1); + assert!(result[0][0].is_nan()); + } + + #[test] + fn test_batch_rsi_basic() { + let data = vec![close_data()]; + let result = batch_rsi(&data, 14).unwrap(); + assert_eq!(result.len(), 1); + // First 14 values should be NaN + for i in 0..14 { + assert!(result[0][i].is_nan(), "index {i} should be NaN"); + } + // Value at index 14 should be a valid RSI + let rsi_val = result[0][14]; + assert!(!rsi_val.is_nan()); + assert!(rsi_val >= 0.0 && rsi_val <= 100.0); + } + + #[test] + fn test_batch_atr_basic() { + let (h, l, c) = hlc_data(); + let high = vec![h]; + let low = vec![l]; + let close = vec![c]; + let result = batch_atr(&high, &low, &close, 14).unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_batch_stoch_basic() { + let (h, l, c) = hlc_data(); + let high = vec![h]; + let low = vec![l]; + let close = vec![c]; + let (k, d) = batch_stoch(&high, &low, &close, 5, 3, 3).unwrap(); + assert_eq!(k.len(), 1); + assert_eq!(d.len(), 1); + assert_eq!(k[0].len(), d[0].len()); + } + + #[test] + fn test_batch_adx_basic() { + let (h, l, c) = hlc_data(); + let high = vec![h]; + let low = vec![l]; + let close = vec![c]; + let result = batch_adx(&high, &low, &close, 14).unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn test_run_close_indicators_basic() { + let close = close_data(); + let names = vec!["SMA".to_string(), "EMA".to_string()]; + let timeperiods = vec![5, 5]; + let result = run_close_indicators(&close, &names, &timeperiods).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].len(), close.len()); + assert_eq!(result[1].len(), close.len()); + } + + #[test] + fn test_run_close_indicators_mismatched_lengths() { + let close = close_data(); + let names = vec!["SMA".to_string()]; + let timeperiods = vec![5, 10]; // different length + assert!(run_close_indicators(&close, &names, &timeperiods).is_err()); + } + + #[test] + fn test_run_close_indicators_linreg_variants() { + let close = close_data(); + let names = vec![ + "LINEARREG".to_string(), + "LINEARREG_SLOPE".to_string(), + "LINEARREG_INTERCEPT".to_string(), + "LINEARREG_ANGLE".to_string(), + "TSF".to_string(), + ]; + let timeperiods = vec![5, 5, 5, 5, 5]; + let result = run_close_indicators(&close, &names, &timeperiods).unwrap(); + assert_eq!(result.len(), 5); + // First 4 values should be NaN for period=5 + for series in &result { + for i in 0..4 { + assert!(series[i].is_nan()); + } + assert!(!series[4].is_nan()); + } + } + + #[test] + fn test_run_hlc_indicators_basic() { + let (h, l, c) = hlc_data(); + let names = vec!["ATR".to_string(), "CCI".to_string()]; + let timeperiods = vec![14, 14]; + let result = run_hlc_indicators(&h, &l, &c, &names, &timeperiods).unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_run_hlc_indicators_unsupported() { + let (h, l, c) = hlc_data(); + let names = vec!["UNKNOWN".to_string()]; + let timeperiods = vec![14]; + assert!(run_hlc_indicators(&h, &l, &c, &names, &timeperiods).is_err()); + } + + #[test] + fn test_validate_hlc_mismatched_columns() { + let high = vec![vec![1.0, 2.0]]; + let low = vec![vec![1.0, 2.0], vec![3.0, 4.0]]; // 2 cols vs 1 + let close = vec![vec![1.0, 2.0]]; + assert!(batch_atr(&high, &low, &close, 5).is_err()); + } + + #[test] + fn test_empty_data() { + let data: Vec> = vec![]; + let result = batch_sma(&data, 3).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_batch_multiple_columns() { + let data = vec![ + vec![1.0, 2.0, 3.0, 4.0, 5.0], + vec![5.0, 4.0, 3.0, 2.0, 1.0], + vec![2.0, 4.0, 6.0, 8.0, 10.0], + ]; + let result = batch_sma(&data, 3).unwrap(); + assert_eq!(result.len(), 3); + // col 0: sma(3) at index 2 = (1+2+3)/3 = 2.0 + assert!((result[0][2] - 2.0).abs() < 1e-10); + // col 1: sma(3) at index 2 = (5+4+3)/3 = 4.0 + assert!((result[1][2] - 4.0).abs() < 1e-10); + // col 2: sma(3) at index 2 = (2+4+6)/3 = 4.0 + assert!((result[2][2] - 4.0).abs() < 1e-10); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/chunked.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/chunked.rs new file mode 100644 index 0000000..9743626 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/chunked.rs @@ -0,0 +1,123 @@ +//! Chunked / out-of-core execution helpers. +//! +//! - `trim_overlap` — remove the first N elements from a slice +//! - `stitch_chunks` — concatenate trimmed chunk results +//! - `make_chunk_ranges` — compute (start, end) index pairs for chunked processing +//! - `forward_fill_nan` — forward-fill NaN values + +/// Remove the first `overlap` elements from a slice. +pub fn trim_overlap(chunk_out: &[f64], overlap: usize) -> Vec { + if overlap > chunk_out.len() { + return vec![]; + } + chunk_out[overlap..].to_vec() +} + +/// Concatenate a list of slices into a single Vec. +pub fn stitch_chunks(chunks: &[&[f64]]) -> Vec { + let mut out = Vec::new(); + for &chunk in chunks { + out.extend_from_slice(chunk); + } + out +} + +/// Compute (start, end) index pairs for chunked processing. +/// +/// Returns a flat Vec of pairs: [start0, end0, start1, end1, ...]. +/// `chunk_size` is the desired output bars per chunk, `overlap` is the warm-up prefix. +pub fn make_chunk_ranges(n: usize, chunk_size: usize, overlap: usize) -> Vec { + if chunk_size == 0 || n == 0 { + return vec![]; + } + let mut ranges: Vec = Vec::new(); + let mut start: usize = 0; + loop { + let end = (start + chunk_size + overlap).min(n); + ranges.push(start as i64); + ranges.push(end as i64); + if end >= n { + break; + } + start = end.saturating_sub(overlap); + } + ranges +} + +/// Forward-fill NaN values in a 1-D array. +/// Leading NaN values are preserved until the first non-NaN value appears. +pub fn forward_fill_nan(values: &[f64]) -> Vec { + let mut out = Vec::with_capacity(values.len()); + let mut last = f64::NAN; + for &value in values { + if value.is_nan() { + out.push(last); + } else { + last = value; + out.push(value); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trim_overlap() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = trim_overlap(&data, 2); + assert_eq!(result, vec![3.0, 4.0, 5.0]); + } + + #[test] + fn test_trim_overlap_zero() { + let data = vec![1.0, 2.0, 3.0]; + assert_eq!(trim_overlap(&data, 0), data); + } + + #[test] + fn test_trim_overlap_exceeds() { + let data = vec![1.0, 2.0]; + assert!(trim_overlap(&data, 5).is_empty()); + } + + #[test] + fn test_stitch_chunks() { + let a = vec![1.0, 2.0]; + let b = vec![3.0, 4.0, 5.0]; + let chunks: Vec<&[f64]> = vec![&a, &b]; + let result = stitch_chunks(&chunks); + assert_eq!(result, vec![1.0, 2.0, 3.0, 4.0, 5.0]); + } + + #[test] + fn test_make_chunk_ranges() { + let ranges = make_chunk_ranges(10, 4, 2); + // Expected: [0,6], [4,10] + assert_eq!(ranges.len() % 2, 0); + assert!(ranges.len() >= 4); + assert_eq!(ranges[0], 0); + } + + #[test] + fn test_forward_fill_nan() { + let data = vec![f64::NAN, 1.0, f64::NAN, f64::NAN, 2.0, f64::NAN]; + let result = forward_fill_nan(&data); + assert!(result[0].is_nan()); // leading NaN preserved + assert!((result[1] - 1.0).abs() < 1e-10); + assert!((result[2] - 1.0).abs() < 1e-10); // filled + assert!((result[3] - 1.0).abs() < 1e-10); // filled + assert!((result[4] - 2.0).abs() < 1e-10); + assert!((result[5] - 2.0).abs() < 1e-10); // filled + } + + #[test] + fn test_empty() { + assert!(trim_overlap(&[], 0).is_empty()); + assert!(stitch_chunks(&[]).is_empty()); + assert!(make_chunk_ranges(0, 4, 2).is_empty()); + assert!(forward_fill_nan(&[]).is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/commission.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/commission.rs new file mode 100644 index 0000000..5003e73 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/commission.rs @@ -0,0 +1,295 @@ +//! Commission, tax, and fee model for Indian and global markets. +//! +//! All `_rate` fields are fractions (0.001 = 0.1%). +//! All per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g., INR). +//! The model is self-contained: pass `trade_value`, `num_lots`, `is_buy` to get total cost. + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +/// Advanced commission and tax model. +/// +/// # Fields (all public for direct construction) +/// - **Brokerage**: `flat_per_order`, `rate_of_value`, `per_lot`, `max_brokerage` +/// - **STT**: `stt_rate`, `stt_on_buy`, `stt_on_sell` +/// - **Levies**: `exchange_charges_rate`, `regulatory_charges_rate`, `gst_rate`, `stamp_duty_rate` +/// - **Sizing**: `lot_size` +/// +/// # Indian market notes +/// - STT (Securities Transaction Tax) is applied on turnover (buy/sell legs vary by segment). +/// - Exchange charges and regulatory body charges are on turnover. +/// - GST (18%) applies on brokerage + exchange charges + regulatory body charges (not STT/stamp). +/// - Stamp duty is on buy-side value only. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct CommissionModel { + // --- Brokerage --------------------------------------------------------- + /// Fixed fee per order (e.g., ₹20 flat fee per order). 0.0 = none. + pub flat_per_order: f64, + /// Proportional brokerage as fraction of `trade_value` (e.g., 0.001 = 0.1%). 0.0 = none. + pub rate_of_value: f64, + /// Fixed fee per lot (e.g., ₹2 per lot). 0.0 = none. + pub per_lot: f64, + /// Brokerage cap in currency units. 0.0 = no cap. + /// Effective brokerage = min(flat + rate × value + per_lot × lots, max_brokerage). + pub max_brokerage: f64, + /// Bid-ask spread model in basis points. Half-spread is paid on each leg (entry and exit), + /// so total roundtrip cost = spread_bps in bps. 0.0 = no spread cost. + pub spread_bps: f64, + + // --- Securities Transaction Tax (STT) ---------------------------------- + /// STT rate as fraction of trade value. 0.0 = no STT. + pub stt_rate: f64, + /// Apply STT on the buy leg. + pub stt_on_buy: bool, + /// Apply STT on the sell leg. + pub stt_on_sell: bool, + + // --- Exchange & Regulatory Levies -------------------------------------- + /// Exchange transaction charges rate (fraction of trade value). + pub exchange_charges_rate: f64, + /// Regulatory body turnover charges rate (fraction of trade value). Typically ~0.000001. + pub regulatory_charges_rate: f64, + /// Indirect tax (GST) rate applied on (brokerage + exchange_charges + regulatory_charges). + /// Typically 0.18 in India. + pub gst_rate: f64, + /// Stamp duty rate on buy side only (fraction of trade value). + pub stamp_duty_rate: f64, + + // --- Instrument Sizing ------------------------------------------------ + /// Lot size for the instrument. + /// Equities: 1.0. Index futures/options: contract lot size (e.g., 25, 50, 75). + /// Used for per_lot cost: cost += per_lot × ceil(quantity / lot_size). + pub lot_size: f64, + + // --- Short Selling ---------------------------------------------------- + /// Annualised short borrow rate as a fraction (e.g. 0.03 = 3% p.a.). + /// Applied per bar to short positions. 0.0 = no borrow cost. + pub short_borrow_rate_annual: f64, +} + +impl Default for CommissionModel { + fn default() -> Self { + Self { + flat_per_order: 0.0, + rate_of_value: 0.0, + per_lot: 0.0, + max_brokerage: 0.0, + spread_bps: 0.0, + stt_rate: 0.0, + stt_on_buy: false, + stt_on_sell: false, + exchange_charges_rate: 0.0, + regulatory_charges_rate: 0.0, + gst_rate: 0.0, + stamp_duty_rate: 0.0, + lot_size: 1.0, + short_borrow_rate_annual: 0.0, + } + } +} + +impl CommissionModel { + // ------------------------------------------------------------------ + // Core computation + // ------------------------------------------------------------------ + + /// Compute total transaction cost in **absolute currency units**. + /// + /// # Parameters + /// - `trade_value`: price × quantity in base currency + /// - `num_lots`: number of lots transacted + /// - `is_buy`: true for buy (entry) leg, false for sell (exit) leg + pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 { + // Brokerage (optionally capped) + let raw_brokerage = + self.flat_per_order + self.rate_of_value * trade_value + self.per_lot * num_lots; + let brokerage = if self.max_brokerage > 0.0 { + raw_brokerage.min(self.max_brokerage) + } else { + raw_brokerage + }; + + // STT + let stt = if (is_buy && self.stt_on_buy) || (!is_buy && self.stt_on_sell) { + self.stt_rate * trade_value + } else { + 0.0 + }; + + let exchange = self.exchange_charges_rate * trade_value; + let regulatory = self.regulatory_charges_rate * trade_value; + + // GST on brokerage + exchange + regulatory (NOT on STT or stamp duty) + let gst = self.gst_rate * (brokerage + exchange + regulatory); + + // Stamp duty only on buy side + let stamp = if is_buy { + self.stamp_duty_rate * trade_value + } else { + 0.0 + }; + + // Bid-ask spread: half-spread paid on each leg + let spread_cost = self.spread_bps / 2.0 / 10_000.0 * trade_value; + + brokerage + stt + exchange + regulatory + gst + stamp + spread_cost + } + + /// Borrow cost per bar for a short position. + /// + /// # Parameters + /// - `trade_value`: abs(price × quantity) + /// - `periods_per_year`: 252 for daily, 52 for weekly, etc. + pub fn short_borrow_cost(&self, trade_value: f64, periods_per_year: f64) -> f64 { + if self.short_borrow_rate_annual <= 0.0 || periods_per_year <= 0.0 { + return 0.0; + } + self.short_borrow_rate_annual / periods_per_year * trade_value + } + + /// Compute cost as a **fraction of `initial_capital`** for use in normalised equity loops. + /// + /// Returns 0.0 if `initial_capital` ≤ 0. + pub fn cost_fraction( + &self, + trade_value: f64, + num_lots: f64, + is_buy: bool, + initial_capital: f64, + ) -> f64 { + if initial_capital <= 0.0 { + return 0.0; + } + self.total_cost(trade_value, num_lots, is_buy) / initial_capital + } + + // ------------------------------------------------------------------ + // Built-in Presets + // ------------------------------------------------------------------ + + /// Zero commission — useful for clean research/comparison runs. + pub fn zero() -> Self { + Self::default() + } + + /// Indian equity **delivery** (long-term hold). + /// + /// Brokerage: 0.1% (capped at ₹20), STT 0.1% both sides, + /// exchange charges, regulatory body charges, 18% GST, stamp duty. + pub fn equity_delivery_india() -> Self { + Self { + flat_per_order: 0.0, + rate_of_value: 0.001, // 0.1% + per_lot: 0.0, + max_brokerage: 20.0, // ₹20 cap + spread_bps: 0.0, + stt_rate: 0.001, // 0.1% + stt_on_buy: true, + stt_on_sell: true, + exchange_charges_rate: 0.0000297, + regulatory_charges_rate: 0.000001, + gst_rate: 0.18, + stamp_duty_rate: 0.00015, + lot_size: 1.0, + short_borrow_rate_annual: 0.0, + } + } + + /// Indian equity **intraday** (same-day square-off). + /// + /// Brokerage: 0.03% (capped at ₹20), STT 0.025% sell side only, + /// exchange charges, regulatory body charges, 18% GST, stamp duty on buy. + pub fn equity_intraday_india() -> Self { + Self { + flat_per_order: 0.0, + rate_of_value: 0.0003, // 0.03% + per_lot: 0.0, + max_brokerage: 20.0, + spread_bps: 0.0, + stt_rate: 0.00025, // 0.025% + stt_on_buy: false, + stt_on_sell: true, + exchange_charges_rate: 0.0000297, + regulatory_charges_rate: 0.000001, + gst_rate: 0.18, + stamp_duty_rate: 0.000003, + lot_size: 1.0, + short_borrow_rate_annual: 0.0, + } + } + + /// Indian **index futures** (indicative rates per current regulations). + /// + /// Flat ₹20 per order, STT 0.05% sell side only, exchange charges, + /// regulatory body charges, 18% GST, stamp duty on buy. + /// `lot_size` defaults to 25 — update as needed for the specific contract. + pub fn futures_india() -> Self { + Self { + flat_per_order: 20.0, + rate_of_value: 0.0, + per_lot: 0.0, + max_brokerage: 0.0, + spread_bps: 0.0, + stt_rate: 0.0005, // 0.05% + stt_on_buy: false, + stt_on_sell: true, + exchange_charges_rate: 0.0000019, + regulatory_charges_rate: 0.000001, + gst_rate: 0.18, + stamp_duty_rate: 0.00002, + lot_size: 25.0, + short_borrow_rate_annual: 0.0, + } + } + + /// Indian **index options** (indicative rates per current regulations). + /// + /// Flat ₹20 per order, STT 0.15% on premium sell side only, exchange charges, + /// regulatory body charges, 18% GST, stamp duty on buy. + /// `lot_size` defaults to 25 — update as needed for the specific contract. + pub fn options_india() -> Self { + Self { + flat_per_order: 20.0, + rate_of_value: 0.0, + per_lot: 0.0, + max_brokerage: 0.0, + spread_bps: 0.0, + stt_rate: 0.0015, // 0.15% on premium + stt_on_buy: false, + stt_on_sell: true, + exchange_charges_rate: 0.0000053, + regulatory_charges_rate: 0.000001, + gst_rate: 0.18, + stamp_duty_rate: 0.000003, + lot_size: 25.0, + short_borrow_rate_annual: 0.0, + } + } + + /// Simple proportional model — e.g., `proportional(0.001)` = 0.1% both sides. + /// + /// No taxes, no levies — suitable for non-Indian markets or simplified modelling. + pub fn proportional(rate: f64) -> Self { + Self { + rate_of_value: rate, + ..Default::default() + } + } + + // ------------------------------------------------------------------ + // JSON serialization (requires "serde" feature) + // ------------------------------------------------------------------ + + /// Serialize to a pretty-printed JSON string. + #[cfg(feature = "serde")] + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Deserialize from a JSON string. + #[cfg(feature = "serde")] + pub fn from_json(s: &str) -> Result { + serde_json::from_str(s) + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/crypto.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/crypto.rs new file mode 100644 index 0000000..21e714a --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/crypto.rs @@ -0,0 +1,91 @@ +//! Crypto and 24/7 market helpers. +//! +//! - `funding_cumulative_pnl` — cumulative PnL from periodic funding rate payments +//! - `continuous_bar_labels` — assign sequential integer labels based on fixed period size +//! - `mark_session_boundaries` — return indices where a new UTC day begins + +/// Compute the cumulative PnL from funding rate payments. +/// +/// `position_size` and `funding_rate` must have the same length. +/// PnL at period i = -position_size[i] * funding_rate[i] (longs pay when rate > 0). +pub fn funding_cumulative_pnl(position_size: &[f64], funding_rate: &[f64]) -> Vec { + let n = position_size.len(); + let mut out = vec![0.0_f64; n]; + let mut cumulative = 0.0_f64; + for i in 0..n { + cumulative += -position_size[i] * funding_rate[i]; + out[i] = cumulative; + } + out +} + +/// Assign a sequential integer label per bar based on a fixed-size period. +/// +/// Bars 0..(period_bars-1) get label 0, bars period_bars..(2*period_bars-1) get label 1, etc. +/// `period_bars` must be >= 1. +pub fn continuous_bar_labels(n_bars: usize, period_bars: usize) -> Vec { + (0..n_bars).map(|i| (i / period_bars) as i64).collect() +} + +/// Return bar indices where a new UTC day begins (based on nanosecond timestamps). +/// +/// Bar 0 is always included as the first boundary. +pub fn mark_session_boundaries(timestamps_ns: &[i64]) -> Vec { + let n = timestamps_ns.len(); + if n == 0 { + return vec![]; + } + const NS_PER_DAY: i64 = 86_400_000_000_000; + let mut out = vec![0i64]; // bar 0 is always a boundary + let mut prev_day = timestamps_ns[0].div_euclid(NS_PER_DAY); + for (i, &t) in timestamps_ns.iter().enumerate().skip(1) { + let day = t.div_euclid(NS_PER_DAY); + if day != prev_day { + out.push(i as i64); + prev_day = day; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_funding_cumulative_pnl() { + let pos = vec![100.0, 100.0, -50.0]; + let rate = vec![0.001, -0.002, 0.001]; + let result = funding_cumulative_pnl(&pos, &rate); + assert!((result[0] - (-0.1)).abs() < 1e-10); + assert!((result[1] - 0.1).abs() < 1e-10); // -0.1 + 0.2 = 0.1 + assert!((result[2] - 0.15).abs() < 1e-10); // 0.1 + 0.05 = 0.15 + } + + #[test] + fn test_continuous_bar_labels() { + let labels = continuous_bar_labels(7, 3); + assert_eq!(labels, vec![0, 0, 0, 1, 1, 1, 2]); + } + + #[test] + fn test_mark_session_boundaries() { + let ns_per_day: i64 = 86_400_000_000_000; + let ts = vec![ + 0, // day 0 + ns_per_day / 2, // day 0 + ns_per_day, // day 1 + ns_per_day + ns_per_day / 2, // day 1 + ns_per_day * 2, // day 2 + ]; + let result = mark_session_boundaries(&ts); + assert_eq!(result, vec![0, 2, 4]); + } + + #[test] + fn test_empty() { + assert!(funding_cumulative_pnl(&[], &[]).is_empty()); + assert!(continuous_bar_labels(0, 1).is_empty()); + assert!(mark_session_boundaries(&[]).is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/currency.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/currency.rs new file mode 100644 index 0000000..6343786 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/currency.rs @@ -0,0 +1,173 @@ +//! Currency metadata and Indian number formatting. + +/// Immutable currency descriptor. +/// +/// Carries the currency code, symbol, decimal places, and whether to use +/// Indian lakh/crore grouping (1,23,45,678.00) instead of standard +/// Western grouping (1,234,567.89). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Currency { + /// IETF currency code, e.g. "INR", "USD". + pub code: &'static str, + /// Display symbol, e.g. "₹", "$". + pub symbol: &'static str, + /// Number of decimal places for formatting. + pub decimal_places: u8, + /// Use Indian lakh/crore digit grouping (true only for INR). + pub lakh_grouping: bool, +} + +impl Currency { + pub const INR: Currency = Currency { + code: "INR", + symbol: "₹", + decimal_places: 2, + lakh_grouping: true, + }; + pub const USD: Currency = Currency { + code: "USD", + symbol: "$", + decimal_places: 2, + lakh_grouping: false, + }; + pub const EUR: Currency = Currency { + code: "EUR", + symbol: "€", + decimal_places: 2, + lakh_grouping: false, + }; + pub const GBP: Currency = Currency { + code: "GBP", + symbol: "£", + decimal_places: 2, + lakh_grouping: false, + }; + pub const JPY: Currency = Currency { + code: "JPY", + symbol: "¥", + decimal_places: 0, + lakh_grouping: false, + }; + pub const USDT: Currency = Currency { + code: "USDT", + symbol: "₮", + decimal_places: 2, + lakh_grouping: false, + }; + + /// Look up a currency by IETF code (case-insensitive). + /// Returns `None` if the code is not recognised. + pub fn from_code(code: &str) -> Option<&'static Currency> { + match code.to_ascii_uppercase().as_str() { + "INR" => Some(&Currency::INR), + "USD" => Some(&Currency::USD), + "EUR" => Some(&Currency::EUR), + "GBP" => Some(&Currency::GBP), + "JPY" => Some(&Currency::JPY), + "USDT" => Some(&Currency::USDT), + _ => None, + } + } + + /// Format `amount` according to this currency's style. + /// + /// - INR uses Indian lakh/crore grouping: `₹1,23,45,678.00` + /// - Others use standard Western grouping: `$1,234,567.89` + pub fn format(&self, amount: f64) -> String { + let neg = amount < 0.0; + let abs = amount.abs(); + let integer_part = abs.floor() as u64; + let frac_part = abs - abs.floor(); + + let grouped = if self.lakh_grouping { + format_lakh(integer_part) + } else { + format_standard(integer_part) + }; + + let dp = self.decimal_places as usize; + let decimal_str = if dp > 0 { + let frac = (frac_part * 10f64.powi(dp as i32)).round() as u64; + format!(".{:0>width$}", frac, width = dp) + } else { + String::new() + }; + + let sign = if neg { "-" } else { "" }; + format!("{}{}{}{}", sign, self.symbol, grouped, decimal_str) + } +} + +/// Indian lakh/crore grouping: last 3 digits, then groups of 2 from the right. +/// e.g. 12345678 → "1,23,45,678" +fn format_lakh(n: u64) -> String { + let s = n.to_string(); + if s.len() <= 3 { + return s; + } + let (rest, last3) = s.split_at(s.len() - 3); + let mut out = String::new(); + let chars: Vec = rest.chars().collect(); + let first_len = chars.len() % 2; + if first_len > 0 { + out.push_str(&chars[..first_len].iter().collect::()); + } + let mut i = first_len; + while i < chars.len() { + if !out.is_empty() { + out.push(','); + } + out.push_str(&chars[i..i + 2].iter().collect::()); + i += 2; + } + if !out.is_empty() { + out.push(','); + } + out.push_str(last3); + out +} + +/// Standard Western grouping: groups of 3 digits from the right. +/// e.g. 1234567 → "1,234,567" +fn format_standard(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(c); + } + out.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inr_format() { + assert_eq!(Currency::INR.format(123456.78), "₹1,23,456.78"); + assert_eq!(Currency::INR.format(10000000.0), "₹1,00,00,000.00"); + assert_eq!(Currency::INR.format(100.0), "₹100.00"); + assert_eq!(Currency::INR.format(-5000.0), "-₹5,000.00"); + } + + #[test] + fn test_usd_format() { + assert_eq!(Currency::USD.format(1234567.89), "$1,234,567.89"); + assert_eq!(Currency::USD.format(0.5), "$0.50"); + } + + #[test] + fn test_jpy_format() { + assert_eq!(Currency::JPY.format(1000000.0), "¥1,000,000"); + } + + #[test] + fn test_from_code() { + assert_eq!(Currency::from_code("inr"), Some(&Currency::INR)); + assert_eq!(Currency::from_code("USD"), Some(&Currency::USD)); + assert_eq!(Currency::from_code("UNKNOWN"), None); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/cycle.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/cycle.rs new file mode 100644 index 0000000..fd5c845 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/cycle.rs @@ -0,0 +1,370 @@ +//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers). +//! +//! Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib. +//! Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers +//! +//! All HT functions share a 63-bar lookback period. + +use std::f64::consts::PI; + +/// Number of leading bars that are set to NaN / zero. +pub const HT_LOOKBACK: usize = 63; + +/// Shared output from the core Hilbert Transform computation. +pub struct HtCore { + pub trendline: Vec, + pub dc_period: Vec, + pub dc_phase: Vec, + pub inphase: Vec, + pub quadrature: Vec, + pub trend_mode: Vec, +} + +/// Run the full Hilbert Transform pipeline on a slice of close prices. +pub fn compute_ht_core(prices: &[f64]) -> HtCore { + let n = prices.len(); + + let mut trendline = vec![f64::NAN; n]; + let mut dc_period = vec![f64::NAN; n]; + let mut dc_phase = vec![f64::NAN; n]; + let mut inphase = vec![f64::NAN; n]; + let mut quadrature = vec![f64::NAN; n]; + let mut trend_mode = vec![0i32; n]; + + if n <= HT_LOOKBACK { + return HtCore { + trendline, + dc_period, + dc_phase, + inphase, + quadrature, + trend_mode, + }; + } + + // Step 1: Smooth the price series (4-bar weighted average) + let mut smooth = vec![0.0f64; n]; + for i in 0..n { + smooth[i] = if i >= 3 { + (4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0 + } else { + prices[i] + }; + } + + // Step 2: Full Hilbert Transform pipeline + let mut detrender = vec![0.0f64; n]; + let mut q1 = vec![0.0f64; n]; + let mut i1 = vec![0.0f64; n]; + let mut ji = vec![0.0f64; n]; + let mut jq = vec![0.0f64; n]; + let mut i2 = vec![0.0f64; n]; + let mut q2 = vec![0.0f64; n]; + let mut re = vec![0.0f64; n]; + let mut im = vec![0.0f64; n]; + let mut period = vec![0.0f64; n]; + let mut smooth_period = vec![0.0f64; n]; + let mut phase = vec![0.0f64; n]; + + for i in 6..n { + let prev_period = period[i - 1]; + // Alpha coefficient for HT filters depends on the current period estimate + let alpha = 0.075 * prev_period + 0.54; + + // Discrete Hilbert Transform of smooth price (detrender) + detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] + - 0.5769 * smooth[i - 4] + - 0.0962 * smooth[i - 6]) + * alpha; + + // Q1: HT of detrender + if i >= 12 { + q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] + - 0.5769 * detrender[i - 4] + - 0.0962 * detrender[i - 6]) + * alpha; + } + + // I1: delayed detrender + if i >= 9 { + i1[i] = detrender[i - 3]; + } + + // jI: HT of I1 + if i >= 15 { + ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) + * alpha; + } + + // jQ: HT of Q1 + if i >= 18 { + jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) + * alpha; + } + + // Phase components + let i2_raw = i1[i] - jq[i]; + let q2_raw = q1[i] + ji[i]; + + // EMA smoothing of I2 and Q2 + let i2_prev = i2[i - 1]; + let q2_prev = q2[i - 1]; + i2[i] = 0.2 * i2_raw + 0.8 * i2_prev; + q2[i] = 0.2 * q2_raw + 0.8 * q2_prev; + + // Cross-product for period estimation + let re_raw = i2[i] * i2_prev + q2[i] * q2_prev; + let im_raw = i2[i] * q2_prev - q2[i] * i2_prev; + + // EMA smoothing of Re and Im + re[i] = 0.2 * re_raw + 0.8 * re[i - 1]; + im[i] = 0.2 * im_raw + 0.8 * im[i - 1]; + + // Compute period from cross-product of consecutive phasors. + let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { + 2.0 * PI / (im[i] / re[i]).atan() + } else { + prev_period + }; + + // Clamp period relative to previous + if prev_period > 0.0 { + if p > 1.5 * prev_period { + p = 1.5 * prev_period; + } + if p < 0.67 * prev_period { + p = 0.67 * prev_period; + } + } + // Hard clamp to [6, 50] bars + p = p.clamp(6.0, 50.0); + + // EMA smooth the period + period[i] = 0.2 * p + 0.8 * prev_period; + + // Smooth the smoothed period once more + smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1]; + + // Phase from I1 and Q1 + phase[i] = if i1[i] != 0.0 { + q1[i].atan2(i1[i]) * 180.0 / PI + } else if q1[i] > 0.0 { + 90.0 + } else if q1[i] < 0.0 { + -90.0 + } else { + 0.0 + }; + + // Write outputs once past lookback + if i >= HT_LOOKBACK { + dc_period[i] = smooth_period[i]; + dc_phase[i] = phase[i]; + inphase[i] = i1[i]; + quadrature[i] = q1[i]; + + // Trend mode: cycle when SmoothPeriod >= 20, trend when < 20 + trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 }; + } + } + + // Trendline: average over the current dominant cycle period + for i in HT_LOOKBACK..n { + let sp = smooth_period[i]; + let dc = (sp.round() as usize).max(1).min(i + 1); + let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum(); + trendline[i] = sum / dc as f64; + } + + HtCore { + trendline, + dc_period, + dc_phase, + inphase, + quadrature, + trend_mode, + } +} + +// --------------------------------------------------------------------------- +// Public indicator functions +// --------------------------------------------------------------------------- + +/// Hilbert Transform Instantaneous Trendline (Ehlers). +/// Smooths price over the dominant cycle period. +pub fn ht_trendline(close: &[f64]) -> Vec { + compute_ht_core(close).trendline +} + +/// Hilbert Transform Dominant Cycle Period in bars. +pub fn ht_dcperiod(close: &[f64]) -> Vec { + compute_ht_core(close).dc_period +} + +/// Hilbert Transform Dominant Cycle Phase in degrees. +pub fn ht_dcphase(close: &[f64]) -> Vec { + compute_ht_core(close).dc_phase +} + +/// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`. +pub fn ht_phasor(close: &[f64]) -> (Vec, Vec) { + let core = compute_ht_core(close); + (core.inphase, core.quadrature) +} + +/// Hilbert Transform SineWave. Returns `(sine, leadsine)` where leadsine +/// leads sine by 45 degrees. +pub fn ht_sine(close: &[f64]) -> (Vec, Vec) { + let n = close.len(); + let core = compute_ht_core(close); + + let mut sine = vec![f64::NAN; n]; + let mut lead_sine = vec![f64::NAN; n]; + + for i in HT_LOOKBACK..n { + if !core.dc_phase[i].is_nan() { + let phase_rad = core.dc_phase[i] * PI / 180.0; + sine[i] = phase_rad.sin(); + lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead + } + } + + (sine, lead_sine) +} + +/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling. +pub fn ht_trendmode(close: &[f64]) -> Vec { + compute_ht_core(close).trend_mode +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Generate a simple sine wave for testing cycle detection. + fn sine_wave(n: usize, period: f64) -> Vec { + (0..n) + .map(|i| 100.0 + 10.0 * (2.0 * PI * i as f64 / period).sin()) + .collect() + } + + /// Flat price series for baseline testing. + fn flat_prices(n: usize) -> Vec { + vec![100.0; n] + } + + #[test] + fn test_ht_trendline_length_and_lookback() { + let close = sine_wave(200, 20.0); + let result = ht_trendline(&close); + assert_eq!(result.len(), close.len()); + // First HT_LOOKBACK values must be NaN + for v in &result[..HT_LOOKBACK] { + assert!(v.is_nan(), "expected NaN in lookback region"); + } + // Values after lookback must be finite + for v in &result[HT_LOOKBACK..] { + assert!(v.is_finite(), "expected finite value after lookback"); + } + } + + #[test] + fn test_ht_dcperiod_length_and_lookback() { + let close = sine_wave(200, 20.0); + let result = ht_dcperiod(&close); + assert_eq!(result.len(), close.len()); + for v in &result[..HT_LOOKBACK] { + assert!(v.is_nan()); + } + // After lookback, period should be positive and finite + for v in &result[HT_LOOKBACK..] { + assert!(v.is_finite()); + assert!(*v >= 6.0 && *v <= 50.0, "period {} out of [6,50]", v); + } + } + + #[test] + fn test_ht_dcphase_length_and_lookback() { + let close = sine_wave(200, 20.0); + let result = ht_dcphase(&close); + assert_eq!(result.len(), close.len()); + for v in &result[..HT_LOOKBACK] { + assert!(v.is_nan()); + } + for v in &result[HT_LOOKBACK..] { + assert!(v.is_finite()); + } + } + + #[test] + fn test_ht_phasor_dual_output() { + let close = sine_wave(200, 20.0); + let (inp, quad) = ht_phasor(&close); + assert_eq!(inp.len(), close.len()); + assert_eq!(quad.len(), close.len()); + for v in &inp[..HT_LOOKBACK] { + assert!(v.is_nan()); + } + for v in &quad[..HT_LOOKBACK] { + assert!(v.is_nan()); + } + } + + #[test] + fn test_ht_sine_dual_output() { + let close = sine_wave(200, 20.0); + let (s, ls) = ht_sine(&close); + assert_eq!(s.len(), close.len()); + assert_eq!(ls.len(), close.len()); + for v in &s[..HT_LOOKBACK] { + assert!(v.is_nan()); + } + // Sine values should be in [-1, 1] + for v in &s[HT_LOOKBACK..] { + assert!(v.is_finite()); + assert!(*v >= -1.0 && *v <= 1.0, "sine {} out of [-1,1]", v); + } + for v in &ls[HT_LOOKBACK..] { + assert!(v.is_finite()); + assert!(*v >= -1.0 && *v <= 1.0, "leadsine {} out of [-1,1]", v); + } + } + + #[test] + fn test_ht_trendmode_values() { + let close = sine_wave(200, 20.0); + let result = ht_trendmode(&close); + assert_eq!(result.len(), close.len()); + // All values must be 0 or 1 + for v in &result { + assert!(*v == 0 || *v == 1, "trend_mode {} not 0 or 1", v); + } + } + + #[test] + fn test_short_input_all_nan() { + let close = vec![100.0; HT_LOOKBACK]; // exactly HT_LOOKBACK, not enough + let tl = ht_trendline(&close); + assert!(tl.iter().all(|v| v.is_nan())); + let dp = ht_dcperiod(&close); + assert!(dp.iter().all(|v| v.is_nan())); + } + + #[test] + fn test_flat_prices_trendline_equals_price() { + let close = flat_prices(200); + let tl = ht_trendline(&close); + // For a flat price, trendline after lookback should be very close to the price + for v in &tl[HT_LOOKBACK..] { + assert!( + (v - 100.0).abs() < 1e-6, + "trendline {} diverged from flat price", + v + ); + } + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/extended.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/extended.rs new file mode 100644 index 0000000..5706800 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/extended.rs @@ -0,0 +1,962 @@ +//! Extended indicators — pure Rust implementations (no PyO3, no numpy). +//! +//! These indicators are not part of TA-Lib and provide additional technical +//! analysis capabilities. All functions operate on `&[f64]` slices and return +//! `Vec` (or tuples thereof). + +#![allow(clippy::too_many_arguments)] + +use crate::math; +use crate::overlap; +// Note: we use a local compute_atr helper (seeds from bar 0) rather than +// crate::volatility::atr (which seeds from bar 1, TA-Lib style). + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Compute ATR array using Wilder smoothing (same algorithm as in the PyO3 +/// extended module — seeds from bar 0, not bar 1 like TA-Lib's `volatility::atr`). +fn compute_atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if n <= timeperiod { + return result; + } + // Seed: SMA of first `timeperiod` true range values + let mut seed_sum = high[0] - low[0]; // first TR has no prev_close + for i in 1..timeperiod { + let hl = high[i] - low[i]; + let hc = (high[i] - close[i - 1]).abs(); + let lc = (low[i] - close[i - 1]).abs(); + seed_sum += hl.max(hc).max(lc); + } + let mut atr = seed_sum / timeperiod as f64; + result[timeperiod - 1] = atr; + let pf = (timeperiod - 1) as f64; + for i in timeperiod..n { + let hl = high[i] - low[i]; + let hc = (high[i] - close[i - 1]).abs(); + let lc = (low[i] - close[i - 1]).abs(); + let tr = hl.max(hc).max(lc); + atr = (atr * pf + tr) / timeperiod as f64; + result[i] = atr; + } + result +} + +// --------------------------------------------------------------------------- +// VWAP +// --------------------------------------------------------------------------- + +/// Volume Weighted Average Price (cumulative or rolling). +/// +/// # Arguments +/// * `high`, `low`, `close`, `volume` — equal-length price/volume slices. +/// * `timeperiod` — 0 for cumulative VWAP from bar 0; >= 1 for a rolling window. +/// +/// # Returns +/// A `Vec` of VWAP values. For rolling mode the first `timeperiod - 1` +/// entries are `NaN`. +pub fn vwap( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + timeperiod: usize, +) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + + if timeperiod == 0 { + let mut cum_tpv = 0.0_f64; + let mut cum_vol = 0.0_f64; + for i in 0..n { + let tp = (high[i] + low[i] + close[i]) / 3.0; + cum_tpv += tp * volume[i]; + cum_vol += volume[i]; + result[i] = if cum_vol != 0.0 { + cum_tpv / cum_vol + } else { + f64::NAN + }; + } + } else { + // Pre-compute cumulative sums for O(n) rolling window + let mut cum_tpv_arr = vec![0.0_f64; n]; + let mut cum_vol_arr = vec![0.0_f64; n]; + for i in 0..n { + let tp = (high[i] + low[i] + close[i]) / 3.0; + let tpv = tp * volume[i]; + cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 }; + cum_vol_arr[i] = volume[i] + if i > 0 { cum_vol_arr[i - 1] } else { 0.0 }; + } + for i in (timeperiod - 1)..n { + let prev_tpv = if i >= timeperiod { + cum_tpv_arr[i - timeperiod] + } else { + 0.0 + }; + let prev_vol = if i >= timeperiod { + cum_vol_arr[i - timeperiod] + } else { + 0.0 + }; + let w_tpv = cum_tpv_arr[i] - prev_tpv; + let w_vol = cum_vol_arr[i] - prev_vol; + result[i] = if w_vol != 0.0 { + w_tpv / w_vol + } else { + f64::NAN + }; + } + } + result +} + +// --------------------------------------------------------------------------- +// VWMA +// --------------------------------------------------------------------------- + +/// Volume Weighted Moving Average. +/// +/// `VWMA = sum(close * volume, n) / sum(volume, n)` +/// +/// # Arguments +/// * `close` — price series. +/// * `volume` — volume series (same length as `close`). +/// * `timeperiod` — rolling window size (>= 1). +/// +/// # Returns +/// A `Vec` with `NaN` for the first `timeperiod - 1` entries. +pub fn vwma(close: &[f64], volume: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + + let mut cum_cv = vec![0.0_f64; n]; + let mut cum_v = vec![0.0_f64; n]; + for i in 0..n { + cum_cv[i] = close[i] * volume[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 }; + cum_v[i] = volume[i] + if i > 0 { cum_v[i - 1] } else { 0.0 }; + } + + for i in (timeperiod - 1)..n { + let prev_cv = if i >= timeperiod { + cum_cv[i - timeperiod] + } else { + 0.0 + }; + let prev_v = if i >= timeperiod { + cum_v[i - timeperiod] + } else { + 0.0 + }; + let w_cv = cum_cv[i] - prev_cv; + let w_v = cum_v[i] - prev_v; + result[i] = if w_v != 0.0 { w_cv / w_v } else { f64::NAN }; + } + result +} + +// --------------------------------------------------------------------------- +// SUPERTREND +// --------------------------------------------------------------------------- + +/// ATR-based Supertrend indicator. +/// +/// # Returns +/// `(supertrend_line, direction)` where direction values are: +/// * `1` = uptrend +/// * `-1` = downtrend +/// * `0` = warmup (first `timeperiod` bars) +pub fn supertrend( + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod: usize, + multiplier: f64, +) -> (Vec, Vec) { + let n = high.len(); + let mut supertrend_out = vec![f64::NAN; n]; + let mut direction = vec![0_i8; n]; + + if timeperiod < 1 || n <= timeperiod { + return (supertrend_out, direction); + } + + let atr = compute_atr(high, low, close, timeperiod); + + let mut upper_band = vec![f64::NAN; n]; + let mut lower_band = vec![f64::NAN; n]; + + let first_valid = timeperiod - 1; + if first_valid >= n || atr[first_valid].is_nan() { + return (supertrend_out, direction); + } + + // Initialize band state at first valid ATR bar (compute basic bands inline) + { + let hl2 = (high[first_valid] + low[first_valid]) / 2.0; + upper_band[first_valid] = hl2 + multiplier * atr[first_valid]; + lower_band[first_valid] = hl2 - multiplier * atr[first_valid]; + } + + for i in (first_valid + 1)..n { + if atr[i].is_nan() { + continue; + } + + // Compute basic bands as scalars — no Vec allocation needed + let hl2 = (high[i] + low[i]) / 2.0; + let upper_basic = hl2 + multiplier * atr[i]; + let lower_basic = hl2 - multiplier * atr[i]; + + // Adjust lower band + lower_band[i] = if lower_basic > lower_band[i - 1] || close[i - 1] < lower_band[i - 1] { + lower_basic + } else { + lower_band[i - 1] + }; + + // Adjust upper band + upper_band[i] = if upper_basic < upper_band[i - 1] || close[i - 1] > upper_band[i - 1] { + upper_basic + } else { + upper_band[i - 1] + }; + + // Direction and output only from index timeperiod (warmup = 0, NaN) + if i >= timeperiod { + let prev_dir = direction[i - 1]; + direction[i] = if prev_dir == 0 || prev_dir == -1 { + if close[i] > upper_band[i] { + 1 + } else { + -1 + } + } else if close[i] < lower_band[i] { + -1 + } else { + 1 + }; + supertrend_out[i] = if direction[i] == 1 { + lower_band[i] + } else { + upper_band[i] + }; + } + } + + (supertrend_out, direction) +} + +// --------------------------------------------------------------------------- +// DONCHIAN +// --------------------------------------------------------------------------- + +/// Donchian Channels — rolling highest high / lowest low. +/// +/// # Returns +/// `(upper, middle, lower)` arrays. +pub fn donchian(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec, Vec, Vec) { + let n = high.len(); + let mut upper = vec![f64::NAN; n]; + let mut lower = vec![f64::NAN; n]; + let mut middle = vec![f64::NAN; n]; + + if timeperiod < 1 || n < timeperiod { + return (upper, middle, lower); + } + + let hh = math::sliding_max(high, timeperiod); + let ll = math::sliding_min(low, timeperiod); + + for i in 0..n { + if !hh[i].is_nan() { + upper[i] = hh[i]; + lower[i] = ll[i]; + middle[i] = (upper[i] + lower[i]) / 2.0; + } + } + + (upper, middle, lower) +} + +// --------------------------------------------------------------------------- +// CHOPPINESS_INDEX +// --------------------------------------------------------------------------- + +/// Choppiness Index — measures market choppiness vs trending. +/// +/// Values near 100 indicate a choppy market; near 0 indicates trending. +/// The first `timeperiod` values are `NaN`. +pub fn choppiness_index(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n <= timeperiod { + return result; + } + + // ATR(1) = True Range per bar + let mut tr = vec![0.0_f64; n]; + tr[0] = high[0] - low[0]; + for i in 1..n { + let hl = high[i] - low[i]; + let hc = (high[i] - close[i - 1]).abs(); + let lc = (low[i] - close[i - 1]).abs(); + tr[i] = hl.max(hc).max(lc); + } + + // Cumulative TR for rolling sum + let mut cum_tr = vec![0.0_f64; n]; + cum_tr[0] = tr[0]; + for i in 1..n { + cum_tr[i] = cum_tr[i - 1] + tr[i]; + } + + let log_n = (timeperiod as f64).log10(); + + let hh = math::sliding_max(high, timeperiod); + let ll = math::sliding_min(low, timeperiod); + + for i in (timeperiod)..n { + let prev_cum = cum_tr[i - timeperiod]; + let sum_tr = cum_tr[i] - prev_cum; + let hl_range = hh[i] - ll[i]; + if hl_range > 0.0 && log_n > 0.0 { + result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n; + } + } + + result +} + +// --------------------------------------------------------------------------- +// KELTNER_CHANNELS +// --------------------------------------------------------------------------- + +/// Keltner Channels — EMA +/- (multiplier x ATR). +/// +/// # Returns +/// `(upper, middle, lower)` arrays. +pub fn keltner_channels( + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod: usize, + atr_period: usize, + multiplier: f64, +) -> (Vec, Vec, Vec) { + let n = high.len(); + if timeperiod < 1 || atr_period < 1 || n < timeperiod || n < atr_period { + let nan = vec![f64::NAN; n]; + return (nan.clone(), nan.clone(), nan); + } + + let middle = overlap::ema(close, timeperiod); + let atr = compute_atr(high, low, close, atr_period); + + 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[i].is_nan() { + let band = multiplier * atr[i]; + upper[i] = middle[i] + band; + lower[i] = middle[i] - band; + } + } + + (upper, middle, lower) +} + +// --------------------------------------------------------------------------- +// HULL_MA +// --------------------------------------------------------------------------- + +/// Hull Moving Average (HMA). +/// +/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))` +pub fn hull_ma(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + if timeperiod < 1 || n < timeperiod { + return vec![f64::NAN; n]; + } + + let half = (timeperiod / 2).max(1); + let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1); + + let wma_full = overlap::wma(close, timeperiod); + let wma_half = overlap::wma(close, half); + + // raw = 2 * wma_half - wma_full + let mut raw = vec![f64::NAN; n]; + for i in 0..n { + if !wma_full[i].is_nan() && !wma_half[i].is_nan() { + raw[i] = 2.0 * wma_half[i] - wma_full[i]; + } + } + + // Find first valid index in raw + let first_valid = raw.iter().position(|x| !x.is_nan()).unwrap_or(n); + let mut hull = vec![f64::NAN; n]; + if first_valid < n { + let raw_valid = &raw[first_valid..]; + let hma_slice = overlap::wma(raw_valid, sqrt_p); + for (k, &v) in hma_slice.iter().enumerate() { + hull[first_valid + k] = v; + } + } + + hull +} + +// --------------------------------------------------------------------------- +// CHANDELIER_EXIT +// --------------------------------------------------------------------------- + +/// Chandelier Exit — ATR-based trailing stop levels. +/// +/// # Returns +/// `(long_exit, short_exit)` arrays. +pub fn chandelier_exit( + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod: usize, + multiplier: f64, +) -> (Vec, Vec) { + let n = high.len(); + if timeperiod < 1 || n < timeperiod { + return (vec![f64::NAN; n], vec![f64::NAN; n]); + } + + let atr = compute_atr(high, low, close, timeperiod); + + let highest_high = math::sliding_max(high, timeperiod); + let lowest_low = math::sliding_min(low, timeperiod); + + let mut long_exit = vec![f64::NAN; n]; + let mut short_exit = vec![f64::NAN; n]; + for i in 0..n { + if !highest_high[i].is_nan() && !atr[i].is_nan() { + long_exit[i] = highest_high[i] - multiplier * atr[i]; + short_exit[i] = lowest_low[i] + multiplier * atr[i]; + } + } + + (long_exit, short_exit) +} + +// --------------------------------------------------------------------------- +// ICHIMOKU +// --------------------------------------------------------------------------- + +/// Ichimoku Cloud (Ichimoku Kinko Hyo). +/// +/// # Returns +/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` arrays. +#[allow(clippy::type_complexity)] +pub fn ichimoku( + high: &[f64], + low: &[f64], + close: &[f64], + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> (Vec, Vec, Vec, Vec, Vec) { + let n = high.len(); + let nan = || vec![f64::NAN; n]; + + if tenkan_period < 1 || kijun_period < 1 || senkou_b_period < 1 { + return (nan(), nan(), nan(), nan(), nan()); + } + + // Helper: rolling (H+L)/2 via shared sliding_max / sliding_min + let midpoint_rolling = |period: usize| -> Vec { + let hh = math::sliding_max(high, period); + let ll = math::sliding_min(low, period); + let mut result = vec![f64::NAN; n]; + for i in 0..n { + if !hh[i].is_nan() { + result[i] = (hh[i] + ll[i]) / 2.0; + } + } + result + }; + + let tenkan = midpoint_rolling(tenkan_period); + let kijun = midpoint_rolling(kijun_period); + let raw_b = midpoint_rolling(senkou_b_period); + + // Senkou A: (tenkan + kijun) / 2 shifted back `displacement` bars + let mut senkou_a = vec![f64::NAN; n]; + if n > displacement { + for i in displacement..n { + if !tenkan[i].is_nan() && !kijun[i].is_nan() { + senkou_a[i - displacement] = (tenkan[i] + kijun[i]) / 2.0; + } + } + } + + // Senkou B: raw_b shifted back `displacement` bars + let mut senkou_b = vec![f64::NAN; n]; + if n > displacement { + senkou_b[..n - displacement].copy_from_slice(&raw_b[displacement..]); + } + + // Chikou: close shifted forward `displacement` bars + let mut chikou = vec![f64::NAN; n]; + if n > displacement { + chikou[displacement..].copy_from_slice(&close[..n - displacement]); + } + + (tenkan, kijun, senkou_a, senkou_b, chikou) +} + +// --------------------------------------------------------------------------- +// PIVOT_POINTS +// --------------------------------------------------------------------------- + +/// Pivot Points — support / resistance levels computed from the previous bar. +/// +/// # Arguments +/// * `method` — `"classic"`, `"fibonacci"`, or `"camarilla"`. Returns all-NaN +/// vectors for unknown methods. +/// +/// # Returns +/// `(pivot, r1, s1, r2, s2)` arrays. Index 0 is always `NaN` (no previous bar). +#[allow(clippy::type_complexity)] +pub fn pivot_points( + high: &[f64], + low: &[f64], + close: &[f64], + method: &str, +) -> (Vec, Vec, Vec, Vec, Vec) { + let n = high.len(); + let mut pivot = vec![f64::NAN; n]; + let mut r1 = vec![f64::NAN; n]; + let mut s1 = vec![f64::NAN; n]; + let mut r2 = vec![f64::NAN; n]; + let mut s2 = vec![f64::NAN; n]; + + let method_lower = method.to_lowercase(); + if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") { + // Unknown method — return all NaN + return (pivot, r1, s1, r2, s2); + } + + for i in 1..n { + let ph = high[i - 1]; + let pl = low[i - 1]; + let pc = close[i - 1]; + let hl = ph - pl; + let p = (ph + pl + pc) / 3.0; + pivot[i] = p; + match method_lower.as_str() { + "classic" => { + r1[i] = 2.0 * p - pl; + s1[i] = 2.0 * p - ph; + r2[i] = p + hl; + s2[i] = p - hl; + } + "fibonacci" => { + r1[i] = p + 0.382 * hl; + s1[i] = p - 0.382 * hl; + r2[i] = p + 0.618 * hl; + s2[i] = p - 0.618 * hl; + } + "camarilla" => { + r1[i] = pc + 1.1 * hl / 12.0; + s1[i] = pc - 1.1 * hl / 12.0; + r2[i] = pc + 1.1 * hl / 6.0; + s2[i] = pc - 1.1 * hl / 6.0; + } + _ => unreachable!(), + } + } + + (pivot, r1, s1, r2, s2) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // Shared test data: 10-bar OHLCV + fn sample_ohlcv() -> (Vec, Vec, Vec, Vec) { + let high = vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.5, 15.5, 16.0, 15.0, 14.0]; + let low = vec![9.0, 10.0, 11.0, 12.0, 13.0, 12.5, 13.5, 14.0, 13.0, 12.0]; + let close = vec![10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 14.5, 15.0, 14.0, 13.0]; + let volume = vec![ + 100.0, 150.0, 200.0, 250.0, 300.0, 200.0, 350.0, 400.0, 180.0, 220.0, + ]; + (high, low, close, volume) + } + + // ----------------------------------------------------------------------- + // VWAP tests + // ----------------------------------------------------------------------- + + #[test] + fn vwap_cumulative_basic() { + let (h, l, c, v) = sample_ohlcv(); + let result = vwap(&h, &l, &c, &v, 0); + assert_eq!(result.len(), h.len()); + // First bar: tp = (11+9+10)/3 = 10.0, tpv = 1000.0, vol = 100.0 => 10.0 + assert!((result[0] - 10.0).abs() < 1e-10); + // All values should be non-NaN for cumulative + for val in &result { + assert!(!val.is_nan()); + } + } + + #[test] + fn vwap_empty_input() { + let result = vwap(&[], &[], &[], &[], 0); + assert!(result.is_empty()); + } + + #[test] + fn vwap_rolling_basic() { + let (h, l, c, v) = sample_ohlcv(); + let result = vwap(&h, &l, &c, &v, 3); + assert_eq!(result.len(), h.len()); + // First 2 values should be NaN + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + // From index 2 onward should be valid + assert!(!result[2].is_nan()); + } + + // ----------------------------------------------------------------------- + // VWMA tests + // ----------------------------------------------------------------------- + + #[test] + fn vwma_basic() { + let (_, _, c, v) = sample_ohlcv(); + let result = vwma(&c, &v, 3); + assert_eq!(result.len(), c.len()); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + // Index 2: sum(c*v, 0..3) / sum(v, 0..3) = (1000+1650+2400)/(100+150+200) = 5050/450 + let expected = (10.0 * 100.0 + 11.0 * 150.0 + 12.0 * 200.0) / (100.0 + 150.0 + 200.0); + assert!((result[2] - expected).abs() < 1e-10); + } + + #[test] + fn vwma_empty_input() { + let result = vwma(&[], &[], 3); + assert!(result.is_empty()); + } + + #[test] + fn vwma_period_larger_than_data() { + let result = vwma(&[1.0, 2.0], &[100.0, 200.0], 5); + assert_eq!(result.len(), 2); + assert!(result.iter().all(|v| v.is_nan())); + } + + // ----------------------------------------------------------------------- + // SUPERTREND tests + // ----------------------------------------------------------------------- + + #[test] + fn supertrend_basic() { + let (h, l, c, _) = sample_ohlcv(); + let (st, dir) = supertrend(&h, &l, &c, 3, 2.0); + assert_eq!(st.len(), h.len()); + assert_eq!(dir.len(), h.len()); + // First 3 bars should be warmup (direction = 0, st = NaN) + for i in 0..3 { + assert_eq!(dir[i], 0); + assert!(st[i].is_nan()); + } + // From bar 3 onward, direction should be 1 or -1 + for i in 3..h.len() { + assert!(dir[i] == 1 || dir[i] == -1); + assert!(!st[i].is_nan()); + } + } + + #[test] + fn supertrend_empty_input() { + let (st, dir) = supertrend(&[], &[], &[], 3, 2.0); + assert!(st.is_empty()); + assert!(dir.is_empty()); + } + + #[test] + fn supertrend_insufficient_data() { + let (st, dir) = supertrend(&[1.0, 2.0], &[0.5, 1.5], &[1.5, 1.8], 5, 2.0); + assert!(st.iter().all(|v| v.is_nan())); + assert!(dir.iter().all(|&d| d == 0)); + } + + // ----------------------------------------------------------------------- + // DONCHIAN tests + // ----------------------------------------------------------------------- + + #[test] + fn donchian_basic() { + let (h, l, _, _) = sample_ohlcv(); + let (upper, middle, lower) = donchian(&h, &l, 3); + assert_eq!(upper.len(), h.len()); + // First 2 are NaN + assert!(upper[0].is_nan()); + assert!(upper[1].is_nan()); + // Index 2: max(11,12,13)=13, min(9,10,11)=9 + assert!((upper[2] - 13.0).abs() < 1e-10); + assert!((lower[2] - 9.0).abs() < 1e-10); + assert!((middle[2] - 11.0).abs() < 1e-10); + } + + #[test] + fn donchian_empty_input() { + let (u, m, l) = donchian(&[], &[], 3); + assert!(u.is_empty()); + assert!(m.is_empty()); + assert!(l.is_empty()); + } + + #[test] + fn donchian_period_1() { + let h = vec![5.0, 3.0, 7.0]; + let l = vec![2.0, 1.0, 4.0]; + let (upper, middle, lower) = donchian(&h, &l, 1); + // Every bar is its own window + assert!((upper[0] - 5.0).abs() < 1e-10); + assert!((lower[0] - 2.0).abs() < 1e-10); + assert!((middle[0] - 3.5).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // CHOPPINESS_INDEX tests + // ----------------------------------------------------------------------- + + #[test] + fn choppiness_index_basic() { + let (h, l, c, _) = sample_ohlcv(); + let result = choppiness_index(&h, &l, &c, 3); + assert_eq!(result.len(), h.len()); + // First 3 values should be NaN (timeperiod=3, i+1 > 3 starts at i=3) + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!(result[2].is_nan()); + // Index 3 should have a valid value (i+1=4 > 3) + assert!(!result[3].is_nan()); + // CI should be between 0 and 100 + for val in result.iter().filter(|v| !v.is_nan()) { + assert!(*val >= 0.0 && *val <= 100.0); + } + } + + #[test] + fn choppiness_index_empty_input() { + let result = choppiness_index(&[], &[], &[], 3); + assert!(result.is_empty()); + } + + // ----------------------------------------------------------------------- + // KELTNER_CHANNELS tests + // ----------------------------------------------------------------------- + + #[test] + fn keltner_channels_basic() { + let (h, l, c, _) = sample_ohlcv(); + let (upper, middle, lower) = keltner_channels(&h, &l, &c, 3, 3, 1.5); + assert_eq!(upper.len(), h.len()); + // Where both EMA and ATR are valid, upper > middle > lower + for i in 0..h.len() { + if !upper[i].is_nan() && !lower[i].is_nan() { + assert!(upper[i] > middle[i]); + assert!(lower[i] < middle[i]); + } + } + } + + #[test] + fn keltner_channels_empty_input() { + let (u, m, l) = keltner_channels(&[], &[], &[], 3, 3, 1.5); + assert!(u.is_empty()); + assert!(m.is_empty()); + assert!(l.is_empty()); + } + + // ----------------------------------------------------------------------- + // HULL_MA tests + // ----------------------------------------------------------------------- + + #[test] + fn hull_ma_basic() { + let prices: Vec = (1..=20).map(|i| i as f64).collect(); + let result = hull_ma(&prices, 4); + assert_eq!(result.len(), prices.len()); + // Should have some NaN warmup, then valid values + let valid_count = result.iter().filter(|v| !v.is_nan()).count(); + assert!(valid_count > 0); + } + + #[test] + fn hull_ma_empty_input() { + let result = hull_ma(&[], 4); + assert!(result.is_empty()); + } + + #[test] + fn hull_ma_period_larger_than_data() { + let result = hull_ma(&[1.0, 2.0], 10); + assert!(result.iter().all(|v| v.is_nan())); + } + + // ----------------------------------------------------------------------- + // CHANDELIER_EXIT tests + // ----------------------------------------------------------------------- + + #[test] + fn chandelier_exit_basic() { + let (h, l, c, _) = sample_ohlcv(); + let (long_exit, short_exit) = chandelier_exit(&h, &l, &c, 3, 2.0); + assert_eq!(long_exit.len(), h.len()); + assert_eq!(short_exit.len(), h.len()); + // Where valid, long_exit should be below highest high + for i in 0..h.len() { + if !long_exit[i].is_nan() { + // long_exit = highest_high - multiplier * atr, should be < max high + assert!(long_exit[i] < 20.0); // sanity + } + } + } + + #[test] + fn chandelier_exit_empty_input() { + let (le, se) = chandelier_exit(&[], &[], &[], 3, 2.0); + assert!(le.is_empty()); + assert!(se.is_empty()); + } + + // ----------------------------------------------------------------------- + // ICHIMOKU tests + // ----------------------------------------------------------------------- + + #[test] + fn ichimoku_basic() { + // Use a larger dataset for ichimoku + let n = 60; + let high: Vec = (0..n).map(|i| 100.0 + i as f64 + 1.0).collect(); + let low: Vec = (0..n).map(|i| 100.0 + i as f64 - 1.0).collect(); + let close: Vec = (0..n).map(|i| 100.0 + i as f64).collect(); + + let (tenkan, kijun, senkou_a, senkou_b, chikou) = + ichimoku(&high, &low, &close, 9, 26, 52, 26); + + assert_eq!(tenkan.len(), n); + assert_eq!(kijun.len(), n); + assert_eq!(senkou_a.len(), n); + assert_eq!(senkou_b.len(), n); + assert_eq!(chikou.len(), n); + + // Tenkan: period 9, first valid at index 8 + assert!(tenkan[7].is_nan()); + assert!(!tenkan[8].is_nan()); + + // Kijun: period 26, first valid at index 25 + assert!(kijun[24].is_nan()); + assert!(!kijun[25].is_nan()); + + // Chikou: close shifted forward by 26 bars + assert!(chikou[25].is_nan()); + assert!(!chikou[26].is_nan()); + assert!((chikou[26] - close[0]).abs() < 1e-10); + } + + #[test] + fn ichimoku_empty_input() { + let (t, k, sa, sb, ch) = ichimoku(&[], &[], &[], 9, 26, 52, 26); + assert!(t.is_empty()); + assert!(k.is_empty()); + assert!(sa.is_empty()); + assert!(sb.is_empty()); + assert!(ch.is_empty()); + } + + // ----------------------------------------------------------------------- + // PIVOT_POINTS tests + // ----------------------------------------------------------------------- + + #[test] + fn pivot_points_classic() { + let h = vec![10.0, 12.0, 11.0]; + let l = vec![8.0, 9.0, 8.5]; + let c = vec![9.0, 11.0, 10.0]; + let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "classic"); + assert_eq!(pivot.len(), 3); + // Index 0 is NaN + assert!(pivot[0].is_nan()); + // Index 1: prev bar H=10, L=8, C=9 => P=(10+8+9)/3=9.0 + assert!((pivot[1] - 9.0).abs() < 1e-10); + // R1 = 2*P - L = 18 - 8 = 10 + assert!((r1[1] - 10.0).abs() < 1e-10); + // S1 = 2*P - H = 18 - 10 = 8 + assert!((s1[1] - 8.0).abs() < 1e-10); + // R2 = P + (H-L) = 9 + 2 = 11 + assert!((r2[1] - 11.0).abs() < 1e-10); + // S2 = P - (H-L) = 9 - 2 = 7 + assert!((s2[1] - 7.0).abs() < 1e-10); + } + + #[test] + fn pivot_points_fibonacci() { + let h = vec![10.0, 12.0]; + let l = vec![8.0, 9.0]; + let c = vec![9.0, 11.0]; + let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "fibonacci"); + // Index 1: P = (10+8+9)/3 = 9.0, HL = 2 + assert!((pivot[1] - 9.0).abs() < 1e-10); + assert!((r1[1] - (9.0 + 0.382 * 2.0)).abs() < 1e-10); + assert!((s1[1] - (9.0 - 0.382 * 2.0)).abs() < 1e-10); + } + + #[test] + fn pivot_points_camarilla() { + let h = vec![10.0, 12.0]; + let l = vec![8.0, 9.0]; + let c = vec![9.0, 11.0]; + let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "camarilla"); + assert!((pivot[1] - 9.0).abs() < 1e-10); + // R1 = C + 1.1 * HL / 12 = 9 + 1.1*2/12 + assert!((r1[1] - (9.0 + 1.1 * 2.0 / 12.0)).abs() < 1e-10); + assert!((s1[1] - (9.0 - 1.1 * 2.0 / 12.0)).abs() < 1e-10); + } + + #[test] + fn pivot_points_unknown_method() { + let h = vec![10.0, 12.0]; + let l = vec![8.0, 9.0]; + let c = vec![9.0, 11.0]; + let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "unknown"); + assert!(pivot.iter().all(|v| v.is_nan())); + assert!(r1.iter().all(|v| v.is_nan())); + assert!(s1.iter().all(|v| v.is_nan())); + assert!(r2.iter().all(|v| v.is_nan())); + assert!(s2.iter().all(|v| v.is_nan())); + } + + #[test] + fn pivot_points_empty_input() { + let (p, r1, s1, r2, s2) = pivot_points(&[], &[], &[], "classic"); + assert!(p.is_empty()); + assert!(r1.is_empty()); + assert!(s1.is_empty()); + assert!(r2.is_empty()); + assert!(s2.is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs new file mode 100644 index 0000000..f7e4ade --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/basis.rs @@ -0,0 +1,55 @@ +//! Basis and carry analytics. + +/// Futures basis: futures - spot. +pub fn basis(spot: f64, future: f64) -> f64 { + if !spot.is_finite() || !future.is_finite() { + f64::NAN + } else { + future - spot + } +} + +/// Annualized simple basis return. +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + if !spot.is_finite() + || !future.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (future / spot - 1.0) / time_to_expiry +} + +/// Implied continuously compounded carry rate. +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + if !spot.is_finite() + || !future.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || future <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (future / spot).ln() / time_to_expiry +} + +/// Carry spread relative to the risk-free rate. +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 { + implied_carry_rate(spot, future, time_to_expiry) - rate +} + +#[cfg(test)] +mod tests { + use super::{annualized_basis, basis, carry_spread, implied_carry_rate}; + + #[test] + fn basis_helpers_work() { + assert_eq!(basis(100.0, 103.0), 3.0); + assert!(annualized_basis(100.0, 103.0, 0.25) > 0.0); + assert!(implied_carry_rate(100.0, 103.0, 0.25) > 0.0); + assert!(carry_spread(100.0, 103.0, 0.02, 0.25).is_finite()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs new file mode 100644 index 0000000..773d7e4 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/curve.rs @@ -0,0 +1,83 @@ +//! Futures curve and term-structure analytics. + +use super::basis; + +/// Curve summary metrics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CurveSummary { + pub front_basis: f64, + pub average_basis: f64, + pub slope: f64, + pub is_contango: bool, +} + +fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 { + if xs.len() != ys.len() || xs.len() < 2 { + return f64::NAN; + } + let n = xs.len() as f64; + let mean_x = xs.iter().sum::() / n; + let mean_y = ys.iter().sum::() / n; + let mut cov = 0.0; + let mut var = 0.0; + for (&x, &y) in xs.iter().zip(ys.iter()) { + cov += (x - mean_x) * (y - mean_y); + var += (x - mean_x) * (x - mean_x); + } + if var == 0.0 { + f64::NAN + } else { + cov / var + } +} + +/// Calendar spreads between adjacent contracts. +pub fn calendar_spreads(futures_prices: &[f64]) -> Vec { + futures_prices.windows(2).map(|w| w[1] - w[0]).collect() +} + +/// Curve slope across tenor buckets. +pub fn curve_slope(tenors: &[f64], futures_prices: &[f64]) -> f64 { + regression_slope(tenors, futures_prices) +} + +/// Summary statistics for a forward curve. +pub fn curve_summary(spot: f64, tenors: &[f64], futures_prices: &[f64]) -> CurveSummary { + if futures_prices.is_empty() || tenors.len() != futures_prices.len() { + return CurveSummary { + front_basis: f64::NAN, + average_basis: f64::NAN, + slope: f64::NAN, + is_contango: false, + }; + } + let bases: Vec = futures_prices + .iter() + .map(|&price| basis::basis(spot, price)) + .collect(); + let average_basis = bases.iter().sum::() / bases.len() as f64; + let is_contango = futures_prices.windows(2).all(|w| w[1] >= w[0]); + CurveSummary { + front_basis: basis::basis(spot, futures_prices[0]), + average_basis, + slope: curve_slope(tenors, futures_prices), + is_contango, + } +} + +#[cfg(test)] +mod tests { + use super::{calendar_spreads, curve_slope, curve_summary}; + + #[test] + fn calendar_spreads_are_correct() { + assert_eq!(calendar_spreads(&[100.0, 101.0, 103.0]), vec![1.0, 2.0]); + } + + #[test] + fn curve_summary_detects_contango() { + let summary = curve_summary(100.0, &[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]); + assert!(summary.is_contango); + assert!(curve_slope(&[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]) > 0.0); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs new file mode 100644 index 0000000..60fc1f6 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/mod.rs @@ -0,0 +1,6 @@ +//! Futures analytics core. + +pub mod basis; +pub mod curve; +pub mod roll; +pub mod synthetic; diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs new file mode 100644 index 0000000..6e08207 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/roll.rs @@ -0,0 +1,109 @@ +//! Continuous futures roll helpers. + +/// Weighted stitching using next-contract weights in [0, 1]. +pub fn weighted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() { + return Vec::new(); + } + front + .iter() + .zip(next.iter()) + .zip(next_weights.iter()) + .map(|((&f, &n), &w)| f * (1.0 - w) + n * w) + .collect() +} + +fn roll_index(weights: &[f64]) -> Option { + if weights.is_empty() { + return None; + } + weights + .iter() + .enumerate() + .find(|(_, w)| **w >= 0.5) + .map(|(idx, _)| idx) + .or_else(|| weights.iter().position(|w| *w > 0.0)) + .or(Some(weights.len() - 1)) +} + +/// Back-adjusted continuous series using the roll date implied by the weights. +pub fn back_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() { + return Vec::new(); + } + let idx = roll_index(next_weights).unwrap_or(front.len() - 1); + let gap = next[idx] - front[idx]; + front + .iter() + .enumerate() + .map(|(i, &value)| if i < idx { value + gap } else { next[i] }) + .collect() +} + +/// Ratio-adjusted continuous series using the roll date implied by the weights. +pub fn ratio_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec { + if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() { + return Vec::new(); + } + let idx = roll_index(next_weights).unwrap_or(front.len() - 1); + let ratio = if front[idx] == 0.0 { + 1.0 + } else { + next[idx] / front[idx] + }; + front + .iter() + .enumerate() + .map(|(i, &value)| if i < idx { value * ratio } else { next[i] }) + .collect() +} + +/// Annualized roll yield from front and next prices. +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 { + if !front_price.is_finite() + || !next_price.is_finite() + || !time_to_expiry.is_finite() + || front_price <= 0.0 + || time_to_expiry <= 0.0 + { + return f64::NAN; + } + (next_price / front_price - 1.0) / time_to_expiry +} + +#[cfg(test)] +mod tests { + use super::{ + back_adjusted_continuous, ratio_adjusted_continuous, roll_yield, weighted_continuous, + }; + + #[test] + fn weighted_roll_blends_contracts() { + let out = weighted_continuous(&[100.0, 101.0], &[102.0, 103.0], &[0.0, 1.0]); + assert_eq!(out, vec![100.0, 103.0]); + } + + #[test] + fn adjusted_rolls_return_full_series() { + let weights = [0.0, 0.25, 0.75, 1.0]; + assert_eq!( + back_adjusted_continuous( + &[100.0, 101.0, 102.0, 103.0], + &[101.0, 102.0, 103.0, 104.0], + &weights + ) + .len(), + 4 + ); + assert_eq!( + ratio_adjusted_continuous( + &[100.0, 101.0, 102.0, 103.0], + &[101.0, 102.0, 103.0, 104.0], + &weights + ) + .len(), + 4 + ); + assert!(roll_yield(100.0, 102.0, 30.0 / 365.0).is_finite()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs new file mode 100644 index 0000000..02a9acd --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/futures/synthetic.rs @@ -0,0 +1,78 @@ +//! Synthetic futures helpers built from put-call parity. + +/// Synthetic forward price from call/put parity. +pub fn synthetic_forward( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !time_to_expiry.is_finite() + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + (call_price - put_price) * (rate * time_to_expiry).exp() + strike +} + +/// Synthetic spot price implied by call/put parity with continuous carry. +pub fn synthetic_spot( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !carry.is_finite() + || !time_to_expiry.is_finite() + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + (call_price - put_price + strike * (-rate * time_to_expiry).exp()) + * (carry * time_to_expiry).exp() +} + +/// Put-call parity residual. Zero means the inputs are parity-consistent. +pub fn parity_gap( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + call_price + - put_price + - (spot * (-carry * time_to_expiry).exp() - strike * (-rate * time_to_expiry).exp()) +} + +#[cfg(test)] +mod tests { + use super::{parity_gap, synthetic_forward}; + + #[test] + fn synthetic_forward_is_consistent() { + let forward = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5); + assert!(forward > 100.0); + } + + #[test] + fn parity_gap_zero_when_consistent() { + let gap = parity_gap(10.45, 5.57, 100.0, 100.0, 0.05, 0.0, 1.0); + assert!(gap.abs() < 0.05); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/lib.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/lib.rs new file mode 100644 index 0000000..2de6ea3 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/lib.rs @@ -0,0 +1,59 @@ +#![forbid(unsafe_code)] + +/*! +ferro_ta_core — Pure Rust indicator library. + +This crate contains all indicator implementations as pure functions operating +on `&[f64]` slices and returning `Vec`. It has **no dependency on PyO3 +or numpy** so it can be used from any Rust project, or compiled to WASM / +Node.js via napi-rs without dragging in Python bindings. + +The Python wheel (`ferro_ta` PyPI package) is built from a thin binding crate +that calls into this core and converts NumPy arrays to/from Rust slices. + +# Two-layer architecture + +The root crate (`ferro_ta`) contains PyO3 `#[pyfunction]` wrappers that convert +numpy arrays to `&[f64]` and delegate to this core crate. + +# Usage (Rust) + +```rust +use ferro_ta_core::overlap; + +let close = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]; +let sma = overlap::sma(&close, 3); +assert!(sma[0].is_nan()); +assert!((sma[2] - 2.0).abs() < 1e-10); +``` +*/ + +pub mod aggregation; +pub mod alerts; +pub mod attribution; +pub mod backtest; +pub mod batch; +pub mod chunked; +pub mod commission; +pub mod crypto; +pub mod currency; +pub mod cycle; +pub mod extended; +pub mod futures; +pub mod math; +pub mod math_ops; +pub mod momentum; +pub mod options; +pub mod overlap; +pub mod pattern; +pub mod portfolio; +pub mod price_transform; +pub mod regime; +pub mod resampling; +pub mod signals; +/// Runtime-dispatched SIMD reduction primitives (internal). +pub(crate) mod simd; +pub mod statistic; +pub mod streaming; +pub mod volatility; +pub mod volume; diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/math.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/math.rs new file mode 100644 index 0000000..a8877ca --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/math.rs @@ -0,0 +1,217 @@ +//! Math utilities. + +use std::collections::VecDeque; + +/// Compute the rolling sum over `timeperiod` bars. +/// +/// Returns a `Vec` of length `n`. The first `timeperiod - 1` values +/// are `NaN`. Uses an incremental algorithm (add new, subtract old) for O(n). +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn sum(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut win: f64 = real[..timeperiod].iter().sum(); + result[timeperiod - 1] = win; + for i in timeperiod..n { + win += real[i] - real[i - timeperiod]; + result[i] = win; + } + result +} + +/// Compute the rolling maximum over `timeperiod` bars. +/// +/// Delegates to [`sliding_max`] for O(n) performance via a monotonic deque. +/// The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn max(real: &[f64], timeperiod: usize) -> Vec { + sliding_max(real, timeperiod) +} + +/// Compute the rolling minimum over `timeperiod` bars. +/// +/// Delegates to [`sliding_min`] for O(n) performance via a monotonic deque. +/// The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn min(real: &[f64], timeperiod: usize) -> Vec { + sliding_min(real, timeperiod) +} + +/// Compute the sliding maximum over `timeperiod` bars in O(n) time. +/// +/// Uses a monotonic decreasing deque so each element is pushed/popped at +/// most once. The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + // Remove indices outside the window + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + // Maintain decreasing deque + while dq.back().map(|&j| real[j] <= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = real[*dq.front().unwrap()]; + } + } + result +} + +/// Compute the sliding minimum over `timeperiod` bars in O(n) time. +/// +/// Uses a monotonic increasing deque so each element is pushed/popped at +/// most once. The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + // Remove indices outside the window + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + // Maintain increasing deque + while dq.back().map(|&j| real[j] >= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = real[*dq.front().unwrap()]; + } + } + result +} + +// --------------------------------------------------------------------------- +// Element-wise arithmetic operators +// --------------------------------------------------------------------------- + +/// Element-wise addition of two arrays. +pub fn add(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect() +} + +/// Element-wise subtraction of two arrays. +pub fn sub(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect() +} + +/// Element-wise multiplication of two arrays. +pub fn mult(a: &[f64], b: &[f64]) -> Vec { + a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect() +} + +/// Element-wise division of two arrays (NaN where b=0). +pub fn div(a: &[f64], b: &[f64]) -> Vec { + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN }) + .collect() +} + +// --------------------------------------------------------------------------- +// Element-wise math transforms +// --------------------------------------------------------------------------- + +macro_rules! unary_transform { + ($name:ident, $method:ident) => { + pub fn $name(real: &[f64]) -> Vec { + real.iter().map(|&x| x.$method()).collect() + } + }; +} + +unary_transform!(math_acos, acos); +unary_transform!(math_asin, asin); +unary_transform!(math_atan, atan); +unary_transform!(math_ceil, ceil); +unary_transform!(math_cos, cos); +unary_transform!(math_cosh, cosh); +unary_transform!(math_exp, exp); +unary_transform!(math_floor, floor); +unary_transform!(math_ln, ln); +unary_transform!(math_log10, log10); +unary_transform!(math_sin, sin); +unary_transform!(math_sinh, sinh); +unary_transform!(math_sqrt, sqrt); +unary_transform!(math_tan, tan); +unary_transform!(math_tanh, tanh); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sum_basic() { + let v = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let r = sum(&v, 3); + assert!(r[0].is_nan()); + assert!((r[2] - 6.0).abs() < 1e-10); + assert!((r[4] - 12.0).abs() < 1e-10); + } + + #[test] + fn max_basic() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0]; + let r = max(&v, 3); + assert!((r[2] - 4.0).abs() < 1e-10); + assert!((r[4] - 5.0).abs() < 1e-10); + } + + #[test] + fn sliding_max_matches_naive() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]; + let naive = max(&v, 3); + let fast = sliding_max(&v, 3); + for i in 0..v.len() { + assert_eq!(naive[i].is_nan(), fast[i].is_nan()); + if !naive[i].is_nan() { + assert!((naive[i] - fast[i]).abs() < 1e-10); + } + } + } + + #[test] + fn sliding_min_matches_naive() { + let v = vec![3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]; + let naive = min(&v, 3); + let fast = sliding_min(&v, 3); + for i in 0..v.len() { + assert_eq!(naive[i].is_nan(), fast[i].is_nan()); + if !naive[i].is_nan() { + assert!((naive[i] - fast[i]).abs() < 1e-10); + } + } + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs new file mode 100644 index 0000000..99800e7 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/math_ops.rs @@ -0,0 +1,154 @@ +//! Rolling math operators — O(n) sliding window implementations. +//! +//! - `rolling_sum` — rolling sum over `timeperiod` bars (prefix-sum based) +//! - `rolling_max` — rolling maximum (O(n) monotonic deque) +//! - `rolling_min` — rolling minimum (O(n) monotonic deque) +//! - `rolling_maxindex` — index of rolling maximum +//! - `rolling_minindex` — index of rolling minimum + +use std::collections::VecDeque; + +/// Rolling sum over `timeperiod` bars using a prefix-sum array. +/// Leading `timeperiod - 1` values are NaN. +pub fn rolling_sum(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let mut cs = vec![0.0f64; n + 1]; + for i in 0..n { + cs[i + 1] = cs[i] + real[i]; + } + for i in (timeperiod - 1)..n { + result[i] = cs[i + 1] - cs[i + 1 - timeperiod]; + } + result +} + +/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque). +/// Delegates to `math::sliding_max`. +pub fn rolling_max(real: &[f64], timeperiod: usize) -> Vec { + crate::math::sliding_max(real, timeperiod) +} + +/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque). +/// Delegates to `math::sliding_min`. +pub fn rolling_min(real: &[f64], timeperiod: usize) -> Vec { + crate::math::sliding_min(real, timeperiod) +} + +/// Index of rolling maximum over `timeperiod` bars. +/// Returns 0-based index. During warmup the value is `-1`. +pub fn rolling_maxindex(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![-1i64; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + while dq.back().map(|&j| real[j] <= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = *dq.front().unwrap() as i64; + } + } + result +} + +/// Index of rolling minimum over `timeperiod` bars. +/// Returns 0-based index. During warmup the value is `-1`. +pub fn rolling_minindex(real: &[f64], timeperiod: usize) -> Vec { + let n = real.len(); + let mut result = vec![-1i64; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let mut dq: VecDeque = VecDeque::new(); + for i in 0..n { + while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) { + dq.pop_front(); + } + while dq.back().map(|&j| real[j] >= real[i]).unwrap_or(false) { + dq.pop_back(); + } + dq.push_back(i); + if i + 1 >= timeperiod { + result[i] = *dq.front().unwrap() as i64; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rolling_sum() { + let data = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = rolling_sum(&data, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 6.0).abs() < 1e-10); // 1+2+3 + assert!((result[3] - 9.0).abs() < 1e-10); // 2+3+4 + assert!((result[4] - 12.0).abs() < 1e-10); // 3+4+5 + } + + #[test] + fn test_rolling_max() { + let data = vec![1.0, 3.0, 2.0, 5.0, 4.0]; + let result = rolling_max(&data, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 3.0).abs() < 1e-10); + assert!((result[3] - 5.0).abs() < 1e-10); + assert!((result[4] - 5.0).abs() < 1e-10); + } + + #[test] + fn test_rolling_min() { + let data = vec![5.0, 3.0, 4.0, 1.0, 2.0]; + let result = rolling_min(&data, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 3.0).abs() < 1e-10); + assert!((result[3] - 1.0).abs() < 1e-10); + assert!((result[4] - 1.0).abs() < 1e-10); + } + + #[test] + fn test_rolling_maxindex() { + let data = vec![1.0, 3.0, 2.0, 5.0, 4.0]; + let result = rolling_maxindex(&data, 3); + assert_eq!(result[0], -1); + assert_eq!(result[1], -1); + assert_eq!(result[2], 1); // max(1,3,2) at index 1 + assert_eq!(result[3], 3); // max(3,2,5) at index 3 + assert_eq!(result[4], 3); // max(2,5,4) at index 3 + } + + #[test] + fn test_rolling_minindex() { + let data = vec![5.0, 3.0, 4.0, 1.0, 2.0]; + let result = rolling_minindex(&data, 3); + assert_eq!(result[0], -1); + assert_eq!(result[1], -1); + assert_eq!(result[2], 1); // min(5,3,4) at index 1 + assert_eq!(result[3], 3); // min(3,4,1) at index 3 + assert_eq!(result[4], 3); // min(4,1,2) at index 3 + } + + #[test] + fn test_short_input() { + let data = vec![1.0, 2.0]; + let result = rolling_sum(&data, 5); + assert!(result.iter().all(|v| v.is_nan())); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs new file mode 100644 index 0000000..b70ac88 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/momentum.rs @@ -0,0 +1,925 @@ +//! Momentum indicators. + +/// Compute the Relative Strength Index (RSI). +/// +/// Returns values in the range `[0, 100]`. Uses Wilder's smoothing method +/// (TA-Lib compatible), seeding avg_gain/avg_loss with the SMA of the first +/// `timeperiod` price changes. The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Lookback period (typically 14). +pub fn rsi(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if n <= timeperiod || timeperiod < 1 { + return result; + } + let mut avg_gain = 0.0_f64; + let mut avg_loss = 0.0_f64; + for i in 1..=timeperiod { + let diff = close[i] - close[i - 1]; + let abs_diff = diff.abs(); + avg_gain += (diff + abs_diff) * 0.5; + avg_loss += (abs_diff - diff) * 0.5; + } + avg_gain /= timeperiod as f64; + avg_loss /= timeperiod as f64; + let p = timeperiod as f64; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[timeperiod] = 100.0 - 100.0 / (1.0 + rs); + for i in (timeperiod + 1)..n { + let diff = close[i] - close[i - 1]; + let abs_diff = diff.abs(); + let gain = (diff + abs_diff) * 0.5; + let loss = (abs_diff - diff) * 0.5; + avg_gain = (avg_gain * (p - 1.0) + gain) / p; + avg_loss = (avg_loss * (p - 1.0) + loss) / p; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[i] = 100.0 - 100.0 / (1.0 + rs); + } + result +} + +/// Compute the Momentum indicator: `close[i] - close[i - timeperiod]`. +/// +/// Returns a `Vec` of length `n`. The first `timeperiod` values are `NaN`. +/// Positive values indicate upward price movement over the lookback window. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Number of bars to look back (must be >= 1). +pub fn mom(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 { + return result; + } + for i in timeperiod..n { + result[i] = close[i] - close[i - timeperiod]; + } + result +} + +/// Compute the Stochastic Oscillator (TA-Lib compatible). +/// +/// Returns `(slow_k, slow_d)`, both in the range `[0, 100]`. +/// - Fast %K = 100 * (close - lowest low) / (highest high - lowest low) +/// - Slow %K = SMA(fast %K, `slowk_period`) +/// - Slow %D = SMA(slow %K, `slowd_period`) +/// +/// Uses O(n) sliding max/min via monotonic deques. Both outputs are +/// `NaN`-padded until slow %D becomes valid (TA-Lib convention). +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `fastk_period` - Lookback for highest high / lowest low. +/// * `slowk_period` - SMA period applied to fast %K. +/// * `slowd_period` - SMA period applied to slow %K. +pub fn stoch( + high: &[f64], + low: &[f64], + close: &[f64], + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> (Vec, Vec) { + let n = high.len(); + let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]); + if n == 0 || fastk_period < 1 || slowk_period < 1 || slowd_period < 1 { + return nan_pair(); + } + if n < fastk_period { + return nan_pair(); + } + + let mut slowk = vec![f64::NAN; n]; + let mut slowd = vec![f64::NAN; n]; + + // Fused pass: compute fast %K inline with sliding max/min. + // For typical small windows (5-14), inline scan beats VecDeque overhead. + let fastk_start = fastk_period - 1; + let fk_len = n - fastk_start; + let mut fastk_valid = vec![0.0_f64; fk_len]; + + for i in fastk_start..n { + // Inline sliding max(high) and min(low) over [i - fastk_period + 1 .. i]. + let win_start = i + 1 - fastk_period; + let mut hh = high[win_start]; + let mut ll = low[win_start]; + for j in (win_start + 1)..=i { + let h = high[j]; + let l = low[j]; + if h > hh { + hh = h; + } + if l < ll { + ll = l; + } + } + let range = hh - ll; + fastk_valid[i - fastk_start] = if range != 0.0 { + 100.0 * (close[i] - ll) / range + } else { + 0.0 + }; + } + + // Slow %K = SMA(fastk_valid, slowk_period). + crate::overlap::sma_into(&fastk_valid, slowk_period, &mut slowk, fastk_start); + + // Slow %D = SMA(slowk, slowd_period). + let slowk_valid_start = fastk_start + slowk_period - 1; + let slowd_valid_start = slowk_valid_start + slowd_period - 1; + + if slowk_valid_start < n { + let slowk_valid_slice = &slowk[slowk_valid_start..]; + crate::overlap::sma_into( + slowk_valid_slice, + slowd_period, + &mut slowd, + slowk_valid_start, + ); + } + + // TA-Lib pads BOTH slowk and slowd with NaNs up to the point where both are valid. + if slowd_valid_start < n { + for v in slowk.iter_mut().take(slowd_valid_start) { + *v = f64::NAN; + } + } else { + for v in slowk.iter_mut().take(n) { + *v = f64::NAN; + } + } + + (slowk, slowd) +} + +// --------------------------------------------------------------------------- +// ADX family +// --------------------------------------------------------------------------- + +/// Return type for ADX inner (pdm_s, mdm_s, plus_di, minus_di, dx, adx). +type AdxInnerOutput = (Vec, Vec, Vec, Vec, Vec, Vec); + +/// Fused inner function for ADX-family indicators. +/// Returns a tuple of (pdm_s, mdm_s, plus_di, minus_di, dx, adx). +fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInnerOutput { + let n = high.len(); + let mut b_pdm = vec![f64::NAN; n]; + let mut b_mdm = vec![f64::NAN; n]; + let mut b_pdi = vec![f64::NAN; n]; + let mut b_mdi = vec![f64::NAN; n]; + let mut b_dx = vec![f64::NAN; n]; + let mut b_adx = vec![f64::NAN; n]; + + if n < period || period < 1 || n < 2 { + return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx); + } + + let m = n - 1; + let mut tr = vec![0.0_f64; m]; + let mut pdm = vec![0.0_f64; m]; + let mut mdm = vec![0.0_f64; m]; + + for i in 0..m { + let j = i + 1; + let h_diff = high[j] - high[i]; + let l_diff = low[i] - low[j]; + let hl = high[j] - low[j]; + let hpc = (high[j] - close[i]).abs(); + let lpc = (low[j] - close[i]).abs(); + tr[i] = hl.max(hpc).max(lpc); + pdm[i] = if h_diff > l_diff && h_diff > 0.0 { + h_diff + } else { + 0.0 + }; + mdm[i] = if l_diff > h_diff && l_diff > 0.0 { + l_diff + } else { + 0.0 + }; + } + + if m < period { + return (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx); + } + + let mut tr_s = tr[..period].iter().sum::(); + let mut pdm_s = pdm[..period].iter().sum::(); + let mut mdm_s = mdm[..period].iter().sum::(); + + // Initial seeded values at index `period` + b_pdm[period] = pdm_s; + b_mdm[period] = mdm_s; + if tr_s != 0.0 { + b_pdi[period] = 100.0 * pdm_s / tr_s; + b_mdi[period] = 100.0 * mdm_s / tr_s; + let s = b_pdi[period] + b_mdi[period]; + b_dx[period] = if s != 0.0 { + 100.0 * (b_pdi[period] - b_mdi[period]).abs() / s + } else { + 0.0 + }; + } + + let decay = (period - 1) as f64 / period as f64; + for i in period..m { + tr_s = tr_s * decay + tr[i]; + pdm_s = pdm_s * decay + pdm[i]; + mdm_s = mdm_s * decay + mdm[i]; + + b_pdm[i + 1] = pdm_s; + b_mdm[i + 1] = mdm_s; + if tr_s != 0.0 { + b_pdi[i + 1] = 100.0 * pdm_s / tr_s; + b_mdi[i + 1] = 100.0 * mdm_s / tr_s; + let s = b_pdi[i + 1] + b_mdi[i + 1]; + b_dx[i + 1] = if s != 0.0 { + 100.0 * (b_pdi[i + 1] - b_mdi[i + 1]).abs() / s + } else { + 0.0 + }; + } + } + + // Wilder smooth DX to get ADX + let adx_start = period + period - 1; + if n > adx_start { + let mut dx_sum = 0.0; + let mut valid_dx = true; + for v in b_dx.iter().skip(period).take(period) { + if v.is_nan() { + valid_dx = false; + break; + } + dx_sum += v; + } + if valid_dx { + let mut adx_s = dx_sum / period as f64; + b_adx[adx_start] = adx_s; + let alpha = 1.0 / period as f64; + for i in adx_start + 1..n { + adx_s = adx_s + alpha * (b_dx[i] - adx_s); + b_adx[i] = adx_s; + } + } + } + + (b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx) +} + +/// Compute all six ADX-family outputs in a single pass. +/// +/// Returns `(plus_dm, minus_dm, plus_di, minus_di, dx, adx)`. +/// Use this when you need multiple ADX-family outputs to avoid redundant +/// computation. All values are in `[0, 100]` except DM which is unbounded. +/// Warmup: DI/DX valid from index `timeperiod`; ADX from `2 * timeperiod - 1`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period (typically 14). +pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> AdxInnerOutput { + adx_inner(high, low, close, timeperiod) +} + +/// Internal helper for plus_dm and minus_dm that doesn't allocate dummy close prices. +/// Returns (plus_dm, minus_dm) smoothed with Wilder's method. +fn dm_only_inner(high: &[f64], low: &[f64], period: usize) -> (Vec, Vec) { + let n = high.len(); + let mut b_pdm = vec![f64::NAN; n]; + let mut b_mdm = vec![f64::NAN; n]; + + if n < period || period < 1 || n < 2 { + return (b_pdm, b_mdm); + } + + let m = n - 1; + let mut pdm = vec![0.0_f64; m]; + let mut mdm = vec![0.0_f64; m]; + + for i in 0..m { + let j = i + 1; + let h_diff = high[j] - high[i]; + let l_diff = low[i] - low[j]; + pdm[i] = if h_diff > l_diff && h_diff > 0.0 { + h_diff + } else { + 0.0 + }; + mdm[i] = if l_diff > h_diff && l_diff > 0.0 { + l_diff + } else { + 0.0 + }; + } + + if m < period { + return (b_pdm, b_mdm); + } + + let mut pdm_s = pdm[..period].iter().sum::(); + let mut mdm_s = mdm[..period].iter().sum::(); + + b_pdm[period] = pdm_s; + b_mdm[period] = mdm_s; + + let decay = (period - 1) as f64 / period as f64; + for i in period..m { + pdm_s = pdm_s * decay + pdm[i]; + mdm_s = mdm_s * decay + mdm[i]; + b_pdm[i + 1] = pdm_s; + b_mdm[i + 1] = mdm_s; + } + + (b_pdm, b_mdm) +} + +/// Compute the Plus Directional Movement (+DM), Wilder smoothed. +/// +/// Measures upward price movement. Returns a `Vec` of length `n`; +/// the first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` - High and low price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let (pdm, _) = dm_only_inner(high, low, timeperiod); + pdm +} + +/// Compute the Minus Directional Movement (-DM), Wilder smoothed. +/// +/// Measures downward price movement. Returns a `Vec` of length `n`; +/// the first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` - High and low price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn minus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let (_, mdm) = dm_only_inner(high, low, timeperiod); + mdm +} + +/// Compute the Plus Directional Indicator (+DI), Wilder smoothed. +/// +/// `+DI = 100 * smoothed(+DM) / smoothed(TR)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, pdi, _, _, _) = adx_inner(high, low, close, timeperiod); + pdi +} + +/// Compute the Minus Directional Indicator (-DI), Wilder smoothed. +/// +/// `-DI = 100 * smoothed(-DM) / smoothed(TR)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, mdi, _, _) = adx_inner(high, low, close, timeperiod); + mdi +} + +/// Compute the Directional Movement Index (DX). +/// +/// `DX = 100 * |+DI - -DI| / (+DI + -DI)`. Returns values in `[0, 100]`. +/// The first `timeperiod` values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period. +pub fn dx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, _, dx_vals, _) = adx_inner(high, low, close, timeperiod); + dx_vals +} + +/// Compute the Average Directional Movement Index (ADX). +/// +/// ADX is Wilder's smoothing of DX, measuring trend strength regardless of +/// direction. Returns values in `[0, 100]`. The first `2 * timeperiod - 1` +/// values are `NaN` (DX warmup + ADX smoothing warmup). +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period (typically 14). +pub fn adx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod); + adx_vals +} + +/// Compute the ADX Rating (ADXR). +/// +/// `ADXR[i] = (ADX[i] + ADX[i - timeperiod]) / 2`. Smooths ADX further +/// by averaging current ADX with its value `timeperiod` bars ago. +/// Returns values in `[0, 100]`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Wilder smoothing period (typically 14). +pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + // Reuse adx_all to compute ADX once, then derive ADXR from it + let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + if !adx_vals[i].is_nan() && !adx_vals[i - timeperiod].is_nan() { + result[i] = (adx_vals[i] + adx_vals[i - timeperiod]) / 2.0; + } + } + result +} + +// --------------------------------------------------------------------------- +// Rate of Change variants +// --------------------------------------------------------------------------- + +/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`. +pub fn roc(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = (close[i] - prev) / prev * 100.0; + } + } + result +} + +/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`. +pub fn rocp(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = (close[i] - prev) / prev; + } + } + result +} + +/// Rate of Change Ratio: `close[i] / close[i-p]`. +pub fn rocr(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = close[i] / prev; + } + } + result +} + +/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`. +pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + for i in timeperiod..n { + let prev = close[i - timeperiod]; + if prev != 0.0 { + result[i] = close[i] / prev * 100.0; + } + } + result +} + +// --------------------------------------------------------------------------- +// Williams %R +// --------------------------------------------------------------------------- + +/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window. +/// Returns values in `[-100, 0]`. +pub fn willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let start = i + 1 - timeperiod; + let mut highest = f64::NEG_INFINITY; + let mut lowest = f64::INFINITY; + for j in start..=i { + if high[j] > highest { + highest = high[j]; + } + if low[j] < lowest { + lowest = low[j]; + } + } + let range = highest - lowest; + result[i] = if range != 0.0 { + -100.0 * (highest - close[i]) / range + } else { + -50.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// Aroon +// --------------------------------------------------------------------------- + +/// Aroon indicator. Returns `(aroon_down, aroon_up)`. +pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec, Vec) { + let n = high.len(); + let mut aroon_down = vec![f64::NAN; n]; + let mut aroon_up = vec![f64::NAN; n]; + if timeperiod == 0 || n <= timeperiod { + return (aroon_down, aroon_up); + } + let period_f = timeperiod as f64; + let window_size = timeperiod + 1; + for i in timeperiod..n { + let start = i + 1 - window_size; + let mut max_val = high[start]; + let mut min_val = low[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if high[start + j] >= max_val { + max_val = high[start + j]; + max_idx = j; + } + if low[start + j] <= min_val { + min_val = low[start + j]; + min_idx = j; + } + } + aroon_up[i] = 100.0 * (max_idx as f64) / period_f; + aroon_down[i] = 100.0 * (min_idx as f64) / period_f; + } + (aroon_down, aroon_up) +} + +/// Aroon Oscillator: `aroon_up - aroon_down`. +pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let (down, up) = aroon(high, low, timeperiod); + up.iter() + .zip(down.iter()) + .map(|(&u, &d)| { + if u.is_nan() || d.is_nan() { + f64::NAN + } else { + u - d + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// CCI +// --------------------------------------------------------------------------- + +/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`. +pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let tp: Vec = high + .iter() + .zip(low.iter()) + .zip(close.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect(); + for i in (timeperiod - 1)..n { + let window = &tp[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::() / timeperiod as f64; + result[i] = if mad != 0.0 { + (tp[i] - mean) / (0.015 * mad) + } else { + 0.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// BOP +// --------------------------------------------------------------------------- + +/// Balance of Power: `(close - open) / (high - low)`. +pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + open.iter() + .zip(high.iter()) + .zip(low.iter()) + .zip(close.iter()) + .map(|(((&o, &h), &l), &c)| { + let range = h - l; + if range != 0.0 { + (c - o) / range + } else { + 0.0 + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Stochastic RSI +// --------------------------------------------------------------------------- + +/// Stochastic RSI. Returns `(fastk, fastd)`. +pub fn stochrsi( + close: &[f64], + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> (Vec, Vec) { + let n = close.len(); + let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]); + if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 { + return nan_pair(); + } + + let rsi_vals = rsi(close, timeperiod); + let rsi_warmup = timeperiod; + let k_warmup = rsi_warmup + fastk_period - 1; + let d_warmup = k_warmup + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for i in k_warmup..n { + if rsi_vals[i].is_nan() { + continue; + } + let start = i + 1 - fastk_period; + if (start..=i).any(|j| rsi_vals[j].is_nan()) { + continue; + } + let mx = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + fastk[i] = if mx != mn { + 100.0 * (rsi_vals[i] - mn) / (mx - mn) + } else { + 50.0 + }; + } + + for i in d_warmup..n { + let start = i + 1 - fastd_period; + let window = &fastk[start..=i]; + if window.iter().all(|v| !v.is_nan()) { + fastd[i] = window.iter().sum::() / fastd_period as f64; + } + } + (fastk, fastd) +} + +// --------------------------------------------------------------------------- +// APO / PPO +// --------------------------------------------------------------------------- + +/// Absolute Price Oscillator: `fast EMA - slow EMA`. +pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod { + return result; + } + let fast = crate::overlap::ema(close, fastperiod); + let slow = crate::overlap::ema(close, slowperiod); + let warmup = slowperiod - 1; + for i in warmup..n { + if !fast[i].is_nan() && !slow[i].is_nan() { + result[i] = fast[i] - slow[i]; + } + } + result +} + +/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`. +/// Returns `(ppo_line, signal_line, histogram)`. +pub fn ppo( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]); + if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod { + return nan3(); + } + let fast = crate::overlap::ema(close, fastperiod); + let slow = crate::overlap::ema(close, slowperiod); + let warmup = slowperiod - 1; + + let mut ppo_line = vec![f64::NAN; n]; + for i in warmup..n { + if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 { + ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0; + } + } + + // Signal line = EMA of PPO line (only over valid values) + let signal = crate::overlap::ema(&ppo_line, signalperiod); + let mut signal_line = vec![f64::NAN; n]; + let mut hist = vec![f64::NAN; n]; + let sig_warmup = warmup + signalperiod - 1; + for i in sig_warmup..n { + if !ppo_line[i].is_nan() && !signal[i].is_nan() { + signal_line[i] = signal[i]; + hist[i] = ppo_line[i] - signal[i]; + } + } + (ppo_line, signal_line, hist) +} + +// --------------------------------------------------------------------------- +// CMO +// --------------------------------------------------------------------------- + +/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`. +pub fn cmo(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod + 1 { + return result; + } + let changes: Vec = close.windows(2).map(|w| w[1] - w[0]).collect(); + for i in timeperiod..n { + let mut ups = 0.0_f64; + let mut downs = 0.0_f64; + for ch in &changes[(i - timeperiod)..i] { + if *ch > 0.0 { + ups += ch; + } else { + downs -= ch; + } + } + let denom = ups + downs; + result[i] = if denom != 0.0 { + 100.0 * (ups - downs) / denom + } else { + 0.0 + }; + } + result +} + +// --------------------------------------------------------------------------- +// TRIX +// --------------------------------------------------------------------------- + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +pub fn trix(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 3 * (timeperiod - 1); + + // Triple EMA: EMA(EMA(EMA(close))) + let ema1 = crate::overlap::ema(close, timeperiod); + let ema2 = crate::overlap::ema(&ema1, timeperiod); + let ema3 = crate::overlap::ema(&ema2, timeperiod); + + for i in (warmup + 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; + } + } + result +} + +// --------------------------------------------------------------------------- +// Ultimate Oscillator +// --------------------------------------------------------------------------- + +/// Ultimate Oscillator: weighted average of buying pressure over three periods. +pub fn ultosc( + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod1: usize, + timeperiod2: usize, + timeperiod3: usize, +) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 { + return result; + } + let max_period = timeperiod1.max(timeperiod2).max(timeperiod3); + if n <= max_period { + return result; + } + + let mut bp = vec![0.0_f64; n]; + let mut tr = vec![0.0_f64; n]; + for i in 1..n { + let true_low = low[i].min(close[i - 1]); + let true_high = high[i].max(close[i - 1]); + bp[i] = close[i] - true_low; + tr[i] = true_high - true_low; + } + + for i in max_period..n { + let avg = |period: usize| -> f64 { + let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + result[i] = + 100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rsi_range() { + let prices: Vec = (1..=50).map(|i| i as f64).collect(); + let result = rsi(&prices, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0); + } + } + + #[test] + fn mom_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = mom(&prices, 2); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); + } + + #[test] + fn stoch_basic() { + let high = vec![10.0, 11.0, 12.0, 11.5, 13.0, 12.5, 14.0, 13.5]; + let low = vec![9.0, 10.0, 11.0, 10.5, 12.0, 11.5, 13.0, 12.5]; + let close = vec![9.5, 10.5, 11.5, 11.0, 12.5, 12.0, 13.5, 13.0]; + let (slowk, slowd) = stoch(&high, &low, &close, 3, 3, 3); + // Check that valid values are in [0, 100] + for v in slowk.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "slowk out of range: {v}"); + } + for v in slowd.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "slowd out of range: {v}"); + } + } + + #[test] + fn adx_nonnegative() { + let h: Vec = (1..=50).map(|i| i as f64 + 1.0).collect(); + let l: Vec = (1..=50).map(|i| i as f64).collect(); + let c: Vec = (1..=50).map(|i| i as f64 + 0.5).collect(); + let result = adx(&h, &l, &c, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0); + } + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs new file mode 100644 index 0000000..6ae41cb --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/american.rs @@ -0,0 +1,410 @@ +//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation. + +use super::normal::cdf; +use super::pricing::black_scholes_price; +use super::OptionKind; + +fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !spot.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop). +fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 { + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + ((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t +} + +/// Find the critical spot price S* for American call early exercise using Newton-Raphson. +/// +/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*))) +/// Rearranged as F(S*) = 0: +/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0 +fn find_critical_call( + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + q2: f64, +) -> f64 { + let carry_discount = (-carry * time_to_expiry).exp(); + + // Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike + let mut s = if q2 > 1.0 { + strike * q2 / (q2 - 1.0) + } else { + // q2 <= 1 means the denominator is small/negative; fall back to a safe value + strike * 2.0 + }; + // Ensure starting guess is positive + if s <= 0.0 { + s = strike * 1.5; + } + + for _ in 0..50 { + let c = black_scholes_price( + s, + strike, + rate, + carry, + time_to_expiry, + volatility, + OptionKind::Call, + ); + let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility); + let nd1 = cdf(d1); + let lhs = c - (s - strike); + let rhs = (s / q2) * (1.0 - carry_discount * nd1); + let f = lhs - rhs; + + // Derivative of F with respect to s: + // dC/ds = e^{-q*T} * N(d1) (BSM delta for call) + // d(s - K)/ds = 1 + // d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1)) + // + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T))) + // = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T)) + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + let phi_d1 = super::normal::pdf(d1); + let d_lhs_ds = carry_discount * nd1 - 1.0; + let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1) + - carry_discount * phi_d1 / (q2 * sigma_sqrt_t); + let df = d_lhs_ds - d_rhs_ds; + + if df.abs() < 1e-14 { + break; + } + let step = f / df; + s -= step; + // Keep s positive + if s <= 0.0 { + s = strike * 0.1; + } + if step.abs() < 1e-8 { + break; + } + } + s +} + +/// Find the critical spot price S** for American put early exercise using Newton-Raphson. +/// +/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**))) +/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0 +fn find_critical_put( + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + q1: f64, +) -> f64 { + let carry_discount = (-carry * time_to_expiry).exp(); + + // Initial guess for put: S** ≈ K * q1 / (q1 - 1) + // q1 is negative, so q1 - 1 < 0, and the guess should be below strike. + let mut s = if (q1 - 1.0).abs() > 1e-10 { + strike * q1 / (q1 - 1.0) + } else { + strike * 0.5 + }; + if s <= 0.0 || s >= strike { + s = strike * 0.5; + } + + for _ in 0..50 { + let p = black_scholes_price( + s, + strike, + rate, + carry, + time_to_expiry, + volatility, + OptionKind::Put, + ); + let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility); + let n_neg_d1 = cdf(-d1); + let lhs = p - (strike - s); + // rhs = -(s/q1) * (1 - carry_discount * N(-d1)) + let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1); + let f = lhs - rhs; + + // Derivative: + // dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1)) + // d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1 + // d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1)) + // + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds] + // = -(1/q1)*(1 - carry_discount*N(-d1)) + // - carry_discount*phi(d1)/(q1*vol*sqrt(T)) + let sigma_sqrt_t = volatility * time_to_expiry.sqrt(); + let phi_d1 = super::normal::pdf(d1); + let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0; + let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1) + - carry_discount * phi_d1 / (q1 * sigma_sqrt_t); + let df = d_lhs_ds - d_rhs_ds; + + if df.abs() < 1e-14 { + break; + } + let step = f / df; + s -= step; + if s <= 0.0 { + s = strike * 0.01; + } + if s >= strike { + s = strike * 0.99; + } + if step.abs() < 1e-8 { + break; + } + } + s +} + +/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation. +/// +/// # Parameters +/// - `spot`: current underlying price +/// - `strike`: option strike price +/// - `rate`: risk-free rate (annualized, decimal) +/// - `carry`: continuous dividend yield / carry rate +/// - `time_to_expiry`: time to expiry in years +/// - `volatility`: implied vol (annualized, decimal) +/// - `kind`: call or put +pub fn american_price_baw( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) + || !rate.is_finite() + || !carry.is_finite() + { + return f64::NAN; + } + + // At expiry: immediate exercise value + if time_to_expiry == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + // At zero vol: deterministic — exercise if ITM + if volatility == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind); + + match kind { + OptionKind::Call => { + // No early exercise premium when there are no dividends (carry == 0 means q==0 + // in BSM parameterisation where carry = q). + if carry <= 0.0 { + return european; + } + + let sigma2 = volatility * volatility; + let m = 2.0 * rate / sigma2; + let n = 2.0 * (rate - carry) / sigma2; + let h = 1.0 - (-rate * time_to_expiry).exp(); + + if h.abs() < 1e-14 { + return european; + } + + let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h; + if discriminant < 0.0 { + return european; + } + + let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0; + + // Find critical price S* + let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2); + + if s_star <= strike { + // Degenerate critical price; fall back to European + return european; + } + + // A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*))) + let carry_discount = (-carry * time_to_expiry).exp(); + let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility); + let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star)); + + if spot >= s_star { + // Immediate exercise is optimal + (spot - strike).max(0.0) + } else { + (european + a2 * (spot / s_star).powf(q2)).max(european) + } + } + + OptionKind::Put => { + // No early exercise when rate == 0 (no time value of money) + if rate <= 0.0 { + return european; + } + + let sigma2 = volatility * volatility; + let m = 2.0 * rate / sigma2; + let n = 2.0 * (rate - carry) / sigma2; + let h = 1.0 - (-rate * time_to_expiry).exp(); + + if h.abs() < 1e-14 { + return european; + } + + let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h; + if discriminant < 0.0 { + return european; + } + + let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0; + + // Find critical price S** + let s_star_star = + find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1); + + if s_star_star <= 0.0 || s_star_star >= strike { + return european; + } + + // A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**))) + let carry_discount = (-carry * time_to_expiry).exp(); + let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility); + let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star)); + + if spot <= s_star_star { + // Immediate exercise is optimal + (strike - spot).max(0.0) + } else { + (european + a1 * (spot / s_star_star).powf(q1)).max(european) + } + } + } +} + +/// Early exercise premium = american_price - european_bsm_price. +/// +/// Always non-negative for valid inputs. +pub fn early_exercise_premium( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind); + let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind); + if american.is_nan() || european.is_nan() { + return f64::NAN; + } + (american - european).max(0.0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::OptionKind; + + #[test] + fn american_call_gte_european_call() { + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.03, + 1.0, + 0.2, + OptionKind::Call, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call); + assert!(american >= european - 1e-10); + } + + #[test] + fn american_put_gte_european_put() { + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Put, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put); + assert!(american >= european - 1e-10); + } + + #[test] + fn early_exercise_premium_nonneg() { + let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call); + assert!(prem >= 0.0); + } + + #[test] + fn american_call_no_dividends_equals_european() { + // With no dividends (carry == 0), no early exercise is optimal for calls + let european = crate::options::pricing::black_scholes_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + ); + let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!((american - european).abs() < 1e-10); + } + + #[test] + fn american_price_returns_nan_for_invalid() { + let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(price.is_nan()); + } + + #[test] + fn american_price_at_expiry_is_intrinsic() { + let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call); + assert!((call - 10.0).abs() < 1e-10); + let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put); + assert!((put - 10.0).abs() < 1e-10); + } + + #[test] + fn american_put_itm_has_positive_premium() { + // Deep ITM put with high rate should have meaningful early exercise premium + let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put); + assert!(prem >= 0.0); + } + + #[test] + fn american_prices_are_finite_for_valid_inputs() { + let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call); + let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put); + assert!(call.is_finite()); + assert!(put.is_finite()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs new file mode 100644 index 0000000..a716ae4 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/chain.rs @@ -0,0 +1,162 @@ +//! Option chain analytics helpers. + +use super::greeks::model_greeks; +use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind}; + +/// Return the index of the strike closest to the reference price. +pub fn atm_index(strikes: &[f64], reference_price: f64) -> Option { + if strikes.is_empty() || !reference_price.is_finite() { + return None; + } + strikes + .iter() + .enumerate() + .filter(|(_, strike)| strike.is_finite()) + .min_by(|(_, a), (_, b)| { + (*a - reference_price) + .abs() + .partial_cmp(&(*b - reference_price).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(idx, _)| idx) +} + +/// Label strikes as ITM (1), ATM (0), or OTM (-1). +pub fn label_moneyness(strikes: &[f64], reference_price: f64, kind: OptionKind) -> Vec { + let mut labels = Vec::with_capacity(strikes.len()); + let atm_idx = atm_index(strikes, reference_price); + for (idx, &strike) in strikes.iter().enumerate() { + if Some(idx) == atm_idx { + labels.push(0); + continue; + } + let label = match kind { + OptionKind::Call => { + if strike < reference_price { + 1 + } else { + -1 + } + } + OptionKind::Put => { + if strike > reference_price { + 1 + } else { + -1 + } + } + }; + labels.push(label); + } + labels +} + +/// Select a strike relative to the ATM strike by offset steps. +pub fn select_strike_by_offset( + strikes: &[f64], + reference_price: f64, + offset: isize, +) -> Option { + let idx = atm_index(strikes, reference_price)? as isize + offset; + if idx < 0 || idx >= strikes.len() as isize { + None + } else { + Some(strikes[idx as usize]) + } +} + +/// Select the strike whose delta is closest to the requested target. +pub fn select_strike_by_delta( + strikes: &[f64], + vols: &[f64], + context: ChainGreeksContext, + target_delta: f64, +) -> Option { + if strikes.len() != vols.len() || strikes.is_empty() { + return None; + } + strikes + .iter() + .zip(vols.iter()) + .filter(|(strike, vol)| strike.is_finite() && vol.is_finite()) + .min_by(|(strike_a, vol_a), (strike_b, vol_b)| { + let delta_a = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike: **strike_a, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: **vol_a, + }) + .delta; + let delta_b = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike: **strike_b, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: **vol_b, + }) + .delta; + (delta_a - target_delta) + .abs() + .partial_cmp(&(delta_b - target_delta).abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(strike, _)| *strike) +} + +#[cfg(test)] +mod tests { + use super::{atm_index, label_moneyness, select_strike_by_delta, select_strike_by_offset}; + use crate::options::{ChainGreeksContext, OptionKind, PricingModel}; + + #[test] + fn atm_index_finds_nearest() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!(atm_index(&strikes, 103.0), Some(1)); + } + + #[test] + fn moneyness_labels_calls() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!( + label_moneyness(&strikes, 100.0, OptionKind::Call), + vec![1, 0, -1] + ); + } + + #[test] + fn offset_selects_expected_strike() { + let strikes = [90.0, 100.0, 110.0]; + assert_eq!(select_strike_by_offset(&strikes, 101.0, 1), Some(110.0)); + } + + #[test] + fn delta_selection_returns_a_strike() { + let strikes = [80.0, 90.0, 100.0, 110.0, 120.0]; + let vols = [0.28, 0.24, 0.20, 0.22, 0.26]; + let strike = select_strike_by_delta( + &strikes, + &vols, + ChainGreeksContext { + model: PricingModel::BlackScholes, + reference_price: 100.0, + rate: 0.01, + carry: 0.0, + time_to_expiry: 0.5, + kind: OptionKind::Call, + }, + 0.25, + ); + assert!(strike.is_some()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs new file mode 100644 index 0000000..e1831b5 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/digital.rs @@ -0,0 +1,382 @@ +//! Digital (binary) option pricing. + +use super::normal::cdf; +use super::OptionKind; + +/// Type of digital option payoff. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DigitalKind { + /// Pays 1 unit of cash if option expires in the money. + CashOrNothing, + /// Pays the underlying asset if option expires in the money. + AssetOrNothing, +} + +fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !spot.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Price a digital (binary) option under BSM. +/// +/// # Parameters +/// - `spot`: current underlying price +/// - `strike`: option strike price +/// - `rate`: risk-free rate (annualized, decimal) +/// - `carry`: continuous dividend yield / carry rate +/// - `time_to_expiry`: time to expiry in years +/// - `volatility`: implied vol (annualized, decimal) +/// - `option_kind`: call or put +/// - `digital_kind`: cash-or-nothing or asset-or-nothing +#[allow(clippy::too_many_arguments)] +pub fn digital_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + option_kind: OptionKind, + digital_kind: DigitalKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) + || !rate.is_finite() + || !carry.is_finite() + { + return f64::NAN; + } + + // At expiry: pay intrinsic based on ITM status + if time_to_expiry == 0.0 { + let itm = match option_kind { + OptionKind::Call => spot > strike, + OptionKind::Put => spot < strike, + }; + return if itm { + match digital_kind { + DigitalKind::CashOrNothing => 1.0, + DigitalKind::AssetOrNothing => spot, + } + } else { + 0.0 + }; + } + + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-carry * time_to_expiry).exp(); + + // At zero vol: deterministic payoff + if volatility == 0.0 { + let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T} + // forward = S * e^{(r-q)*T}; ITM if forward > K for call + let itm = match option_kind { + OptionKind::Call => spot * carry_discount > strike * discount, + OptionKind::Put => spot * carry_discount < strike * discount, + }; + let _ = forward; // suppress unused warning + return if itm { + match digital_kind { + DigitalKind::CashOrNothing => discount, + DigitalKind::AssetOrNothing => spot * carry_discount, + } + } else { + 0.0 + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = ((spot / strike).ln() + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + match digital_kind { + DigitalKind::CashOrNothing => match option_kind { + OptionKind::Call => discount * cdf(d2), + OptionKind::Put => discount * cdf(-d2), + }, + DigitalKind::AssetOrNothing => match option_kind { + OptionKind::Call => spot * carry_discount * cdf(d1), + OptionKind::Put => spot * carry_discount * cdf(-d1), + }, + } +} + +/// Compute numerical delta, gamma, and vega for a digital option. +/// +/// Uses central finite differences: +/// - delta/gamma: bump spot by ε = spot * 1e-3 +/// - vega: bump volatility by 1e-3 +/// +/// Returns `(delta, gamma, vega)`. +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + option_kind: OptionKind, + digital_kind: DigitalKind, +) -> (f64, f64, f64) { + let eps = spot * 1e-3; + if eps <= 0.0 { + return (f64::NAN, f64::NAN, f64::NAN); + } + + let price_mid = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + let price_up = digital_price( + spot + eps, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + let price_dn = digital_price( + spot - eps, + strike, + rate, + carry, + time_to_expiry, + volatility, + option_kind, + digital_kind, + ); + + let delta = (price_up - price_dn) / (2.0 * eps); + let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps); + + let vol_bump = 1e-3; + let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 { + let price_vup = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility + vol_bump, + option_kind, + digital_kind, + ); + let price_vdn = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility - vol_bump, + option_kind, + digital_kind, + ); + (price_vup - price_vdn) / (2.0 * vol_bump) + } else { + // vol too close to zero; one-sided bump + let price_vup = digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility + vol_bump, + option_kind, + digital_kind, + ); + (price_vup - price_mid) / vol_bump + }; + + (delta, gamma, vega) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::options::OptionKind; + + #[test] + fn cash_or_nothing_call_atm() { + // ATM cash-or-nothing call: price = e^{-rT} * N(d2) + // At S=K=100, r=0.05, q=0, T=1, σ=0.2: + // d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596 + // price ≈ e^{-0.05} * 0.5596 ≈ 0.532 + let price = digital_price( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!( + price > 0.0 && price < 1.0, + "price should be between 0 and 1" + ); + assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}"); + } + + #[test] + fn asset_or_nothing_call_at_zero_vol() { + // At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T} + let price = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.0, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + assert!((price - 110.0).abs() < 1e-6); + } + + #[test] + fn digital_price_returns_nan_for_invalid() { + let price = digital_price( + -1.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!(price.is_nan()); + } + + #[test] + fn cash_or_nothing_put_call_parity() { + // Cash-or-nothing call + cash-or-nothing put = e^{-rT} + let call = digital_price( + 100.0, + 100.0, + 0.05, + 0.02, + 1.0, + 0.25, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + let put = digital_price( + 100.0, + 100.0, + 0.05, + 0.02, + 1.0, + 0.25, + OptionKind::Put, + DigitalKind::CashOrNothing, + ); + let discount = (-0.05_f64).exp(); + assert!((call + put - discount).abs() < 1e-10); + } + + #[test] + fn asset_or_nothing_put_call_parity() { + // Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T} + let s = 100.0_f64; + let q = 0.02_f64; + let call = digital_price( + s, + 100.0, + 0.05, + q, + 1.0, + 0.25, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + let put = digital_price( + s, + 100.0, + 0.05, + q, + 1.0, + 0.25, + OptionKind::Put, + DigitalKind::AssetOrNothing, + ); + let expected = s * (-q).exp(); + assert!((call + put - expected).abs() < 1e-10); + } + + #[test] + fn digital_greeks_are_finite_for_valid_inputs() { + let (delta, gamma, vega) = digital_greeks( + 100.0, + 100.0, + 0.05, + 0.0, + 1.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!(delta.is_finite()); + assert!(gamma.is_finite()); + assert!(vega.is_finite()); + } + + #[test] + fn digital_at_expiry_itm_returns_intrinsic() { + let price = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!((price - 1.0).abs() < 1e-10); + let price2 = digital_price( + 110.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::AssetOrNothing, + ); + assert!((price2 - 110.0).abs() < 1e-10); + } + + #[test] + fn digital_at_expiry_otm_returns_zero() { + let price = digital_price( + 90.0, + 100.0, + 0.05, + 0.0, + 0.0, + 0.2, + OptionKind::Call, + DigitalKind::CashOrNothing, + ); + assert!((price - 0.0).abs() < 1e-10); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs new file mode 100644 index 0000000..cae6761 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/greeks.rs @@ -0,0 +1,327 @@ +//! Option Greeks. + +use super::normal::{cdf, pdf}; +use super::pricing::{black_76_price, black_scholes_price}; +use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel}; + +fn bs_inputs_valid( + underlying: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, +) -> bool { + underlying.is_finite() + && strike.is_finite() + && rate.is_finite() + && carry.is_finite() + && time_to_expiry.is_finite() + && volatility.is_finite() + && underlying > 0.0 + && strike > 0.0 + && time_to_expiry > 0.0 + && volatility > 0.0 +} + +fn numerical_theta(time_to_expiry: f64, price_fn: F) -> f64 +where + F: Fn(f64) -> f64, +{ + if time_to_expiry <= 0.0 { + return 0.0; + } + let h = time_to_expiry.clamp(1e-6, 1.0 / 365.0); + let t_minus = (time_to_expiry - h).max(1e-8); + let t_plus = time_to_expiry + h; + let price_minus = price_fn(t_minus); + let price_plus = price_fn(t_plus); + (price_minus - price_plus) / (t_plus - t_minus) +} + +/// Black-Scholes-Merton Greeks. +pub fn black_scholes_greeks( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> Greeks { + if !bs_inputs_valid( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + ) { + return Greeks { + delta: f64::NAN, + gamma: f64::NAN, + vega: f64::NAN, + theta: f64::NAN, + rho: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let delta = match kind { + OptionKind::Call => carry_discount * cdf(d1), + OptionKind::Put => carry_discount * (cdf(d1) - 1.0), + }; + let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t); + let vega = spot * carry_discount * pdf_d1 * sqrt_t; + let theta = match kind { + OptionKind::Call => { + -(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t) + - rate * strike * discount * cdf(d2) + + dividend_yield * spot * carry_discount * cdf(d1) + } + OptionKind::Put => { + -(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t) + + rate * strike * discount * cdf(-d2) + - dividend_yield * spot * carry_discount * cdf(-d1) + } + }; + let rho = match kind { + OptionKind::Call => strike * time_to_expiry * discount * cdf(d2), + OptionKind::Put => -strike * time_to_expiry * discount * cdf(-d2), + }; + + Greeks { + delta, + gamma, + vega, + theta, + rho, + } +} + +/// Black-76 Greeks with respect to the forward. +pub fn black_76_greeks( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> Greeks { + if !bs_inputs_valid(forward, strike, rate, 0.0, time_to_expiry, volatility) { + return Greeks { + delta: f64::NAN, + gamma: f64::NAN, + vega: f64::NAN, + theta: f64::NAN, + rho: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let discount = (-rate * time_to_expiry).exp(); + let d1 = + ((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let delta = match kind { + OptionKind::Call => discount * cdf(d1), + OptionKind::Put => -discount * cdf(-d1), + }; + let gamma = discount * pdf_d1 / (forward * sigma_sqrt_t); + let vega = discount * forward * pdf_d1 * sqrt_t; + let theta = numerical_theta(time_to_expiry, |t| { + black_76_price(forward, strike, rate, t, volatility, kind) + }); + let rho = + -time_to_expiry * black_76_price(forward, strike, rate, time_to_expiry, volatility, kind); + + Greeks { + delta, + gamma, + vega, + theta, + rho, + } +} + +/// Model-dispatched Greeks. +pub fn model_greeks(input: OptionEvaluation) -> Greeks { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + } +} + +/// Price derivative with respect to calendar time using the selected model. +pub fn model_theta(input: OptionEvaluation) -> f64 { + let contract = input.contract; + numerical_theta(contract.time_to_expiry, |t| match contract.model { + PricingModel::BlackScholes => black_scholes_price( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + t, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_price( + contract.underlying, + contract.strike, + contract.rate, + t, + input.volatility, + contract.kind, + ), + }) +} + +/// Extended Greeks under Black-Scholes-Merton (closed-form). +/// +/// All inputs must be positive finite; returns NaN fields for invalid inputs. +pub fn black_scholes_extended_greeks( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + _kind: OptionKind, +) -> ExtendedGreeks { + if !bs_inputs_valid( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + ) { + return ExtendedGreeks { + vanna: f64::NAN, + volga: f64::NAN, + charm: f64::NAN, + speed: f64::NAN, + color: f64::NAN, + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + let pdf_d1 = pdf(d1); + + let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t); + + let vanna = -carry_discount * pdf_d1 * d2 / volatility; + let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility; + let charm = -carry_discount + * pdf_d1 + * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t) + / (2.0 * time_to_expiry * sigma_sqrt_t); + let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0); + let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t) + * (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0 + - d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t) + / sigma_sqrt_t); + + ExtendedGreeks { + vanna, + volga, + charm, + speed, + color, + } +} + +/// Model-dispatched extended Greeks. +/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN). +pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_extended_greeks( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => ExtendedGreeks { + vanna: f64::NAN, + volga: f64::NAN, + charm: f64::NAN, + speed: f64::NAN, + color: f64::NAN, + }, + } +} + +#[cfg(test)] +mod tests { + use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks}; + use crate::options::OptionKind; + + #[test] + fn bsm_greeks_are_finite() { + let g = black_scholes_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(g.delta.is_finite()); + assert!(g.gamma.is_finite()); + assert!(g.vega.is_finite()); + assert!(g.theta.is_finite()); + assert!(g.rho.is_finite()); + } + + #[test] + fn black_76_greeks_are_finite() { + let g = black_76_greeks(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put); + assert!(g.delta.is_finite()); + assert!(g.gamma.is_finite()); + assert!(g.vega.is_finite()); + assert!(g.theta.is_finite()); + assert!(g.rho.is_finite()); + } + + #[test] + fn extended_greeks_finite_for_valid_inputs() { + let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + assert!(eg.vanna.is_finite()); + assert!(eg.volga.is_finite()); + assert!(eg.charm.is_finite()); + assert!(eg.speed.is_finite()); + assert!(eg.color.is_finite()); + // Volga must be positive (convex in vol) + assert!(eg.volga >= 0.0); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs new file mode 100644 index 0000000..2302886 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/iv.rs @@ -0,0 +1,241 @@ +//! Implied volatility inversion and IV-series helpers. + +use super::greeks::model_greeks; +use super::pricing::{model_price, price_lower_bound, price_upper_bound}; +use super::{IvSolverConfig, OptionContract, OptionEvaluation}; + +/// Solve implied volatility with guarded Newton iterations and bisection fallback. +pub fn implied_volatility( + contract: OptionContract, + target_price: f64, + config: IvSolverConfig, +) -> f64 { + if !target_price.is_finite() + || !contract.underlying.is_finite() + || !contract.strike.is_finite() + || !contract.rate.is_finite() + || !contract.carry.is_finite() + || !contract.time_to_expiry.is_finite() + || target_price < 0.0 + || contract.underlying <= 0.0 + || contract.strike <= 0.0 + || contract.time_to_expiry < 0.0 + { + return f64::NAN; + } + if contract.time_to_expiry == 0.0 { + return 0.0; + } + + let lower = price_lower_bound(contract); + let upper = price_upper_bound(contract); + if target_price < lower - config.tolerance || target_price > upper + config.tolerance { + return f64::NAN; + } + if (target_price - lower).abs() <= config.tolerance { + return 0.0; + } + + let mut low_vol = 1e-9; + let mut high_vol = config.initial_guess.max(0.25).max(low_vol * 10.0); + let mut high_price = model_price(OptionEvaluation { + contract, + volatility: high_vol, + }); + while high_price < target_price && high_vol < 10.0 { + high_vol *= 2.0; + high_price = model_price(OptionEvaluation { + contract, + volatility: high_vol, + }); + } + if high_price < target_price { + return f64::NAN; + } + + let mut vol = config.initial_guess.clamp(low_vol, high_vol).max(1e-4); + for _ in 0..config.max_iterations.max(1) { + let price = model_price(OptionEvaluation { + contract, + volatility: vol, + }); + let diff = price - target_price; + if diff.abs() <= config.tolerance { + return vol; + } + + if diff > 0.0 { + high_vol = high_vol.min(vol); + } else { + low_vol = low_vol.max(vol); + } + + let vega = model_greeks(OptionEvaluation { + contract, + volatility: vol, + }) + .vega; + + let next = if vega.is_finite() && vega.abs() > 1e-10 { + let candidate = vol - diff / vega; + if candidate > low_vol && candidate < high_vol { + candidate + } else { + 0.5 * (low_vol + high_vol) + } + } else { + 0.5 * (low_vol + high_vol) + }; + vol = next; + } + + let final_price = model_price(OptionEvaluation { + contract, + volatility: vol, + }); + if (final_price - target_price).abs() <= config.tolerance * 10.0 { + vol + } else { + f64::NAN + } +} + +fn validate_window(window: usize) -> bool { + window >= 1 +} + +/// Rolling IV rank. +pub fn iv_rank(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut min_v = f64::INFINITY; + let mut max_v = f64::NEG_INFINITY; + for &v in &iv_series[start..=end] { + if v.is_finite() { + min_v = min_v.min(v); + max_v = max_v.max(v); + } + } + let current = iv_series[end]; + if !current.is_finite() || !min_v.is_finite() || !max_v.is_finite() { + out[end] = f64::NAN; + continue; + } + let spread = max_v - min_v; + out[end] = if spread == 0.0 { + 0.0 + } else { + (current - min_v) / spread + }; + } + out +} + +/// Rolling IV percentile. +pub fn iv_percentile(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let current = iv_series[end]; + let count = iv_series[start..=end] + .iter() + .filter(|&&v| v <= current) + .count(); + out[end] = count as f64 / window as f64; + } + out +} + +/// Rolling IV z-score. +pub fn iv_zscore(iv_series: &[f64], window: usize) -> Vec { + let n = iv_series.len(); + let mut out = vec![f64::NAN; n]; + if !validate_window(window) || n < window { + return out; + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut count = 0usize; + let mut sum = 0.0; + for &v in &iv_series[start..=end] { + if v.is_finite() { + count += 1; + sum += v; + } + } + if count == 0 { + out[end] = f64::NAN; + continue; + } + let mean = sum / count as f64; + let mut var = 0.0; + for &v in &iv_series[start..=end] { + if v.is_finite() { + let d = v - mean; + var += d * d; + } + } + let std = (var / count as f64).sqrt(); + let current = iv_series[end]; + out[end] = if !current.is_finite() || std == 0.0 { + f64::NAN + } else { + (current - mean) / std + }; + } + out +} + +#[cfg(test)] +mod tests { + use super::{implied_volatility, iv_percentile, iv_rank, iv_zscore}; + use crate::options::pricing::black_scholes_price; + use crate::options::{IvSolverConfig, OptionContract, OptionKind, PricingModel}; + + #[test] + fn solver_recovers_input_vol() { + let price = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + let iv = implied_volatility( + OptionContract { + model: PricingModel::BlackScholes, + underlying: 100.0, + strike: 100.0, + rate: 0.05, + carry: 0.0, + time_to_expiry: 1.0, + kind: OptionKind::Call, + }, + price, + IvSolverConfig { + initial_guess: 0.3, + tolerance: 1e-8, + max_iterations: 100, + }, + ); + assert!((iv - 0.2).abs() < 1e-6); + } + + #[test] + fn iv_helpers_match_expected_values() { + let iv = [10.0, 20.0, 30.0, 15.0, 22.0]; + let rank = iv_rank(&iv, 3); + let pct = iv_percentile(&iv, 3); + let z = iv_zscore(&iv, 3); + assert!(rank[0].is_nan() && rank[1].is_nan()); + assert!((rank[2] - 1.0).abs() < 1e-12); + assert!((pct[3] - (1.0 / 3.0)).abs() < 1e-12); + assert!((z[2] - 1.224_744_871).abs() < 1e-6); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs new file mode 100644 index 0000000..5d806fa --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/mod.rs @@ -0,0 +1,102 @@ +//! Options analytics core. +//! +//! This module contains pricing, Greeks, implied volatility inversion, +//! IV-series helpers, and smile/chain utilities. The public API is scalar-first +//! and is used by the PyO3 bridge to build vectorized batch functions. + +pub mod american; +pub mod chain; +pub mod digital; +pub mod greeks; +pub mod iv; +pub mod normal; +pub mod payoff; +pub mod pricing; +pub mod realized_vol; +pub mod surface; + +/// Option side. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OptionKind { + /// Call option. + Call, + /// Put option. + Put, +} + +impl OptionKind { + /// Returns +1 for calls and -1 for puts. + pub fn sign(self) -> f64 { + match self { + Self::Call => 1.0, + Self::Put => -1.0, + } + } +} + +/// Supported pricing models. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PricingModel { + /// Black-Scholes-Merton with continuous carry/dividend yield. + BlackScholes, + /// Black-76 using the forward price as the underlying input. + Black76, +} + +/// Primary first-order Greeks returned by the pricing engine. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Greeks { + pub delta: f64, + pub gamma: f64, + pub vega: f64, + pub theta: f64, + pub rho: f64, +} + +/// Second-order and cross Greeks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExtendedGreeks { + pub vanna: f64, // ∂Δ/∂σ + pub volga: f64, // ∂²V/∂σ² (vomma) + pub charm: f64, // ∂Δ/∂t + pub speed: f64, // ∂Γ/∂S + pub color: f64, // ∂Γ/∂t +} + +/// Shared contract fields for model-based option analytics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OptionContract { + pub model: PricingModel, + pub underlying: f64, + pub strike: f64, + pub rate: f64, + pub carry: f64, + pub time_to_expiry: f64, + pub kind: OptionKind, +} + +/// Contract plus volatility for pricing and Greeks. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OptionEvaluation { + pub contract: OptionContract, + pub volatility: f64, +} + +/// Solver configuration for implied volatility inversion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IvSolverConfig { + pub initial_guess: f64, + pub tolerance: f64, + pub max_iterations: usize, +} + +/// Shared context for strike selection and smile analytics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChainGreeksContext { + pub model: PricingModel, + pub reference_price: f64, + pub rate: f64, + pub carry: f64, + pub time_to_expiry: f64, + pub kind: OptionKind, +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs new file mode 100644 index 0000000..bb66380 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/normal.rs @@ -0,0 +1,44 @@ +//! Normal distribution helpers. + +const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7; + +/// Standard normal probability density function. +pub fn pdf(x: f64) -> f64 { + INV_SQRT_2PI * (-0.5 * x * x).exp() +} + +/// Standard normal cumulative distribution function. +/// +/// Uses a common Abramowitz-Stegun style approximation that is fast and +/// sufficiently accurate for option pricing work. +pub fn cdf(x: f64) -> f64 { + let ax = x.abs(); + let t = 1.0 / (1.0 + 0.231_641_9 * ax); + let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782) + * t + + 0.319_381_530) + * t; + let approx = 1.0 - pdf(ax) * poly; + if x >= 0.0 { + approx + } else { + 1.0 - approx + } +} + +#[cfg(test)] +mod tests { + use super::{cdf, pdf}; + + #[test] + fn cdf_is_reasonable() { + assert!((cdf(0.0) - 0.5).abs() < 1e-7); + assert!((cdf(1.0) - 0.841_344_746).abs() < 5e-5); + assert!((cdf(-1.0) - 0.158_655_254).abs() < 5e-5); + } + + #[test] + fn pdf_is_reasonable() { + assert!((pdf(0.0) - 0.398_942_280_4).abs() < 1e-10); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs new file mode 100644 index 0000000..a1fd4f3 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/payoff.rs @@ -0,0 +1,392 @@ +//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions. +//! +//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;` +//! for this module to be reachable from the rest of the crate and from the PyO3 bridge. + +use super::pricing::black_scholes_price; +use super::OptionKind; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Instrument codes: 0=option, 1=future, 2=stock. +const INSTRUMENT_OPTION: i64 = 0; +const INSTRUMENT_FUTURE: i64 = 1; +const INSTRUMENT_STOCK: i64 = 2; + +/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0). +#[inline] +fn side_sign(v: i64) -> f64 { + if v == 1 { + 1.0 + } else if v == -1 { + -1.0 + } else { + f64::NAN + } +} + +/// Option kind from encoded value: 1=call, -1=put. +#[inline] +fn option_kind(v: i64) -> Option { + match v { + 1 => Some(OptionKind::Call), + -1 => Some(OptionKind::Put), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// strategy_payoff_dense +// --------------------------------------------------------------------------- + +/// Aggregate strategy payoff over a spot grid. +/// +/// Parameters (all slices of length n_legs): +/// - `instruments`: 0=option, 1=future, 2=stock +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures/stocks) +/// - `strikes`: strike for options +/// - `premiums`: premium for options +/// - `entry_prices`: entry price for futures/stocks +/// - `quantities`, `multipliers`: applied to all instruments +/// +/// Returns a Vec of length spot_grid.len() with aggregate P&L per spot point. +#[allow(clippy::too_many_arguments)] +pub fn strategy_payoff_dense( + spot_grid: &[f64], + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], +) -> Vec { + let n_legs = instruments.len(); + // Validate that all leg slices are the same length; return zeros if not. + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || premiums.len() != n_legs + || entry_prices.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + { + return vec![0.0; spot_grid.len()]; + } + + let mut total = vec![0.0_f64; spot_grid.len()]; + + for leg_idx in 0..n_legs { + let inst = instruments[leg_idx]; + let sign = side_sign(sides[leg_idx]); + if sign.is_nan() { + // Invalid side — skip leg (treat as zero contribution). + continue; + } + let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx]; + + match inst { + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[leg_idx]) { + Some(k) => k, + None => continue, // Invalid option type — skip. + }; + let k = strikes[leg_idx]; + let p = premiums[leg_idx]; + for (i, &s) in spot_grid.iter().enumerate() { + let intrinsic = match kind { + OptionKind::Call => (s - k).max(0.0), + OptionKind::Put => (k - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - p); + } + } + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + let e = entry_prices[leg_idx]; + for (i, &s) in spot_grid.iter().enumerate() { + total[i] += leg_scale * (s - e); + } + } + _ => { + // Unknown instrument code — skip leg (NaN would propagate; zeros are safer). + } + } + } + + total +} + +// --------------------------------------------------------------------------- +// strategy_value_dense / strategy_value_grid +// --------------------------------------------------------------------------- + +/// Current BSM value of a strategy at a single spot (pre-expiry). +/// +/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather +/// than intrinsic value. +/// +/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs: +/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks) +/// - `volatilities`: vol for each option leg (ignored for futures/stocks) +/// - `rates`: risk-free rate for each leg +/// - `carries`: carry/dividend yield for each option leg +/// +/// Returns a scalar f64 (strategy P&L at the given spot). +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_dense( + spot: f64, + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], + time_to_expiries: &[f64], + volatilities: &[f64], + rates: &[f64], + carries: &[f64], +) -> f64 { + let n_legs = instruments.len(); + // Validate that all leg slices are the same length; return NaN if not. + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || premiums.len() != n_legs + || entry_prices.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + || time_to_expiries.len() != n_legs + || volatilities.len() != n_legs + || rates.len() != n_legs + || carries.len() != n_legs + { + return f64::NAN; + } + + let mut total = 0.0_f64; + + for leg_idx in 0..n_legs { + let inst = instruments[leg_idx]; + let sign = side_sign(sides[leg_idx]); + if sign.is_nan() { + continue; + } + let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx]; + + match inst { + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[leg_idx]) { + Some(k) => k, + None => continue, + }; + let bsm = black_scholes_price( + spot, + strikes[leg_idx], + rates[leg_idx], + carries[leg_idx], + time_to_expiries[leg_idx], + volatilities[leg_idx], + kind, + ); + total += leg_scale * (bsm - premiums[leg_idx]); + } + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + total += leg_scale * (spot - entry_prices[leg_idx]); + } + _ => {} + } + } + + total +} + +// --------------------------------------------------------------------------- +// aggregate_greeks_dense +// --------------------------------------------------------------------------- + +/// Aggregate BSM Greeks for a multi-leg strategy at a single spot. +/// +/// Parameters (all slices of length n_legs): +/// - `instruments`: 0=option, 1=future, 2=stock +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures/stocks) +/// - `strikes`: strike price for option legs +/// - `volatilities`: implied vol for option legs +/// - `time_to_expiries`: TTE in years for option legs +/// - `rates`: risk-free rate for each leg +/// - `carries`: carry/dividend yield for option legs +/// - `quantities`, `multipliers`: applied to all instruments +/// +/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs. +/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0). +#[allow(clippy::too_many_arguments)] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + volatilities: &[f64], + time_to_expiries: &[f64], + rates: &[f64], + carries: &[f64], + quantities: &[f64], + multipliers: &[f64], +) -> (f64, f64, f64, f64, f64) { + use super::greeks::model_greeks; + use super::{OptionContract, OptionEvaluation, PricingModel}; + + let n_legs = instruments.len(); + if sides.len() != n_legs + || option_types.len() != n_legs + || strikes.len() != n_legs + || volatilities.len() != n_legs + || time_to_expiries.len() != n_legs + || rates.len() != n_legs + || carries.len() != n_legs + || quantities.len() != n_legs + || multipliers.len() != n_legs + { + return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN); + } + + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for i in 0..n_legs { + let sign = side_sign(sides[i]); + if sign.is_nan() { + continue; + } + let leg_scale = sign * quantities[i] * multipliers[i]; + + match instruments[i] { + INSTRUMENT_FUTURE | INSTRUMENT_STOCK => { + delta += leg_scale; + } + INSTRUMENT_OPTION => { + let kind = match option_kind(option_types[i]) { + Some(k) => k, + None => continue, + }; + let greeks = model_greeks(OptionEvaluation { + contract: OptionContract { + model: PricingModel::BlackScholes, + underlying: spot, + strike: strikes[i], + rate: rates[i], + carry: carries[i], + time_to_expiry: time_to_expiries[i], + kind, + }, + volatility: volatilities[i], + }); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + _ => {} + } + } + + (delta, gamma, vega, theta, rho) +} + +/// Evaluate `strategy_value_dense` for each point in `spot_grid`. +/// +/// Returns a `Vec` of length `spot_grid.len()`. +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_grid( + spot_grid: &[f64], + instruments: &[i64], + sides: &[i64], + option_types: &[i64], + strikes: &[f64], + premiums: &[f64], + entry_prices: &[f64], + quantities: &[f64], + multipliers: &[f64], + time_to_expiries: &[f64], + volatilities: &[f64], + rates: &[f64], + carries: &[f64], +) -> Vec { + spot_grid + .iter() + .map(|&s| { + strategy_value_dense( + s, + instruments, + sides, + option_types, + strikes, + premiums, + entry_prices, + quantities, + multipliers, + time_to_expiries, + volatilities, + rates, + carries, + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payoff_single_call() { + let grid = vec![90.0, 100.0, 110.0, 120.0]; + let out = strategy_payoff_dense( + &grid, + &[0], + &[1], + &[1], + &[100.0], + &[5.0], + &[0.0], + &[1.0], + &[1.0], + ); + assert!(out[0] < 0.0); // below strike, loss = premium + assert!((out[0] - (-5.0)).abs() < 1e-10); + assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5 + } + + #[test] + fn stock_leg_linear() { + let grid = vec![90.0, 100.0, 110.0]; + let out = strategy_payoff_dense( + &grid, + &[2], + &[1], + &[0], + &[0.0], + &[0.0], + &[100.0], + &[1.0], + &[1.0], + ); + assert!((out[0] - (-10.0)).abs() < 1e-10); + assert!((out[1] - 0.0).abs() < 1e-10); + assert!((out[2] - 10.0).abs() < 1e-10); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs new file mode 100644 index 0000000..44983ac --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/pricing.rs @@ -0,0 +1,218 @@ +//! Option pricing models. + +use super::normal::cdf; +use super::{OptionContract, OptionEvaluation, OptionKind, PricingModel}; + +fn invalid_inputs(underlying: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool { + !underlying.is_finite() + || !strike.is_finite() + || !time_to_expiry.is_finite() + || !volatility.is_finite() + || underlying <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + || volatility < 0.0 +} + +/// Black-Scholes-Merton price with continuous carry/dividend yield. +pub fn black_scholes_price( + spot: f64, + strike: f64, + rate: f64, + dividend_yield: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(spot, strike, time_to_expiry, volatility) || !rate.is_finite() { + return f64::NAN; + } + if time_to_expiry == 0.0 { + return match kind { + OptionKind::Call => (spot - strike).max(0.0), + OptionKind::Put => (strike - spot).max(0.0), + }; + } + + let discount = (-rate * time_to_expiry).exp(); + let carry_discount = (-dividend_yield * time_to_expiry).exp(); + if volatility == 0.0 { + return match kind { + OptionKind::Call => (spot * carry_discount - strike * discount).max(0.0), + OptionKind::Put => (strike * discount - spot * carry_discount).max(0.0), + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = ((spot / strike).ln() + + (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry) + / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + match kind { + OptionKind::Call => spot * carry_discount * cdf(d1) - strike * discount * cdf(d2), + OptionKind::Put => strike * discount * cdf(-d2) - spot * carry_discount * cdf(-d1), + } +} + +/// Black-76 price using the forward price as the underlying input. +pub fn black_76_price( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + kind: OptionKind, +) -> f64 { + if invalid_inputs(forward, strike, time_to_expiry, volatility) || !rate.is_finite() { + return f64::NAN; + } + let discount = (-rate * time_to_expiry).exp(); + if time_to_expiry == 0.0 { + return discount + * match kind { + OptionKind::Call => (forward - strike).max(0.0), + OptionKind::Put => (strike - forward).max(0.0), + }; + } + if volatility == 0.0 { + return discount + * match kind { + OptionKind::Call => (forward - strike).max(0.0), + OptionKind::Put => (strike - forward).max(0.0), + }; + } + + let sqrt_t = time_to_expiry.sqrt(); + let sigma_sqrt_t = volatility * sqrt_t; + let d1 = + ((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t; + let d2 = d1 - sigma_sqrt_t; + + let signed = kind.sign(); + discount * signed * (forward * cdf(signed * d1) - strike * cdf(signed * d2)) +} + +/// Model-dispatched option price. +pub fn model_price(input: OptionEvaluation) -> f64 { + let contract = input.contract; + match contract.model { + PricingModel::BlackScholes => black_scholes_price( + contract.underlying, + contract.strike, + contract.rate, + contract.carry, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + PricingModel::Black76 => black_76_price( + contract.underlying, + contract.strike, + contract.rate, + contract.time_to_expiry, + input.volatility, + contract.kind, + ), + } +} + +/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`. +/// +/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the +/// magnitude of mispricing or data error. +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + if !call_price.is_finite() + || !put_price.is_finite() + || !spot.is_finite() + || !strike.is_finite() + || !rate.is_finite() + || !carry.is_finite() + || !time_to_expiry.is_finite() + || spot <= 0.0 + || strike <= 0.0 + || time_to_expiry < 0.0 + { + return f64::NAN; + } + let pv_forward = spot * (-carry * time_to_expiry).exp(); + let pv_strike = strike * (-rate * time_to_expiry).exp(); + call_price - put_price - (pv_forward - pv_strike) +} + +/// Lower no-arbitrage bound for the option price. +pub fn price_lower_bound(contract: OptionContract) -> f64 { + match contract.model { + PricingModel::BlackScholes => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + let carry_discount = (-contract.carry * contract.time_to_expiry).exp(); + match contract.kind { + OptionKind::Call => { + (contract.underlying * carry_discount - contract.strike * discount).max(0.0) + } + OptionKind::Put => { + (contract.strike * discount - contract.underlying * carry_discount).max(0.0) + } + } + } + PricingModel::Black76 => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + discount + * match contract.kind { + OptionKind::Call => (contract.underlying - contract.strike).max(0.0), + OptionKind::Put => (contract.strike - contract.underlying).max(0.0), + } + } + } +} + +/// Upper no-arbitrage bound for the option price. +pub fn price_upper_bound(contract: OptionContract) -> f64 { + match contract.model { + PricingModel::BlackScholes => match contract.kind { + OptionKind::Call => { + contract.underlying * (-contract.carry * contract.time_to_expiry).exp() + } + OptionKind::Put => contract.strike * (-contract.rate * contract.time_to_expiry).exp(), + }, + PricingModel::Black76 => { + let discount = (-contract.rate * contract.time_to_expiry).exp(); + discount + * match contract.kind { + OptionKind::Call => contract.underlying, + OptionKind::Put => contract.strike, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{black_76_price, black_scholes_price}; + use crate::options::OptionKind; + + #[test] + fn black_scholes_prices_are_reasonable() { + let call = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call); + let put = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put); + assert!((call - 10.4506).abs() < 1e-3); + assert!((put - 5.5735).abs() < 1e-3); + } + + #[test] + fn black_76_prices_are_reasonable() { + let call = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Call); + let put = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put); + assert!((call - 7.730_148).abs() < 1e-3); + assert!((put - 7.730_148).abs() < 1e-3); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs new file mode 100644 index 0000000..7270f96 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/realized_vol.rs @@ -0,0 +1,445 @@ +//! Historical (realized) volatility estimators and volatility cone. + +/// Rolling close-to-close realized volatility. +/// +/// Returns a `Vec` of the same length as `close`. The first `window` values +/// are NaN (we need `window` log-returns, which require `window+1` prices, so the +/// first valid output sits at index `window`). +/// +/// Annualization: `sqrt(sum(r²) / window * trading_days)`. +pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec { + let n = close.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n <= window { + return out; + } + + // Precompute log-returns; returns[i] = ln(close[i+1] / close[i]) + let mut returns = vec![f64::NAN; n - 1]; + for i in 0..(n - 1) { + if close[i] > 0.0 && close[i + 1] > 0.0 { + returns[i] = (close[i + 1] / close[i]).ln(); + } + } + + // Rolling sum of squared returns over `window` bars. + // The output at position `end` (in the original close array) uses + // returns[end-window .. end-1], i.e. `window` returns. + for end in window..n { + let slice = &returns[(end - window)..end]; + let sum_sq: f64 = slice.iter().map(|&r| r * r).sum(); + let var = sum_sq / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + out +} + +/// Rolling Parkinson high-low realized volatility estimator. +/// +/// Returns a `Vec` of the same length as `high`. The first `window-1` values +/// are NaN. +#[allow(clippy::needless_range_loop)] +pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec { + let n = high.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || low.len() != n { + return out; + } + + let factor = 1.0 / (4.0 * 2_f64.ln()); + + for end in (window - 1)..n { + let start = end + 1 - window; + let mut sum_sq = 0.0; + let mut valid = true; + for i in start..=end { + if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() { + valid = false; + break; + } + let u = (high[i] / low[i]).ln(); + sum_sq += u * u; + } + if valid { + let var = factor * sum_sq / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Rolling Garman-Klass OHLC realized volatility estimator. +/// +/// Returns a `Vec` of the same length as the inputs. The first `window-1` +/// values are NaN. All four slices must have the same length. +pub fn garman_klass_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + let ln2 = 2_f64.ln(); + + // Precompute per-bar GK contributions. + let mut gk = vec![f64::NAN; n]; + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + let u = (h / o).ln(); + let d = (l / o).ln(); + let ci = (c / o).ln(); + gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci; + } + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let slice = &gk[start..=end]; + if slice.iter().all(|v| v.is_finite()) { + let sum: f64 = slice.iter().sum(); + let var = sum / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Compute the Rogers-Satchell per-bar variance contribution. +fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 { + let u = (high / close).ln(); + let d = (low / close).ln(); + let uo = (high / open).ln(); + let do_ = (low / open).ln(); + u * uo + d * do_ +} + +/// Rolling Rogers-Satchell OHLC realized volatility estimator. +/// +/// Returns a `Vec` of the same length as the inputs. The first `window-1` +/// values are NaN. All four slices must have the same length. +pub fn rogers_satchell_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + // Precompute per-bar RS contributions. + let mut rs = vec![f64::NAN; n]; + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + rs[i] = rs_bar(o, h, l, c); + } + } + + for end in (window - 1)..n { + let start = end + 1 - window; + let slice = &rs[start..=end]; + if slice.iter().all(|v| v.is_finite()) { + let sum: f64 = slice.iter().sum(); + let var = sum / window as f64 * trading_days; + out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN }; + } + } + out +} + +/// Rolling Yang-Zhang OHLC realized volatility estimator. +/// +/// Handles overnight gaps. Returns a `Vec` of the same length as the inputs. +/// The first `window` values are NaN (we need `window` bars plus the prior close +/// for overnight returns, so valid output starts at index `window`). +/// All four slices must have the same length. +pub fn yang_zhang_vol( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + window: usize, + trading_days: f64, +) -> Vec { + let n = open.len(); + let mut out = vec![f64::NAN; n]; + if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n { + return out; + } + + let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10)); + + // Precompute per-bar components; index 0 has no overnight return. + // overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1 + // openclose[i] = ln(C_i / O_i) + // rs[i] = Rogers-Satchell for bar i + let mut overnight = vec![f64::NAN; n]; + let mut openclose = vec![f64::NAN; n]; + let mut rs = vec![f64::NAN; n]; + + for i in 0..n { + let o = open[i]; + let h = high[i]; + let l = low[i]; + let c = close[i]; + if o > 0.0 + && h > 0.0 + && l > 0.0 + && c > 0.0 + && o.is_finite() + && h.is_finite() + && l.is_finite() + && c.is_finite() + { + openclose[i] = (c / o).ln(); + rs[i] = rs_bar(o, h, l, c); + + if i > 0 { + let prev_c = close[i - 1]; + if prev_c > 0.0 && prev_c.is_finite() { + overnight[i] = (o / prev_c).ln(); + } + } + } + } + + // Valid windows start at index `window` (using bars [end-window+1 .. end], + // all of which have valid overnight returns since they start at index >= 1). + for end in window..n { + let start = end + 1 - window; // start >= 1 because end >= window + + let o_slice = &overnight[start..=end]; + let c_slice = &openclose[start..=end]; + let r_slice = &rs[start..=end]; + + if !o_slice.iter().all(|v| v.is_finite()) + || !c_slice.iter().all(|v| v.is_finite()) + || !r_slice.iter().all(|v| v.is_finite()) + { + continue; + } + + let w = window as f64; + + let o_sum: f64 = o_slice.iter().sum(); + let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum(); + let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0); + + let c_sum: f64 = c_slice.iter().sum(); + let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum(); + let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0); + + let rs_sum: f64 = r_slice.iter().sum(); + let rs_var = rs_sum / w; + + let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var; + let annualized = yz_var * trading_days; + out[end] = if annualized >= 0.0 { + annualized.sqrt() + } else { + f64::NAN + }; + } + out +} + +/// Summary statistics of realized vol distribution for one window length. +#[derive(Clone, Copy, Debug)] +pub struct VolConeSlice { + pub window: usize, + pub min: f64, + pub p25: f64, + pub median: f64, + pub p75: f64, + pub max: f64, +} + +/// Compute a percentile via linear interpolation on a sorted slice. +/// +/// `sorted` must be non-empty and already sorted ascending. +fn percentile_sorted(sorted: &[f64], p: f64) -> f64 { + let n = sorted.len(); + if n == 1 { + return sorted[0]; + } + let idx = (n - 1) as f64 * p; + let lo = idx.floor() as usize; + let hi = idx.ceil() as usize; + let frac = idx - lo as f64; + sorted[lo] + frac * (sorted[hi] - sorted[lo]) +} + +/// Compute vol cone: distribution of realized vols across multiple window lengths. +/// +/// For each window in `windows`, the close-to-close rolling vol is computed, +/// NaN values are filtered out, and the distribution statistics (min, p25, +/// median, p75, max) are derived via linear interpolation. +pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec { + windows + .iter() + .map(|&w| { + let vols = close_to_close_vol(close, w, trading_days); + let mut valid: Vec = vols.into_iter().filter(|v| v.is_finite()).collect(); + valid.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + if valid.is_empty() { + return VolConeSlice { + window: w, + min: f64::NAN, + p25: f64::NAN, + median: f64::NAN, + p75: f64::NAN, + max: f64::NAN, + }; + } + + VolConeSlice { + window: w, + min: valid[0], + p25: percentile_sorted(&valid, 0.25), + median: percentile_sorted(&valid, 0.5), + p75: percentile_sorted(&valid, 0.75), + max: *valid.last().unwrap(), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_prices(n: usize) -> Vec { + // simple synthetic price series + let mut prices = vec![100.0_f64; n]; + for i in 1..n { + prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01); + } + prices + } + + #[test] + fn close_to_close_returns_nans_for_warmup() { + let close = fake_prices(100); + let result = close_to_close_vol(&close, 20, 252.0); + assert_eq!(result.len(), 100); + // first 20 values should be NaN (window-1 of returns warmup + 1 for diff) + for i in 0..20 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[20].is_finite()); + } + + #[test] + fn parkinson_vol_is_positive() { + let close = fake_prices(100); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = parkinson_vol(&high, &low, 20, 252.0); + for v in result.iter().skip(19) { + assert!(v.is_finite() && *v >= 0.0); + } + } + + #[test] + fn vol_cone_is_ordered() { + let close = fake_prices(300); + let cones = vol_cone(&close, &[20, 60], 252.0); + assert_eq!(cones.len(), 2); + for cone in &cones { + assert!(cone.min <= cone.p25); + assert!(cone.p25 <= cone.median); + assert!(cone.median <= cone.p75); + assert!(cone.p75 <= cone.max); + } + } + + #[test] + fn garman_klass_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..9 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[9].is_finite()); + } + + #[test] + fn rogers_satchell_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..9 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[9].is_finite()); + } + + #[test] + fn yang_zhang_returns_nans_for_warmup() { + let close = fake_prices(50); + let high: Vec = close.iter().map(|&c| c * 1.01).collect(); + let low: Vec = close.iter().map(|&c| c * 0.99).collect(); + let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0); + assert_eq!(result.len(), 50); + for i in 0..10 { + assert!(result[i].is_nan(), "result[{i}] should be NaN"); + } + assert!(result[10].is_finite()); + } + + #[test] + fn mismatched_lengths_return_all_nan() { + let a = vec![100.0_f64; 20]; + let b = vec![101.0_f64; 15]; // wrong length + let result = parkinson_vol(&a, &b, 5, 252.0); + assert!(result.iter().all(|v| v.is_nan())); + } + + #[test] + fn window_larger_than_data_returns_all_nan() { + let close = fake_prices(10); + let result = close_to_close_vol(&close, 20, 252.0); + assert!(result.iter().all(|v| v.is_nan())); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs new file mode 100644 index 0000000..aec0337 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/options/surface.rs @@ -0,0 +1,269 @@ +//! Smile and surface analytics helpers. + +use super::chain::atm_index; +use super::greeks::model_greeks; +use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind, PricingModel}; + +/// Smile summary metrics. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SmileMetrics { + pub atm_iv: f64, + pub risk_reversal_25d: f64, + pub butterfly_25d: f64, + pub skew_slope: f64, + pub convexity: f64, +} + +/// Linear interpolation helper. +pub fn linear_interpolate(xs: &[f64], ys: &[f64], target: f64) -> f64 { + if xs.len() != ys.len() || xs.is_empty() { + return f64::NAN; + } + if target <= xs[0] { + return ys[0]; + } + for i in 1..xs.len() { + if target <= xs[i] { + let x0 = xs[i - 1]; + let x1 = xs[i]; + let y0 = ys[i - 1]; + let y1 = ys[i]; + let w = if x1 == x0 { + 0.0 + } else { + (target - x0) / (x1 - x0) + }; + return y0 + w * (y1 - y0); + } + } + ys[ys.len() - 1] +} + +/// ATM implied volatility by nearest strike. +pub fn atm_iv(strikes: &[f64], vols: &[f64], reference_price: f64) -> f64 { + if strikes.len() != vols.len() || strikes.is_empty() || !reference_price.is_finite() { + return f64::NAN; + } + atm_index(strikes, reference_price) + .and_then(|idx| vols.get(idx).copied()) + .unwrap_or(f64::NAN) +} + +fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 { + if xs.len() != ys.len() || xs.len() < 2 { + return f64::NAN; + } + let n = xs.len() as f64; + let mean_x = xs.iter().sum::() / n; + let mean_y = ys.iter().sum::() / n; + let mut cov = 0.0; + let mut var = 0.0; + for (&x, &y) in xs.iter().zip(ys.iter()) { + cov += (x - mean_x) * (y - mean_y); + var += (x - mean_x) * (x - mean_x); + } + if var == 0.0 { + f64::NAN + } else { + cov / var + } +} + +fn closest_delta_iv( + strikes: &[f64], + vols: &[f64], + context: ChainGreeksContext, + target_delta: f64, +) -> f64 { + let mut best_iv = f64::NAN; + let mut best_distance = f64::INFINITY; + for (&strike, &vol) in strikes.iter().zip(vols.iter()) { + if !strike.is_finite() || !vol.is_finite() { + continue; + } + let delta = model_greeks(OptionEvaluation { + contract: OptionContract { + model: context.model, + underlying: context.reference_price, + strike, + rate: context.rate, + carry: context.carry, + time_to_expiry: context.time_to_expiry, + kind: context.kind, + }, + volatility: vol, + }) + .delta; + if !delta.is_finite() { + continue; + } + let distance = (delta - target_delta).abs(); + if distance < best_distance { + best_distance = distance; + best_iv = vol; + } + } + best_iv +} + +/// Smile metrics from a single expiry slice. +pub fn smile_metrics( + strikes: &[f64], + vols: &[f64], + reference_price: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + model: PricingModel, +) -> SmileMetrics { + if strikes.len() != vols.len() || strikes.len() < 3 || reference_price <= 0.0 { + return SmileMetrics { + atm_iv: f64::NAN, + risk_reversal_25d: f64::NAN, + butterfly_25d: f64::NAN, + skew_slope: f64::NAN, + convexity: f64::NAN, + }; + } + + let atm_idx = match atm_index(strikes, reference_price) { + Some(idx) => idx, + None => { + return SmileMetrics { + atm_iv: f64::NAN, + risk_reversal_25d: f64::NAN, + butterfly_25d: f64::NAN, + skew_slope: f64::NAN, + convexity: f64::NAN, + } + } + }; + let atm_iv = vols[atm_idx]; + + let call_25 = closest_delta_iv( + strikes, + vols, + ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind: OptionKind::Call, + }, + 0.25, + ); + let put_25 = closest_delta_iv( + strikes, + vols, + ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind: OptionKind::Put, + }, + -0.25, + ); + let risk_reversal_25d = call_25 - put_25; + let butterfly_25d = 0.5 * (call_25 + put_25) - atm_iv; + + let log_moneyness: Vec = strikes + .iter() + .map(|&k| (k / reference_price).ln()) + .collect(); + let skew_slope = regression_slope(&log_moneyness, vols); + let convexity = if atm_idx > 0 && atm_idx + 1 < strikes.len() { + let x0 = log_moneyness[atm_idx - 1]; + let x1 = log_moneyness[atm_idx]; + let x2 = log_moneyness[atm_idx + 1]; + let y0 = vols[atm_idx - 1]; + let y1 = vols[atm_idx]; + let y2 = vols[atm_idx + 1]; + let left = if x1 == x0 { 0.0 } else { (y1 - y0) / (x1 - x0) }; + let right = if x2 == x1 { 0.0 } else { (y2 - y1) / (x2 - x1) }; + right - left + } else { + f64::NAN + }; + + SmileMetrics { + atm_iv, + risk_reversal_25d, + butterfly_25d, + skew_slope, + convexity, + } +} + +/// Term-structure slope from (tenor, atm_iv) points. +pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 { + regression_slope(tenors, atm_ivs) +} + +/// Expected ±1σ move over `days_to_expiry` calendar days. +/// +/// Returns `(lower_move, upper_move)` as absolute changes from `spot`. +/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105. +/// +/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} − spot`. +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> (f64, f64) { + if !spot.is_finite() + || !iv.is_finite() + || !days_to_expiry.is_finite() + || !trading_days_per_year.is_finite() + || spot <= 0.0 + || iv < 0.0 + || days_to_expiry < 0.0 + || trading_days_per_year <= 0.0 + { + return (f64::NAN, f64::NAN); + } + let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt(); + let upper = spot * sigma_sqrt_t.exp() - spot; + let lower = spot * (-sigma_sqrt_t).exp() - spot; + (lower, upper) +} + +#[cfg(test)] +mod tests { + use super::{atm_iv, smile_metrics, term_structure_slope}; + use crate::options::PricingModel; + + #[test] + fn atm_selection_works() { + let strikes = [90.0, 100.0, 110.0]; + let vols = [0.24, 0.20, 0.22]; + assert!((atm_iv(&strikes, &vols, 102.0) - 0.20).abs() < 1e-12); + } + + #[test] + fn smile_metrics_are_finite() { + let strikes = [80.0, 90.0, 100.0, 110.0, 120.0]; + let vols = [0.30, 0.25, 0.20, 0.22, 0.27]; + let metrics = smile_metrics( + &strikes, + &vols, + 100.0, + 0.02, + 0.0, + 0.5, + PricingModel::BlackScholes, + ); + assert!(metrics.atm_iv.is_finite()); + assert!(metrics.skew_slope.is_finite()); + } + + #[test] + fn term_slope_is_reasonable() { + let tenors = [0.1, 0.5, 1.0]; + let vols = [0.18, 0.20, 0.22]; + assert!(term_structure_slope(&tenors, &vols) > 0.0); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs new file mode 100644 index 0000000..1c203de --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/overlap.rs @@ -0,0 +1,1054 @@ +//! Overlap studies — moving averages and trend indicators. +//! +//! All functions return a `Vec` of the same length as the input. +//! Leading values are `f64::NAN` for the warm-up period. + +/// Compute the Simple Moving Average (SMA) over a rolling window. +/// +/// Returns a `Vec` of the same length as `close`. The first +/// `timeperiod - 1` values are `NaN` (warmup period). +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Rolling window size (must be >= 1). +/// +/// # Edge Cases +/// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`. +pub fn sma(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + sma_into(close, timeperiod, &mut result, 0); + result +} + +/// Write a Simple Moving Average directly into a pre-allocated buffer. +/// +/// Values before `dest_offset + timeperiod - 1` are left untouched. +/// This avoids an intermediate allocation when composing indicators +/// (e.g., Stochastic slow %K and slow %D). +/// +/// # Arguments +/// * `src` - Input price series. +/// * `timeperiod` - Rolling window size (must be >= 1). +/// * `dest` - Output buffer (must be at least `dest_offset + src.len()` long). +/// * `dest_offset` - Starting index in `dest` to write results. +pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) { + let n = src.len(); + if timeperiod < 1 || n < timeperiod { + return; + } + + // Seed the rolling window with a runtime-dispatched reduction. The O(n) + // streaming recurrence below is inherently sequential, so SIMD only ever + // applies to this initial window sum. + let mut window_sum = crate::simd::sum(&src[..timeperiod]); + let tp_f64 = timeperiod as f64; + dest[dest_offset + timeperiod - 1] = window_sum / tp_f64; + + let mut i = timeperiod; + while i + 1 < n { + let old0 = src[i - timeperiod]; + let new0 = src[i]; + window_sum += new0 - old0; + dest[dest_offset + i] = window_sum / tp_f64; + + let old1 = src[i + 1 - timeperiod]; + let new1 = src[i + 1]; + window_sum += new1 - old1; + dest[dest_offset + i + 1] = window_sum / tp_f64; + + i += 2; + } + if i < n { + window_sum += src[i] - src[i - timeperiod]; + dest[dest_offset + i] = window_sum / tp_f64; + } +} + +/// Compute the Exponential Moving Average (EMA). +/// +/// The EMA is seeded with the SMA of the first `timeperiod` bars and uses +/// a smoothing factor of `k = 2 / (timeperiod + 1)`. Returns a `Vec` +/// of the same length as `close`; the first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Lookback period (must be >= 1). +pub fn ema(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let k = 2.0 / (timeperiod as f64 + 1.0); + let seed: f64 = close[..timeperiod].iter().sum::() / timeperiod as f64; + result[timeperiod - 1] = seed; + for i in timeperiod..n { + result[i] = (result[i - 1] * (1.0 - k)).mul_add(1.0, close[i] * k); + } + result +} + +/// Compute the Weighted Moving Average (WMA). +/// +/// Assigns linearly increasing weights (1, 2, ..., timeperiod) to the window. +/// Uses an O(n) incremental recurrence to avoid recomputing weights each bar. +/// Returns a `Vec` of length `n`; the first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - Rolling window size (must be >= 1). +pub fn wma(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64; + let p = timeperiod as f64; + + // Seed: compute T and S for the first window via a runtime-dispatched + // reduction (the streaming recurrence below is sequential). + let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]); + + result[timeperiod - 1] = t / denom; + + let mut i = timeperiod; + while i + 1 < n { + t += p * close[i] - s; + s += close[i] - close[i - timeperiod]; + result[i] = t / denom; + + t += p * close[i + 1] - s; + s += close[i + 1] - close[i + 1 - timeperiod]; + result[i + 1] = t / denom; + + i += 2; + } + if i < n { + t += p * close[i] - s; + result[i] = t / denom; + } + result +} + +/// Compute Bollinger Bands, returning `(upper, middle, lower)`. +/// +/// The middle band is the SMA; upper and lower bands are offset by +/// `nbdevup` and `nbdevdn` standard deviations respectively. Uses +/// Welford's rolling algorithm for numerically stable variance in O(n). +/// +/// # Arguments +/// * `close` - Price series. +/// * `timeperiod` - SMA / standard deviation window (must be >= 1). +/// * `nbdevup` - Number of standard deviations above the mean for the upper band. +/// * `nbdevdn` - Number of standard deviations below the mean for the lower band. +/// +/// # Returns +/// `(upper, middle, lower)` -- each `Vec` of length `n`. The first +/// `timeperiod - 1` values in each vector are `NaN`. +/// +/// ## Welford's rolling algorithm +/// +/// We maintain `mean` and `m2` (sum of squared deviations from the current +/// mean) across a sliding window of size `N`. When a new value `x_new` +/// replaces an old value `x_old` (window size stays constant): +/// +/// ```text +/// delta = x_new - x_old +/// old_mean = mean +/// mean += delta / N +/// m2 += delta * ((x_new - mean) + (x_old - old_mean)) +/// +/// variance = m2 / N // population variance +/// stddev = sqrt(variance) +/// ``` +/// +/// The initial window is seeded using the standard (non-rolling) Welford +/// incremental algorithm. +/// +/// This avoids the catastrophic cancellation inherent in the naïve +/// `Σx²/N − mean²` formula when values are large but close together. +pub fn bbands( + close: &[f64], + timeperiod: usize, + nbdevup: f64, + nbdevdn: f64, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return (nan.clone(), nan.clone(), nan); + } + let mut upper = vec![f64::NAN; n]; + let mut middle = vec![f64::NAN; n]; + let mut lower = vec![f64::NAN; n]; + let p = timeperiod as f64; + + // --- Seed: build initial mean and m2 for the first window using + // Welford's incremental (non-rolling) algorithm. --- + let mut mean = 0.0_f64; + let mut m2 = 0.0_f64; + for (k, &x) in close[..timeperiod].iter().enumerate() { + let count = (k + 1) as f64; + let delta = x - mean; + mean += delta / count; + let delta2 = x - mean; + m2 += delta * delta2; + } + + let var = (m2 / p).max(0.0); + let std = var.sqrt(); + middle[timeperiod - 1] = mean; + upper[timeperiod - 1] = mean + nbdevup * std; + lower[timeperiod - 1] = mean - nbdevdn * std; + + // --- Rolling phase: slide the window one element at a time, + // removing the oldest value and adding the newest. --- + + /// Inline helper: replace `x_old` with `x_new` in the Welford accumulator + /// (constant window size `p`), then write band values into the output slots. + /// + /// Combined rolling Welford update (window size stays constant at N): + /// + /// ```text + /// delta = x_new - x_old + /// old_mean = mean + /// mean += delta / N + /// m2 += delta * ((x_new - mean) + (x_old - old_mean)) + /// ``` + /// + /// This is algebraically equivalent to removing `x_old` and adding `x_new` + /// in two separate Welford steps, but avoids the intermediate N-1 state. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + fn welford_step( + x_old: f64, + x_new: f64, + mean: &mut f64, + m2: &mut f64, + p: f64, + nbdevup: f64, + nbdevdn: f64, + upper: &mut f64, + middle: &mut f64, + lower: &mut f64, + ) { + let delta = x_new - x_old; + let old_mean = *mean; + *mean += delta / p; + // Update m2 using both old and new deviations. + *m2 += delta * ((x_new - *mean) + (x_old - old_mean)); + + // Clamp m2 to zero to guard against floating-point drift. + if *m2 < 0.0 { + *m2 = 0.0; + } + + let var = *m2 / p; + let std = var.sqrt(); + *middle = *mean; + *upper = *mean + nbdevup * std; + *lower = *mean - nbdevdn * std; + } + + // Process two iterations at a time (loop unrolling) for throughput. + let mut i = timeperiod; + while i + 1 < n { + welford_step( + close[i - timeperiod], + close[i], + &mut mean, + &mut m2, + p, + nbdevup, + nbdevdn, + &mut upper[i], + &mut middle[i], + &mut lower[i], + ); + welford_step( + close[i + 1 - timeperiod], + close[i + 1], + &mut mean, + &mut m2, + p, + nbdevup, + nbdevdn, + &mut upper[i + 1], + &mut middle[i + 1], + &mut lower[i + 1], + ); + i += 2; + } + if i < n { + welford_step( + close[i - timeperiod], + close[i], + &mut mean, + &mut m2, + p, + nbdevup, + nbdevdn, + &mut upper[i], + &mut middle[i], + &mut lower[i], + ); + } + + (upper, middle, lower) +} + +/// Compute the Moving Average Convergence/Divergence (MACD). +/// +/// `MACD = EMA(close, fastperiod) - EMA(close, slowperiod)`. +/// The signal line is `EMA(macd, signalperiod)` and the histogram is +/// `macd - signal`. TA-Lib compatible: leading values are `NaN` up to +/// the point where all three outputs are valid. +/// +/// # Arguments +/// * `close` - Price series. +/// * `fastperiod` - Fast EMA period (must be < `slowperiod`). +/// * `slowperiod` - Slow EMA period. +/// * `signalperiod` - Signal line EMA period. +/// +/// # Returns +/// `(macd_line, signal_line, histogram)` -- each `Vec` of length `n`. +pub fn macd( + close: &[f64], + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan_vec = || vec![f64::NAN; n]; + if fastperiod < 1 || slowperiod < 1 || signalperiod < 1 || fastperiod >= slowperiod { + return (nan_vec(), nan_vec(), nan_vec()); + } + if n < slowperiod { + return (nan_vec(), nan_vec(), nan_vec()); + } + + let kf = 2.0 / (fastperiod as f64 + 1.0); + let ks = 2.0 / (slowperiod as f64 + 1.0); + + // Seed fast EMA from SMA of first fastperiod bars. + let mut fast_val: f64 = close[..fastperiod].iter().sum::() / fastperiod as f64; + // Seed slow EMA from SMA of first slowperiod bars. + let mut slow_val: f64 = close[..slowperiod].iter().sum::() / slowperiod as f64; + + let mut macd_line = nan_vec(); + + // From fastperiod-1 to slowperiod-2: advance fast EMA only. + for &price in close.iter().take(slowperiod - 1).skip(fastperiod) { + fast_val = price * kf + fast_val * (1.0 - kf); + } + + // From fastperiod to slowperiod-1: advance fastEMA and compute initial MACD at slowperiod-1 + // Actually, fast_val currently holds the value for `slowperiod - 2` after `take(slowperiod - 1)` + // So we apply it for `slowperiod - 1`. + fast_val = close[slowperiod - 1] * kf + fast_val * (1.0 - kf); + macd_line[slowperiod - 1] = fast_val - slow_val; + for i in slowperiod..n { + fast_val = close[i] * kf + fast_val * (1.0 - kf); + slow_val = close[i] * ks + slow_val * (1.0 - ks); + macd_line[i] = fast_val - slow_val; + } + + // Signal line: EMA of macd_line, seeded from the first valid macd value. + // The signal line starts producing values after slowperiod - 1 + signalperiod - 1 bars. + let sig_start = slowperiod - 1 + signalperiod - 1; + let mut signal_line = nan_vec(); + let mut histogram = nan_vec(); + + if sig_start >= n { + // If we can't compute signal, TA-Lib clears MACD! + for v in macd_line.iter_mut().take(n) { + *v = f64::NAN; + } + return (macd_line, signal_line, histogram); + } + + let ksig = 2.0 / (signalperiod as f64 + 1.0); + // Seed signal EMA with SMA of the first signalperiod macd values. + let sig_seed: f64 = macd_line[(slowperiod - 1)..(slowperiod - 1 + signalperiod)] + .iter() + .sum::() + / signalperiod as f64; + signal_line[sig_start] = sig_seed; + histogram[sig_start] = macd_line[sig_start] - signal_line[sig_start]; + + for i in (sig_start + 1)..n { + signal_line[i] = macd_line[i] * ksig + signal_line[i - 1] * (1.0 - ksig); + } + for i in (sig_start + 1)..n { + histogram[i] = macd_line[i] - signal_line[i]; + } + + // TA-Lib pads the MACD line itself with NaNs up to `sig_start`! + for v in macd_line.iter_mut().take(sig_start) { + *v = f64::NAN; + } + + (macd_line, signal_line, histogram) +} + +// --------------------------------------------------------------------------- +// DEMA — Double Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`. +pub fn dema(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 2 * (timeperiod - 1); + let ema1 = ema(close, timeperiod); + let ema2 = ema(&ema1, timeperiod); + for i in warmup..n { + if !ema1[i].is_nan() && !ema2[i].is_nan() { + result[i] = 2.0 * ema1[i] - ema2[i]; + } + } + result +} + +// --------------------------------------------------------------------------- +// TEMA — Triple Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`. +pub fn tema(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let warmup = 3 * (timeperiod - 1); + let ema1 = ema(close, timeperiod); + let ema2 = ema(&ema1, timeperiod); + let ema3 = ema(&ema2, timeperiod); + for i in warmup..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]; + } + } + result +} + +// --------------------------------------------------------------------------- +// TRIMA — Triangular Moving Average +// --------------------------------------------------------------------------- + +/// Triangular Moving Average (triangle-weighted). +pub fn trima(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let half = timeperiod.div_ceil(2); + let mut weights = Vec::with_capacity(timeperiod); + for i in 1..=timeperiod { + let w = if i <= half { i } else { timeperiod + 1 - i }; + weights.push(w as f64); + } + let weight_sum: f64 = weights.iter().sum(); + for i in (timeperiod - 1)..n { + let mut val = 0.0_f64; + for (j, &w) in weights.iter().enumerate() { + val += close[i - (timeperiod - 1 - j)] * w; + } + result[i] = val / weight_sum; + } + result +} + +// --------------------------------------------------------------------------- +// KAMA — Kaufman Adaptive Moving Average +// --------------------------------------------------------------------------- + +/// Kaufman Adaptive Moving Average. +pub fn kama(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let fast_sc = 2.0 / 3.0_f64; + let slow_sc = 2.0 / 31.0_f64; + let mut kama_val = close[timeperiod - 1]; + result[timeperiod - 1] = kama_val; + for i in timeperiod..n { + let direction = (close[i] - close[i - timeperiod]).abs(); + let mut volatility = 0.0_f64; + for j in 1..=timeperiod { + volatility += (close[i - j + 1] - close[i - j]).abs(); + } + let er = if volatility > 0.0 { + direction / volatility + } else { + 0.0 + }; + let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2); + kama_val += sc * (close[i] - kama_val); + result[i] = kama_val; + } + result +} + +// --------------------------------------------------------------------------- +// T3 — Tillson T3 +// --------------------------------------------------------------------------- + +/// Tillson T3: 6x smoothed EMA with volume factor. +pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 { + return result; + } + let k = 2.0 / (timeperiod as f64 + 1.0); + let v = vfactor; + let c1 = -(v * v * v); + let c2 = 3.0 * v * v + 3.0 * v * v * v; + let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v; + let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v; + let warmup = 6 * (timeperiod - 1); + let mut e = [0.0_f64; 6]; + for (i, &price) in close.iter().enumerate() { + if i == 0 { + for ej in e.iter_mut() { + *ej = price; + } + } else { + e[0] += k * (price - e[0]); + for j in 1..6 { + e[j] += k * (e[j - 1] - e[j]); + } + } + if i >= warmup { + result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2]; + } + } + result +} + +// --------------------------------------------------------------------------- +// SAR — Parabolic SAR +// --------------------------------------------------------------------------- + +/// Parabolic SAR. +pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec { + let n = high.len(); + if n < 2 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + let mut is_rising = high[1] >= high[0]; + let mut af = acceleration; + let (mut ep, mut sar_val) = if is_rising { + (high[1], low[0]) + } else { + (low[1], high[0]) + }; + result[1] = sar_val; + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + if is_rising { + sar_val = sar_val.min(low[i - 1]).min(low[i - 2]); + if low[i] < sar_val { + is_rising = false; + sar_val = ep; + ep = low[i]; + af = acceleration; + } else if high[i] > ep { + ep = high[i]; + af = (af + acceleration).min(maximum); + } + } else { + sar_val = sar_val.max(high[i - 1]).max(high[i - 2]); + if high[i] > sar_val { + is_rising = true; + sar_val = ep; + ep = high[i]; + af = acceleration; + } else if low[i] < ep { + ep = low[i]; + af = (af + acceleration).min(maximum); + } + } + result[i] = sar_val; + } + result +} + +// --------------------------------------------------------------------------- +// SAREXT — Extended Parabolic SAR +// --------------------------------------------------------------------------- + +/// Parabolic SAR Extended with configurable acceleration factors. +#[allow(clippy::too_many_arguments)] +pub fn sarext( + high: &[f64], + low: &[f64], + startvalue: f64, + offsetonreverse: f64, + accelerationinitlong: f64, + accelerationlong: f64, + accelerationmaxlong: f64, + accelerationinitshort: f64, + accelerationshort: f64, + accelerationmaxshort: f64, +) -> Vec { + let n = high.len(); + if n < 2 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + let mut is_rising = high[1] >= high[0]; + let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising { + (accelerationinitlong, accelerationlong, accelerationmaxlong) + } else { + ( + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + }; + let (mut ep, mut sar_val) = if is_rising { + ( + high[1], + if startvalue != 0.0 { + startvalue + } else { + low[0] + }, + ) + } else { + ( + low[1], + if startvalue != 0.0 { + -startvalue + } else { + high[0] + }, + ) + }; + result[1] = sar_val; + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + if is_rising { + sar_val = sar_val.min(low[i - 1]).min(low[i - 2]); + if low[i] < sar_val { + is_rising = false; + sar_val = ep + sar_val.abs() * offsetonreverse; + ep = low[i]; + af = accelerationinitshort; + af_step_cur = accelerationshort; + af_max_cur = accelerationmaxshort; + } else if high[i] > ep { + ep = high[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } else { + sar_val = sar_val.max(high[i - 1]).max(high[i - 2]); + if high[i] > sar_val { + is_rising = true; + sar_val = ep - sar_val.abs() * offsetonreverse; + ep = high[i]; + af = accelerationinitlong; + af_step_cur = accelerationlong; + af_max_cur = accelerationmaxlong; + } else if low[i] < ep { + ep = low[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } + result[i] = sar_val; + } + result +} + +// --------------------------------------------------------------------------- +// MAMA — MESA Adaptive Moving Average +// --------------------------------------------------------------------------- + +/// MESA Adaptive Moving Average. Returns `(mama, fama)`. +pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec, Vec) { + let n = close.len(); + let lookback = 32; + let mut mama_arr = vec![f64::NAN; n]; + let mut fama_arr = vec![f64::NAN; n]; + if n <= lookback { + return (mama_arr, fama_arr); + } + + let mut smooth = vec![0.0f64; n]; + for i in 0..n { + smooth[i] = if i >= 3 { + (4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0 + } else { + close[i] + }; + } + + let mut detrender = vec![0.0f64; n]; + let mut q1 = vec![0.0f64; n]; + let mut i1 = vec![0.0f64; n]; + let mut ji = vec![0.0f64; n]; + let mut jq = vec![0.0f64; n]; + let mut i2 = vec![0.0f64; n]; + let mut q2 = vec![0.0f64; n]; + let mut re = vec![0.0f64; n]; + let mut im = vec![0.0f64; n]; + let mut period = vec![0.0f64; n]; + let mut phase = vec![0.0f64; n]; + let mut mama_val = close[0]; + let mut fama_val = close[0]; + + for i in 6..n { + let prev_period = period[i - 1].max(1.0); + let alpha = 0.075 * prev_period + 0.54; + detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] + - 0.5769 * smooth[i - 4] + - 0.0962 * smooth[i - 6]) + * alpha; + if i >= 12 { + q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] + - 0.5769 * detrender[i - 4] + - 0.0962 * detrender[i - 6]) + * alpha; + } + if i >= 9 { + i1[i] = detrender[i - 3]; + } + if i >= 15 { + ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) + * alpha; + } + if i >= 18 { + jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) + * alpha; + } + let i2_raw = i1[i] - jq[i]; + let q2_raw = q1[i] + ji[i]; + i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1]; + q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1]; + re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1]; + im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1]; + let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { + std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan() + } else { + prev_period + }; + p = p + .clamp(0.67 * prev_period, 1.5 * prev_period) + .clamp(6.0, 50.0); + period[i] = 0.2 * p + 0.8 * prev_period; + phase[i] = if i1[i] != 0.0 { + q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI + } else if q1[i] > 0.0 { + 90.0 + } else if q1[i] < 0.0 { + -90.0 + } else { + 0.0 + }; + let mut delta_phase = phase[i - 1] - phase[i]; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit); + if i >= lookback { + mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val; + fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val; + mama_arr[i] = mama_val; + fama_arr[i] = fama_val; + } else { + mama_val = close[i]; + fama_val = close[i]; + } + } + (mama_arr, fama_arr) +} + +// --------------------------------------------------------------------------- +// MIDPOINT / MIDPRICE +// --------------------------------------------------------------------------- + +/// Midpoint: `(max(close) + min(close)) / 2` over rolling window. +pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &close[(i + 1 - timeperiod)..=i]; + let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let mn = window.iter().cloned().fold(f64::INFINITY, f64::min); + result[i] = (mx + mn) / 2.0; + } + result +} + +/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window. +pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let start = i + 1 - timeperiod; + let mx = high[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min); + result[i] = (mx + mn) / 2.0; + } + result +} + +// --------------------------------------------------------------------------- +// MACDFIX / MACDEXT +// --------------------------------------------------------------------------- + +/// MACD with fixed 12/26 periods. +pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec, Vec, Vec) { + macd(close, 12, 26, signalperiod) +} + +/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec { + match matype { + 0 => sma(close, timeperiod), + 1 => ema(close, timeperiod), + 2 => wma(close, timeperiod), + 3 => dema(close, timeperiod), + 4 => tema(close, timeperiod), + 5 => trima(close, timeperiod), + 6 => kama(close, timeperiod), + 7 => t3(close, timeperiod, 0.7), + _ => sma(close, timeperiod), + } +} + +/// MACD with configurable MA types for fast/slow/signal. +pub fn macdext( + close: &[f64], + fastperiod: usize, + fastmatype: u8, + slowperiod: usize, + slowmatype: u8, + signalperiod: usize, + signalmatype: u8, +) -> (Vec, Vec, Vec) { + let n = close.len(); + let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]); + if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod { + return nan3(); + } + let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype); + let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype); + let macd_start = slowperiod - 1; + let mut macd_line = vec![f64::NAN; n]; + for i in macd_start..n { + if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() { + macd_line[i] = fast_ma[i] - slow_ma[i]; + } + } + let macd_valid: Vec = macd_line[macd_start..].to_vec(); + let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype); + let mut signal_line = vec![f64::NAN; n]; + let warmup = macd_start + signalperiod - 1; + #[allow(clippy::needless_range_loop)] + for i in warmup..n { + let j = i - macd_start; + if j < signal_slice.len() && !signal_slice[j].is_nan() { + signal_line[i] = signal_slice[j]; + } + } + let mut histogram = vec![f64::NAN; n]; + for i in 0..n { + if !macd_line[i].is_nan() && !signal_line[i].is_nan() { + histogram[i] = macd_line[i] - signal_line[i]; + } + } + (macd_line, signal_line, histogram) +} + +// --------------------------------------------------------------------------- +// MA (generic dispatcher) / MAVP (variable period) +// --------------------------------------------------------------------------- + +/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec { + compute_ma_by_type(close, timeperiod, matype) +} + +/// Moving Average with Variable Period per bar (SMA over period from periods array). +pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec { + let n = close.len(); + let mut result = vec![f64::NAN; n]; + if minperiod == 0 || maxperiod < minperiod { + return result; + } + for i in 0..n { + if i >= periods.len() { + break; + } + let p = (periods[i].round() as usize).clamp(minperiod, maxperiod); + if i + 1 >= p { + let sum: f64 = close[(i + 1 - p)..=i].iter().sum(); + result[i] = sum / p as f64; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sma_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = sma(&prices, 3); + 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 ema_basic() { + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let result = ema(&prices, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 2.0).abs() < 1e-10); // seed = SMA(3) + } + + #[test] + fn wma_basic() { + let prices = vec![1.0, 2.0, 3.0]; + let result = wma(&prices, 3); + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + // weights: 1, 2, 3; denom 6 => (1*1 + 2*2 + 3*3)/6 = 14/6 + assert!((result[2] - 14.0 / 6.0).abs() < 1e-10); + } + + #[test] + fn bbands_basic() { + let prices = vec![2.0, 2.0, 2.0, 2.0, 2.0]; + let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0); + assert!((middle[2] - 2.0).abs() < 1e-10); + assert!((upper[2] - 2.0).abs() < 1e-10); // std = 0 + assert!((lower[2] - 2.0).abs() < 1e-10); + } + + #[test] + fn bbands_varying_prices() { + // Verify against hand-computed values for a small window. + let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0); + + // First two values should be NaN (warmup). + assert!(middle[0].is_nan()); + assert!(middle[1].is_nan()); + + // Window [1,2,3]: mean = 2.0, pop_var = 2/3, std = sqrt(2/3) + let expected_mean = 2.0; + let expected_std = (2.0_f64 / 3.0).sqrt(); + assert!((middle[2] - expected_mean).abs() < 1e-10); + assert!((upper[2] - (expected_mean + 2.0 * expected_std)).abs() < 1e-10); + assert!((lower[2] - (expected_mean - 2.0 * expected_std)).abs() < 1e-10); + + // Window [2,3,4]: mean = 3.0, pop_var = 2/3, std = sqrt(2/3) + assert!((middle[3] - 3.0).abs() < 1e-10); + assert!((upper[3] - (3.0 + 2.0 * expected_std)).abs() < 1e-10); + + // Window [3,4,5]: mean = 4.0, pop_var = 2/3, std = sqrt(2/3) + assert!((middle[4] - 4.0).abs() < 1e-10); + assert!((upper[4] - (4.0 + 2.0 * expected_std)).abs() < 1e-10); + } + + #[test] + fn bbands_numerical_stability() { + // Large offset with tiny variation — this is where the naïve sum_sq + // formula suffers from catastrophic cancellation. + let base = 1e12; + let prices: Vec = (0..100).map(|i| base + (i as f64) * 0.01).collect(); + let (upper, middle, lower) = bbands(&prices, 20, 2.0, 2.0); + + // Check that middle band matches SMA. + for i in 19..100 { + let window = &prices[i - 19..=i]; + let expected_mean: f64 = window.iter().sum::() / 20.0; + // At scale 1e12, f64 absolute precision is ~2.2e-4; use 1e-3 headroom. + assert!( + (middle[i] - expected_mean).abs() < 1e-3, + "mean mismatch at {i}: got {} expected {}", + middle[i], + expected_mean, + ); + // Bands should be above/below middle. + assert!(upper[i] >= middle[i]); + assert!(lower[i] <= middle[i]); + } + } + + #[test] + fn bbands_edge_cases() { + // timeperiod == 1: every bar should have std = 0, bands == price. + let prices = vec![10.0, 20.0, 30.0]; + let (upper, middle, lower) = bbands(&prices, 1, 2.0, 2.0); + for i in 0..3 { + assert!((middle[i] - prices[i]).abs() < 1e-10); + assert!((upper[i] - prices[i]).abs() < 1e-10); + assert!((lower[i] - prices[i]).abs() < 1e-10); + } + + // Input shorter than timeperiod: all NaN. + let (u, m, l) = bbands(&[1.0, 2.0], 5, 2.0, 2.0); + assert!(u.iter().all(|v| v.is_nan())); + assert!(m.iter().all(|v| v.is_nan())); + assert!(l.iter().all(|v| v.is_nan())); + } + + #[test] + fn macd_basic() { + // 40 bars of linearly increasing prices — MACD line should converge + let prices: Vec = (1..=40).map(|i| i as f64).collect(); + let (macd_line, signal_line, histogram) = macd(&prices, 3, 5, 2); + // TA-Lib pads MACD line with NaN up to sig_start = slowperiod-1 + signalperiod-1 = 5 + for i in 0..5 { + assert!(macd_line[i].is_nan(), "expected NaN at {i}"); + } + // First valid macd bar is at index 5 (sig_start) + assert!(!macd_line[5].is_nan()); + // First valid signal bar is at index 5 + assert!(!signal_line[5].is_nan()); + // histogram = macd - signal + assert!((histogram[5] - (macd_line[5] - signal_line[5])).abs() < 1e-10); + } + + #[test] + fn macd_invalid_params() { + let prices = vec![1.0; 50]; + // fastperiod >= slowperiod should return all-NaN + let (m, s, h) = macd(&prices, 5, 3, 9); + assert!(m.iter().all(|v| v.is_nan())); + assert!(s.iter().all(|v| v.is_nan())); + assert!(h.iter().all(|v| v.is_nan())); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/pattern.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/pattern.rs new file mode 100644 index 0000000..e50ba19 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/pattern.rs @@ -0,0 +1,1806 @@ +//! Candlestick pattern recognition — pure Rust implementations. +//! +//! Each function takes `(open, high, low, close)` as `&[f64]` slices and returns +//! `Vec` with values -100, 0, or 100 indicating bearish, neutral, or bullish +//! pattern signals respectively. + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Epsilon for doji-like candles (body ~ 0) to avoid division by zero. +pub const DOJI_BODY_EPSILON: f64 = 0.0001; + +#[inline] +pub fn body_size(open: f64, close: f64) -> f64 { + (close - open).abs() +} + +#[inline] +pub fn upper_shadow(open: f64, high: f64, close: f64) -> f64 { + high - open.max(close) +} + +#[inline] +pub fn lower_shadow(open: f64, low: f64, close: f64) -> f64 { + open.min(close) - low +} + +#[inline] +pub fn candle_range(high: f64, low: f64) -> f64 { + high - low +} + +#[inline] +pub fn is_bullish(open: f64, close: f64) -> bool { + close >= open +} + +#[inline] +pub fn is_bearish(open: f64, close: f64) -> bool { + close < open +} + +/// Validate that all four OHLC slices have the same length. Returns `Err` with +/// a descriptive message on mismatch. +pub fn validate_ohlc( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], +) -> Result { + let n = open.len(); + if high.len() != n || low.len() != n || close.len() != n { + return Err(format!( + "OHLC length mismatch: open={}, high={}, low={}, close={}", + n, + high.len(), + low.len(), + close.len() + )); + } + Ok(n) +} + +// --------------------------------------------------------------------------- +// 61 candlestick pattern functions +// --------------------------------------------------------------------------- + +/// Two Crows (bearish) +pub fn cdl2crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let (o3, c3) = (open[i], close[i]); + if is_bullish(o1, c1) + && is_bearish(o2, c2) + && o2 > c1 + && c2 > c1 + && is_bearish(o3, c3) + && o3 < o2 + && o3 > c2 + && c3 > o1 + && c3 < c1 + { + result[i] = -100; + } + } + result +} + +/// Three Black Crows (bearish) +pub fn cdl3blackcrows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let range3 = candle_range(h3, l3); + + let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; + let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; + + let open2_in_body1 = o2 < o1 && o2 > c1; + let open3_in_body2 = o3 < o2 && o3 > c2; + + let small_upper1 = upper_shadow(o1, h1, c1) <= body1 * 0.3; + let small_upper2 = upper_shadow(o2, h2, c2) <= body2 * 0.3; + let small_upper3 = upper_shadow(o3, h3, c3) <= body3 * 0.3; + + if is_bearish(o1, c1) + && is_bearish(o2, c2) + && is_bearish(o3, c3) + && long_body1 + && long_body2 + && long_body3 + && open2_in_body1 + && open3_in_body2 + && small_upper1 + && small_upper2 + && small_upper3 + && c2 < c1 + && c3 < c2 + && l3 < l2 + && l2 < l1 + { + result[i] = -100; + } + } + result +} + +/// Three Inside Up/Down +pub fn cdl3inside(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let c3 = close[i]; + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.5; + + if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) && c3 > c2 { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) && c3 < c2 { + result[i] = -100; + } + } + result +} + +/// Three-Line Strike +pub fn cdl3linestrike(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, c0) = (open[i - 3], close[i - 3]); + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let (o3, c3) = (open[i], close[i]); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && c1 < c0 + && c2 < c1 + && is_bullish(o3, c3) + && o3 < c2 + && c3 > o0 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && c1 > c0 + && c2 > c1 + && is_bearish(o3, c3) + && o3 > c2 + && c3 < o0 + { + result[i] = -100; + } + } + result +} + +/// Three Outside Up/Down +pub fn cdl3outside(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, c1) = (open[i - 2], close[i - 2]); + let (o2, c2) = (open[i - 1], close[i - 1]); + let c3 = close[i]; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + let engulfs = body2_high > body1_high && body2_low < body1_low; + + if is_bearish(o1, c1) && is_bullish(o2, c2) && engulfs && c3 > c2 { + result[i] = 100; + } else if is_bullish(o1, c1) && is_bearish(o2, c2) && engulfs && c3 < c2 { + result[i] = -100; + } + } + result +} + +/// Three Stars In The South (bullish) +pub fn cdl3starsinsouth(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && h1 <= h0 + && l1 >= l0 + && h2 <= h1 + && l2 >= l1 + && body_size(o2, c2) <= body_size(o1, c1) * 0.6 + && upper_shadow(o2, h2, c2) <= body_size(o2, c2) * 0.2 + { + result[i] = 100; + } + } + result +} + +/// Three Advancing White Soldiers (bullish) +pub fn cdl3whitesoldiers(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let range3 = candle_range(h3, l3); + + let long_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let long_body2 = range2 > 0.0 && body2 >= range2 * 0.5; + let long_body3 = range3 > 0.0 && body3 >= range3 * 0.5; + + let open2_in_body1 = o2 > o1 && o2 < c1; + let open3_in_body2 = o3 > o2 && o3 < c2; + + let small_lower1 = lower_shadow(o1, l1, c1) <= body1 * 0.3; + let small_lower2 = lower_shadow(o2, l2, c2) <= body2 * 0.3; + let small_lower3 = lower_shadow(o3, l3, c3) <= body3 * 0.3; + + if is_bullish(o1, c1) + && is_bullish(o2, c2) + && is_bullish(o3, c3) + && long_body1 + && long_body2 + && long_body3 + && open2_in_body1 + && open3_in_body2 + && small_lower1 + && small_lower2 + && small_lower3 + && c2 > c1 + && c3 > c2 + && h3 > h2 + && h2 > h1 + { + result[i] = 100; + } + } + result +} + +/// Abandoned Baby +pub fn cdlabandonedbaby(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let is_doji1 = range1 > 0.0 && body1 / range1 <= 0.1; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_doji1 + && h1 < l0 + && is_bullish(o2, c2) + && range2 > 0.0 + && body2 >= range2 * 0.5 + && l2 > h1 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_doji1 + && l1 > h0 + && is_bearish(o2, c2) + && range2 > 0.0 + && body2 >= range2 * 0.5 + && h2 < l1 + { + result[i] = -100; + } + } + result +} + +/// Advance Block (bearish) +pub fn cdladvanceblock(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let us0 = upper_shadow(o0, h0, c0); + let us1 = upper_shadow(o1, h1, c1); + let us2 = upper_shadow(o2, h2, c2); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && c1 > c0 + && c2 > c1 + && o1 >= o0 + && o1 <= c0 + && o2 >= o1 + && o2 <= c1 + && (body1 < body0 || body2 < body1 || us2 > us1 || us1 > us0) + { + result[i] = -100; + } + } + result +} + +/// Belt-hold +pub fn cdlbelthold(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + let body = body_size(o, c); + if range == 0.0 { + continue; + } + let long_body = body >= range * 0.6; + if is_bullish(o, c) && long_body && (o - l).abs() <= range * 0.01 { + result[i] = 100; + } else if is_bearish(o, c) && long_body && (h - o).abs() <= range * 0.01 { + result[i] = -100; + } + } + result +} + +/// Breakaway +pub fn cdlbreakaway(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let c1 = close[i - 3]; + let c2 = close[i - 2]; + let c3 = close[i - 1]; + let (o4, c4) = (open[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 < l0 + && c2 < c1 + && c3 < c2 + && is_bullish(o4, c4) + && c4 > c1 + && c4 < c0 + { + result[i] = 100; + } else if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 > h0 + && c2 > c1 + && c3 > c2 + && is_bearish(o4, c4) + && c4 < c1 + && c4 > c0 + { + result[i] = -100; + } + } + result +} + +/// Closing Marubozu +pub fn cdlclosingmarubozu(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body < range * 0.4 { + continue; + } + if is_bullish(o, c) && (h - c).abs() <= range * 0.01 { + result[i] = 100; + } else if is_bearish(o, c) && (c - l).abs() <= range * 0.01 { + result[i] = -100; + } + } + result +} + +/// Concealing Baby Swallow (bullish) +pub fn cdlconcealbabyswall(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, h0, l0, c0) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, h2, l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + let gap_down = o2 < c1; + let shadow_into = h2 >= c1; + let engulfs = o3 >= o2 && c3 <= c2 && h3 >= h2 && l3 <= l2; + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && maru0 + && maru1 + && is_bearish(o2, c2) + && gap_down + && shadow_into + && is_bearish(o3, c3) + && engulfs + { + result[i] = 100; + } + } + result +} + +/// Counterattack +pub fn cdlcounterattack(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let long0 = range0 > 0.0 && body0 >= range0 * 0.5; + let long1 = range1 > 0.0 && body1 >= range1 * 0.5; + let same_close = (c1 - c0).abs() <= range0 * 0.02; + if is_bearish(o0, c0) && long0 && is_bullish(o1, c1) && long1 && same_close { + result[i] = 100; + } else if is_bullish(o0, c0) && long0 && is_bearish(o1, c1) && long1 && same_close { + result[i] = -100; + } + } + result +} + +/// Dark Cloud Cover (bearish) +pub fn cdldarkcloudcover(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bearish(o1, c1) + && o1 > h0 + && c1 < midpoint0 + && c1 > o0 + { + result[i] = -100; + } + } + result +} + +/// Doji +pub fn cdldoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + if range > 0.0 && body / range <= 0.1 { + result[i] = 100; + } + } + result +} + +/// Doji Star +pub fn cdldojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + + let gap_down = o2.max(c2) < l1; + if is_bearish(o1, c1) && large_body1 && is_doji2 && gap_down { + result[i] = 100; + } + let gap_up = o2.min(c2) > h1; + if is_bullish(o1, c1) && large_body1 && is_doji2 && gap_up { + result[i] = -100; + } + } + result +} + +/// Dragonfly Doji (bullish) +pub fn cdldragonflydoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && us / range <= 0.1 && ls >= range * 0.6 { + result[i] = 100; + } + } + result +} + +/// Engulfing +pub fn cdlengulfing(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let prev_o = open[i - 1]; + let prev_c = close[i - 1]; + let curr_o = open[i]; + let curr_c = close[i]; + + let prev_body_high = prev_o.max(prev_c); + let prev_body_low = prev_o.min(prev_c); + let curr_body_high = curr_o.max(curr_c); + let curr_body_low = curr_o.min(curr_c); + + if is_bearish(prev_o, prev_c) + && is_bullish(curr_o, curr_c) + && curr_body_high > prev_body_high + && curr_body_low < prev_body_low + { + result[i] = 100; + } else if is_bullish(prev_o, prev_c) + && is_bearish(curr_o, curr_c) + && curr_body_high > prev_body_high + && curr_body_low < prev_body_low + { + result[i] = -100; + } + } + result +} + +/// Evening Doji Star (bearish) +pub fn cdleveningdojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bullish(o1, c1) + && large_body1 + && is_doji2 + && is_bearish(o3, c3) + && large_body3 + && c3 < (o1 + c1) / 2.0 + { + result[i] = -100; + } + } + result +} + +/// Evening Star (bearish) +pub fn cdleveningstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bullish(o1, c1) + && large_body1 + && small_body2 + && is_bearish(o3, c3) + && large_body3 + && c3 < (o1 + c1) / 2.0 + { + result[i] = -100; + } + } + result +} + +/// Up/Down-gap side-by-side white lines +pub fn cdlgapsidesidewhite(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let both_bullish = is_bullish(o1, c1) && is_bullish(o2, c2); + let similar_size = body1 > 0.0 && (body2 - body1).abs() / body1 <= 0.3; + let similar_open = body1 > 0.0 && (o2 - o1).abs() / body1 <= 0.3; + if is_bullish(o0, c0) && both_bullish && similar_size && similar_open && o1 > c0 { + result[i] = 100; + } else if is_bearish(o0, c0) && both_bullish && similar_size && similar_open && c1 < o0 { + result[i] = -100; + } + } + result +} + +/// Gravestone Doji (bearish) +pub fn cdlgravestonedoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && ls / range <= 0.1 && us >= range * 0.6 { + result[i] = -100; + } + } + result +} + +/// Hammer (bullish) +pub fn cdlhammer(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && lower >= 2.0 * body && upper <= body + { + result[i] = 100; + } + } + result +} + +/// Hanging Man (bearish) +pub fn cdlhangingman(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if range > 0.0 && body > 0.0 && ls >= body * 2.0 && us <= body && body / range <= 0.4 { + result[i] = -100; + } + } + result +} + +/// Harami +pub fn cdlharami(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let body2_high = o2.max(c2); + let body2_low = o2.min(c2); + + let inside = body2_high <= body1_high && body2_low >= body1_low && body2 < body1 * 0.6; + + if is_bearish(o1, c1) && large_body1 && inside && is_bullish(o2, c2) { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && inside && is_bearish(o2, c2) { + result[i] = -100; + } + } + result +} + +/// Harami Cross +pub fn cdlharamicross(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.5; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + let doji_mid = (o2 + c2) / 2.0; + let inside = doji_mid <= body1_high && doji_mid >= body1_low; + + if is_bearish(o1, c1) && large_body1 && is_doji2 && inside { + result[i] = 100; + } else if is_bullish(o1, c1) && large_body1 && is_doji2 && inside { + result[i] = -100; + } + } + result +} + +/// High-Wave Candle +pub fn cdlhighwave(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.3 && us >= range * 0.3 && ls >= range * 0.3 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Hikkake Pattern +pub fn cdlhikkake(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let h1 = high[i - 1]; + let l1 = low[i - 1]; + let h2 = high[i]; + let l2 = low[i]; + let inside = h1 <= h0 && l1 >= l0; + if !inside { + continue; + } + if is_bearish(o0, c0) && h2 > h1 && l2 > l1 { + result[i] = 100; + } else if is_bullish(o0, c0) && l2 < l1 && h2 < h1 { + result[i] = -100; + } + } + result +} + +/// Modified Hikkake Pattern +pub fn cdlhikkakemod(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 3..n { + let (o0, h0, l0, c0) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let h1 = high[i - 2]; + let l1 = low[i - 2]; + let h2 = high[i - 1]; + let l2 = low[i - 1]; + let h3 = high[i]; + let l3 = low[i]; + let inside = h1 <= h0 && l1 >= l0; + if !inside { + continue; + } + if is_bearish(o0, c0) && l2 < l1 && h3 > h1 && l3 > l1 { + result[i] = 100; + } else if is_bullish(o0, c0) && h2 > h1 && l3 < l1 && h3 < h1 { + result[i] = -100; + } + } + result +} + +/// Homing Pigeon (bullish) +pub fn cdlhomingpigeon(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, _h0, _l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0_high = o0.max(c0); + let body0_low = o0.min(c0); + let body1_high = o1.max(c1); + let body1_low = o1.min(c1); + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && body1_high <= body0_high + && body1_low >= body0_low + { + result[i] = 100; + } + } + result +} + +/// Identical Three Crows (bearish) +pub fn cdlidentical3crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let tol0 = range0 * 0.03; + let tol1 = range1 * 0.03; + if is_bearish(o0, c0) + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && c1 < c0 + && c2 < c1 + && (o1 - c0).abs() <= tol0 + && (o2 - c1).abs() <= tol1 + && range0 > 0.0 + && range1 > 0.0 + && candle_range(h2, l2) > 0.0 + { + result[i] = -100; + } + } + result +} + +/// In-Neck Pattern (bearish) +pub fn cdlinneck(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && (c1 - c0).abs() <= range0 * 0.03 + { + result[i] = -100; + } + } + result +} + +/// Inverted Hammer (bullish) +pub fn cdlinvertedhammer(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body > 0.0 && us >= body * 2.0 && ls <= body && body / range <= 0.4 { + result[i] = 100; + } + } + result +} + +/// Kicking +pub fn cdlkicking(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + if is_bearish(o0, c0) && maru0 && is_bullish(o1, c1) && maru1 && o1 > o0 { + result[i] = 100; + } else if is_bullish(o0, c0) && maru0 && is_bearish(o1, c1) && maru1 && o1 < o0 { + result[i] = -100; + } + } + result +} + +/// Kicking — bull/bear determined by longer of the two marubozu +pub fn cdlkickingbylength(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let maru0 = range0 > 0.0 + && upper_shadow(o0, h0, c0) <= range0 * 0.02 + && lower_shadow(o0, l0, c0) <= range0 * 0.02; + let maru1 = range1 > 0.0 + && upper_shadow(o1, h1, c1) <= range1 * 0.02 + && lower_shadow(o1, l1, c1) <= range1 * 0.02; + let opposite = (is_bearish(o0, c0) && is_bullish(o1, c1)) + || (is_bullish(o0, c0) && is_bearish(o1, c1)); + let has_gap = (o1 - c0).abs() > 0.0; + if maru0 && maru1 && opposite && has_gap { + if range1 >= range0 { + if is_bullish(o1, c1) { + result[i] = 100; + } else { + result[i] = -100; + } + } else if is_bullish(o0, c0) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Ladder Bottom (bullish) +pub fn cdlladderbottom(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, _h0, _l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, _h1, _l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, _h2, _l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, h3, _l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let three_bear = is_bearish(o0, c0) && is_bearish(o1, c1) && is_bearish(o2, c2); + let descend = c1 < c0 && c2 < c1; + let us3 = upper_shadow(o3, h3, c3); + let body3 = body_size(o3, c3); + let inv_hammer = us3 >= body3 * 1.5; + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + let large_bull = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5; + if three_bear && descend && inv_hammer && large_bull && c4 > c2 { + result[i] = 100; + } + } + result +} + +/// Long Legged Doji +pub fn cdllongleggeddoji(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + if body / range <= 0.1 && us >= range * 0.3 && ls >= range * 0.3 { + result[i] = 100; + } + } + result +} + +/// Long Line Candle +pub fn cdllongline(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body >= range * 0.7 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Marubozu +pub fn cdlmarubozu(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body >= range * 0.95 && upper <= range * 0.025 && lower <= range * 0.025 { + if is_bullish(open[i], close[i]) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Matching Low (bullish) +pub fn cdlmatchinglow(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let tol = range0 * 0.02; + if is_bearish(o0, c0) && is_bearish(o1, c1) && (c1 - c0).abs() <= tol { + result[i] = 100; + } + } + result +} + +/// Mat Hold (bullish) +pub fn cdlmathold(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, _h1, l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, _h2, l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, _h3, l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + let large_bull0 = is_bullish(o0, c0) && range0 > 0.0 && body0 >= range0 * 0.5; + let small_bears = is_bearish(o1, c1) && is_bearish(o2, c2) && is_bearish(o3, c3); + let stay_above = l1 >= o0 && l2 >= o0 && l3 >= o0; + let large_bull4 = is_bullish(o4, c4) && range4 > 0.0 && body4 >= range4 * 0.5 && c4 > c0; + if large_bull0 && small_bears && stay_above && large_bull4 { + result[i] = 100; + } + } + result +} + +/// Morning Doji Star (bullish) +pub fn cdlmorningdojistar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range2 = candle_range(o2.min(c2) - DOJI_BODY_EPSILON, o2.max(c2)); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let is_doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bearish(o1, c1) + && large_body1 + && is_doji2 + && is_bullish(o3, c3) + && large_body3 + && c3 > (o1 + c1) / 2.0 + { + result[i] = 100; + } + } + result +} + +/// Morning Star (bullish) +pub fn cdlmorningstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o1, h1, l1, c1) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o2, _h2, _l2, c2) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o3, h3, l3, c3) = (open[i], high[i], low[i], close[i]); + + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let body3 = body_size(o3, c3); + let range1 = candle_range(h1, l1); + let range3 = candle_range(h3, l3); + + let large_body1 = range1 > 0.0 && body1 >= range1 * 0.6; + let small_body2 = range1 > 0.0 && body2 < body1 * 0.3; + let large_body3 = range3 > 0.0 && body3 >= range3 * 0.6; + + if is_bearish(o1, c1) + && large_body1 + && small_body2 + && is_bullish(o3, c3) + && large_body3 + && c3 > (o1 + c1) / 2.0 + { + result[i] = 100; + } + } + result +} + +/// On-Neck Pattern (bearish) +pub fn cdlonneck(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && (c1 - l0).abs() <= range0 * 0.03 + { + result[i] = -100; + } + } + result +} + +/// Piercing Pattern (bullish) +pub fn cdlpiercing(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && c1 > midpoint0 + && c1 < o0 + { + result[i] = 100; + } + } + result +} + +/// Rickshaw Man +pub fn cdlrickshawman(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + let us = upper_shadow(o, h, c); + let ls = lower_shadow(o, l, c); + let body_mid = (o + c) / 2.0; + let range_mid = (h + l) / 2.0; + let is_doji = body / range <= 0.1; + let long_shadows = us >= range * 0.3 && ls >= range * 0.3; + let near_center = (body_mid - range_mid).abs() <= range * 0.15; + if is_doji && long_shadows && near_center { + result[i] = 100; + } + } + result +} + +/// Rising/Falling Three Methods +pub fn cdlrisefall3methods(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 4..n { + let (o0, h0, l0, c0) = (open[i - 4], high[i - 4], low[i - 4], close[i - 4]); + let (o1, h1, l1, c1) = (open[i - 3], high[i - 3], low[i - 3], close[i - 3]); + let (o2, h2, l2, c2) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o3, h3, l3, c3) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o4, h4, l4, c4) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let range4 = candle_range(h4, l4); + let body4 = body_size(o4, c4); + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bearish(o1, c1) + && is_bearish(o2, c2) + && is_bearish(o3, c3) + && h1 <= h0 + && l1 >= l0 + && h2 <= h0 + && l2 >= l0 + && h3 <= h0 + && l3 >= l0 + && is_bullish(o4, c4) + && range4 > 0.0 + && body4 >= range4 * 0.5 + && c4 > c0 + && o4 > c3 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.5 + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && is_bullish(o3, c3) + && h1 <= h0 + && l1 >= l0 + && h2 <= h0 + && l2 >= l0 + && h3 <= h0 + && l3 >= l0 + && is_bearish(o4, c4) + && range4 > 0.0 + && body4 >= range4 * 0.5 + && c4 < c0 + && o4 < c3 + { + result[i] = -100; + } + } + result +} + +/// Separating Lines +pub fn cdlseparatinglines(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, h1, l1, c1) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body1 = body_size(o1, c1); + let range1 = candle_range(h1, l1); + let same_open = range0 > 0.0 && (o1 - o0).abs() <= range0 * 0.02; + let long1 = range1 > 0.0 && body1 >= range1 * 0.5; + if is_bearish(o0, c0) && is_bullish(o1, c1) && same_open && long1 { + result[i] = 100; + } else if is_bullish(o0, c0) && is_bearish(o1, c1) && same_open && long1 { + result[i] = -100; + } + } + result +} + +/// Shooting Star (bearish) +pub fn cdlshootingstar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper >= 2.0 * body && lower <= body + { + result[i] = -100; + } + } + result +} + +/// Short Line Candle +pub fn cdlshortline(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c); + if body > 0.0 && body <= range * 0.3 { + if is_bullish(o, c) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Spinning Top +pub fn cdlspinningtop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let body = body_size(open[i], close[i]); + let range = candle_range(high[i], low[i]); + let lower = lower_shadow(open[i], low[i], close[i]); + let upper = upper_shadow(open[i], high[i], close[i]); + if range > 0.0 && body > 0.0 && body <= range / 3.0 && upper > body && lower > body { + if is_bullish(open[i], close[i]) { + result[i] = 100; + } else { + result[i] = -100; + } + } + } + result +} + +/// Stalled Pattern (bearish) +pub fn cdlstalledpattern(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && is_bullish(o2, c2) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && c1 > c0 + && c2 > c1 + && o1 >= o0 + && o1 <= c0 + && o2 >= c1 * 0.99 + && body2 < body1 * 0.7 + { + result[i] = -100; + } + } + result +} + +/// Stick Sandwich (bullish) +pub fn cdlsticksandwich(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let tol = range0 * 0.02; + if is_bearish(o0, c0) + && is_bullish(o1, c1) + && is_bearish(o2, c2) + && (c2 - c0).abs() <= tol + && o1 >= c0 + && c1 <= o0 + { + result[i] = 100; + } + } + result +} + +/// Takuri (Dragonfly Doji with very long lower shadow) +pub fn cdltakuri(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 0..n { + let (o, h, l, c) = (open[i], high[i], low[i], close[i]); + let range = candle_range(h, l); + if range == 0.0 { + continue; + } + let body = body_size(o, c) + DOJI_BODY_EPSILON; + let ls = lower_shadow(o, l, c); + let us = upper_shadow(o, h, c); + if ls >= body * 3.0 && us <= range * 0.1 { + result[i] = 100; + } + } + result +} + +/// Tasuki Gap +pub fn cdltasukigap(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 >= l1 + && o2 <= c1 + && c2 > c0 + && c2 < o1 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && is_bearish(o1, c1) + && o1 < c0 + && is_bullish(o2, c2) + && o2 >= c1 + && o2 <= h1 + && c2 < c0 + && c2 > o1 + { + result[i] = -100; + } + } + result +} + +/// Thrusting Pattern (bearish) +pub fn cdlthrusting(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 1..n { + let (o0, h0, l0, c0) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o1, _h1, _l1, c1) = (open[i], high[i], low[i], close[i]); + let body0 = body_size(o0, c0); + let range0 = candle_range(h0, l0); + let midpoint0 = (o0 + c0) / 2.0; + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bullish(o1, c1) + && o1 < l0 + && c1 > c0 + && c1 < midpoint0 + { + result[i] = -100; + } + } + result +} + +/// Tristar Pattern +pub fn cdltristar(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let range1 = candle_range(h1, l1); + let range2 = candle_range(h2, l2); + let body0 = body_size(o0, c0); + let body1 = body_size(o1, c1); + let body2 = body_size(o2, c2); + let doji0 = range0 > 0.0 && body0 / range0 <= 0.1; + let doji1 = range1 > 0.0 && body1 / range1 <= 0.1; + let doji2 = range2 > 0.0 && body2 / range2 <= 0.1; + if doji0 && doji1 && doji2 { + if l1 < l0 && h1 < h0 && c2 > c1 { + result[i] = 100; + } else if l1 > l0 && h1 > h0 && c2 < c1 { + result[i] = -100; + } + } + } + result +} + +/// Unique 3 River (bullish) +pub fn cdlunique3river(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, h2, l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + let body2 = body_size(o2, c2); + let range2 = candle_range(h2, l2); + if is_bearish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bearish(o1, c1) + && l1 < l0 + && lower_shadow(o1, l1, c1) > 0.0 + && is_bullish(o2, c2) + && range2 > 0.0 + && body2 <= range2 * 0.5 + && c2 < c1 + && c2 > l1 + { + result[i] = 100; + } + } + result +} + +/// Upside Gap Two Crows (bearish) +pub fn cdlupsidegap2crows(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, h0, l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + let range0 = candle_range(h0, l0); + let body0 = body_size(o0, c0); + if is_bullish(o0, c0) + && range0 > 0.0 + && body0 >= range0 * 0.4 + && is_bearish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 > o1 + && c2 < o1 + && c2 > c0 + { + result[i] = -100; + } + } + result +} + +/// Upside/Downside Gap Three Methods +pub fn cdlxsidegap3methods(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = validate_ohlc(open, high, low, close).expect("OHLC length mismatch"); + let mut result = vec![0i32; n]; + for i in 2..n { + let (o0, _h0, _l0, c0) = (open[i - 2], high[i - 2], low[i - 2], close[i - 2]); + let (o1, _h1, _l1, c1) = (open[i - 1], high[i - 1], low[i - 1], close[i - 1]); + let (o2, _h2, _l2, c2) = (open[i], high[i], low[i], close[i]); + if is_bullish(o0, c0) + && is_bullish(o1, c1) + && o1 > c0 + && is_bearish(o2, c2) + && o2 <= c1 + && o2 >= o1 + && c2 >= c0 + && c2 <= o1 + { + result[i] = 100; + } else if is_bearish(o0, c0) + && is_bearish(o1, c1) + && o1 < c0 + && is_bullish(o2, c2) + && o2 >= c1 + && o2 <= o1 + && c2 <= c0 + && c2 >= o1 + { + result[i] = -100; + } + } + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_doji_basic() { + let open = vec![10.0]; + let high = vec![11.0]; + let low = vec![9.0]; + let close = vec![10.0]; + let r = cdldoji(&open, &high, &low, &close); + assert_eq!(r, vec![100]); + } + + #[test] + fn test_doji_not_detected() { + let open = vec![9.0]; + let high = vec![11.0]; + let low = vec![9.0]; + let close = vec![11.0]; + let r = cdldoji(&open, &high, &low, &close); + assert_eq!(r, vec![0]); + } + + #[test] + fn test_engulfing_bullish() { + // prev: bearish (open=10, close=8), body_high=10, body_low=8 + // curr: bullish (open=7.5, close=11), body_high=11, body_low=7.5 + // curr engulfs prev: 11>10 && 7.5<8 + let open = vec![10.0, 7.5]; + let high = vec![10.5, 11.5]; + let low = vec![7.5, 7.0]; + let close = vec![8.0, 11.0]; + let r = cdlengulfing(&open, &high, &low, &close); + assert_eq!(r[1], 100); + } + + #[test] + fn test_engulfing_bearish() { + let open = vec![8.0, 11.5]; + let high = vec![11.0, 12.0]; + let low = vec![7.5, 7.0]; + let close = vec![11.0, 7.5]; + let r = cdlengulfing(&open, &high, &low, &close); + assert_eq!(r[1], -100); + } + + #[test] + fn test_hammer() { + let open = vec![10.0]; + let high = vec![10.2]; + let low = vec![7.0]; + let close = vec![10.1]; + let r = cdlhammer(&open, &high, &low, &close); + assert_eq!(r[0], 100); + } + + #[test] + fn test_marubozu_bullish() { + let open = vec![10.0]; + let high = vec![12.0]; + let low = vec![10.0]; + let close = vec![12.0]; + let r = cdlmarubozu(&open, &high, &low, &close); + assert_eq!(r[0], 100); + } + + #[test] + fn test_empty_input() { + let empty: Vec = vec![]; + let r = cdldoji(&empty, &empty, &empty, &empty); + assert!(r.is_empty()); + } + + #[test] + fn test_morning_star() { + let open = vec![20.0, 14.5, 15.0]; + let high = vec![20.5, 15.0, 19.5]; + let low = vec![14.0, 14.0, 14.5]; + let close = vec![14.5, 14.6, 19.0]; + let r = cdlmorningstar(&open, &high, &low, &close); + assert_eq!(r[2], 100); + } + + #[test] + fn test_validate_ohlc_mismatch() { + let a = vec![1.0, 2.0]; + let b = vec![1.0]; + assert!(validate_ohlc(&a, &b, &a, &a).is_err()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs new file mode 100644 index 0000000..83b7671 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/portfolio.rs @@ -0,0 +1,631 @@ +//! Pure Rust portfolio analytics — no PyO3, no numpy, no ndarray. +//! +//! Functions: +//! - `portfolio_volatility` — sqrt(w' Σ w) +//! - `beta_full` — Cov/Var OLS beta +//! - `rolling_beta` — rolling beta with NaN warmup +//! - `drawdown_series` — per-bar drawdown + max drawdown +//! - `correlation_matrix` — pairwise Pearson correlation +//! - `relative_strength` — cumulative return ratio +//! - `spread` — A - hedge * B +//! - `ratio` — A / B (NaN for zero) +//! - `zscore_series` — rolling z-score, NaN warmup +//! - `compose_weighted` — weighted sum per row + +// --------------------------------------------------------------------------- +// portfolio_volatility +// --------------------------------------------------------------------------- + +/// Compute portfolio volatility: sqrt(w' Σ w). +/// +/// `cov_matrix` is an n×n covariance matrix stored as a slice of row-Vecs. +/// `weights` has length n. +/// +/// Panics if dimensions are inconsistent. +pub fn portfolio_volatility(cov_matrix: &[Vec], weights: &[f64]) -> f64 { + let n = weights.len(); + assert!( + cov_matrix.len() == n, + "cov_matrix must have {} rows, got {}", + n, + cov_matrix.len() + ); + let mut variance = 0.0_f64; + for i in 0..n { + assert!( + cov_matrix[i].len() == n, + "cov_matrix row {} must have length {}, got {}", + i, + n, + cov_matrix[i].len() + ); + let mut row_sum = 0.0_f64; + for j in 0..n { + row_sum += weights[j] * cov_matrix[i][j]; + } + variance += weights[i] * row_sum; + } + variance.max(0.0).sqrt() +} + +// --------------------------------------------------------------------------- +// beta_full +// --------------------------------------------------------------------------- + +/// Compute the full-sample OLS beta of `asset_returns` vs `benchmark_returns`. +/// +/// Beta = Cov(asset, bench) / Var(bench). +/// +/// Panics if lengths differ or are < 2, or if benchmark has zero variance. +pub fn beta_full(asset_returns: &[f64], benchmark_returns: &[f64]) -> f64 { + let n = asset_returns.len(); + assert!( + n >= 2 && benchmark_returns.len() == n, + "asset_returns and benchmark_returns must have equal length >= 2" + ); + let mean_a: f64 = asset_returns.iter().sum::() / n as f64; + let mean_b: f64 = benchmark_returns.iter().sum::() / n as f64; + let mut cov = 0.0_f64; + let mut var_b = 0.0_f64; + for i in 0..n { + let da = asset_returns[i] - mean_a; + let db = benchmark_returns[i] - mean_b; + cov += da * db; + var_b += db * db; + } + assert!( + var_b != 0.0, + "benchmark_returns has zero variance; cannot compute beta" + ); + cov / var_b +} + +// --------------------------------------------------------------------------- +// rolling_beta +// --------------------------------------------------------------------------- + +/// Compute rolling beta of `asset` vs `benchmark` over a sliding `window`. +/// +/// Returns a Vec of the same length as the inputs. The first `window - 1` +/// entries are NaN (warmup period). `window` must be >= 2. +pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Vec { + assert!(window >= 2, "window must be >= 2"); + let n = asset.len(); + assert!( + n > 0 && benchmark.len() == n, + "asset and benchmark must be non-empty and equal length" + ); + let mut result = vec![f64::NAN; n]; + for i in (window - 1)..n { + let start = i + 1 - window; + let a_win = &asset[start..=i]; + let b_win = &benchmark[start..=i]; + let mean_a: f64 = a_win.iter().sum::() / window as f64; + let mean_b: f64 = b_win.iter().sum::() / window as f64; + let mut cov = 0.0_f64; + let mut var_b = 0.0_f64; + for k in 0..window { + let da = a_win[k] - mean_a; + let db = b_win[k] - mean_b; + cov += da * db; + var_b += db * db; + } + result[i] = if var_b == 0.0 { f64::NAN } else { cov / var_b }; + } + result +} + +// --------------------------------------------------------------------------- +// drawdown_series +// --------------------------------------------------------------------------- + +/// Compute the drawdown series and maximum drawdown for an equity/price series. +/// +/// Drawdown at bar i = (equity[i] - running_max) / running_max (always <= 0). +/// +/// Returns `(dd_array, max_dd)` where `max_dd` is the most negative drawdown. +/// +/// Panics if `equity` is empty. +pub fn drawdown_series(equity: &[f64]) -> (Vec, f64) { + let n = equity.len(); + assert!(n > 0, "equity must be non-empty"); + let mut dd = vec![0.0_f64; n]; + let mut peak = equity[0]; + let mut max_dd = 0.0_f64; + for i in 0..n { + if equity[i] > peak { + peak = equity[i]; + } + let d = if peak == 0.0 { + 0.0 + } else { + (equity[i] - peak) / peak + }; + dd[i] = d; + if d < max_dd { + max_dd = d; + } + } + (dd, max_dd) +} + +// --------------------------------------------------------------------------- +// correlation_matrix +// --------------------------------------------------------------------------- + +/// Compute the pairwise Pearson correlation matrix. +/// +/// `data` is a slice of column vectors — `data[j]` is the return series for +/// asset j, so `data[j][i]` is the return of asset j at bar i. All columns +/// must have the same length (>= 2). +/// +/// Returns an n_assets × n_assets matrix stored as `Vec>`. +pub fn correlation_matrix(data: &[Vec]) -> Vec> { + let n_assets = data.len(); + assert!(n_assets > 0, "data must contain at least one asset column"); + let n_bars = data[0].len(); + assert!(n_bars >= 2, "data must have at least 2 rows (bars)"); + #[allow(clippy::needless_range_loop)] + for j in 1..n_assets { + assert!( + data[j].len() == n_bars, + "all columns must have equal length; column 0 has {} but column {} has {}", + n_bars, + j, + data[j].len() + ); + } + + // Means + let mut means = vec![0.0_f64; n_assets]; + for j in 0..n_assets { + means[j] = data[j].iter().sum::() / n_bars as f64; + } + + // Standard deviations (population) + let mut stds = vec![0.0_f64; n_assets]; + for j in 0..n_assets { + let var: f64 = data[j].iter().map(|&v| (v - means[j]).powi(2)).sum::() / n_bars as f64; + stds[j] = var.sqrt(); + } + + // Build correlation matrix (exploit symmetry: compute each pair once) + let mut result = vec![vec![0.0_f64; n_assets]; n_assets]; + #[allow(clippy::needless_range_loop)] + for j1 in 0..n_assets { + result[j1][j1] = 1.0; + for j2 in (j1 + 1)..n_assets { + let mut cov = 0.0_f64; + for i in 0..n_bars { + cov += (data[j1][i] - means[j1]) * (data[j2][i] - means[j2]); + } + cov /= n_bars as f64; + let denom = stds[j1] * stds[j2]; + let corr = if denom == 0.0 { f64::NAN } else { cov / denom }; + result[j1][j2] = corr; + result[j2][j1] = corr; + } + } + result +} + +// --------------------------------------------------------------------------- +// relative_strength +// --------------------------------------------------------------------------- + +/// Compute relative strength of an asset vs a benchmark. +/// +/// result[i] = cumprod(1 + asset_returns[0..=i]) / cumprod(1 + benchmark_returns[0..=i]) +/// +/// Panics if lengths differ or are zero. +pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Vec { + let n = asset_returns.len(); + assert!( + n > 0 && benchmark_returns.len() == n, + "asset_returns and benchmark_returns must be non-empty and equal length" + ); + let mut result = vec![0.0_f64; n]; + let mut cum_a = 1.0_f64; + let mut cum_b = 1.0_f64; + for i in 0..n { + cum_a *= 1.0 + asset_returns[i]; + cum_b *= 1.0 + benchmark_returns[i]; + result[i] = if cum_b == 0.0 { + f64::NAN + } else { + cum_a / cum_b + }; + } + result +} + +// --------------------------------------------------------------------------- +// spread +// --------------------------------------------------------------------------- + +/// Compute the spread between two series: a - hedge * b. +/// +/// Panics if lengths differ or are zero. +pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Vec { + let n = a.len(); + assert!( + n > 0 && b.len() == n, + "a and b must be non-empty and equal length" + ); + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| x - hedge * y) + .collect() +} + +// --------------------------------------------------------------------------- +// ratio +// --------------------------------------------------------------------------- + +/// Compute the ratio between two series: a / b. +/// +/// Where b is 0, returns NaN. +/// +/// Panics if lengths differ or are zero. +pub fn ratio(a: &[f64], b: &[f64]) -> Vec { + let n = a.len(); + assert!( + n > 0 && b.len() == n, + "a and b must be non-empty and equal length" + ); + a.iter() + .zip(b.iter()) + .map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y }) + .collect() +} + +// --------------------------------------------------------------------------- +// zscore_series +// --------------------------------------------------------------------------- + +/// Compute the rolling Z-score of a 1-D series. +/// +/// Z[i] = (x[i] - mean(window)) / std(window) +/// +/// The first `window - 1` entries are NaN. `window` must be >= 2. +/// +/// Panics if `x` is empty or `window < 2`. +pub fn zscore_series(x: &[f64], window: usize) -> Vec { + assert!(window >= 2, "window must be >= 2"); + let n = x.len(); + assert!(n > 0, "x must be non-empty"); + let mut result = vec![f64::NAN; n]; + for i in (window - 1)..n { + let win = &x[i + 1 - window..=i]; + let mean: f64 = win.iter().sum::() / window as f64; + let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::() / window as f64; + let std = var.sqrt(); + result[i] = if std == 0.0 { + f64::NAN + } else { + (x[i] - mean) / std + }; + } + result +} + +// --------------------------------------------------------------------------- +// compose_weighted +// --------------------------------------------------------------------------- + +/// Weighted combination of multiple signal columns. +/// +/// `data` is a slice of column vectors — `data[j]` is one signal column. +/// `weights` has one entry per column. +/// +/// Returns a Vec of length n_bars where each entry is the weighted sum across +/// columns for that bar. +/// +/// Panics if weights length != number of columns, or columns have unequal lengths. +pub fn compose_weighted(data: &[Vec], weights: &[f64]) -> Vec { + let n_sigs = data.len(); + assert!( + weights.len() == n_sigs, + "weights length ({}) must equal number of signal columns ({})", + weights.len(), + n_sigs + ); + if n_sigs == 0 { + return vec![]; + } + let n_bars = data[0].len(); + #[allow(clippy::needless_range_loop)] + for j in 1..n_sigs { + assert!( + data[j].len() == n_bars, + "all columns must have equal length" + ); + } + let mut result = vec![0.0_f64; n_bars]; + for i in 0..n_bars { + let mut s = 0.0_f64; + for j in 0..n_sigs { + s += data[j][i] * weights[j]; + } + result[i] = s; + } + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const EPS: f64 = 1e-10; + + fn approx_eq(a: f64, b: f64) -> bool { + (a - b).abs() < EPS + } + + // -- portfolio_volatility ------------------------------------------------- + + #[test] + fn test_portfolio_volatility_identity_cov() { + // Identity covariance, equal weights => sqrt(sum(w_i^2)) + let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let w = vec![0.5, 0.5]; + let vol = portfolio_volatility(&cov, &w); + // w' I w = 0.25 + 0.25 = 0.5, sqrt = 0.7071... + assert!(approx_eq(vol, (0.5_f64).sqrt())); + } + + #[test] + fn test_portfolio_volatility_single_asset() { + let cov = vec![vec![0.04]]; + let w = vec![1.0]; + assert!(approx_eq(portfolio_volatility(&cov, &w), 0.2)); + } + + #[test] + fn test_portfolio_volatility_correlated() { + // Fully correlated: cov = [[0.04, 0.04], [0.04, 0.04]] + let cov = vec![vec![0.04, 0.04], vec![0.04, 0.04]]; + let w = vec![0.5, 0.5]; + // w' Σ w = 0.04, sqrt = 0.2 + let vol = portfolio_volatility(&cov, &w); + assert!(approx_eq(vol, 0.2)); + } + + // -- beta_full ------------------------------------------------------------ + + #[test] + fn test_beta_full_same_series() { + let r = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + assert!(approx_eq(beta_full(&r, &r), 1.0)); + } + + #[test] + fn test_beta_full_double() { + let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let asset: Vec = bench.iter().map(|x| x * 2.0).collect(); + assert!(approx_eq(beta_full(&asset, &bench), 2.0)); + } + + #[test] + #[should_panic] + fn test_beta_full_zero_variance() { + let a = vec![0.01, 0.02]; + let b = vec![0.05, 0.05]; // zero variance + beta_full(&a, &b); + } + + // -- rolling_beta --------------------------------------------------------- + + #[test] + fn test_rolling_beta_warmup_nan() { + let a = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let b = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let rb = rolling_beta(&a, &b, 3); + assert_eq!(rb.len(), 5); + assert!(rb[0].is_nan()); + assert!(rb[1].is_nan()); + // From index 2 onward, beta of identical series = 1.0 + assert!(approx_eq(rb[2], 1.0)); + assert!(approx_eq(rb[3], 1.0)); + assert!(approx_eq(rb[4], 1.0)); + } + + #[test] + fn test_rolling_beta_double() { + let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let asset: Vec = bench.iter().map(|x| x * 3.0).collect(); + let rb = rolling_beta(&asset, &bench, 3); + for i in 2..5 { + assert!(approx_eq(rb[i], 3.0)); + } + } + + // -- drawdown_series ------------------------------------------------------ + + #[test] + fn test_drawdown_series_monotonic_up() { + let eq = vec![100.0, 110.0, 120.0, 130.0]; + let (dd, max_dd) = drawdown_series(&eq); + for &d in &dd { + assert!(approx_eq(d, 0.0)); + } + assert!(approx_eq(max_dd, 0.0)); + } + + #[test] + fn test_drawdown_series_with_dip() { + let eq = vec![100.0, 120.0, 90.0, 110.0]; + let (dd, max_dd) = drawdown_series(&eq); + assert!(approx_eq(dd[0], 0.0)); + assert!(approx_eq(dd[1], 0.0)); + // dd[2] = (90 - 120) / 120 = -0.25 + assert!(approx_eq(dd[2], -0.25)); + // dd[3] = (110 - 120) / 120 = -1/12 + assert!((dd[3] - (-1.0 / 12.0)).abs() < EPS); + assert!(approx_eq(max_dd, -0.25)); + } + + // -- correlation_matrix --------------------------------------------------- + + #[test] + fn test_correlation_matrix_identical() { + let col = vec![0.01, -0.02, 0.03, -0.01, 0.02]; + let data = vec![col.clone(), col.clone()]; + let cm = correlation_matrix(&data); + assert_eq!(cm.len(), 2); + assert!(approx_eq(cm[0][0], 1.0)); + assert!(approx_eq(cm[1][1], 1.0)); + assert!(approx_eq(cm[0][1], 1.0)); + assert!(approx_eq(cm[1][0], 1.0)); + } + + #[test] + fn test_correlation_matrix_negatively_correlated() { + let col_a = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let col_b: Vec = col_a.iter().map(|x| -x).collect(); + let data = vec![col_a, col_b]; + let cm = correlation_matrix(&data); + assert!(approx_eq(cm[0][1], -1.0)); + assert!(approx_eq(cm[1][0], -1.0)); + } + + #[test] + fn test_correlation_matrix_single_asset() { + let data = vec![vec![1.0, 2.0, 3.0]]; + let cm = correlation_matrix(&data); + assert_eq!(cm.len(), 1); + assert!(approx_eq(cm[0][0], 1.0)); + } + + // -- relative_strength ---------------------------------------------------- + + #[test] + fn test_relative_strength_equal() { + let r = vec![0.01, -0.02, 0.03]; + let rs = relative_strength(&r, &r); + for &v in &rs { + assert!(approx_eq(v, 1.0)); + } + } + + #[test] + fn test_relative_strength_outperformance() { + let a = vec![0.10, 0.10]; + let b = vec![0.05, 0.05]; + let rs = relative_strength(&a, &b); + // rs[0] = 1.10 / 1.05 + assert!((rs[0] - 1.10 / 1.05).abs() < EPS); + // rs[1] = 1.21 / 1.1025 + assert!((rs[1] - 1.21 / 1.1025).abs() < EPS); + } + + // -- spread --------------------------------------------------------------- + + #[test] + fn test_spread_basic() { + let a = vec![10.0, 20.0, 30.0]; + let b = vec![5.0, 10.0, 15.0]; + let s = spread(&a, &b, 2.0); + assert!(approx_eq(s[0], 0.0)); + assert!(approx_eq(s[1], 0.0)); + assert!(approx_eq(s[2], 0.0)); + } + + #[test] + fn test_spread_hedge_one() { + let a = vec![10.0, 20.0]; + let b = vec![3.0, 7.0]; + let s = spread(&a, &b, 1.0); + assert!(approx_eq(s[0], 7.0)); + assert!(approx_eq(s[1], 13.0)); + } + + // -- ratio ---------------------------------------------------------------- + + #[test] + fn test_ratio_basic() { + let a = vec![10.0, 20.0, 30.0]; + let b = vec![5.0, 10.0, 15.0]; + let r = ratio(&a, &b); + assert!(approx_eq(r[0], 2.0)); + assert!(approx_eq(r[1], 2.0)); + assert!(approx_eq(r[2], 2.0)); + } + + #[test] + fn test_ratio_zero_denominator() { + let a = vec![10.0, 20.0]; + let b = vec![0.0, 5.0]; + let r = ratio(&a, &b); + assert!(r[0].is_nan()); + assert!(approx_eq(r[1], 4.0)); + } + + // -- zscore_series -------------------------------------------------------- + + #[test] + fn test_zscore_warmup_nan() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let z = zscore_series(&x, 3); + assert!(z[0].is_nan()); + assert!(z[1].is_nan()); + assert!(!z[2].is_nan()); + assert!(!z[3].is_nan()); + assert!(!z[4].is_nan()); + } + + #[test] + fn test_zscore_constant_window() { + // All same values in window => std = 0 => NaN + let x = vec![5.0, 5.0, 5.0, 5.0]; + let z = zscore_series(&x, 3); + assert!(z[2].is_nan()); + assert!(z[3].is_nan()); + } + + #[test] + fn test_zscore_known_value() { + // Window [1, 2, 3]: mean=2, pop_std = sqrt(2/3) ~0.8165 + // z = (3 - 2) / sqrt(2/3) = sqrt(3/2) ~ 1.2247 + let x = vec![1.0, 2.0, 3.0]; + let z = zscore_series(&x, 3); + let expected = (3.0_f64 / 2.0).sqrt(); + assert!((z[2] - expected).abs() < EPS); + } + + // -- compose_weighted ----------------------------------------------------- + + #[test] + fn test_compose_weighted_basic() { + let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]]; + let weights = vec![0.3, 0.7]; + let cw = compose_weighted(&data, &weights); + // bar 0: 1*0.3 + 4*0.7 = 3.1 + assert!(approx_eq(cw[0], 3.1)); + // bar 1: 2*0.3 + 5*0.7 = 4.1 + assert!(approx_eq(cw[1], 4.1)); + // bar 2: 3*0.3 + 6*0.7 = 5.1 + assert!(approx_eq(cw[2], 5.1)); + } + + #[test] + fn test_compose_weighted_single_column() { + let data = vec![vec![10.0, 20.0]]; + let weights = vec![2.0]; + let cw = compose_weighted(&data, &weights); + assert!(approx_eq(cw[0], 20.0)); + assert!(approx_eq(cw[1], 40.0)); + } + + #[test] + fn test_compose_weighted_empty() { + let data: Vec> = vec![]; + let weights: Vec = vec![]; + let cw = compose_weighted(&data, &weights); + assert!(cw.is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs new file mode 100644 index 0000000..a42f746 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/price_transform.rs @@ -0,0 +1,89 @@ +//! Price transformations — synthesize OHLC arrays into single price arrays. + +/// Average Price: (open + high + low + close) / 4. +pub fn avgprice(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec { + open.iter() + .zip(high.iter()) + .zip(low.iter()) + .zip(close.iter()) + .map(|(((&o, &h), &l), &c)| (o + h + l + c) / 4.0) + .collect() +} + +/// Median Price: (high + low) / 2. +pub fn medprice(high: &[f64], low: &[f64]) -> Vec { + high.iter() + .zip(low.iter()) + .map(|(&h, &l)| (h + l) / 2.0) + .collect() +} + +/// Typical Price: (high + low + close) / 3. +pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec { + high.iter() + .zip(low.iter()) + .zip(close.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect() +} + +/// Weighted Close Price: (high + low + close * 2) / 4. +pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec { + high.iter() + .zip(low.iter()) + .zip(close.iter()) + .map(|((&h, &l), &c)| (h + l + c * 2.0) / 4.0) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_avgprice() { + let o = vec![1.0, 2.0, 3.0]; + let h = vec![4.0, 5.0, 6.0]; + let l = vec![0.5, 1.5, 2.5]; + let c = vec![2.5, 3.5, 4.5]; + let result = avgprice(&o, &h, &l, &c); + assert_eq!(result.len(), 3); + assert!((result[0] - 2.0).abs() < 1e-10); // (1+4+0.5+2.5)/4 = 2.0 + } + + #[test] + fn test_medprice() { + let h = vec![10.0, 20.0]; + let l = vec![6.0, 12.0]; + let result = medprice(&h, &l); + assert!((result[0] - 8.0).abs() < 1e-10); + assert!((result[1] - 16.0).abs() < 1e-10); + } + + #[test] + fn test_typprice() { + let h = vec![10.0]; + let l = vec![6.0]; + let c = vec![8.0]; + let result = typprice(&h, &l, &c); + assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+8)/3 = 8.0 + } + + #[test] + fn test_wclprice() { + let h = vec![10.0]; + let l = vec![6.0]; + let c = vec![8.0]; + let result = wclprice(&h, &l, &c); + assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+16)/4 = 8.0 + } + + #[test] + fn test_empty_inputs() { + let empty: Vec = vec![]; + assert!(avgprice(&empty, &empty, &empty, &empty).is_empty()); + assert!(medprice(&empty, &empty).is_empty()); + assert!(typprice(&empty, &empty, &empty).is_empty()); + assert!(wclprice(&empty, &empty, &empty).is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/regime.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/regime.rs new file mode 100644 index 0000000..27e9260 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/regime.rs @@ -0,0 +1,166 @@ +//! Regime detection and structural breaks. +//! +//! - `regime_adx` — label trend (1) vs range (0) using ADX threshold +//! - `regime_combined` — combine ADX + ATR-ratio for robust regime labelling +//! - `detect_breaks_cusum` — CUSUM-based structural break detection +//! - `rolling_variance_break` — variance ratio break detection + +/// Label each bar as trend (1) or range (0) based on ADX level. +/// +/// Returns `Vec`: `1` = trend (ADX > threshold), `0` = range, `-1` = NaN/warmup. +pub fn regime_adx(adx: &[f64], threshold: f64) -> Vec { + adx.iter() + .map(|&v| { + if v.is_nan() { + -1i8 + } else if v > threshold { + 1i8 + } else { + 0i8 + } + }) + .collect() +} + +/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule. +/// +/// A bar is trending when: `adx[i] > adx_threshold` AND `atr[i] / close[i] > atr_pct_threshold`. +/// +/// Returns `Vec`: `1` = trend, `0` = range, `-1` = NaN. +pub fn regime_combined( + adx: &[f64], + atr: &[f64], + close: &[f64], + adx_threshold: f64, + atr_pct_threshold: f64, +) -> Vec { + let n = adx.len(); + (0..n) + .map(|i| { + let av = adx[i]; + let rv = atr[i]; + let cv = close[i]; + if av.is_nan() || rv.is_nan() || cv.is_nan() || cv == 0.0 { + -1i8 + } else if av > adx_threshold && (rv / cv) > atr_pct_threshold { + 1i8 + } else { + 0i8 + } + }) + .collect() +} + +/// Detect structural breaks using a CUSUM (cumulative sum) approach. +/// +/// `window` must be >= 2. Returns `Vec`: `1` at break bars, `0` elsewhere. +pub fn detect_breaks_cusum(series: &[f64], window: usize, threshold: f64, slack: f64) -> Vec { + let n = series.len(); + let mut out = vec![0i8; n]; + if n < window || window < 2 { + return out; + } + let mut cusum_pos = 0.0_f64; + let mut cusum_neg = 0.0_f64; + for i in window..n { + let slice = &series[(i - window)..i]; + let mean: f64 = slice.iter().sum::() / window as f64; + let var: f64 = + slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (window - 1) as f64; + let std = var.sqrt(); + if std == 0.0 || std.is_nan() || series[i].is_nan() { + continue; + } + let z = (series[i] - mean) / std; + cusum_pos = (cusum_pos + z - slack).max(0.0); + cusum_neg = (cusum_neg - z - slack).max(0.0); + if cusum_pos > threshold || cusum_neg > threshold { + out[i] = 1; + cusum_pos = 0.0; + cusum_neg = 0.0; + } + } + out +} + +/// Detect volatility regime breaks using rolling variance ratio. +/// +/// `short_window` must be >= 2, `long_window` must be > `short_window`. +/// Returns `Vec`: `1` at break bars, `0` elsewhere. +pub fn rolling_variance_break( + series: &[f64], + short_window: usize, + long_window: usize, + threshold: f64, +) -> Vec { + let n = series.len(); + let mut out = vec![0i8; n]; + if n < long_window || short_window < 2 || long_window <= short_window { + return out; + } + + let variance = |slice: &[f64]| -> f64 { + let k = slice.len(); + let mean: f64 = slice.iter().sum::() / k as f64; + slice.iter().map(|&v| (v - mean) * (v - mean)).sum::() / (k - 1) as f64 + }; + + for i in long_window..n { + let long_slice = &series[(i - long_window)..i]; + let short_slice = &series[(i - short_window)..i]; + let long_var = variance(long_slice); + let short_var = variance(short_slice); + if long_var == 0.0 || long_var.is_nan() || short_var.is_nan() { + continue; + } + if short_var / long_var > threshold { + out[i] = 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_regime_adx_basic() { + let adx = vec![f64::NAN, 20.0, 30.0, 10.0, 50.0]; + let result = regime_adx(&adx, 25.0); + assert_eq!(result, vec![-1, 0, 1, 0, 1]); + } + + #[test] + fn test_regime_combined() { + let adx = vec![30.0, 30.0, 10.0]; + let atr = vec![1.0, 0.001, 1.0]; + let close = vec![100.0, 100.0, 100.0]; + let result = regime_combined(&adx, &atr, &close, 25.0, 0.005); + assert_eq!(result[0], 1); // ADX>25 and ATR/close=0.01>0.005 + assert_eq!(result[1], 0); // ATR/close=0.00001 < 0.005 + assert_eq!(result[2], 0); // ADX<25 + } + + #[test] + fn test_detect_breaks_cusum_short_input() { + let series = vec![1.0, 2.0]; + let result = detect_breaks_cusum(&series, 5, 3.0, 0.5); + assert!(result.iter().all(|&v| v == 0)); + } + + #[test] + fn test_rolling_variance_break_short_input() { + let series = vec![1.0, 2.0, 3.0]; + let result = rolling_variance_break(&series, 2, 5, 2.0); + assert!(result.iter().all(|&v| v == 0)); + } + + #[test] + fn test_empty() { + assert!(regime_adx(&[], 25.0).is_empty()); + assert!(regime_combined(&[], &[], &[], 25.0, 0.005).is_empty()); + assert!(detect_breaks_cusum(&[], 2, 3.0, 0.5).is_empty()); + assert!(rolling_variance_break(&[], 2, 5, 2.0).is_empty()); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs new file mode 100644 index 0000000..f573adf --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/resampling.rs @@ -0,0 +1,277 @@ +//! Resampling — OHLCV resampling and multi-timeframe helpers, pure Rust. +//! +//! # Functions +//! - `volume_bars` — Aggregate OHLCV bars into bars of fixed volume size. +//! - `ohlcv_agg` — Aggregate OHLCV bars given contiguous integer group labels. + +/// OHLCV 5-tuple return type alias. +type Ohlcv5 = (Vec, Vec, Vec, Vec, Vec); + +// --------------------------------------------------------------------------- +// volume_bars +// --------------------------------------------------------------------------- + +/// Aggregate OHLCV data into volume bars of a fixed volume threshold. +/// +/// Each output bar accumulates input bars until `volume_threshold` units of +/// volume have been consumed. The resulting bar has: +/// - open = first open of the group +/// - high = max high of the group +/// - low = min low of the group +/// - close = last close of the group +/// - volume = sum of volumes (approximately `volume_threshold`) +/// +/// Returns `(open, high, low, close, volume)`. +/// +/// # Panics +/// Panics if arrays are empty, have unequal lengths, or `volume_threshold <= 0`. +pub fn volume_bars( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + volume_threshold: f64, +) -> Ohlcv5 { + assert!(volume_threshold > 0.0, "volume_threshold must be > 0"); + let n = open.len(); + assert!(n > 0, "input arrays must be non-empty"); + assert!( + high.len() == n && low.len() == n && close.len() == n && volume.len() == n, + "all input arrays must have equal length" + ); + + let mut out_open: Vec = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = Vec::new(); + + let mut bar_open = open[0]; + let mut bar_high = high[0]; + let mut bar_low = low[0]; + let mut bar_close = close[0]; + let mut bar_vol = volume[0]; + + for i in 1..n { + bar_high = bar_high.max(high[i]); + bar_low = bar_low.min(low[i]); + bar_close = close[i]; + bar_vol += volume[i]; + + if bar_vol >= volume_threshold { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + // Start new bar + if i + 1 < n { + bar_open = open[i + 1]; + bar_high = high[i + 1]; + bar_low = low[i + 1]; + bar_close = close[i + 1]; + bar_vol = volume[i + 1]; + } + } + } + // Push any remaining partial bar + if bar_vol > 0.0 && out_vol.last().is_none_or(|&last| last != bar_vol) { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + } + + (out_open, out_high, out_low, out_close, out_vol) +} + +// --------------------------------------------------------------------------- +// ohlcv_agg +// --------------------------------------------------------------------------- + +/// Aggregate OHLCV bars by integer group labels. +/// +/// Groups consecutive bars with the same label and computes: +/// - open = first open of the group +/// - high = max high of the group +/// - low = min low of the group +/// - close = last close of the group +/// - volume = sum of volumes +/// +/// `labels` must be non-decreasing (groups are contiguous). +/// +/// Returns `(open, high, low, close, volume)`. +/// +/// # Panics +/// Panics if arrays are empty or have unequal lengths. +pub fn ohlcv_agg( + open: &[f64], + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + labels: &[i64], +) -> Ohlcv5 { + let n = open.len(); + assert!(n > 0, "input arrays must be non-empty"); + assert!( + high.len() == n + && low.len() == n + && close.len() == n + && volume.len() == n + && labels.len() == n, + "all input arrays must have equal length" + ); + + let mut out_open: Vec = Vec::new(); + let mut out_high: Vec = Vec::new(); + let mut out_low: Vec = Vec::new(); + let mut out_close: Vec = Vec::new(); + let mut out_vol: Vec = Vec::new(); + + let mut cur_label = labels[0]; + let mut bar_open = open[0]; + let mut bar_high = high[0]; + let mut bar_low = low[0]; + let mut bar_close = close[0]; + let mut bar_vol = volume[0]; + + for i in 1..n { + if labels[i] != cur_label { + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + cur_label = labels[i]; + bar_open = open[i]; + bar_high = high[i]; + bar_low = low[i]; + bar_close = close[i]; + bar_vol = volume[i]; + } else { + bar_high = bar_high.max(high[i]); + bar_low = bar_low.min(low[i]); + bar_close = close[i]; + bar_vol += volume[i]; + } + } + out_open.push(bar_open); + out_high.push(bar_high); + out_low.push(bar_low); + out_close.push(bar_close); + out_vol.push(bar_vol); + + (out_open, out_high, out_low, out_close, out_vol) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- volume_bars --------------------------------------------------------- + + #[test] + fn test_volume_bars_basic() { + let o = [100.0, 101.0, 102.0, 103.0, 104.0]; + let h = [105.0, 106.0, 107.0, 108.0, 109.0]; + let l = [95.0, 96.0, 97.0, 98.0, 99.0]; + let c = [101.0, 102.0, 103.0, 104.0, 105.0]; + let v = [50.0, 60.0, 40.0, 70.0, 30.0]; + // threshold 100: first bar covers indices 0..2 (vol=110>=100) + let (ro, rh, rl, rc, rv) = volume_bars(&o, &h, &l, &c, &v, 100.0); + assert!(rv.len() >= 2); + // First bar: vol = 50+60 = 110 + assert!((rv[0] - 110.0).abs() < 1e-10); + assert!((ro[0] - 100.0).abs() < 1e-10); + assert!((rh[0] - 106.0).abs() < 1e-10); + assert!((rl[0] - 95.0).abs() < 1e-10); + assert!((rc[0] - 102.0).abs() < 1e-10); + } + + #[test] + fn test_volume_bars_single_element() { + let (ro, rh, rl, rc, rv) = volume_bars(&[10.0], &[12.0], &[8.0], &[11.0], &[50.0], 100.0); + assert_eq!(rv.len(), 1); + assert!((rv[0] - 50.0).abs() < 1e-10); + assert!((ro[0] - 10.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "volume_threshold must be > 0")] + fn test_volume_bars_zero_threshold() { + volume_bars(&[1.0], &[1.0], &[1.0], &[1.0], &[1.0], 0.0); + } + + #[test] + #[should_panic(expected = "input arrays must be non-empty")] + fn test_volume_bars_empty() { + volume_bars(&[], &[], &[], &[], &[], 100.0); + } + + // -- ohlcv_agg ----------------------------------------------------------- + + #[test] + fn test_ohlcv_agg_basic() { + let o = [100.0, 101.0, 102.0, 103.0]; + let h = [105.0, 106.0, 108.0, 109.0]; + let l = [95.0, 96.0, 97.0, 98.0]; + let c = [101.0, 102.0, 103.0, 104.0]; + let v = [10.0, 20.0, 30.0, 40.0]; + let labels: [i64; 4] = [0, 0, 1, 1]; + let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels); + assert_eq!(ro.len(), 2); + // Group 0: open=100, high=max(105,106)=106, low=min(95,96)=95, close=102, vol=30 + assert!((ro[0] - 100.0).abs() < 1e-10); + assert!((rh[0] - 106.0).abs() < 1e-10); + assert!((rl[0] - 95.0).abs() < 1e-10); + assert!((rc[0] - 102.0).abs() < 1e-10); + assert!((rv[0] - 30.0).abs() < 1e-10); + // Group 1: open=102, high=max(108,109)=109, low=min(97,98)=97, close=104, vol=70 + assert!((ro[1] - 102.0).abs() < 1e-10); + assert!((rh[1] - 109.0).abs() < 1e-10); + assert!((rl[1] - 97.0).abs() < 1e-10); + assert!((rc[1] - 104.0).abs() < 1e-10); + assert!((rv[1] - 70.0).abs() < 1e-10); + } + + #[test] + fn test_ohlcv_agg_single_group() { + let o = [100.0, 101.0]; + let h = [105.0, 106.0]; + let l = [95.0, 96.0]; + let c = [101.0, 102.0]; + let v = [10.0, 20.0]; + let labels: [i64; 2] = [0, 0]; + let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels); + assert_eq!(ro.len(), 1); + assert!((rv[0] - 30.0).abs() < 1e-10); + } + + #[test] + fn test_ohlcv_agg_each_bar_own_group() { + let o = [100.0, 101.0, 102.0]; + let h = [105.0, 106.0, 107.0]; + let l = [95.0, 96.0, 97.0]; + let c = [101.0, 102.0, 103.0]; + let v = [10.0, 20.0, 30.0]; + let labels: [i64; 3] = [0, 1, 2]; + let (ro, _rh, _rl, _rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels); + assert_eq!(ro.len(), 3); + assert!((rv[0] - 10.0).abs() < 1e-10); + assert!((rv[1] - 20.0).abs() < 1e-10); + assert!((rv[2] - 30.0).abs() < 1e-10); + } + + #[test] + #[should_panic(expected = "input arrays must be non-empty")] + fn test_ohlcv_agg_empty() { + ohlcv_agg(&[], &[], &[], &[], &[], &[]); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/signals.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/signals.rs new file mode 100644 index 0000000..1c83e86 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/signals.rs @@ -0,0 +1,131 @@ +//! Signal processing helpers. +//! +//! - `rank_values` — fractional rank of a slice (1-based, ties averaged) +//! - `compose_rank` — rank-based composite scores for a 2-D signal matrix +//! - `top_n_indices` — indices of the N largest values +//! - `bottom_n_indices` — indices of the N smallest values + +/// Compute fractional rank of each element (1-based, ascending). +/// Ties receive the average of their rank positions. +pub fn rank_values(x: &[f64]) -> Vec { + let n = x.len(); + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal)); + + let mut ranks = vec![0.0_f64; n]; + let mut i = 0; + while i < n { + let val = x[order[i]]; + let mut j = i + 1; + while j < n && x[order[j]] == val { + j += 1; + } + let avg_rank = (i + 1 + j) as f64 / 2.0; + for k in i..j { + ranks[order[k]] = avg_rank; + } + i = j; + } + ranks +} + +/// Compute rank-based composite scores for a 2-D signal matrix. +/// +/// Each column is ranked independently, and the per-row ranks are summed. +/// `signals` is a slice of columns, each column being a `&[f64]` of the same length. +pub fn compose_rank(signals: &[&[f64]]) -> Vec { + if signals.is_empty() { + return vec![]; + } + let n_bars = signals[0].len(); + let mut scores = vec![0.0_f64; n_bars]; + for &column in signals { + let ranks = rank_values(column); + for (bar_idx, rank) in ranks.into_iter().enumerate() { + scores[bar_idx] += rank; + } + } + scores +} + +/// Return the indices of the N largest values in `x` (descending by value). +pub fn top_n_indices(x: &[f64], n: usize) -> Vec { + let len = x.len(); + let k = n.min(len); + let mut order: Vec = (0..len).collect(); + order.sort_by(|&a, &b| x[b].partial_cmp(&x[a]).unwrap_or(std::cmp::Ordering::Equal)); + order[..k].iter().map(|&i| i as i64).collect() +} + +/// Return the indices of the N smallest values in `x` (ascending by value). +pub fn bottom_n_indices(x: &[f64], n: usize) -> Vec { + let len = x.len(); + let k = n.min(len); + let mut order: Vec = (0..len).collect(); + order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal)); + order[..k].iter().map(|&i| i as i64).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rank_values() { + let x = vec![3.0, 1.0, 2.0]; + let ranks = rank_values(&x); + assert!((ranks[0] - 3.0).abs() < 1e-10); // 3.0 is largest → rank 3 + assert!((ranks[1] - 1.0).abs() < 1e-10); // 1.0 is smallest → rank 1 + assert!((ranks[2] - 2.0).abs() < 1e-10); // 2.0 is middle → rank 2 + } + + #[test] + fn test_rank_values_ties() { + let x = vec![1.0, 2.0, 2.0, 4.0]; + let ranks = rank_values(&x); + assert!((ranks[0] - 1.0).abs() < 1e-10); + assert!((ranks[1] - 2.5).abs() < 1e-10); // tied → average + assert!((ranks[2] - 2.5).abs() < 1e-10); + assert!((ranks[3] - 4.0).abs() < 1e-10); + } + + #[test] + fn test_compose_rank() { + let col1 = vec![3.0, 1.0, 2.0]; + let col2 = vec![1.0, 3.0, 2.0]; + let signals: Vec<&[f64]> = vec![&col1, &col2]; + let scores = compose_rank(&signals); + // Row 0: rank(3.0)=3 + rank(1.0)=1 = 4 + // Row 1: rank(1.0)=1 + rank(3.0)=3 = 4 + // Row 2: rank(2.0)=2 + rank(2.0)=2 = 4 + assert!((scores[0] - 4.0).abs() < 1e-10); + assert!((scores[1] - 4.0).abs() < 1e-10); + assert!((scores[2] - 4.0).abs() < 1e-10); + } + + #[test] + fn test_top_n_indices() { + let x = vec![10.0, 50.0, 30.0, 20.0, 40.0]; + let result = top_n_indices(&x, 3); + assert_eq!(result.len(), 3); + assert_eq!(result[0], 1); // 50.0 + assert_eq!(result[1], 4); // 40.0 + assert_eq!(result[2], 2); // 30.0 + } + + #[test] + fn test_bottom_n_indices() { + let x = vec![10.0, 50.0, 30.0, 20.0, 40.0]; + let result = bottom_n_indices(&x, 2); + assert_eq!(result.len(), 2); + assert_eq!(result[0], 0); // 10.0 + assert_eq!(result[1], 3); // 20.0 + } + + #[test] + fn test_top_n_exceeds_len() { + let x = vec![1.0, 2.0]; + let result = top_n_indices(&x, 5); + assert_eq!(result.len(), 2); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/simd.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/simd.rs new file mode 100644 index 0000000..91f78d2 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/simd.rs @@ -0,0 +1,161 @@ +//! Runtime-dispatched SIMD primitives. +//! +//! Each public reduction here is compiled into several CPU-feature-specific +//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …) +//! by [`multiversion`]. The fastest variant the *current* CPU supports is +//! chosen at runtime via CPUID. This gives one binary that: +//! +//! * runs on **any** CPU of the target architecture — no illegal-instruction +//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`; +//! * still uses wide vector units where the hardware has them. +//! +//! The hot loops accumulate into **independent lanes** before a final +//! horizontal combine. That is what lets the optimizer auto-vectorize them: +//! a plain sequential `iter().sum()` is a dependency chain LLVM may not +//! reorder (doing so would change floating-point rounding). As a consequence +//! these results differ from a strict left-to-right sum by a few ULPs — well +//! inside every indicator's documented tolerance. + +/// Number of independent accumulator lanes. Eight `f64` lanes cover the +/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2, +/// NEON) simply use a subset. +#[cfg(feature = "simd")] +const LANES: usize = 8; + +/// Sum of a slice of `f64`, runtime-dispatched. +#[cfg(feature = "simd")] +#[multiversion::multiversion(targets = "simd")] +pub(crate) fn sum(data: &[f64]) -> f64 { + let mut acc = [0.0f64; LANES]; + let mut chunks = data.chunks_exact(LANES); + for chunk in &mut chunks { + for (a, &v) in acc.iter_mut().zip(chunk) { + *a += v; + } + } + let remainder: f64 = chunks.remainder().iter().sum(); + remainder + acc.iter().sum::() +} + +/// Pure-scalar fallback when the `simd` feature is disabled. +#[cfg(not(feature = "simd"))] +pub(crate) fn sum(data: &[f64]) -> f64 { + data.iter().sum() +} + +/// Weighted-moving-average seed for the first window. +/// +/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights) +/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence. +#[cfg(feature = "simd")] +#[multiversion::multiversion(targets = "simd")] +pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) { + // Lane-local accumulation (same idea as `sum`) so each CPU-feature clone + // can vectorize: `t` weights each value by its 1-based global index. + let mut t_acc = [0.0f64; LANES]; + let mut s_acc = [0.0f64; LANES]; + let mut chunks = data.chunks_exact(LANES); + let mut base = 0.0f64; // global index of this chunk's first element + for chunk in &mut chunks { + for (lane, ((t, s), &v)) in t_acc + .iter_mut() + .zip(s_acc.iter_mut()) + .zip(chunk) + .enumerate() + { + *t += v * (base + lane as f64 + 1.0); + *s += v; + } + base += LANES as f64; + } + let mut t = 0.0; + let mut s = 0.0; + for (i, &v) in chunks.remainder().iter().enumerate() { + t += v * (base + i as f64 + 1.0); + s += v; + } + (t + t_acc.iter().sum::(), s + s_acc.iter().sum::()) +} + +/// Pure-scalar fallback when the `simd` feature is disabled. +#[cfg(not(feature = "simd"))] +pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) { + let mut t = 0.0; + let mut s = 0.0; + for (k, &v) in data.iter().enumerate() { + t += v * (k + 1) as f64; + s += v; + } + (t, s) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Strict sequential reference — the ground truth we compare against. + fn naive_sum(data: &[f64]) -> f64 { + data.iter().sum() + } + + fn naive_wma_seed(data: &[f64]) -> (f64, f64) { + let t = data + .iter() + .enumerate() + .map(|(k, &v)| v * (k + 1) as f64) + .sum(); + let s = data.iter().sum(); + (t, s) + } + + /// Deterministic test vectors spanning the lane boundaries: empty, a + /// partial chunk (< LANES), an exact multiple, and an exact-multiple + + /// remainder. This exercises every branch of the chunked reduction. + fn cases() -> Vec> { + let big: Vec = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect(); + vec![ + vec![], + vec![42.0], + vec![1.0, 2.0, 3.0], // < LANES + (1..=8).map(|i| i as f64).collect(), // exactly LANES + (1..=17).map(|i| i as f64).collect(), // LANES*2 + 1 + big, + ] + } + + #[test] + fn sum_matches_sequential_within_tolerance() { + for data in cases() { + let got = sum(&data); + let want = naive_sum(&data); + assert!( + (got - want).abs() <= 1e-9 * want.abs().max(1.0), + "sum mismatch: got {got}, want {want}, len {}", + data.len() + ); + } + } + + #[test] + fn wma_seed_matches_sequential_within_tolerance() { + for data in cases() { + let (t, s) = wma_seed(&data); + let (wt, ws) = naive_wma_seed(&data); + assert!( + (t - wt).abs() <= 1e-9 * wt.abs().max(1.0), + "wma t mismatch: got {t}, want {wt}, len {}", + data.len() + ); + assert!( + (s - ws).abs() <= 1e-9 * ws.abs().max(1.0), + "wma s mismatch: got {s}, want {ws}, len {}", + data.len() + ); + } + } + + #[test] + fn sum_empty_is_zero() { + assert_eq!(sum(&[]), 0.0); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs new file mode 100644 index 0000000..3ad0987 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/statistic.rs @@ -0,0 +1,492 @@ +//! Statistic functions. + +/// Compute the rolling population standard deviation, scaled by `nbdev`. +/// +/// Uses population variance (`ddof = 0`). Returns `nbdev * stddev` for +/// each window. The first `timeperiod - 1` values are `NaN`. +/// +/// # Arguments +/// * `real` - Input series. +/// * `timeperiod` - Rolling window size (must be >= 1). +/// * `nbdev` - Multiplier applied to the standard deviation (use 1.0 for raw stddev). +pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &real[i + 1 - timeperiod..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let var: f64 = window.iter().map(|&x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = var.sqrt() * nbdev; + } + result +} + +/// Rolling population variance, scaled by `nbdev²`. +pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec { + let n = real.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n < timeperiod { + return result; + } + for i in (timeperiod - 1)..n { + let window = &real[i + 1 - timeperiod..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let variance: f64 = + window.iter().map(|&x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = variance * nbdev * nbdev; + } + result +} + +// --------------------------------------------------------------------------- +// Linear regression helpers +// --------------------------------------------------------------------------- + +fn rolling_linreg_apply(prices: &[f64], timeperiod: usize, mut map: F) -> Vec +where + F: FnMut(f64, f64) -> f64, +{ + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + let period = timeperiod as f64; + let last_x = (timeperiod - 1) as f64; + let sum_x = last_x * period / 2.0; + let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0; + let denom = period * sum_x2 - sum_x * sum_x; + + let mut sum_y: f64 = prices[..timeperiod].iter().sum(); + let mut sum_xy: f64 = prices[..timeperiod] + .iter() + .enumerate() + .map(|(idx, &v)| idx as f64 * v) + .sum(); + + for end in (timeperiod - 1)..n { + let slope = if denom != 0.0 { + (period * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / period; + result[end] = map(slope, intercept); + if end + 1 < n { + let outgoing = prices[end + 1 - timeperiod]; + let incoming = prices[end + 1]; + let prev_sum_y = sum_y; + sum_y = prev_sum_y - outgoing + incoming; + sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming; + } + } + result +} + +/// Linear regression fitted value at the last point of the window. +pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec { + let last_x = if timeperiod > 0 { + (timeperiod - 1) as f64 + } else { + 0.0 + }; + rolling_linreg_apply(close, timeperiod, |slope, intercept| { + intercept + slope * last_x + }) +} + +/// Slope of the rolling linear regression line. +pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |slope, _| slope) +} + +/// Intercept of the rolling linear regression line. +pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |_, intercept| intercept) +} + +/// Angle of the regression line in degrees. +pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec { + rolling_linreg_apply(close, timeperiod, |slope, _| { + slope.atan() * 180.0 / std::f64::consts::PI + }) +} + +/// Time Series Forecast: linear regression extrapolated one period ahead. +pub fn tsf(close: &[f64], timeperiod: usize) -> Vec { + let forecast_x = timeperiod as f64; + rolling_linreg_apply(close, timeperiod, |slope, intercept| { + intercept + slope * forecast_x + }) +} + +// --------------------------------------------------------------------------- +// Beta (rolling, return-based) +// --------------------------------------------------------------------------- + +/// Rolling beta: regression of real1 daily returns on real0 daily returns. +pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec { + let n = real0.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n <= timeperiod { + return result; + } + + let price_return = |curr: f64, prev: f64| -> f64 { + if prev != 0.0 { + curr / prev - 1.0 + } else { + f64::NAN + } + }; + let rx: Vec = real0.windows(2).map(|w| price_return(w[1], w[0])).collect(); + let ry: Vec = real1.windows(2).map(|w| price_return(w[1], w[0])).collect(); + + let period = timeperiod as f64; + let mut sum_rx = 0.0_f64; + let mut sum_ry = 0.0_f64; + let mut sum_rx2 = 0.0_f64; + let mut sum_rxry = 0.0_f64; + let mut invalid = 0usize; + + for idx in 0..timeperiod { + let (ret_x, ret_y) = (rx[idx], ry[idx]); + if ret_x.is_finite() && ret_y.is_finite() { + sum_rx += ret_x; + sum_ry += ret_y; + sum_rx2 += ret_x * ret_x; + sum_rxry += ret_x * ret_y; + } else { + invalid += 1; + } + } + + for end in timeperiod..n { + result[end] = if invalid == 0 { + let denom = period * sum_rx2 - sum_rx * sum_rx; + if denom != 0.0 { + (period * sum_rxry - sum_rx * sum_ry) / denom + } else { + f64::NAN + } + } else { + f64::NAN + }; + + if end + 1 < n { + let out = end - timeperiod; + let (ox, oy) = (rx[out], ry[out]); + if ox.is_finite() && oy.is_finite() { + sum_rx -= ox; + sum_ry -= oy; + sum_rx2 -= ox * ox; + sum_rxry -= ox * oy; + } else { + invalid -= 1; + } + let (ix, iy) = (rx[end], ry[end]); + if ix.is_finite() && iy.is_finite() { + sum_rx += ix; + sum_ry += iy; + sum_rx2 += ix * ix; + sum_rxry += ix * iy; + } else { + invalid += 1; + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Correlation (rolling Pearson) +// --------------------------------------------------------------------------- + +/// Rolling Pearson correlation coefficient between two series. +pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec { + let n = real0.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + + let period = timeperiod as f64; + let mut sum_x: f64 = real0[..timeperiod].iter().sum(); + let mut sum_y: f64 = real1[..timeperiod].iter().sum(); + let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum(); + let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum(); + let mut sum_xy: f64 = real0[..timeperiod] + .iter() + .zip(real1[..timeperiod].iter()) + .map(|(&a, &b)| a * b) + .sum(); + + #[allow(clippy::needless_range_loop)] + for end in (timeperiod - 1)..n { + let denom_x = period * sum_x2 - sum_x * sum_x; + let denom_y = period * sum_y2 - sum_y * sum_y; + result[end] = if denom_x > 0.0 && denom_y > 0.0 { + (period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt() + } else { + f64::NAN + }; + + if end + 1 < n { + let out = end + 1 - timeperiod; + let inc = end + 1; + sum_x += real0[inc] - real0[out]; + sum_y += real1[inc] - real1[out]; + sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out]; + sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out]; + sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out]; + } + } + result +} + +// --------------------------------------------------------------------------- +// Dynamic Time Warping (DTW) +// --------------------------------------------------------------------------- + +/// Internal helper: build the full DTW accumulated-cost matrix. +/// +/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series). +/// This matches the convention used by `dtaidistance.dtw.distance()`. +/// +/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`. +fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option) -> Vec> { + let n = s1.len(); + let m = s2.len(); + let mut dp = vec![vec![f64::INFINITY; m]; n]; + for i in 0..n { + // Window convention matches dtaidistance: window=w means |i-j| < w. + // None = unconstrained (full matrix). + let (j_lo, j_hi) = match window { + None => (0, m), + Some(w) => { + let lo = i.saturating_sub(w.saturating_sub(1)); + let hi = i.saturating_add(w).min(m); + (lo, hi) + } + }; + for j in j_lo..j_hi { + // Squared Euclidean local cost — matches dtaidistance convention. + // The final sqrt is applied only once at the top level (not per-step). + let cost = (s1[i] - s2[j]).powi(2); + let prev = if i == 0 && j == 0 { + 0.0 + } else if i == 0 { + dp[0][j - 1] + } else if j == 0 { + dp[i - 1][0] + } else { + dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1]) + }; + dp[i][j] = cost + prev; + } + } + dp +} + +/// Compute the Dynamic Time Warping distance between two 1-D series. +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention. +/// +/// # Arguments +/// * `s1` - First time series. +/// * `s2` - Second time series. +/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns `f64::NAN` if either input is empty. +pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option) -> f64 { + if s1.is_empty() || s2.is_empty() { + return f64::NAN; + } + let dp = dtw_matrix(s1, s2, window); + // sqrt applied once at the end — matches dtaidistance.dtw.distance() convention. + dp[s1.len() - 1][s2.len() - 1].sqrt() +} + +/// Compute the DTW distance and the optimal warping path between two 1-D series. +/// +/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs, +/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing. +/// +/// # Arguments +/// * `s1` - First time series. +/// * `s2` - Second time series. +/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns `(f64::NAN, vec![])` if either input is empty. +pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option) -> (f64, Vec<(usize, usize)>) { + if s1.is_empty() || s2.is_empty() { + return (f64::NAN, vec![]); + } + let dp = dtw_matrix(s1, s2, window); + let dist = dp[s1.len() - 1][s2.len() - 1].sqrt(); + + // Backtrace from (n-1, m-1) to (0, 0) + let mut path = Vec::new(); + let (mut i, mut j) = (s1.len() - 1, s2.len() - 1); + path.push((i, j)); + while i > 0 || j > 0 { + let (ni, nj) = match (i, j) { + (0, _) => (0, j - 1), + (_, 0) => (i - 1, 0), + _ => { + let diag = dp[i - 1][j - 1]; + let up = dp[i - 1][j]; + let left = dp[i][j - 1]; + let best = diag.min(up).min(left); + if best == diag { + (i - 1, j - 1) + } else if best == up { + (i - 1, j) + } else { + (i, j - 1) + } + } + }; + i = ni; + j = nj; + path.push((i, j)); + } + path.reverse(); + (dist, path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stddev_constant() { + let prices = vec![5.0; 5]; + let result = stddev(&prices, 3, 1.0); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(v.abs() < 1e-10); + } + } + + #[test] + fn dtw_identical_series_is_zero() { + let a = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + assert_eq!(dtw_distance(&a, &a, None), 0.0); + } + + #[test] + fn dtw_known_shifted_series() { + // [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt. + // Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2). + // Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142... + let a = vec![0.0, 1.0, 2.0]; + let b = vec![1.0, 2.0, 3.0]; + let expected = 2.0_f64.sqrt(); + let result = dtw_distance(&a, &b, None); + assert!( + (result - expected).abs() < 1e-12, + "got {result}, expected {expected}" + ); + } + + #[test] + fn dtw_known_even_shift() { + // [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3). + // Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320... + let a = vec![0.0, 2.0, 4.0]; + let b = vec![1.0, 3.0, 5.0]; + let expected = 3.0_f64.sqrt(); + let result = dtw_distance(&a, &b, None); + assert!( + (result - expected).abs() < 1e-12, + "got {result}, expected {expected}" + ); + } + + #[test] + fn dtw_single_element() { + let a = vec![3.0]; + let b = vec![7.0]; + assert_eq!(dtw_distance(&a, &b, None), 4.0); + } + + #[test] + fn dtw_empty_returns_nan() { + assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan()); + assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan()); + } + + #[test] + fn dtw_path_endpoints() { + let a = vec![1.0, 2.0, 3.0, 4.0]; + let b = vec![1.5, 2.5, 3.5, 4.5]; + let (_, path) = dtw_path(&a, &b, None); + assert_eq!(path.first(), Some(&(0, 0))); + assert_eq!(path.last(), Some(&(3, 3))); + } + + #[test] + fn dtw_path_is_monotone() { + let a = vec![1.0, 3.0, 2.0, 5.0, 4.0]; + let b = vec![2.0, 1.0, 4.0, 3.0, 6.0]; + let (_, path) = dtw_path(&a, &b, None); + for k in 1..path.len() { + assert!(path[k].0 >= path[k - 1].0); + assert!(path[k].1 >= path[k - 1].1); + } + } + + #[test] + fn dtw_path_distance_matches_distance_only() { + let a = vec![1.0, 4.0, 2.0, 8.0, 3.0]; + let b = vec![2.0, 3.0, 7.0, 4.0, 5.0]; + let d1 = dtw_distance(&a, &b, None); + let (d2, _) = dtw_path(&a, &b, None); + assert!((d1 - d2).abs() < 1e-12); + } + + #[test] + fn dtw_nan_in_input_propagates() { + // NaN in either input must propagate to the distance (IEEE 754 semantics). + let a = vec![1.0, 2.0, f64::NAN, 4.0]; + let b = vec![1.0, 2.0, 3.0, 4.0]; + assert!(dtw_distance(&a, &b, None).is_nan()); + assert!(dtw_distance(&b, &a, None).is_nan()); + } + + #[test] + fn dtw_is_symmetric() { + let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0]; + let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0]; + let d_ab = dtw_distance(&a, &b, None); + let d_ba = dtw_distance(&b, &a, None); + assert!((d_ab - d_ba).abs() < 1e-12); + } + + #[test] + fn dtw_path_length_bounded() { + // A valid warp path has length between max(n, m) and n + m - 1. + let a: Vec = (0..7).map(|x| x as f64).collect(); + let b: Vec = (0..10).map(|x| (x as f64).sin()).collect(); + let (_, path) = dtw_path(&a, &b, None); + let n = a.len(); + let m = b.len(); + assert!(path.len() >= n.max(m)); + assert!(path.len() <= n + m - 1); + } + + #[test] + fn dtw_window_constrained_ge_unconstrained() { + // window convention matches dtaidistance: Some(w) means |i-j| < w. + // A narrow window restricts warping, so constrained distance >= unconstrained. + let a: Vec = (0..20).map(|x| x as f64).collect(); + let b: Vec = (0..20).map(|x| x as f64 + 3.0).collect(); + let d_full = dtw_distance(&a, &b, None); + let d_narrow = dtw_distance(&a, &b, Some(3)); + assert!(d_narrow >= d_full - 1e-12); + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs new file mode 100644 index 0000000..fd5d867 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/streaming.rs @@ -0,0 +1,946 @@ +//! Streaming / Incremental Indicators — bar-by-bar stateful structs. +//! +//! Pure Rust implementations with no PyO3 dependency. Each struct: +//! - Accepts one value per call to `update()`. +//! - Returns `NaN` (or a NaN tuple) during the warm-up window. +//! - Exposes a `reset()` method to restart from scratch. +//! - Has a `period()` accessor (where applicable). + +use std::collections::VecDeque; + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Validation error for streaming indicator parameters. +#[derive(Debug, Clone)] +pub struct StreamingError(pub String); + +impl std::fmt::Display for StreamingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for StreamingError {} + +fn validate_timeperiod(value: usize, name: &str, minimum: usize) -> Result<(), StreamingError> { + if value < minimum { + return Err(StreamingError(format!( + "{} must be >= {}, got {}", + name, minimum, value + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Internal helper: EMA state (used inside composite classes) +// --------------------------------------------------------------------------- + +/// SMA-seeded EMA state machine. Not exposed directly — used by +/// `StreamingEMA`, `StreamingMACD`, etc. +pub(crate) struct EmaState { + period: usize, + alpha: f64, + ema: f64, + seed_buf: Vec, + seeded: bool, +} + +impl EmaState { + pub fn new(period: usize) -> Self { + Self { + period, + alpha: 2.0 / (period as f64 + 1.0), + ema: 0.0, + seed_buf: Vec::with_capacity(period), + seeded: false, + } + } + + pub fn update(&mut self, value: f64) -> f64 { + if !self.seeded { + self.seed_buf.push(value); + if self.seed_buf.len() < self.period { + return f64::NAN; + } + let seed = self.seed_buf.iter().sum::() / self.period as f64; + self.ema = seed; + self.seeded = true; + return seed; + } + self.ema += self.alpha * (value - self.ema); + self.ema + } + + pub fn reset(&mut self) { + self.ema = 0.0; + self.seed_buf.clear(); + self.seeded = false; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// Internal helper: ATR state (Wilder smoothing) +// --------------------------------------------------------------------------- + +/// Wilder-smoothed ATR state machine. Used by `StreamingATR` and +/// `StreamingSupertrend`. +pub(crate) struct AtrState { + period: usize, + prev_close: f64, + tr_buf: Vec, + atr: f64, + seeded: bool, + has_prev: bool, +} + +impl AtrState { + pub fn new(period: usize) -> Self { + Self { + period, + prev_close: 0.0, + tr_buf: Vec::with_capacity(period), + atr: 0.0, + seeded: false, + has_prev: false, + } + } + + pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 { + let tr = if self.has_prev { + let hl = high - low; + let hc = (high - self.prev_close).abs(); + let lc = (low - self.prev_close).abs(); + hl.max(hc).max(lc) + } else { + high - low + }; + self.prev_close = close; + self.has_prev = true; + + if !self.seeded { + self.tr_buf.push(tr); + if self.tr_buf.len() < self.period { + return f64::NAN; + } + let seed = self.tr_buf.iter().sum::() / self.period as f64; + self.atr = seed; + self.seeded = true; + return f64::NAN; // first `period` bars (including this one) return NaN + } + let pf = (self.period - 1) as f64; + self.atr = (self.atr * pf + tr) / self.period as f64; + self.atr + } + + pub fn reset(&mut self) { + self.prev_close = 0.0; + self.has_prev = false; + self.tr_buf.clear(); + self.atr = 0.0; + self.seeded = false; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// StreamingSMA +// --------------------------------------------------------------------------- + +/// Simple Moving Average — O(1) per update via running sum. +/// +/// Returns NaN during the first `period - 1` bars. +pub struct StreamingSMA { + period: usize, + buf: VecDeque, + running_sum: f64, + count: usize, +} + +impl StreamingSMA { + pub fn new(period: usize) -> Result { + validate_timeperiod(period, "period", 1)?; + Ok(Self { + period, + buf: VecDeque::with_capacity(period + 1), + running_sum: 0.0, + count: 0, + }) + } + + /// Add a new bar and return the current SMA (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + if self.buf.len() == self.period { + if let Some(old) = self.buf.pop_front() { + self.running_sum -= old; + } + } + self.buf.push_back(value); + self.running_sum += value; + self.count += 1; + if self.count < self.period { + f64::NAN + } else { + self.running_sum / self.period as f64 + } + } + + /// Reset state to initial condition. + pub fn reset(&mut self) { + self.buf.clear(); + self.running_sum = 0.0; + self.count = 0; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// StreamingEMA +// --------------------------------------------------------------------------- + +/// Exponential Moving Average with SMA seeding. +/// +/// Uses a simple SMA for the first `period` bars to seed the EMA, then +/// switches to the standard EMA formula (alpha = 2 / (period + 1)). +/// Returns NaN during the warmup window. +pub struct StreamingEMA { + inner: EmaState, +} + +impl StreamingEMA { + pub fn new(period: usize) -> Result { + validate_timeperiod(period, "period", 1)?; + Ok(Self { + inner: EmaState::new(period), + }) + } + + /// Add a new bar and return the current EMA (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + pub fn period(&self) -> usize { + self.inner.period() + } +} + +// --------------------------------------------------------------------------- +// StreamingRSI +// --------------------------------------------------------------------------- + +/// Relative Strength Index with TA-Lib-compatible Wilder seeding. +/// +/// Returns NaN during the first `period` bars. +pub struct StreamingRSI { + period: usize, + prev: f64, + has_prev: bool, + gains: Vec, + losses: Vec, + avg_gain: f64, + avg_loss: f64, + seeded: bool, +} + +impl StreamingRSI { + pub fn new(period: usize) -> Result { + validate_timeperiod(period, "period", 1)?; + Ok(Self { + period, + prev: 0.0, + has_prev: false, + gains: Vec::with_capacity(period), + losses: Vec::with_capacity(period), + avg_gain: 0.0, + avg_loss: 0.0, + seeded: false, + }) + } + + /// Add a new close and return RSI in [0, 100] (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + if !self.has_prev { + self.prev = value; + self.has_prev = true; + return f64::NAN; + } + let delta = value - self.prev; + self.prev = value; + let gain = if delta > 0.0 { delta } else { 0.0 }; + let loss = if delta < 0.0 { -delta } else { 0.0 }; + + if !self.seeded { + self.gains.push(gain); + self.losses.push(loss); + if self.gains.len() < self.period { + return f64::NAN; + } + self.avg_gain = self.gains.iter().sum::() / self.period as f64; + self.avg_loss = self.losses.iter().sum::() / self.period as f64; + self.seeded = true; + } else { + let pf = (self.period - 1) as f64; + self.avg_gain = (self.avg_gain * pf + gain) / self.period as f64; + self.avg_loss = (self.avg_loss * pf + loss) / self.period as f64; + } + + if self.avg_loss == 0.0 { + return 100.0; + } + let rs = self.avg_gain / self.avg_loss; + 100.0 - 100.0 / (1.0 + rs) + } + + pub fn reset(&mut self) { + self.prev = 0.0; + self.has_prev = false; + self.gains.clear(); + self.losses.clear(); + self.avg_gain = 0.0; + self.avg_loss = 0.0; + self.seeded = false; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// StreamingATR +// --------------------------------------------------------------------------- + +/// Average True Range with TA-Lib-compatible Wilder seeding. +/// +/// Accepts (high, low, close) per bar. +/// Returns NaN during the first `period` bars. +pub struct StreamingATR { + inner: AtrState, +} + +impl StreamingATR { + pub fn new(period: usize) -> Result { + validate_timeperiod(period, "period", 1)?; + Ok(Self { + inner: AtrState::new(period), + }) + } + + /// Add a new bar (high, low, close) and return ATR (NaN during warmup). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + pub fn period(&self) -> usize { + self.inner.period() + } +} + +// --------------------------------------------------------------------------- +// StreamingBBands +// --------------------------------------------------------------------------- + +/// Bollinger Bands — streaming variant using Welford's online algorithm. +/// +/// Returns (upper, middle, lower). +/// NaN tuple during the warmup window. +pub struct StreamingBBands { + period: usize, + nbdevup: f64, + nbdevdn: f64, + buf: VecDeque, + mean: f64, + m2: f64, +} + +impl StreamingBBands { + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result { + validate_timeperiod(period, "period", 2)?; + Ok(Self { + period, + nbdevup, + nbdevdn, + buf: VecDeque::with_capacity(period + 1), + mean: 0.0, + m2: 0.0, + }) + } + + /// Add a new bar; return (upper, middle, lower). NaN tuple during warmup. + pub fn update(&mut self, value: f64) -> (f64, f64, f64) { + let n = self.buf.len(); + + if n == self.period { + let x_old = self.buf.pop_front().unwrap(); + let count = self.period as f64; + let delta_old = x_old - self.mean; + self.mean -= delta_old / (count - 1.0); + let delta2_old = x_old - self.mean; + self.m2 -= delta_old * delta2_old; + } + + self.buf.push_back(value); + let count = self.buf.len() as f64; + let delta_new = value - self.mean; + self.mean += delta_new / count; + let delta2_new = value - self.mean; + self.m2 += delta_new * delta2_new; + + if self.m2 < 0.0 { + self.m2 = 0.0; + } + + if self.buf.len() < self.period { + return (f64::NAN, f64::NAN, f64::NAN); + } + + let variance = self.m2 / (count - 1.0); + let std = variance.sqrt(); + ( + self.mean + self.nbdevup * std, + self.mean, + self.mean - self.nbdevdn * std, + ) + } + + pub fn reset(&mut self) { + self.buf.clear(); + self.mean = 0.0; + self.m2 = 0.0; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// StreamingMACD +// --------------------------------------------------------------------------- + +/// MACD — fast EMA, slow EMA, signal EMA. +/// +/// Returns (macd_line, signal_line, histogram). +/// NaN values during warmup. +pub struct StreamingMACD { + fast: EmaState, + slow: EmaState, + signal: EmaState, +} + +impl StreamingMACD { + pub fn new( + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, + ) -> Result { + validate_timeperiod(fastperiod, "fastperiod", 1)?; + validate_timeperiod(slowperiod, "slowperiod", 1)?; + validate_timeperiod(signalperiod, "signalperiod", 1)?; + if fastperiod >= slowperiod { + return Err(StreamingError( + "fastperiod must be < slowperiod".to_string(), + )); + } + Ok(Self { + fast: EmaState::new(fastperiod), + slow: EmaState::new(slowperiod), + signal: EmaState::new(signalperiod), + }) + } + + /// Add a new close; return (macd_line, signal_line, histogram). + pub fn update(&mut self, value: f64) -> (f64, f64, f64) { + let fast_val = self.fast.update(value); + let slow_val = self.slow.update(value); + + if slow_val.is_nan() { + return (f64::NAN, f64::NAN, f64::NAN); + } + + let macd = fast_val - slow_val; + let signal = self.signal.update(macd); + if signal.is_nan() { + return (macd, f64::NAN, f64::NAN); + } + (macd, signal, macd - signal) + } + + pub fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal.reset(); + } + + pub fn fast_period(&self) -> usize { + self.fast.period() + } + + pub fn slow_period(&self) -> usize { + self.slow.period() + } + + pub fn signal_period(&self) -> usize { + self.signal.period() + } +} + +// --------------------------------------------------------------------------- +// StreamingStoch +// --------------------------------------------------------------------------- + +/// Slow Stochastic (SMA-smoothed). +/// +/// Returns (slowk, slowd). +/// NaN tuple during warmup. +pub struct StreamingStoch { + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, + high_buf: VecDeque, + low_buf: VecDeque, + fastk_buf: VecDeque, + slowk_buf: VecDeque, +} + +impl StreamingStoch { + pub fn new( + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, + ) -> Result { + validate_timeperiod(fastk_period, "fastk_period", 1)?; + validate_timeperiod(slowk_period, "slowk_period", 1)?; + validate_timeperiod(slowd_period, "slowd_period", 1)?; + Ok(Self { + fastk_period, + slowk_period, + slowd_period, + high_buf: VecDeque::with_capacity(fastk_period + 1), + low_buf: VecDeque::with_capacity(fastk_period + 1), + fastk_buf: VecDeque::with_capacity(slowk_period + 1), + slowk_buf: VecDeque::with_capacity(slowd_period + 1), + }) + } + + /// Add a new bar (high, low, close); return (slowk, slowd). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, f64) { + if self.high_buf.len() == self.fastk_period { + self.high_buf.pop_front(); + self.low_buf.pop_front(); + } + self.high_buf.push_back(high); + self.low_buf.push_back(low); + + if self.high_buf.len() < self.fastk_period { + return (f64::NAN, f64::NAN); + } + + let max_h = self + .high_buf + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let min_l = self.low_buf.iter().cloned().fold(f64::INFINITY, f64::min); + + let fastk = if max_h != min_l { + 100.0 * (close - min_l) / (max_h - min_l) + } else { + 0.0 + }; + + if self.fastk_buf.len() == self.slowk_period { + self.fastk_buf.pop_front(); + } + self.fastk_buf.push_back(fastk); + if self.fastk_buf.len() < self.slowk_period { + return (f64::NAN, f64::NAN); + } + + let slowk = self.fastk_buf.iter().sum::() / self.slowk_period as f64; + + if self.slowk_buf.len() == self.slowd_period { + self.slowk_buf.pop_front(); + } + self.slowk_buf.push_back(slowk); + if self.slowk_buf.len() < self.slowd_period { + return (slowk, f64::NAN); + } + + let slowd = self.slowk_buf.iter().sum::() / self.slowd_period as f64; + (slowk, slowd) + } + + pub fn reset(&mut self) { + self.high_buf.clear(); + self.low_buf.clear(); + self.fastk_buf.clear(); + self.slowk_buf.clear(); + } + + pub fn period(&self) -> usize { + self.fastk_period + } +} + +// --------------------------------------------------------------------------- +// StreamingVWAP +// --------------------------------------------------------------------------- + +/// Cumulative Volume Weighted Average Price. +/// +/// Resets automatically when `reset()` is called (e.g. at session open). +/// Accepts (high, low, close, volume) per bar. +#[derive(Default)] +pub struct StreamingVWAP { + cum_tpv: f64, + cum_vol: f64, +} + +impl StreamingVWAP { + pub fn new() -> Self { + Self { + cum_tpv: 0.0, + cum_vol: 0.0, + } + } + + /// Add a new bar (high, low, close, volume) and return cumulative VWAP. + pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 { + let tp = (high + low + close) / 3.0; + self.cum_tpv += tp * volume; + self.cum_vol += volume; + if self.cum_vol == 0.0 { + f64::NAN + } else { + self.cum_tpv / self.cum_vol + } + } + + /// Reset for a new session. + pub fn reset(&mut self) { + self.cum_tpv = 0.0; + self.cum_vol = 0.0; + } +} + +// --------------------------------------------------------------------------- +// StreamingSupertrend +// --------------------------------------------------------------------------- + +/// ATR-based Supertrend — streaming variant. +/// +/// Accepts (high, low, close) per bar. +/// Returns (supertrend_line, direction). +/// direction: 1 = uptrend, -1 = downtrend, 0 = warmup. +pub struct StreamingSupertrend { + period: usize, + multiplier: f64, + atr: AtrState, + upper_band: f64, + lower_band: f64, + has_bands: bool, + direction: i8, + prev_close: f64, + has_prev: bool, +} + +impl StreamingSupertrend { + pub fn new(period: usize, multiplier: f64) -> Result { + validate_timeperiod(period, "period", 1)?; + Ok(Self { + period, + multiplier, + atr: AtrState::new(period), + upper_band: 0.0, + lower_band: 0.0, + has_bands: false, + direction: 0, + prev_close: 0.0, + has_prev: false, + }) + } + + /// Add a new bar (high, low, close); return (supertrend_line, direction). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, i8) { + let atr = self.atr.update(high, low, close); + if atr.is_nan() { + self.prev_close = close; + self.has_prev = true; + return (f64::NAN, 0); + } + + let hl2 = (high + low) / 2.0; + let upper_basic = hl2 + self.multiplier * atr; + let lower_basic = hl2 - self.multiplier * atr; + + if !self.has_bands { + self.upper_band = upper_basic; + self.lower_band = lower_basic; + self.has_bands = true; + self.direction = -1; + self.prev_close = close; + self.has_prev = true; + return (self.upper_band, self.direction); + } + + let prev_close = self.prev_close; + + let new_lower = if lower_basic > self.lower_band || prev_close < self.lower_band { + lower_basic + } else { + self.lower_band + }; + let new_upper = if upper_basic < self.upper_band || prev_close > self.upper_band { + upper_basic + } else { + self.upper_band + }; + + self.lower_band = new_lower; + self.upper_band = new_upper; + + self.direction = if self.direction == -1 { + if close > new_upper { + 1 + } else { + -1 + } + } else if close < new_lower { + -1 + } else { + 1 + }; + + self.prev_close = close; + let line = if self.direction == 1 { + new_lower + } else { + new_upper + }; + (line, self.direction) + } + + pub fn reset(&mut self) { + self.atr.reset(); + self.upper_band = 0.0; + self.lower_band = 0.0; + self.has_bands = false; + self.direction = 0; + self.prev_close = 0.0; + self.has_prev = false; + } + + pub fn period(&self) -> usize { + self.period + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: compare two f64 values, treating NaN == NaN as true. + fn approx_eq(a: f64, b: f64, tol: f64) -> bool { + if a.is_nan() && b.is_nan() { + return true; + } + (a - b).abs() < tol + } + + #[test] + fn test_sma_basic() { + let mut sma = StreamingSMA::new(3).unwrap(); + assert!(sma.update(1.0).is_nan()); + assert!(sma.update(2.0).is_nan()); + let v = sma.update(3.0); + assert!(approx_eq(v, 2.0, 1e-10)); + let v = sma.update(4.0); + assert!(approx_eq(v, 3.0, 1e-10)); + let v = sma.update(5.0); + assert!(approx_eq(v, 4.0, 1e-10)); + assert_eq!(sma.period(), 3); + } + + #[test] + fn test_sma_reset() { + let mut sma = StreamingSMA::new(2).unwrap(); + sma.update(10.0); + sma.update(20.0); + sma.reset(); + assert!(sma.update(5.0).is_nan()); + let v = sma.update(7.0); + assert!(approx_eq(v, 6.0, 1e-10)); + } + + #[test] + fn test_ema_warmup_and_decay() { + let mut ema = StreamingEMA::new(3).unwrap(); + assert!(ema.update(2.0).is_nan()); + assert!(ema.update(4.0).is_nan()); + // Third bar: SMA seed = (2+4+6)/3 = 4.0 + let v = ema.update(6.0); + assert!(approx_eq(v, 4.0, 1e-10)); + // Fourth bar: alpha = 0.5, ema = 4.0 + 0.5*(8.0-4.0) = 6.0 + let v = ema.update(8.0); + assert!(approx_eq(v, 6.0, 1e-10)); + } + + #[test] + fn test_rsi_warmup() { + let mut rsi = StreamingRSI::new(3).unwrap(); + // First bar: no prev + assert!(rsi.update(44.0).is_nan()); + // Bars 2-4: collecting gains/losses + assert!(rsi.update(44.5).is_nan()); + assert!(rsi.update(43.5).is_nan()); + // Bar 5: seeded + let v = rsi.update(44.5); + assert!(!v.is_nan()); + assert!(v >= 0.0 && v <= 100.0); + } + + #[test] + fn test_atr_warmup() { + let mut atr = StreamingATR::new(3).unwrap(); + // First 3 bars return NaN (period = 3, seed happens on bar 3 but still NaN) + assert!(atr.update(10.0, 9.0, 9.5).is_nan()); + assert!(atr.update(11.0, 9.5, 10.5).is_nan()); + assert!(atr.update(10.5, 9.0, 9.5).is_nan()); + // Bar 4: first real value + let v = atr.update(11.0, 10.0, 10.5); + assert!(!v.is_nan()); + assert!(v > 0.0); + } + + #[test] + fn test_bbands_warmup() { + let mut bb = StreamingBBands::new(3, 2.0, 2.0).unwrap(); + let (u, m, l) = bb.update(10.0); + assert!(u.is_nan() && m.is_nan() && l.is_nan()); + let (u, m, l) = bb.update(11.0); + assert!(u.is_nan() && m.is_nan() && l.is_nan()); + let (u, m, l) = bb.update(12.0); + assert!(!u.is_nan() && !m.is_nan() && !l.is_nan()); + assert!(approx_eq(m, 11.0, 1e-10)); + assert!(u > m && l < m); + } + + #[test] + fn test_macd_basic() { + let mut macd = StreamingMACD::new(3, 5, 2).unwrap(); + // Feed enough bars for the slow (5) to seed + for i in 0..4 { + let (m, s, h) = macd.update(100.0 + i as f64); + assert!(m.is_nan()); + } + // Bar 5: slow seeds + let (m, s, _h) = macd.update(104.0); + assert!(!m.is_nan()); + } + + #[test] + fn test_macd_fast_ge_slow_rejected() { + assert!(StreamingMACD::new(5, 3, 2).is_err()); + assert!(StreamingMACD::new(5, 5, 2).is_err()); + } + + #[test] + fn test_stoch_basic() { + let mut stoch = StreamingStoch::new(3, 2, 2).unwrap(); + // Need fastk_period bars, then slowk_period, then slowd_period + let (k, d) = stoch.update(10.0, 8.0, 9.0); + assert!(k.is_nan() && d.is_nan()); + let (k, d) = stoch.update(11.0, 9.0, 10.0); + assert!(k.is_nan() && d.is_nan()); + // Bar 3: fastk ready, collecting slowk + let (k, d) = stoch.update(12.0, 10.0, 11.0); + assert!(k.is_nan()); + // Bar 4 + let (k, d) = stoch.update(13.0, 11.0, 12.0); + assert!(!k.is_nan()); + } + + #[test] + fn test_vwap_basic() { + let mut vwap = StreamingVWAP::new(); + let v = vwap.update(10.0, 8.0, 9.0, 100.0); + // tp = (10+8+9)/3 = 9.0, vwap = 9.0*100/100 = 9.0 + assert!(approx_eq(v, 9.0, 1e-10)); + let v = vwap.update(12.0, 10.0, 11.0, 200.0); + // tp2 = 11.0, cum_tpv = 900+2200=3100, cum_vol=300, vwap=10.333.. + assert!(approx_eq(v, 3100.0 / 300.0, 1e-10)); + } + + #[test] + fn test_vwap_zero_volume() { + let mut vwap = StreamingVWAP::new(); + let v = vwap.update(10.0, 8.0, 9.0, 0.0); + assert!(v.is_nan()); + } + + #[test] + fn test_supertrend_warmup() { + let mut st = StreamingSupertrend::new(3, 2.0).unwrap(); + let (line, dir) = st.update(10.0, 9.0, 9.5); + assert!(line.is_nan() && dir == 0); + let (line, dir) = st.update(11.0, 9.5, 10.5); + assert!(line.is_nan() && dir == 0); + let (line, dir) = st.update(10.5, 9.0, 9.5); + assert!(line.is_nan() && dir == 0); + // Bar 4: first real value + let (line, dir) = st.update(11.0, 10.0, 10.5); + assert!(!line.is_nan()); + assert!(dir == 1 || dir == -1); + } + + #[test] + fn test_streaming_sma_matches_batch() { + // Compare streaming SMA against a simple batch computation + let data = vec![1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0]; + let period = 3; + let mut sma = StreamingSMA::new(period).unwrap(); + let streaming: Vec = data.iter().map(|&v| sma.update(v)).collect(); + + // Batch SMA + for i in 0..data.len() { + if i + 1 < period { + assert!(streaming[i].is_nan(), "bar {} should be NaN", i); + } else { + let batch: f64 = data[i + 1 - period..=i].iter().sum::() / period as f64; + assert!( + approx_eq(streaming[i], batch, 1e-10), + "bar {}: streaming={} batch={}", + i, + streaming[i], + batch + ); + } + } + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs new file mode 100644 index 0000000..67778cf --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/volatility.rs @@ -0,0 +1,95 @@ +//! Volatility indicators. + +/// Compute the Average True Range (ATR), Wilder smoothed (TA-Lib compatible). +/// +/// ATR measures market volatility by smoothing the True Range with Wilder's +/// method. Seeded with the SMA of `TR[1..=timeperiod]` (bar 0 is skipped, +/// matching TA-Lib). Returns non-negative values; the first `timeperiod` +/// indices are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `timeperiod` - Smoothing period (typically 14). +pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if n <= timeperiod || timeperiod < 1 { + return result; + } + // Seed: SMA of TR[1..=timeperiod] (TA-Lib skips TR[0]). + // Compute TR on-the-fly to avoid a separate Vec allocation. + let mut seed = 0.0_f64; + for i in 1..=timeperiod { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + seed += hl.max(hpc).max(lpc); + } + seed /= timeperiod as f64; + result[timeperiod] = seed; + let p = timeperiod as f64; + for i in (timeperiod + 1)..n { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + let tr = hl.max(hpc).max(lpc); + result[i] = (result[i - 1] * (p - 1.0) + tr) / p; + } + result +} + +/// Compute the True Range for each bar. +/// +/// `TR = max(H - L, |H - C_prev|, |L - C_prev|)`. For bar 0, TR is +/// simply `H - L` (no previous close available). Returns non-negative +/// values for every bar (no `NaN` warmup). +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if n == 0 { + return result; + } + result[0] = high[0] - low[0]; + for i in 1..n { + let hl = high[i] - low[i]; + let hpc = (high[i] - close[i - 1]).abs(); + let lpc = (low[i] - close[i - 1]).abs(); + result[i] = hl.max(hpc).max(lpc); + } + result +} + +/// Normalized Average True Range: `ATR / close * 100`. +pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec { + let atr_vals = atr(high, low, close, timeperiod); + atr_vals + .iter() + .zip(close.iter()) + .map(|(&a, &c)| { + if a.is_nan() || c == 0.0 { + f64::NAN + } else { + a / c * 100.0 + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atr_nonnegative() { + let h = vec![2.0, 3.0, 4.0, 5.0, 6.0]; + let l = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let c = vec![1.5, 2.5, 3.5, 4.5, 5.5]; + let result = atr(&h, &l, &c, 3); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0); + } + } +} diff --git a/vendor/ferro-ta-main/crates/ferro_ta_core/src/volume.rs b/vendor/ferro-ta-main/crates/ferro_ta_core/src/volume.rs new file mode 100644 index 0000000..4696a04 --- /dev/null +++ b/vendor/ferro-ta-main/crates/ferro_ta_core/src/volume.rs @@ -0,0 +1,193 @@ +//! Volume indicators. + +/// Compute On-Balance Volume (OBV). +/// +/// OBV is a cumulative indicator that adds volume on up-close bars and +/// subtracts volume on down-close bars. Unchanged closes contribute zero. +/// Returns a `Vec` of length `n` with no `NaN` values. +/// +/// # Arguments +/// * `close` - Price series. +/// * `volume` - Volume series (same length as `close`). +pub fn obv(close: &[f64], volume: &[f64]) -> Vec { + let n = close.len(); + let mut result = vec![0.0_f64; n]; + if n == 0 { + return result; + } + // result[0] stays 0; accumulation starts from bar 1 + for i in 1..n { + result[i] = result[i - 1] + + if close[i] > close[i - 1] { + volume[i] + } else if close[i] < close[i - 1] { + -volume[i] + } else { + 0.0 + }; + } + result +} + +/// Compute the Money Flow Index (MFI). +/// +/// MFI is a volume-weighted RSI, returning values in `[0, 100]`. +/// `typical_price = (H + L + C) / 3`; money flow is positive when +/// typical price rises, negative when it falls. The first `timeperiod` +/// values are `NaN`. +/// +/// # Arguments +/// * `high` / `low` / `close` - OHLC price series (same length). +/// * `volume` - Volume series (same length). +/// * `timeperiod` - Lookback window (typically 14). +pub fn mfi( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + timeperiod: usize, +) -> Vec { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod < 1 || n <= timeperiod { + return result; + } + + let mut pos_flow = vec![0.0_f64; n]; + let mut neg_flow = vec![0.0_f64; n]; + let mut tp_prev = (high[0] + low[0] + close[0]) / 3.0; + + for i in 1..n { + let tp_cur = (high[i] + low[i] + close[i]) / 3.0; + let rmf = tp_cur * volume[i]; + if tp_cur > tp_prev { + pos_flow[i] = rmf; + } else if tp_cur < tp_prev { + neg_flow[i] = rmf; + } + tp_prev = tp_cur; + } + + // Sliding window sum over timeperiod bars (indices i+1-timeperiod ..= i). + // First valid window: indices 1..=timeperiod. + let mut pos_sum: f64 = pos_flow[1..=timeperiod].iter().sum(); + let mut neg_sum: f64 = neg_flow[1..=timeperiod].iter().sum(); + let mfr = if neg_sum == 0.0 { + f64::MAX + } else { + pos_sum / neg_sum + }; + result[timeperiod] = 100.0 - 100.0 / (1.0 + mfr); + + for i in (timeperiod + 1)..n { + pos_sum += pos_flow[i] - pos_flow[i - timeperiod]; + neg_sum += neg_flow[i] - neg_flow[i - timeperiod]; + let mfr = if neg_sum == 0.0 { + f64::MAX + } else { + pos_sum / neg_sum + }; + result[i] = 100.0 - 100.0 / (1.0 + mfr); + } + result +} + +/// Chaikin Accumulation/Distribution Line. +/// +/// Cumulates `(close - low - (high - close)) / (high - low) * volume`. +pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Vec { + let n = high.len(); + let mut result = vec![0.0_f64; n]; + let mut ad_val = 0.0_f64; + for i in 0..n { + let hl = high[i] - low[i]; + let clv = if hl != 0.0 { + ((close[i] - low[i]) - (high[i] - close[i])) / hl + } else { + 0.0 + }; + ad_val += clv * volume[i]; + result[i] = ad_val; + } + result +} + +/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD. +/// +/// Uses the core EMA implementation from `overlap::ema`. +pub fn adosc( + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + fastperiod: usize, + slowperiod: usize, +) -> Vec { + let n = high.len(); + let ad_vals = ad(high, low, close, volume); + let fast_ema = crate::overlap::ema(&ad_vals, fastperiod); + let slow_ema = crate::overlap::ema(&ad_vals, slowperiod); + let warmup = slowperiod - 1; + let mut result = vec![f64::NAN; n]; + for i in warmup..n { + if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() { + result[i] = fast_ema[i] - slow_ema[i]; + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn obv_up_trend() { + let c = vec![1.0, 2.0, 3.0]; + let v = vec![100.0, 200.0, 300.0]; + let result = obv(&c, &v); + assert!((result[0] - 0.0).abs() < 1e-10); + assert!((result[1] - 200.0).abs() < 1e-10); + assert!((result[2] - 500.0).abs() < 1e-10); + } + + #[test] + fn ad_basic() { + let h = vec![10.0, 12.0, 11.0]; + let l = vec![8.0, 9.0, 9.0]; + let c = vec![9.0, 11.0, 10.0]; + let v = vec![1000.0, 2000.0, 1500.0]; + let result = ad(&h, &l, &c, &v); + assert_eq!(result.len(), 3); + // CLV[0] = ((9-8) - (10-9)) / (10-8) = (1 - 1) / 2 = 0 + assert!((result[0] - 0.0).abs() < 1e-10); + } + + #[test] + fn adosc_basic() { + let n = 30; + let h: Vec = (1..=n).map(|i| i as f64 + 1.0).collect(); + let l: Vec = (1..=n).map(|i| i as f64 - 1.0).collect(); + let c: Vec = (1..=n).map(|i| i as f64).collect(); + let v: Vec = vec![1000.0; n]; + let result = adosc(&h, &l, &c, &v, 3, 10); + assert_eq!(result.len(), n); + // Warmup period should be NaN + for i in 0..9 { + assert!(result[i].is_nan()); + } + } + + #[test] + fn mfi_range() { + let n = 50; + let high: Vec = (1..=n).map(|i| i as f64 + 0.5).collect(); + let low: Vec = (1..=n).map(|i| i as f64 - 0.5).collect(); + let close: Vec = (1..=n).map(|i| i as f64).collect(); + let volume: Vec = vec![1_000_000.0; n]; + let result = mfi(&high, &low, &close, &volume, 14); + for v in result.iter().filter(|v| !v.is_nan()) { + assert!(*v >= 0.0 && *v <= 100.0, "MFI out of range: {v}"); + } + } +} diff --git a/vendor/ferro-ta-main/deny.toml b/vendor/ferro-ta-main/deny.toml new file mode 100644 index 0000000..7ef18fb --- /dev/null +++ b/vendor/ferro-ta-main/deny.toml @@ -0,0 +1,76 @@ +# cargo-deny configuration +# Run: cargo deny check +# CI: add `cargo install cargo-deny && cargo deny check` to the audit job + +[graph] +# Targets to check — all platforms used in CI +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "wasm32-unknown-unknown", +] + +# --------------------------------------------------------------------------- +# Licenses +# --------------------------------------------------------------------------- +[licenses] +# Confidence threshold for detecting a license (0.0 – 1.0) +confidence-threshold = 0.8 + +# List of explicitly allowed SPDX license identifiers +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", + "CC0-1.0", +] + +# --------------------------------------------------------------------------- +# Bans — duplicate crates, banned crates +# --------------------------------------------------------------------------- +[bans] +# Deny multiple versions of the same crate (set to "warn" to downgrade) +multiple-versions = "warn" +# Deny wildcard dependencies +wildcards = "deny" +# Skip certain crates that intentionally have multiple versions +skip = [] + +# --------------------------------------------------------------------------- +# Advisories — security vulnerability database +# --------------------------------------------------------------------------- +[advisories] +# Path to local advisory database (leave empty to use the bundled one) +# db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +# Deny known security vulnerabilities +version = 2 +ignore = [ + # pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API + # migration (IntoPy/ToPyObject were removed in 0.26). Ignored here + # because ferro-ta does NOT use either affected code path: + # * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back + # iterators: the crate has zero PyList/PyTuple iterator usage. + # * RUSTSEC-2026-0177 — missing Sync bound on + # PyCFunction::new_closure: the crate uses #[pyfunction], never + # new_closure. + # Tracked for removal once the pyo3 0.29 upgrade lands. + "RUSTSEC-2026-0176", + "RUSTSEC-2026-0177", +] + +# --------------------------------------------------------------------------- +# Sources — only allow crates from crates.io and our own path deps +# --------------------------------------------------------------------------- +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/vendor/ferro-ta-main/docs/_static/.gitkeep b/vendor/ferro-ta-main/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/docs/adjacent_tooling.rst b/vendor/ferro-ta-main/docs/adjacent_tooling.rst new file mode 100644 index 0000000..3b0a6e1 --- /dev/null +++ b/vendor/ferro-ta-main/docs/adjacent_tooling.rst @@ -0,0 +1,121 @@ +Adjacent Tooling +================ + +These modules are useful, but they are secondary to ferro-ta's core identity as +a Python technical analysis library. + +.. list-table:: + :header-rows: 1 + + * - Area + - Status + - What it is + * - Backtesting engine + - Adjacent + - Vectorized Rust backtester: OHLCV fill, stop-loss/TP, 23 performance + metrics, trade extraction, parallel Monte Carlo, walk-forward analysis, + and multi-asset portfolio simulation. See :ref:`backtesting-engine`. + * - Derivatives analytics + - Adjacent + - Options pricing, Greeks, implied volatility helpers, futures basis, + curve, and roll utilities. See :doc:`derivatives`. + * - Agent workflow wrappers + - Adjacent + - Tool and workflow helpers for agent-style integrations. See + `docs/agentic.md `_. + * - MCP server + - Experimental or adjacent + - FastMCP-based server exposing selected ferro-ta capabilities to + MCP-compatible clients. See + `docs/mcp.md `_. + * - WASM package + - Experimental + - Browser and Node.js package with a smaller indicator subset. See + `wasm/README.md `_. + * - GPU backend + - Experimental + - Optional PyTorch-backed acceleration for a limited subset of indicators. + See `docs/gpu-backend.md `_. + * - Plugin system + - Experimental + - Registry and plugin packaging model for custom indicators. See + :doc:`plugins`. + +.. _backtesting-engine: + +Backtesting Engine +------------------ + +``ferro_ta.analysis.backtest`` ships a production-grade backtesting engine +backed entirely by Rust hot-path functions. + +**Core API:** + +.. code-block:: python + + from ferro_ta.analysis.backtest import BacktestEngine, monte_carlo, walk_forward + + result = ( + BacktestEngine() + .with_commission(0.001) + .with_slippage(5.0) # basis points + .with_ohlcv(high=high, low=low, open_=open_) + .with_stop_loss(0.02) + .with_take_profit(0.04) + .run(close, "sma_crossover") + ) + + print(result.metrics["sharpe"]) # one of 23 metrics + print(result.trades) # pandas DataFrame + print(result.drawdown_series.min()) # max drawdown + + mc = monte_carlo(result, n_sims=1000) # parallel bootstrap + wf = walk_forward(close, "rsi", param_grid=[{"timeperiod": t} for t in [10,14,20]], + train_bars=500, test_bars=100) + +**Available Rust primitives** (``ferro_ta._ferro_ta``): + +- ``backtest_core`` — close-only, vectorized, commission + slippage +- ``backtest_ohlcv_core`` — fill at open, intrabar stop-loss / take-profit +- ``compute_performance_metrics`` — 23 metrics in one pass (Sharpe, Sortino, + Calmar, CAGR, Omega, Ulcer, win rate, profit factor, tail ratio, etc.) +- ``extract_trades_ohlcv`` — 9 parallel arrays (entry/exit bar, MAE, MFE, …) +- ``backtest_multi_asset_core`` — N-asset parallel backtest via Rayon +- ``monte_carlo_bootstrap`` — parallel block bootstrap, returns (n_sims, n_bars) +- ``walk_forward_indices`` — anchored/rolling fold index generator +- ``kelly_fraction`` / ``half_kelly_fraction`` + +**Speed vs competitors** (100k bars, SMA crossover, Apple M-series): + +.. list-table:: + :header-rows: 1 + + * - Library + - Time + - vs ferro-ta + * - ferro-ta ``backtest_core`` + - 0.29 ms + - — + * - NumPy vectorized + - 0.46 ms + - 1.6× slower + * - vectorbt + - 2.9 ms + - 10× slower + * - backtesting.py + - 320 ms + - 1,100× slower + * - backtrader + - ~520 ms (10k bars) + - >15,000× slower + +How to read the project +----------------------- + +When evaluating ferro-ta: + +- Start with the core library docs, migration guide, support matrix, and benchmarks. +- Treat adjacent tooling as opt-in layers, not as proof that the core indicator + library is broader or more stable than it is. +- Check the release notes and stability policy before depending on experimental + surfaces in production. diff --git a/vendor/ferro-ta-main/docs/agentic.md b/vendor/ferro-ta-main/docs/agentic.md new file mode 100644 index 0000000..20e5a94 --- /dev/null +++ b/vendor/ferro-ta-main/docs/agentic.md @@ -0,0 +1,203 @@ +# Agentic Workflow and Tools + +ferro-ta provides stable tool wrappers and a workflow orchestrator that make +it easy to integrate with AI agents, LangChain, LlamaIndex, or any +framework that supports function calling. + +--- + +## Overview + +The agentic API consists of two modules: + +| Module | Purpose | +|--------|---------| +| `ferro_ta.tools` | Stable, documented functions for agent wrapping | +| `ferro_ta.workflow` | End-to-end pipeline: indicators → strategy → alerts | + +--- + +## `ferro_ta.tools` — Tool wrappers + +```python +from ferro_ta.tools import compute_indicator, run_backtest, list_indicators, describe_indicator +import numpy as np + +close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 200)) * 100 + +# Compute any indicator by name +sma = compute_indicator("SMA", close, timeperiod=20) +rsi = compute_indicator("RSI", close, timeperiod=14) +bb = compute_indicator("BBANDS", close, timeperiod=20) # returns dict + +# Run a backtest +summary = run_backtest("rsi_30_70", close) +print(f"Final equity: {summary['final_equity']:.4f}") +print(f"Trades: {summary['n_trades']}") + +# List all indicators +names = list_indicators() # sorted list of strings + +# Describe an indicator (returns first paragraph of docstring) +desc = describe_indicator("RSI") +``` + +### Function signatures + +```python +def compute_indicator(name: str, *args, **kwargs) -> ndarray | dict: + ... + +def run_backtest(strategy: str, close, commission_per_trade=0.0, slippage_bps=0.0, **kwargs) -> dict: + ... + +def list_indicators() -> list[str]: + ... + +def describe_indicator(name: str) -> str: + ... +``` + +--- + +## `ferro_ta.workflow` — End-to-end pipeline + +```python +from ferro_ta.workflow import Workflow +import numpy as np + +rng = np.random.default_rng(42) +close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + +result = ( + Workflow() + .add_indicator("sma_20", "SMA", timeperiod=20) + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_strategy("rsi_30_70") + .add_alert("rsi_14", level=30.0, direction=-1) # alert when RSI crosses below 30 + .run(close) +) + +print(result.keys()) +# dict_keys(['sma_20', 'rsi_14', 'backtest', 'alert_rsi_14_30_-1']) +``` + +### Functional interface + +```python +from ferro_ta.workflow import run_pipeline + +result = run_pipeline( + close, + indicators={ + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + }, + strategy="rsi_30_70", + alert_indicator="rsi_14", + alert_level=30.0, + alert_direction=-1, +) +``` + +--- + +## LangChain integration + +Wrap the tools as LangChain `Tool` objects: + +```python +from langchain.tools import Tool +from ferro_ta.tools import compute_indicator, run_backtest, list_indicators +import numpy as np +import json + +def _compute_tool(input_str: str) -> str: + """Parse JSON input and compute an indicator.""" + args = json.loads(input_str) + name = args.pop("name") + close = np.asarray(args.pop("close"), dtype=np.float64) + result = compute_indicator(name, close, **args) + if isinstance(result, dict): + return json.dumps({k: v.tolist() for k, v in result.items()}) + return json.dumps(result.tolist()) + +def _backtest_tool(input_str: str) -> str: + args = json.loads(input_str) + close = np.asarray(args.pop("close"), dtype=np.float64) + strategy = args.pop("strategy", "rsi_30_70") + summary = run_backtest(strategy, close, **args) + return json.dumps(summary) + +tools = [ + Tool( + name="compute_indicator", + func=_compute_tool, + description=( + 'Compute a technical indicator. Input JSON: {"name": "SMA", ' + '"close": [...], "timeperiod": 14}' + ), + ), + Tool( + name="run_backtest", + func=_backtest_tool, + description=( + 'Run a backtest. Input JSON: {"strategy": "rsi_30_70", ' + '"close": [...]}' + ), + ), + Tool( + name="list_indicators", + func=lambda _: json.dumps(list_indicators()), + description="List all available indicator names. No input required.", + ), +] +``` + +--- + +## Scheduling + +### Run once + +```python +python examples/run_workflow.py +``` + +### Run every N minutes (cron) + +Add to your crontab: + +``` +*/15 * * * * /usr/bin/python /path/to/examples/run_workflow.py >> /var/log/ferro_ta.log 2>&1 +``` + +### Run on a schedule with `schedule` library + +```python +import schedule +import time + +def job(): + import numpy as np + from ferro_ta.workflow import run_pipeline + # fetch latest prices here ... + close = np.ones(100) # replace with real data + result = run_pipeline(close, indicators={"rsi": {"name": "RSI", "timeperiod": 14}}) + print(result) + +schedule.every(15).minutes.do(job) +while True: + schedule.run_pending() + time.sleep(1) +``` + +--- + +## See also + +- `ferro_ta.tools` — module source. +- `ferro_ta.workflow` — module source. +- `docs/mcp.md` — MCP server for MCP-compatible clients. +- `ferro_ta.backtest` — backtest harness. +- `ferro_ta.registry` — indicator registry. diff --git a/vendor/ferro-ta-main/docs/api/analysis.rst b/vendor/ferro-ta-main/docs/api/analysis.rst new file mode 100644 index 0000000..a04dbe8 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/analysis.rst @@ -0,0 +1,22 @@ +Analysis Modules +================ + +.. automodule:: ferro_ta.analysis.options + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.futures + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.options_strategy + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: ferro_ta.analysis.derivatives_payoff + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/batch.rst b/vendor/ferro-ta-main/docs/api/batch.rst new file mode 100644 index 0000000..48435e2 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/batch.rst @@ -0,0 +1,7 @@ +Batch API +========= + +.. automodule:: ferro_ta.batch + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/cycle.rst b/vendor/ferro-ta-main/docs/api/cycle.rst new file mode 100644 index 0000000..2e3d276 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/cycle.rst @@ -0,0 +1,7 @@ +Cycle +===== + +.. automodule:: ferro_ta.cycle + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/exceptions.rst b/vendor/ferro-ta-main/docs/api/exceptions.rst new file mode 100644 index 0000000..9af9aaf --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/exceptions.rst @@ -0,0 +1,8 @@ +Exceptions and validation +========================= + +.. automodule:: ferro_ta.exceptions + :members: + :undoc-members: + :show-inheritance: + :exclude-members: code, suggestion diff --git a/vendor/ferro-ta-main/docs/api/extended.rst b/vendor/ferro-ta-main/docs/api/extended.rst new file mode 100644 index 0000000..ab67c3e --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/extended.rst @@ -0,0 +1,7 @@ +Extended +======== + +.. automodule:: ferro_ta.extended + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/index.rst b/vendor/ferro-ta-main/docs/api/index.rst new file mode 100644 index 0000000..00b61ad --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/index.rst @@ -0,0 +1,20 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 1 + + exceptions + overlap + momentum + volume + volatility + statistic + price_transform + pattern + cycle + math_ops + extended + streaming + batch + analysis diff --git a/vendor/ferro-ta-main/docs/api/math_ops.rst b/vendor/ferro-ta-main/docs/api/math_ops.rst new file mode 100644 index 0000000..f14af9e --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/math_ops.rst @@ -0,0 +1,7 @@ +Math Ops +======== + +.. automodule:: ferro_ta.math_ops + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/momentum.rst b/vendor/ferro-ta-main/docs/api/momentum.rst new file mode 100644 index 0000000..a0a13ab --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/momentum.rst @@ -0,0 +1,7 @@ +Momentum +======== + +.. automodule:: ferro_ta.momentum + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/overlap.rst b/vendor/ferro-ta-main/docs/api/overlap.rst new file mode 100644 index 0000000..b4e5ec7 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/overlap.rst @@ -0,0 +1,7 @@ +Overlap Studies +=============== + +.. automodule:: ferro_ta.overlap + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/pattern.rst b/vendor/ferro-ta-main/docs/api/pattern.rst new file mode 100644 index 0000000..2a2f8dc --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/pattern.rst @@ -0,0 +1,7 @@ +Pattern +======= + +.. automodule:: ferro_ta.pattern + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/price_transform.rst b/vendor/ferro-ta-main/docs/api/price_transform.rst new file mode 100644 index 0000000..b2d4467 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/price_transform.rst @@ -0,0 +1,7 @@ +Price Transform +=============== + +.. automodule:: ferro_ta.price_transform + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/statistic.rst b/vendor/ferro-ta-main/docs/api/statistic.rst new file mode 100644 index 0000000..30b4d1e --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/statistic.rst @@ -0,0 +1,7 @@ +Statistic +========= + +.. automodule:: ferro_ta.statistic + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/streaming.rst b/vendor/ferro-ta-main/docs/api/streaming.rst new file mode 100644 index 0000000..ebf127e --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/streaming.rst @@ -0,0 +1,7 @@ +Streaming +========= + +.. automodule:: ferro_ta.streaming + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/volatility.rst b/vendor/ferro-ta-main/docs/api/volatility.rst new file mode 100644 index 0000000..c7eee2a --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/volatility.rst @@ -0,0 +1,7 @@ +Volatility +========== + +.. automodule:: ferro_ta.volatility + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api/volume.rst b/vendor/ferro-ta-main/docs/api/volume.rst new file mode 100644 index 0000000..9388a18 --- /dev/null +++ b/vendor/ferro-ta-main/docs/api/volume.rst @@ -0,0 +1,7 @@ +Volume +====== + +.. automodule:: ferro_ta.volume + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/api_manifest.json b/vendor/ferro-ta-main/docs/api_manifest.json new file mode 100644 index 0000000..0ff3fea --- /dev/null +++ b/vendor/ferro-ta-main/docs/api_manifest.json @@ -0,0 +1,7118 @@ +{ + "surfaces": { + "python": { + "indicator_count": 211, + "method_count": 467, + "categories": [ + "aggregation", + "alerts", + "batch", + "crypto", + "cycle", + "extended", + "features", + "math_ops", + "momentum", + "overlap", + "pattern", + "portfolio", + "price_transform", + "regime", + "resampling", + "signals", + "statistic", + "streaming", + "volatility", + "volume" + ], + "indicators": [ + { + "name": "ACOS", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "AD", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + }, + { + "name": "ADD", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ADOSC", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + }, + { + "name": "ADX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ADXR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "APO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "AROON", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "AROONOSC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ASIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ATAN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ATR", + "category": "volatility", + "module": "ferro_ta.indicators.volatility", + "doc": "", + "params": [] + }, + { + "name": "AVGPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "AlertEvent", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "AlertManager", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "BATCH_DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "BBANDS", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "BETA", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "BOP", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "CCI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "CDL2CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3BLACKCROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3INSIDE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3LINESTRIKE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3OUTSIDE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3STARSINSOUTH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3WHITESOLDIERS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLABANDONEDBABY", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLADVANCEBLOCK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLBELTHOLD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLBREAKAWAY", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCLOSINGMARUBOZU", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCONCEALBABYSWALL", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCOUNTERATTACK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDARKCLOUDCOVER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDRAGONFLYDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLENGULFING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLGAPSIDESIDEWHITE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLGRAVESTONEDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHAMMER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHANGINGMAN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMICROSS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIGHWAVE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKEMOD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHOMINGPIGEON", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLIDENTICAL3CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLINNECK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLINVERTEDHAMMER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKINGBYLENGTH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLADDERBOTTOM", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLEGGEDDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLINE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMARUBOZU", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMATCHINGLOW", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMATHOLD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLONNECK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLPIERCING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLRICKSHAWMAN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLRISEFALL3METHODS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSEPARATINGLINES", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSHOOTINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSHORTLINE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSPINNINGTOP", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSTALLEDPATTERN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSTICKSANDWICH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTAKURI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTASUKIGAP", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTHRUSTING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTRISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLUNIQUE3RIVER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLUPSIDEGAP2CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLXSIDEGAP3METHODS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CEIL", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "CHANDELIER_EXIT", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "CHOPPINESS_INDEX", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "CMO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "CORREL", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "COS", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "COSH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "DEMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "DIV", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "DONCHIAN", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DTW_DISTANCE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "EMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "EXP", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "FLOOR", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPERIOD", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPHASE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_PHASOR", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_SINE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDLINE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDMODE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HULL_MA", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "ICHIMOKU", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "KAMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "KELTNER_CHANNELS", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_ANGLE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_INTERCEPT", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_SLOPE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "LOG10", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACD", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACDEXT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACDFIX", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MAMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MAVP", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MAX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MAXINDEX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MEDPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "MFI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MIDPOINT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MIDPRICE", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MININDEX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MOM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MULT", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "NATR", + "category": "volatility", + "module": "ferro_ta.indicators.volatility", + "doc": "", + "params": [] + }, + { + "name": "OBV", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + }, + { + "name": "PIVOT_POINTS", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "PPO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCP", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCR100", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "RSI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "SAR", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SAREXT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SINH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SQRT", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "STDDEV", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "STOCH", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "STOCHF", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "STOCHRSI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "SUB", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SUM", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SUPERTREND", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "StreamingATR", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingBBands", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingEMA", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingMACD", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingRSI", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingSMA", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingStoch", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingSupertrend", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingVWAP", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "T3", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "TAN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "TANH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "TEMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "TRANGE", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "TRIMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "TRIX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "TSF", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "TYPPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "TickAggregator", + "category": "aggregation", + "module": "ferro_ta.data.aggregation", + "doc": "", + "params": [] + }, + { + "name": "ULTOSC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "VAR", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "VWAP", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "VWMA", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "WCLPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "WILLR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "WMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "aggregate_ticks", + "category": "aggregation", + "module": "ferro_ta.data.aggregation", + "doc": "", + "params": [] + }, + { + "name": "batch_apply", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_ema", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_rsi", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_sma", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "beta", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "check_cross", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "check_threshold", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "collect_alert_bars", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "compose", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "compute_many", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "continuous_bar_labels", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "correlation_matrix", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "detect_breaks_cusum", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "drawdown", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "feature_matrix", + "category": "features", + "module": "ferro_ta.analysis.features", + "doc": "", + "params": [] + }, + { + "name": "funding_pnl", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "multi_timeframe", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + }, + { + "name": "portfolio_volatility", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "rank_signals", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "regime", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "regime_adx", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "regime_combined", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "resample", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + }, + { + "name": "resample_continuous", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "rolling_variance_break", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "screen", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "session_boundaries", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "structural_breaks", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "volume_bars", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + } + ], + "methods": [ + { + "name": "TickAggregator", + "category": "aggregation", + "module": "ferro_ta.data.aggregation", + "doc": "", + "params": [] + }, + { + "name": "aggregate_ticks", + "category": "aggregation", + "module": "ferro_ta.data.aggregation", + "doc": "", + "params": [] + }, + { + "name": "AlertEvent", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "AlertManager", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "check_cross", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "check_threshold", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "collect_alert_bars", + "category": "alerts", + "module": "ferro_ta.tools.alerts", + "doc": "", + "params": [] + }, + { + "name": "TradeStats", + "category": "attribution", + "module": "ferro_ta.analysis.attribution", + "doc": "", + "params": [] + }, + { + "name": "attribution_by_month", + "category": "attribution", + "module": "ferro_ta.analysis.attribution", + "doc": "", + "params": [] + }, + { + "name": "attribution_by_signal", + "category": "attribution", + "module": "ferro_ta.analysis.attribution", + "doc": "", + "params": [] + }, + { + "name": "from_backtest", + "category": "attribution", + "module": "ferro_ta.analysis.attribution", + "doc": "", + "params": [] + }, + { + "name": "trade_stats", + "category": "attribution", + "module": "ferro_ta.analysis.attribution", + "doc": "", + "params": [] + }, + { + "name": "batch_apply", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_ema", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_rsi", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "batch_sma", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "compute_many", + "category": "batch", + "module": "ferro_ta.data.batch", + "doc": "", + "params": [] + }, + { + "name": "ratio", + "category": "cross_asset", + "module": "ferro_ta.analysis.cross_asset", + "doc": "", + "params": [] + }, + { + "name": "relative_strength", + "category": "cross_asset", + "module": "ferro_ta.analysis.cross_asset", + "doc": "", + "params": [] + }, + { + "name": "rolling_beta", + "category": "cross_asset", + "module": "ferro_ta.analysis.cross_asset", + "doc": "", + "params": [] + }, + { + "name": "spread", + "category": "cross_asset", + "module": "ferro_ta.analysis.cross_asset", + "doc": "", + "params": [] + }, + { + "name": "zscore", + "category": "cross_asset", + "module": "ferro_ta.analysis.cross_asset", + "doc": "", + "params": [] + }, + { + "name": "continuous_bar_labels", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "funding_pnl", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "resample_continuous", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "session_boundaries", + "category": "crypto", + "module": "ferro_ta.analysis.crypto", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPERIOD", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPHASE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_PHASOR", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_SINE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDLINE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDMODE", + "category": "cycle", + "module": "ferro_ta.indicators.cycle", + "doc": "", + "params": [] + }, + { + "name": "PayoffLeg", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "aggregate_greeks", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "futures_leg_payoff", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "option_leg_payoff", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "stock_leg_payoff", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "strategy_payoff", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "strategy_value", + "category": "derivatives_payoff", + "module": "ferro_ta.analysis.derivatives_payoff", + "doc": "", + "params": [] + }, + { + "name": "CHANDELIER_EXIT", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "CHOPPINESS_INDEX", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "DONCHIAN", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "HULL_MA", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "ICHIMOKU", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "KELTNER_CHANNELS", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "PIVOT_POINTS", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "SUPERTREND", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "VWAP", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "VWMA", + "category": "extended", + "module": "ferro_ta.indicators.extended", + "doc": "", + "params": [] + }, + { + "name": "feature_matrix", + "category": "features", + "module": "ferro_ta.analysis.features", + "doc": "", + "params": [] + }, + { + "name": "CurveSummary", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "annualized_basis", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "back_adjusted_continuous_contract", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "basis", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "calendar_spreads", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "carry_spread", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "curve_slope", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "curve_summary", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "implied_carry_rate", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "parity_gap", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "ratio_adjusted_continuous_contract", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "roll_yield", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "synthetic_forward", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "synthetic_spot", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "weighted_continuous_contract", + "category": "futures", + "module": "ferro_ta.analysis.futures", + "doc": "", + "params": [] + }, + { + "name": "ACOS", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ADD", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ASIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ATAN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "CEIL", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "COS", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "COSH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "DIV", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "EXP", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "FLOOR", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "LN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "LOG10", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MAX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MAXINDEX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MININDEX", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "MULT", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SIN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SINH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SQRT", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SUB", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "SUM", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "TAN", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "TANH", + "category": "math_ops", + "module": "ferro_ta.indicators.math_ops", + "doc": "", + "params": [] + }, + { + "name": "ADX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ADXR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "APO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "AROON", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "AROONOSC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "BOP", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "CCI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "CMO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "DX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MFI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "MOM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DM", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "PPO", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCP", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ROCR100", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "RSI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "STOCH", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "STOCHF", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "STOCHRSI", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "TRANGE", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "TRIX", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ULTOSC", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "WILLR", + "category": "momentum", + "module": "ferro_ta.indicators.momentum", + "doc": "", + "params": [] + }, + { + "name": "ExtendedGreeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "OptionGreeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "SmileMetrics", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "VolCone", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "american_option_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "black_76_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "black_scholes_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "close_to_close_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "digital_option_greeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "digital_option_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "early_exercise_premium", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "expected_move", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "extended_greeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "garman_klass_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "greeks", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "implied_volatility", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "iv_percentile", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "iv_rank", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "iv_zscore", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "label_moneyness", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "option_price", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "parkinson_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "put_call_parity_deviation", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "rogers_satchell_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "select_strike", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "smile_metrics", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "term_structure_slope", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "vol_cone", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "yang_zhang_vol", + "category": "options", + "module": "ferro_ta.analysis.options", + "doc": "", + "params": [] + }, + { + "name": "DerivativesStrategy", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "ExpirySelector", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "ExpirySelectorKind", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "LegPreset", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "RiskControl", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "RiskMode", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "SimulationLimits", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "StrategyLeg", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "StrikeSelector", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "StrikeSelectorKind", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "build_strategy_preset", + "category": "options_strategy", + "module": "ferro_ta.analysis.options_strategy", + "doc": "", + "params": [] + }, + { + "name": "BBANDS", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "DEMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "EMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "KAMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACD", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACDEXT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MACDFIX", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MAMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MAVP", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MIDPOINT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "MIDPRICE", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SAR", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SAREXT", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "SMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "T3", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "TEMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "TRIMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "WMA", + "category": "overlap", + "module": "ferro_ta.indicators.overlap", + "doc": "", + "params": [] + }, + { + "name": "CDL2CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3BLACKCROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3INSIDE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3LINESTRIKE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3OUTSIDE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3STARSINSOUTH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDL3WHITESOLDIERS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLABANDONEDBABY", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLADVANCEBLOCK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLBELTHOLD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLBREAKAWAY", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCLOSINGMARUBOZU", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCONCEALBABYSWALL", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLCOUNTERATTACK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDARKCLOUDCOVER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLDRAGONFLYDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLENGULFING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLGAPSIDESIDEWHITE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLGRAVESTONEDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHAMMER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHANGINGMAN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMICROSS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIGHWAVE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKEMOD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLHOMINGPIGEON", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLIDENTICAL3CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLINNECK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLINVERTEDHAMMER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKINGBYLENGTH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLADDERBOTTOM", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLEGGEDDOJI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLINE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMARUBOZU", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMATCHINGLOW", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMATHOLD", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGDOJISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLONNECK", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLPIERCING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLRICKSHAWMAN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLRISEFALL3METHODS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSEPARATINGLINES", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSHOOTINGSTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSHORTLINE", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSPINNINGTOP", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSTALLEDPATTERN", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLSTICKSANDWICH", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTAKURI", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTASUKIGAP", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTHRUSTING", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLTRISTAR", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLUNIQUE3RIVER", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLUPSIDEGAP2CROWS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "CDLXSIDEGAP3METHODS", + "category": "pattern", + "module": "ferro_ta.indicators.pattern", + "doc": "", + "params": [] + }, + { + "name": "beta", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "correlation_matrix", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "drawdown", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "portfolio_volatility", + "category": "portfolio", + "module": "ferro_ta.analysis.portfolio", + "doc": "", + "params": [] + }, + { + "name": "AVGPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "MEDPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "TYPPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "WCLPRICE", + "category": "price_transform", + "module": "ferro_ta.indicators.price_transform", + "doc": "", + "params": [] + }, + { + "name": "detect_breaks_cusum", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "regime", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "regime_adx", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "regime_combined", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "rolling_variance_break", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "structural_breaks", + "category": "regime", + "module": "ferro_ta.analysis.regime", + "doc": "", + "params": [] + }, + { + "name": "multi_timeframe", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + }, + { + "name": "resample", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + }, + { + "name": "volume_bars", + "category": "resampling", + "module": "ferro_ta.data.resampling", + "doc": "", + "params": [] + }, + { + "name": "compose", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "rank_signals", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "screen", + "category": "signals", + "module": "ferro_ta.analysis.signals", + "doc": "", + "params": [] + }, + { + "name": "BATCH_DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "BETA", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "CORREL", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DTW", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "DTW_DISTANCE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_ANGLE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_INTERCEPT", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_SLOPE", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "STDDEV", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "TSF", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "VAR", + "category": "statistic", + "module": "ferro_ta.indicators.statistic", + "doc": "", + "params": [] + }, + { + "name": "StreamingATR", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingBBands", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingEMA", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingMACD", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingRSI", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingSMA", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingStoch", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingSupertrend", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "StreamingVWAP", + "category": "streaming", + "module": "ferro_ta.data.streaming", + "doc": "", + "params": [] + }, + { + "name": "compute_indicator", + "category": "tools", + "module": "ferro_ta.tools.tools", + "doc": "", + "params": [] + }, + { + "name": "describe_indicator", + "category": "tools", + "module": "ferro_ta.tools.tools", + "doc": "", + "params": [] + }, + { + "name": "list_indicators", + "category": "tools", + "module": "ferro_ta.tools.tools", + "doc": "", + "params": [] + }, + { + "name": "run_backtest", + "category": "tools", + "module": "ferro_ta.tools.tools", + "doc": "", + "params": [] + }, + { + "name": "ACOS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "AD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ADD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ADOSC", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ADX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ADXR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "APO", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "AROON", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "AROONOSC", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ASIN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ATAN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ATR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "AVGPRICE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "BBANDS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "BETA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "BOP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CCI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL2CROWS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3BLACKCROWS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3INSIDE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3LINESTRIKE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3OUTSIDE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3STARSINSOUTH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDL3WHITESOLDIERS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLABANDONEDBABY", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLADVANCEBLOCK", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLBELTHOLD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLBREAKAWAY", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLCLOSINGMARUBOZU", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLCONCEALBABYSWALL", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLCOUNTERATTACK", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLDARKCLOUDCOVER", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLDOJISTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLDRAGONFLYDOJI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLENGULFING", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGDOJISTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLEVENINGSTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLGAPSIDESIDEWHITE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLGRAVESTONEDOJI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHAMMER", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHANGINGMAN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHARAMICROSS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHIGHWAVE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHIKKAKEMOD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLHOMINGPIGEON", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLIDENTICAL3CROWS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLINNECK", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLINVERTEDHAMMER", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKING", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLKICKINGBYLENGTH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLLADDERBOTTOM", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLEGGEDDOJI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLLONGLINE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLMARUBOZU", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLMATCHINGLOW", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLMATHOLD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGDOJISTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLMORNINGSTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLONNECK", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLPIERCING", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLRICKSHAWMAN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLRISEFALL3METHODS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSEPARATINGLINES", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSHOOTINGSTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSHORTLINE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSPINNINGTOP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSTALLEDPATTERN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLSTICKSANDWICH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLTAKURI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLTASUKIGAP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLTHRUSTING", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLTRISTAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLUNIQUE3RIVER", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLUPSIDEGAP2CROWS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CDLXSIDEGAP3METHODS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CEIL", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CHANDELIER_EXIT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CHOPPINESS_INDEX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CMO", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "CORREL", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "COS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "COSH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "DEMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "DIV", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "DONCHIAN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "DX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "EMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "EXP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "FLOOR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPERIOD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_DCPHASE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_PHASOR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_SINE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDLINE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HT_TRENDMODE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "HULL_MA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ICHIMOKU", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "KAMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "KELTNER_CHANNELS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_ANGLE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_INTERCEPT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LINEARREG_SLOPE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "LOG10", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MACD", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MACDEXT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MACDFIX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MAMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MAVP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MAX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MAXINDEX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MEDPRICE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MFI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MIDPOINT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MIDPRICE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MIN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MININDEX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MINUS_DM", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MOM", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "MULT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "NATR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "OBV", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "PIVOT_POINTS", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "PLUS_DM", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "PPO", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ROC", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ROCP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ROCR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ROCR100", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "RSI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SAREXT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SIN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SINH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SQRT", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "STDDEV", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "STOCH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "STOCHF", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "STOCHRSI", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SUB", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SUM", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "SUPERTREND", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "T3", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TAN", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TANH", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TEMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TRANGE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TRIMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TRIX", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TSF", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "TYPPRICE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "ULTOSC", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "VAR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "VWAP", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "VWMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "WCLPRICE", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "WILLR", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "WMA", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "__version__", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "about", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "benchmark", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "debug_mode", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "disable_debug", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "enable_debug", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "get_logger", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "indicators", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "info", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "log_call", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "methods", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "traced", + "category": "top_level", + "module": "ferro_ta", + "doc": "", + "params": [] + }, + { + "name": "plot", + "category": "viz", + "module": "ferro_ta.tools.viz", + "doc": "", + "params": [] + }, + { + "name": "ATR", + "category": "volatility", + "module": "ferro_ta.indicators.volatility", + "doc": "", + "params": [] + }, + { + "name": "NATR", + "category": "volatility", + "module": "ferro_ta.indicators.volatility", + "doc": "", + "params": [] + }, + { + "name": "TRANGE", + "category": "volatility", + "module": "ferro_ta.indicators.volatility", + "doc": "", + "params": [] + }, + { + "name": "AD", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + }, + { + "name": "ADOSC", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + }, + { + "name": "OBV", + "category": "volume", + "module": "ferro_ta.indicators.volume", + "doc": "", + "params": [] + } + ] + }, + "rust_core": { + "public_function_count": 351, + "functions": [ + { + "module": "aggregation", + "function": "aggregate_tick_bars", + "file": "aggregation.rs" + }, + { + "module": "aggregation", + "function": "aggregate_time_bars", + "file": "aggregation.rs" + }, + { + "module": "aggregation", + "function": "aggregate_volume_bars_ticks", + "file": "aggregation.rs" + }, + { + "module": "alerts", + "function": "check_cross", + "file": "alerts.rs" + }, + { + "module": "alerts", + "function": "check_threshold", + "file": "alerts.rs" + }, + { + "module": "alerts", + "function": "collect_alert_bars", + "file": "alerts.rs" + }, + { + "module": "attribution", + "function": "extract_trades", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "monthly_contribution", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "signal_attribution", + "file": "attribution.rs" + }, + { + "module": "attribution", + "function": "trade_stats", + "file": "attribution.rs" + }, + { + "module": "backtest", + "function": "backtest_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "backtest_multi_asset_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "backtest_ohlcv_core", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "close_position", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "commission_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "compute_performance_metrics", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "extract_trades_ohlcv", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "half_kelly_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "kelly_formula", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "kelly_fraction", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "lcg_index", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "lcg_next", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "macd_crossover_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "monte_carlo_bootstrap", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "nan_to_num", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "new", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "new", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "on_bar", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "reset", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "resolve_commission_model", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "rsi_threshold_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "single_asset_backtest", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "sma_crossover_signals", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "summary", + "file": "backtest.rs" + }, + { + "module": "backtest", + "function": "walk_forward_indices", + "file": "backtest.rs" + }, + { + "module": "batch", + "function": "batch_adx", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_atr", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_ema", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_rsi", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_sma", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "batch_stoch", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "run_close_indicators", + "file": "batch.rs" + }, + { + "module": "batch", + "function": "run_hlc_indicators", + "file": "batch.rs" + }, + { + "module": "chunked", + "function": "forward_fill_nan", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "make_chunk_ranges", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "stitch_chunks", + "file": "chunked.rs" + }, + { + "module": "chunked", + "function": "trim_overlap", + "file": "chunked.rs" + }, + { + "module": "commission", + "function": "cost_fraction", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "equity_delivery_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "equity_intraday_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "from_json", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "futures_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "options_india", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "proportional", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "short_borrow_cost", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "to_json", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "total_cost", + "file": "commission.rs" + }, + { + "module": "commission", + "function": "zero", + "file": "commission.rs" + }, + { + "module": "crypto", + "function": "continuous_bar_labels", + "file": "crypto.rs" + }, + { + "module": "crypto", + "function": "funding_cumulative_pnl", + "file": "crypto.rs" + }, + { + "module": "crypto", + "function": "mark_session_boundaries", + "file": "crypto.rs" + }, + { + "module": "currency", + "function": "format", + "file": "currency.rs" + }, + { + "module": "currency", + "function": "from_code", + "file": "currency.rs" + }, + { + "module": "cycle", + "function": "compute_ht_core", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_dcperiod", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_dcphase", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_phasor", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_sine", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_trendline", + "file": "cycle.rs" + }, + { + "module": "cycle", + "function": "ht_trendmode", + "file": "cycle.rs" + }, + { + "module": "extended", + "function": "chandelier_exit", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "choppiness_index", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "donchian", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "hull_ma", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "ichimoku", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "keltner_channels", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "pivot_points", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "supertrend", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "vwap", + "file": "extended.rs" + }, + { + "module": "extended", + "function": "vwma", + "file": "extended.rs" + }, + { + "module": "futures.basis", + "function": "annualized_basis", + "file": "futures/basis.rs" + }, + { + "module": "futures.basis", + "function": "basis", + "file": "futures/basis.rs" + }, + { + "module": "futures.basis", + "function": "carry_spread", + "file": "futures/basis.rs" + }, + { + "module": "futures.basis", + "function": "implied_carry_rate", + "file": "futures/basis.rs" + }, + { + "module": "futures.curve", + "function": "calendar_spreads", + "file": "futures/curve.rs" + }, + { + "module": "futures.curve", + "function": "curve_slope", + "file": "futures/curve.rs" + }, + { + "module": "futures.curve", + "function": "curve_summary", + "file": "futures/curve.rs" + }, + { + "module": "futures.roll", + "function": "back_adjusted_continuous", + "file": "futures/roll.rs" + }, + { + "module": "futures.roll", + "function": "ratio_adjusted_continuous", + "file": "futures/roll.rs" + }, + { + "module": "futures.roll", + "function": "roll_yield", + "file": "futures/roll.rs" + }, + { + "module": "futures.roll", + "function": "weighted_continuous", + "file": "futures/roll.rs" + }, + { + "module": "futures.synthetic", + "function": "parity_gap", + "file": "futures/synthetic.rs" + }, + { + "module": "futures.synthetic", + "function": "synthetic_forward", + "file": "futures/synthetic.rs" + }, + { + "module": "futures.synthetic", + "function": "synthetic_spot", + "file": "futures/synthetic.rs" + }, + { + "module": "math", + "function": "add", + "file": "math.rs" + }, + { + "module": "math", + "function": "div", + "file": "math.rs" + }, + { + "module": "math", + "function": "max", + "file": "math.rs" + }, + { + "module": "math", + "function": "min", + "file": "math.rs" + }, + { + "module": "math", + "function": "mult", + "file": "math.rs" + }, + { + "module": "math", + "function": "sliding_max", + "file": "math.rs" + }, + { + "module": "math", + "function": "sliding_min", + "file": "math.rs" + }, + { + "module": "math", + "function": "sub", + "file": "math.rs" + }, + { + "module": "math", + "function": "sum", + "file": "math.rs" + }, + { + "module": "math_ops", + "function": "rolling_max", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_maxindex", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_min", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_minindex", + "file": "math_ops.rs" + }, + { + "module": "math_ops", + "function": "rolling_sum", + "file": "math_ops.rs" + }, + { + "module": "momentum", + "function": "adx", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "adx_all", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "adxr", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "apo", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "aroon", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "aroonosc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "bop", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "cci", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "cmo", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "dx", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "minus_di", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "minus_dm", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "mom", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "plus_di", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "plus_dm", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "ppo", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "roc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocp", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocr", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rocr100", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "rsi", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "stoch", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "stochrsi", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "trix", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "ultosc", + "file": "momentum.rs" + }, + { + "module": "momentum", + "function": "willr", + "file": "momentum.rs" + }, + { + "module": "options.american", + "function": "american_price_baw", + "file": "options/american.rs" + }, + { + "module": "options.american", + "function": "early_exercise_premium", + "file": "options/american.rs" + }, + { + "module": "options.chain", + "function": "atm_index", + "file": "options/chain.rs" + }, + { + "module": "options.chain", + "function": "label_moneyness", + "file": "options/chain.rs" + }, + { + "module": "options.chain", + "function": "select_strike_by_delta", + "file": "options/chain.rs" + }, + { + "module": "options.chain", + "function": "select_strike_by_offset", + "file": "options/chain.rs" + }, + { + "module": "options.digital", + "function": "digital_greeks", + "file": "options/digital.rs" + }, + { + "module": "options.digital", + "function": "digital_price", + "file": "options/digital.rs" + }, + { + "module": "options.greeks", + "function": "black_76_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "black_scholes_extended_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "black_scholes_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "model_extended_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "model_greeks", + "file": "options/greeks.rs" + }, + { + "module": "options.greeks", + "function": "model_theta", + "file": "options/greeks.rs" + }, + { + "module": "options.iv", + "function": "implied_volatility", + "file": "options/iv.rs" + }, + { + "module": "options.iv", + "function": "iv_percentile", + "file": "options/iv.rs" + }, + { + "module": "options.iv", + "function": "iv_rank", + "file": "options/iv.rs" + }, + { + "module": "options.iv", + "function": "iv_zscore", + "file": "options/iv.rs" + }, + { + "module": "options.mod", + "function": "sign", + "file": "options/mod.rs" + }, + { + "module": "options.normal", + "function": "cdf", + "file": "options/normal.rs" + }, + { + "module": "options.normal", + "function": "pdf", + "file": "options/normal.rs" + }, + { + "module": "options.payoff", + "function": "aggregate_greeks_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_payoff_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_value_dense", + "file": "options/payoff.rs" + }, + { + "module": "options.payoff", + "function": "strategy_value_grid", + "file": "options/payoff.rs" + }, + { + "module": "options.pricing", + "function": "black_76_price", + "file": "options/pricing.rs" + }, + { + "module": "options.pricing", + "function": "black_scholes_price", + "file": "options/pricing.rs" + }, + { + "module": "options.pricing", + "function": "model_price", + "file": "options/pricing.rs" + }, + { + "module": "options.pricing", + "function": "price_lower_bound", + "file": "options/pricing.rs" + }, + { + "module": "options.pricing", + "function": "price_upper_bound", + "file": "options/pricing.rs" + }, + { + "module": "options.pricing", + "function": "put_call_parity_deviation", + "file": "options/pricing.rs" + }, + { + "module": "options.realized_vol", + "function": "close_to_close_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "garman_klass_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "parkinson_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "rogers_satchell_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "vol_cone", + "file": "options/realized_vol.rs" + }, + { + "module": "options.realized_vol", + "function": "yang_zhang_vol", + "file": "options/realized_vol.rs" + }, + { + "module": "options.surface", + "function": "atm_iv", + "file": "options/surface.rs" + }, + { + "module": "options.surface", + "function": "expected_move", + "file": "options/surface.rs" + }, + { + "module": "options.surface", + "function": "linear_interpolate", + "file": "options/surface.rs" + }, + { + "module": "options.surface", + "function": "smile_metrics", + "file": "options/surface.rs" + }, + { + "module": "options.surface", + "function": "term_structure_slope", + "file": "options/surface.rs" + }, + { + "module": "overlap", + "function": "bbands", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "dema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "ema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "kama", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "ma", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macd", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macdext", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "macdfix", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "mama", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "mavp", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "midpoint", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "midprice", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sar", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sarext", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sma", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "sma_into", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "t3", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "tema", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "trima", + "file": "overlap.rs" + }, + { + "module": "overlap", + "function": "wma", + "file": "overlap.rs" + }, + { + "module": "pattern", + "function": "body_size", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "candle_range", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl2crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3blackcrows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3inside", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3linestrike", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3outside", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3starsinsouth", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdl3whitesoldiers", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlabandonedbaby", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdladvanceblock", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlbelthold", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlbreakaway", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlclosingmarubozu", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlconcealbabyswall", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlcounterattack", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldarkcloudcover", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdldragonflydoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlengulfing", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdleveningdojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdleveningstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlgapsidesidewhite", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlgravestonedoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhammer", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhangingman", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlharami", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlharamicross", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhighwave", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhikkake", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhikkakemod", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlhomingpigeon", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlidentical3crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlinneck", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlinvertedhammer", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlkicking", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlkickingbylength", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlladderbottom", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdllongleggeddoji", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdllongline", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmarubozu", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmatchinglow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmathold", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmorningdojistar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlmorningstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlonneck", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlpiercing", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlrickshawman", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlrisefall3methods", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlseparatinglines", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlshootingstar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlshortline", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlspinningtop", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlstalledpattern", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlsticksandwich", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltakuri", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltasukigap", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlthrusting", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdltristar", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlunique3river", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlupsidegap2crows", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "cdlxsidegap3methods", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "is_bearish", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "is_bullish", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "lower_shadow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "upper_shadow", + "file": "pattern.rs" + }, + { + "module": "pattern", + "function": "validate_ohlc", + "file": "pattern.rs" + }, + { + "module": "portfolio", + "function": "beta_full", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "compose_weighted", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "correlation_matrix", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "drawdown_series", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "portfolio_volatility", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "ratio", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "relative_strength", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "rolling_beta", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "spread", + "file": "portfolio.rs" + }, + { + "module": "portfolio", + "function": "zscore_series", + "file": "portfolio.rs" + }, + { + "module": "price_transform", + "function": "avgprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "medprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "typprice", + "file": "price_transform.rs" + }, + { + "module": "price_transform", + "function": "wclprice", + "file": "price_transform.rs" + }, + { + "module": "regime", + "function": "detect_breaks_cusum", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "regime_adx", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "regime_combined", + "file": "regime.rs" + }, + { + "module": "regime", + "function": "rolling_variance_break", + "file": "regime.rs" + }, + { + "module": "resampling", + "function": "ohlcv_agg", + "file": "resampling.rs" + }, + { + "module": "resampling", + "function": "volume_bars", + "file": "resampling.rs" + }, + { + "module": "signals", + "function": "bottom_n_indices", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "compose_rank", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "rank_values", + "file": "signals.rs" + }, + { + "module": "signals", + "function": "top_n_indices", + "file": "signals.rs" + }, + { + "module": "statistic", + "function": "beta", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "correl", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "dtw_distance", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "dtw_path", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_angle", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_intercept", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "linearreg_slope", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "stddev", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "tsf", + "file": "statistic.rs" + }, + { + "module": "statistic", + "function": "var", + "file": "statistic.rs" + }, + { + "module": "streaming", + "function": "fast_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "new", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "reset", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "signal_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "slow_period", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "streaming", + "function": "update", + "file": "streaming.rs" + }, + { + "module": "volatility", + "function": "atr", + "file": "volatility.rs" + }, + { + "module": "volatility", + "function": "natr", + "file": "volatility.rs" + }, + { + "module": "volatility", + "function": "trange", + "file": "volatility.rs" + }, + { + "module": "volume", + "function": "ad", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "adosc", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "mfi", + "file": "volume.rs" + }, + { + "module": "volume", + "function": "obv", + "file": "volume.rs" + } + ] + }, + "wasm_node": { + "export_count": 222, + "exports": [ + "ad", + "adosc", + "adx", + "adx_all", + "adxr", + "aggregate_greeks_dense", + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "american_price", + "annualized_basis", + "apo", + "aroon", + "aroonosc", + "atm_index", + "atm_iv", + "atr", + "avgprice", + "back_adjusted_continuous", + "backtest_core", + "batch_adx", + "batch_atr", + "batch_ema", + "batch_rsi", + "batch_sma", + "batch_stoch", + "bbands", + "beta_full", + "beta_rolling", + "black_76_greeks", + "black_76_price", + "black_scholes_greeks", + "black_scholes_price", + "bop", + "bottom_n_indices", + "calendar_spreads", + "carry_spread", + "cci", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "close_to_close_vol", + "cmo", + "collect_alert_bars", + "compose_rank", + "compose_weighted", + "compute_performance_metrics", + "continuous_bar_labels", + "correl", + "correlation_matrix", + "curve_slope", + "curve_summary", + "dema", + "detect_breaks_cusum", + "digital_greeks", + "digital_price", + "donchian", + "drawdown_series", + "dtw_distance", + "dx", + "early_exercise_premium", + "ema", + "exchange_charges_rate", + "expected_move", + "extended_greeks", + "extract_trades", + "fast_period", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "futures_basis", + "garman_klass_vol", + "gst_rate", + "half_kelly_fraction", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "implied_carry_rate", + "implied_volatility", + "iv_percentile", + "iv_rank", + "iv_zscore", + "kama", + "kelly_fraction", + "keltner_channels", + "label_moneyness", + "linear_interpolate", + "linearreg", + "linearreg_angle", + "linearreg_intercept", + "linearreg_slope", + "lot_size", + "ma", + "macd", + "macd_crossover_signals", + "macdfix", + "make_chunk_ranges", + "mama", + "mark_session_boundaries", + "math_add", + "math_div", + "math_mult", + "math_sub", + "mavp", + "max_brokerage", + "medprice", + "mfi", + "midpoint", + "midprice", + "minus_di", + "minus_dm", + "model_greeks", + "model_price", + "model_theta", + "mom", + "monte_carlo_bootstrap", + "monthly_contribution", + "natr", + "new", + "obv", + "ohlcv_agg", + "parity_gap", + "parkinson_vol", + "per_lot", + "period", + "pivot_points", + "plus_di", + "plus_dm", + "portfolio_volatility", + "ppo", + "price_lower_bound", + "price_upper_bound", + "put_call_parity_deviation", + "rank_series", + "rank_values", + "rate_of_value", + "ratio", + "ratio_adjusted_continuous", + "regime_adx", + "regime_combined", + "regulatory_charges_rate", + "relative_strength", + "roc", + "rocp", + "rocr", + "rocr100", + "rogers_satchell_vol", + "roll_yield", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "rolling_variance_break", + "rsi", + "rsi_threshold_signals", + "sar", + "select_strike_by_offset", + "set_exchange_charges_rate", + "set_flat_per_order", + "set_gst_rate", + "set_lot_size", + "set_max_brokerage", + "set_per_lot", + "set_rate_of_value", + "set_regulatory_charges_rate", + "set_stamp_duty_rate", + "set_stt_on_buy", + "set_stt_on_sell", + "set_stt_rate", + "signal_attribution", + "signal_period", + "single_asset_backtest", + "slow_period", + "sma", + "sma_crossover_signals", + "spread", + "stamp_duty_rate", + "stddev", + "stitch_chunks", + "stoch", + "stochf", + "stochrsi", + "strategy_payoff_dense", + "strategy_value_grid", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "supertrend", + "synthetic_forward", + "synthetic_spot", + "t3", + "tema", + "term_structure_slope", + "top_n_indices", + "trade_stats", + "trange", + "trim_overlap", + "trima", + "trix_indicator", + "tsf", + "typprice", + "ultosc", + "var", + "vol_cone", + "volume_bars", + "vwap", + "vwma", + "walk_forward_indices", + "wclprice", + "weighted_continuous", + "willr", + "wma", + "yang_zhang_vol", + "zscore_series" + ] + } + }, + "parity_summary": { + "python_indicator_count": 210, + "wasm_export_count": 222, + "common_python_wasm_count": 92, + "common_python_wasm": [ + "ad", + "adosc", + "adx", + "adxr", + "apo", + "aroon", + "aroonosc", + "atr", + "avgprice", + "batch_ema", + "batch_rsi", + "batch_sma", + "bbands", + "bop", + "cci", + "chandelier_exit", + "check_cross", + "check_threshold", + "choppiness_index", + "cmo", + "collect_alert_bars", + "continuous_bar_labels", + "correl", + "correlation_matrix", + "dema", + "detect_breaks_cusum", + "donchian", + "dtw_distance", + "dx", + "ema", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendline", + "ht_trendmode", + "hull_ma", + "ichimoku", + "kama", + "keltner_channels", + "linearreg", + "linearreg_angle", + "linearreg_intercept", + "linearreg_slope", + "ma", + "macd", + "macdfix", + "mama", + "mavp", + "medprice", + "mfi", + "midpoint", + "midprice", + "minus_di", + "minus_dm", + "mom", + "natr", + "obv", + "pivot_points", + "plus_di", + "plus_dm", + "portfolio_volatility", + "ppo", + "regime_adx", + "regime_combined", + "roc", + "rocp", + "rocr", + "rocr100", + "rolling_variance_break", + "rsi", + "sar", + "sma", + "stddev", + "stoch", + "stochf", + "stochrsi", + "supertrend", + "t3", + "tema", + "trange", + "trima", + "tsf", + "typprice", + "ultosc", + "var", + "volume_bars", + "vwap", + "vwma", + "wclprice", + "willr", + "wma" + ], + "python_only_vs_wasm": [ + "acos", + "add", + "aggregate_ticks", + "alertevent", + "alertmanager", + "asin", + "atan", + "batch_apply", + "batch_dtw", + "beta", + "cdl2crows", + "cdl3blackcrows", + "cdl3inside", + "cdl3linestrike", + "cdl3outside", + "cdl3starsinsouth", + "cdl3whitesoldiers", + "cdlabandonedbaby", + "cdladvanceblock", + "cdlbelthold", + "cdlbreakaway", + "cdlclosingmarubozu", + "cdlconcealbabyswall", + "cdlcounterattack", + "cdldarkcloudcover", + "cdldoji", + "cdldojistar", + "cdldragonflydoji", + "cdlengulfing", + "cdleveningdojistar", + "cdleveningstar", + "cdlgapsidesidewhite", + "cdlgravestonedoji", + "cdlhammer", + "cdlhangingman", + "cdlharami", + "cdlharamicross", + "cdlhighwave", + "cdlhikkake", + "cdlhikkakemod", + "cdlhomingpigeon", + "cdlidentical3crows", + "cdlinneck", + "cdlinvertedhammer", + "cdlkicking", + "cdlkickingbylength", + "cdlladderbottom", + "cdllongleggeddoji", + "cdllongline", + "cdlmarubozu", + "cdlmatchinglow", + "cdlmathold", + "cdlmorningdojistar", + "cdlmorningstar", + "cdlonneck", + "cdlpiercing", + "cdlrickshawman", + "cdlrisefall3methods", + "cdlseparatinglines", + "cdlshootingstar", + "cdlshortline", + "cdlspinningtop", + "cdlstalledpattern", + "cdlsticksandwich", + "cdltakuri", + "cdltasukigap", + "cdlthrusting", + "cdltristar", + "cdlunique3river", + "cdlupsidegap2crows", + "cdlxsidegap3methods", + "ceil", + "compose", + "compute_many", + "cos", + "cosh", + "div", + "drawdown", + "dtw", + "exp", + "feature_matrix", + "floor", + "funding_pnl", + "ln", + "log10", + "macdext", + "max", + "maxindex", + "min", + "minindex", + "mult", + "multi_timeframe", + "rank_signals", + "regime", + "resample", + "resample_continuous", + "sarext", + "screen", + "session_boundaries", + "sin", + "sinh", + "sqrt", + "streamingatr", + "streamingbbands", + "streamingema", + "streamingmacd", + "streamingrsi", + "streamingsma", + "streamingstoch", + "streamingsupertrend", + "streamingvwap", + "structural_breaks", + "sub", + "sum", + "tan", + "tanh", + "tickaggregator", + "trix" + ], + "wasm_only_vs_python": [ + "adx_all", + "aggregate_greeks_dense", + "aggregate_tick_bars", + "aggregate_time_bars", + "aggregate_volume_bars_ticks", + "american_price", + "annualized_basis", + "atm_index", + "atm_iv", + "back_adjusted_continuous", + "backtest_core", + "batch_adx", + "batch_atr", + "batch_stoch", + "beta_full", + "beta_rolling", + "black_76_greeks", + "black_76_price", + "black_scholes_greeks", + "black_scholes_price", + "bottom_n_indices", + "calendar_spreads", + "carry_spread", + "close_to_close_vol", + "compose_rank", + "compose_weighted", + "compute_performance_metrics", + "curve_slope", + "curve_summary", + "digital_greeks", + "digital_price", + "drawdown_series", + "early_exercise_premium", + "exchange_charges_rate", + "expected_move", + "extended_greeks", + "extract_trades", + "fast_period", + "flat_per_order", + "forward_fill_nan", + "funding_cumulative_pnl", + "futures_basis", + "garman_klass_vol", + "gst_rate", + "half_kelly_fraction", + "implied_carry_rate", + "implied_volatility", + "iv_percentile", + "iv_rank", + "iv_zscore", + "kelly_fraction", + "label_moneyness", + "linear_interpolate", + "lot_size", + "macd_crossover_signals", + "make_chunk_ranges", + "mark_session_boundaries", + "math_add", + "math_div", + "math_mult", + "math_sub", + "max_brokerage", + "model_greeks", + "model_price", + "model_theta", + "monte_carlo_bootstrap", + "monthly_contribution", + "new", + "ohlcv_agg", + "parity_gap", + "parkinson_vol", + "per_lot", + "period", + "price_lower_bound", + "price_upper_bound", + "put_call_parity_deviation", + "rank_series", + "rank_values", + "rate_of_value", + "ratio", + "ratio_adjusted_continuous", + "regulatory_charges_rate", + "relative_strength", + "rogers_satchell_vol", + "roll_yield", + "rolling_beta", + "rolling_max", + "rolling_maxindex", + "rolling_min", + "rolling_minindex", + "rolling_sum", + "rsi_threshold_signals", + "select_strike_by_offset", + "set_exchange_charges_rate", + "set_flat_per_order", + "set_gst_rate", + "set_lot_size", + "set_max_brokerage", + "set_per_lot", + "set_rate_of_value", + "set_regulatory_charges_rate", + "set_stamp_duty_rate", + "set_stt_on_buy", + "set_stt_on_sell", + "set_stt_rate", + "signal_attribution", + "signal_period", + "single_asset_backtest", + "slow_period", + "sma_crossover_signals", + "spread", + "stamp_duty_rate", + "stitch_chunks", + "strategy_payoff_dense", + "strategy_value_grid", + "stt_on_buy", + "stt_on_sell", + "stt_rate", + "synthetic_forward", + "synthetic_spot", + "term_structure_slope", + "top_n_indices", + "trade_stats", + "trim_overlap", + "trix_indicator", + "vol_cone", + "walk_forward_indices", + "weighted_continuous", + "yang_zhang_vol", + "zscore_series" + ] + } +} diff --git a/vendor/ferro-ta-main/docs/architecture.md b/vendor/ferro-ta-main/docs/architecture.md new file mode 100644 index 0000000..103e962 --- /dev/null +++ b/vendor/ferro-ta-main/docs/architecture.md @@ -0,0 +1,176 @@ +# Architecture + +This document describes the internal layout of **ferro-ta** — how the Rust and +Python layers are organised, how they communicate, and what each component is +responsible for. + +--- + +## Repository Layout + +``` +ferro-ta/ +├── src/ # Root PyO3 crate (Python extension, _ferro_ta) +│ ├── lib.rs # Module registration — assembles all sub-modules +│ ├── overlap/ # SMA, EMA, WMA, DEMA, TEMA, KAMA, BBANDS, … +│ ├── momentum/ # RSI, STOCH, ADX, CCI, AROON, WILLR, MFI, … +│ ├── volatility/ # ATR, NATR, TRANGE +│ ├── volume/ # AD, ADOSC, OBV +│ ├── statistic/ # STDDEV, VAR, LINEARREG, BETA, CORREL, … +│ ├── price_transform/ # AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE +│ ├── pattern/ # 61 CDL candlestick patterns +│ ├── cycle/ # HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, … +│ └── common.rs # Shared helpers (Wilder smoothing, etc.) +│ +├── crates/ +│ └── ferro_ta_core/ # Pure-Rust library (no PyO3 / numpy) +│ └── src/ # Used by fuzz targets and WASM binding +│ +├── python/ +│ └── ferro_ta/ # Python package +│ ├── __init__.py # Public API — re-exports + pandas/polars wraps +│ ├── _utils.py # _to_f64, pandas_wrap, polars_wrap, get_ohlcv +│ ├── overlap.py # Thin wrappers around _ferro_ta overlap functions +│ ├── momentum.py # … momentum +│ ├── volatility.py # … volatility +│ ├── volume.py # … volume +│ ├── statistic.py # … statistic +│ ├── price_transform.py # … price_transform +│ ├── pattern.py # … pattern (61 CDL functions) +│ ├── cycle.py # … cycle +│ ├── math_ops.py # ADD, SUB, MULT, DIV, SUM, MAX, MIN, math transforms +│ ├── extended.py # Extended indicators (VWAP, SUPERTREND, ICHIMOKU, …) +│ ├── streaming.py # Stateful streaming classes (StreamingSMA, …) +│ ├── batch.py # Batch execution API (batch_sma, batch_ema, …) +│ ├── pipeline.py # Pipeline / make_pipeline +│ ├── config.py # set_default / Config +│ ├── registry.py # Indicator registry (list_indicators, run) +│ ├── backtest.py # Simple backtest helpers +│ ├── gpu.py # CuPy-backed GPU PoC (SMA, EMA, RSI) +│ ├── exceptions.py # FerroTAError, FerroTAValueError, FerroTAInputError +│ ├── utils.py # Public re-export of get_ohlcv +│ └── py.typed # PEP 561 marker +│ +├── fuzz/ # cargo-fuzz targets (fuzz_sma, fuzz_rsi, …) +├── wasm/ # wasm-pack / wasm-bindgen binding (uses ferro_ta_core) +├── benches/ # Rust criterion benchmarks +├── benchmarks/ # Python pytest-benchmark benchmarks +├── docs/ # Sphinx documentation source +└── tests/ # Python pytest test suite +``` + +--- + +## Two Rust Crates + +ferro-ta has **two** Rust crates that serve different purposes: + +### 1. Root crate (`src/`) — Python extension (`_ferro_ta`) + +| Property | Value | +|----------------|---------------------------------------------------| +| Crate type | `cdylib` (compiled to a `.so` / `.pyd` file) | +| PyO3 / numpy | Yes — depends on `pyo3` and `numpy` | +| Depends on | `ta` crate (provides TA-Lib-compatible algorithms)| +| Used by | Python extension (`ferro_ta._ferro_ta`) | + +Each category module (`src/overlap/`, `src/momentum/`, …) registers +`#[pyfunction]`s that accept `numpy` arrays (via `PyReadonlyArray1`) +and return `Vec` which PyO3 converts to a Python list/ndarray. + +### 2. `crates/ferro_ta_core/` — Pure Rust library + +| Property | Value | +|----------------|-------------------------------------------------------------------| +| Crate type | `lib` (not a Python extension) | +| PyO3 / numpy | No — pure Rust, no Python dependency | +| Depends on | Nothing outside `std` | +| Used by | `fuzz/` targets and `wasm/` binding | + +`ferro_ta_core` provides the same indicator categories with a `&[f64]` API, +making it usable from WASM and fuzz targets without pulling in PyO3 or numpy. + +> **Note:** The root crate and `ferro_ta_core` are *independent* implementations. +> They are not merged by design — merging them would require careful testing of +> both the Python and WASM/fuzz surfaces. If you want to share code, the +> recommended path is to make the root crate depend on `ferro_ta_core` and wrap +> its `&[f64]` API with PyO3 `#[pyfunction]`s; that is a future refactor. + +--- + +## Python Binding Flow + +``` +User code + │ + ├── from ferro_ta import SMA # __init__.py re-export + │ │ + │ └── python/ferro_ta/overlap.py::SMA + │ │ + │ ├── _utils._to_f64(close) # convert to float64 ndarray + │ ├── check_timeperiod(n) # validate parameters + │ └── _ferro_ta.sma(arr, n) # call Rust extension + │ │ + │ └── src/overlap/sma.rs # pure Rust computation + │ + ├── SMA(pd.Series(...)) # pandas_wrap intercepts first + │ │ + │ ├── extracts .to_numpy(dtype=float64) + │ ├── calls SMA(ndarray) + │ └── wraps result in pd.Series(result, index=original_index) + │ + └── SMA(pl.Series(...)) # polars_wrap intercepts first + │ + ├── extracts .cast(Float64).to_numpy() + ├── calls SMA(ndarray) + └── wraps result in pl.Series(name, np.asarray(result)) +``` + +Both `pandas_wrap` and `polars_wrap` are applied to every public name in +`__init__.py` so the same function transparently handles numpy arrays, +pandas Series, and polars Series. + +--- + +## Extended Indicators, Streaming, and Batch + +| Module | Implementation | Notes | +|---------------|-----------------------------|-------------------------------------------------------------| +| `extended.py` | Rust (`src/extended/`) | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, … | +| `streaming.py`| Rust re-export | Stateful classes (StreamingSMA, StreamingEMA, …) from `_ferro_ta`; no Python fallback | +| `batch.py` | Rust for 2-D SMA/EMA/RSI | `batch_sma`, `batch_ema`, `batch_rsi` call Rust batch functions; `batch_apply` is a Python loop for other indicators | + +Streaming and batch 2-D paths are implemented in Rust for maximum performance. +The generic `batch_apply` remains for indicators that do not have a dedicated +Rust batch implementation (see `docs/performance.md`). + +--- + +## Packaging and Build + +- **Build backend:** [maturin](https://www.maturin.rs/) — compiles the root + crate and packages it alongside the Python source into a wheel. +- **`python-source = "python"`** in `pyproject.toml` tells maturin where the + Python package lives. +- **`module-name = "ferro_ta._ferro_ta"`** tells maturin to place the compiled + `.so` at `ferro_ta/_ferro_ta.so` inside the wheel. +- Wheels are built for Linux (manylinux), Windows, and macOS via CI on release. + +--- + +## Where Validation Lives + +Currently most validation (array length checks, `timeperiod` range checks) is +done in Python wrappers before the Rust call. A future improvement is to move +these checks into the `#[pyfunction]`s so that callers using the raw +`_ferro_ta` extension directly also get clear errors. + +--- + +## Related Documents + +- [`docs/performance.md`](performance.md) — when to use raw numpy vs pandas/polars, + how to avoid unnecessary conversion, batch performance notes. +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — development workflow, running tests, + adding a new indicator. +- [`CHANGELOG.md`](../CHANGELOG.md) — version history. diff --git a/vendor/ferro-ta-main/docs/batch.rst b/vendor/ferro-ta-main/docs/batch.rst new file mode 100644 index 0000000..cf155f8 --- /dev/null +++ b/vendor/ferro-ta-main/docs/batch.rst @@ -0,0 +1,42 @@ +Batch Execution API +=================== + +The batch API lets you run indicators on multiple price series in a single +call. This reduces Python overhead compared to calling the 1-D function in a +loop and naturally maps to multi-asset / multi-symbol workflows. + +All batch functions accept a 2-D array of shape ``(n_samples, n_series)`` and +return a 2-D array of the same shape. Passing a 1-D array falls back to the +single-series behaviour. + +Usage +----- + +.. code-block:: python + + import numpy as np + from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply + + # 100 bars, 5 symbols + close = np.random.rand(100, 5) + 50.0 + + sma = batch_sma(close, timeperiod=14) # shape (100, 5) + ema = batch_ema(close, timeperiod=14) # shape (100, 5) + rsi = batch_rsi(close, timeperiod=14) # shape (100, 5) + + # Apply any indicator using batch_apply + from ferro_ta import MACD + # MACD returns a tuple so we wrap it + def macd_line(c, **kw): + return MACD(c, **kw)[0] + + macd = batch_apply(close, macd_line) # shape (100, 5) + +API Reference +------------- + +.. automodule:: ferro_ta.batch + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/benchmarks.rst b/vendor/ferro-ta-main/docs/benchmarks.rst new file mode 100644 index 0000000..7b5a3d3 --- /dev/null +++ b/vendor/ferro-ta-main/docs/benchmarks.rst @@ -0,0 +1,246 @@ +Benchmarks +========== + +The benchmark suite is meant to support a narrow claim: ferro-ta is often +faster on selected indicators, and the evidence is published in a reproducible +form. + +What is published +----------------- + +The authoritative benchmark workflow lives in ``benchmarks/``: + +- Cross-library speed suite: ``benchmarks/test_speed.py`` +- Cross-library accuracy suite: ``benchmarks/test_accuracy.py`` +- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py`` +- Backtesting engine benchmark: ``benchmarks/bench_backtest.py`` +- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py`` +- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py`` + +Backtesting engine — competitor comparison +------------------------------------------ + +Measured on Apple M-series, Python 3.13, Rust 1.91, using an SMA(20/50) +crossover strategy with 0.1% commission and 5 bps slippage. Median of 5 runs. + +.. list-table:: Speed vs backtesting libraries (signal → equity curve) + :header-rows: 1 + + * - Library + - 1k bars + - 10k bars + - 100k bars + - vs ferro-ta core (100k) + * - **ferro-ta** ``backtest_core`` + - 0.004 ms + - 0.033 ms + - 0.286 ms + - — + * - **ferro-ta** ``backtest_ohlcv_core`` + - 0.004 ms + - 0.037 ms + - 0.332 ms + - ~same + * - NumPy vectorized (manual) + - 0.013 ms + - 0.042 ms + - 0.459 ms + - 1.6× slower + * - vectorbt 0.28 + - 1.32 ms + - 1.31 ms + - 2.90 ms + - **10× slower** + * - backtesting.py + - 10.5 ms + - 42.3 ms + - 319.6 ms + - **1,117× slower** + * - backtrader 1.9 + - 53.9 ms + - 518 ms + - n/a (skipped) + - **>15,000× slower** + +Accuracy: ferro-ta positions and bar-returns are **bit-exact** against the NumPy +reference implementation (max per-bar equity diff = 0.00e+00 with zero +commission/slippage). + +Additional ferro-ta capabilities not present in the libraries above: + +.. list-table:: + :header-rows: 1 + + * - Capability + - ferro-ta result + - NumPy baseline + - Speedup + * - Monte Carlo 1,000 sims (100k bars) + - 50 ms (parallel Rayon + LCG) + - 612 ms (Python loop) + - **12×** + * - 23 performance metrics, single call (100k bars) + - 2.8 ms + - 0.36 ms (2 metrics only) + - 0.12 ms / metric + * - Multi-asset 100 assets (100k bars) + - 43 ms parallel / 88 ms serial + - — + - 2× parallel speedup + * - Walk-forward fold indices (100k bars) + - 0.3 µs + - — + - — + +Reproduce the backtest benchmark: + +.. code-block:: bash + + python benchmarks/bench_backtest.py --sizes 10000 100000 \ + --json benchmarks/artifacts/latest/bench_backtest_results.json + +Latest checked-in TA-Lib artifact +--------------------------------- + +The current checked-in TA-Lib comparison artifact benchmarks contiguous +``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical +cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the +default release profile (``lto = true``, ``codegen-units = 1``). + +Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``: + +.. list-table:: + :header-rows: 1 + + * - Size + - Rows + - ferro-ta wins + - Median speedup + - TA-Lib wins or ties + * - ``10,000`` + - 12 + - 6 + - ``1.0850x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + * - ``100,000`` + - 12 + - 6 + - ``1.0784x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + +Examples from the 100k-bar run: + +.. list-table:: + :header-rows: 1 + + * - Indicator + - ferro-ta + - TA-Lib + - Speedup + - Read + * - ``SMA`` + - ``0.0985 ms`` + - ``0.2241 ms`` + - ``2.2751x`` + - clear ferro-ta win + * - ``BBANDS`` + - ``0.2122 ms`` + - ``0.4966 ms`` + - ``2.3402x`` + - clear ferro-ta win + * - ``MACD`` + - ``0.5152 ms`` + - ``0.7111 ms`` + - ``1.3801x`` + - ferro-ta win + * - ``STOCH`` + - ``1.7064 ms`` + - ``0.7603 ms`` + - ``0.4455x`` + - TA-Lib win + * - ``ADX`` + - ``0.7910 ms`` + - ``0.5769 ms`` + - ``0.7294x`` + - TA-Lib win + * - ``ATR`` + - ``0.5087 ms`` + - ``0.5147 ms`` + - ``1.0118x`` + - tie on this machine + +Methodology notes +----------------- + +- The head-to-head script uses the same synthetic OHLCV generator, the same + parameters, and the same contiguous ``float64`` array layout for both + libraries. +- Reported speedup is ``TA-Lib median time / ferro-ta median time``. +- The script uses 1 warmup run and 7 measured runs per case, and now records + the full per-run timing samples, not just one selected number. +- Published JSON artifacts include machine/runtime metadata, git metadata, Rust + toolchain and build-profile metadata, per-run variance statistics, and + Python-tracked peak allocation snapshots. +- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked + allocations only; they are not full native RSS profiles. +- If your workload uses non-contiguous arrays, different dtypes, or different + batch sizes, benchmark that exact workload. Those factors can materially + change the result. + +Reproduce the TA-Lib comparison +------------------------------- + +.. code-block:: bash + + pip install ta-lib + python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json + +The JSON output is the main artifact to review when publishing performance +claims. + +Cross-library suite +------------------- + +Run the broader speed suite on 100,000 bars: + +.. code-block:: bash + + uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v + +Selected throughput examples from the checked-in table: + +.. list-table:: + :header-rows: 1 + + * - Indicator + - Throughput + * - ``ADD`` + - 1.9 G bars/s + * - ``CDLENGULFING`` + - 454 M bars/s + * - ``EMA`` + - 444 M bars/s + * - ``SMA`` + - 259 M bars/s + * - ``RSI`` + - 145 M bars/s + * - ``ATR`` + - 70 M bars/s + * - ``MACD`` + - 104 M bars/s + * - ``STOCH`` + - 33 M bars/s + +Perf-contract artifacts +----------------------- + +Use the perf-contract runner when you want a compact, machine-readable artifact +bundle for single-series latency, batch throughput, streaming throughput, and +hotspot attribution: + +.. code-block:: bash + + uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest + +See ``benchmarks/README.md`` for the detailed benchmark playbook and the +checked-in comparison tables. diff --git a/vendor/ferro-ta-main/docs/changelog.rst b/vendor/ferro-ta-main/docs/changelog.rst new file mode 100644 index 0000000..27e4d36 --- /dev/null +++ b/vendor/ferro-ta-main/docs/changelog.rst @@ -0,0 +1,271 @@ +Release Notes +============= + +These docs track package version ``1.2.0``. + +1.1.0-audit (2026-03-28) +------------------------ + +**Comprehensive audit: 90 findings addressed** + +*Code quality & correctness* + +- **Welford's algorithm for BBANDS**: replaced naive ``sum_sq/N - mean^2`` variance + with numerically stable Welford's rolling algorithm in both batch and streaming BBANDS. + Fixes catastrophic cancellation for large-valued series (e.g., prices near 1e12). +- **FFI boundary safety**: ``transpose_to_series_major()`` in ``batch/mod.rs`` now + returns ``PyResult`` instead of using ``expect()``. Remaining ``as_slice().expect()`` + calls in ``allow_threads`` closures are documented with SAFETY comments (structurally + infallible after C-contiguous transpose). +- **Clippy clean**: resolved all clippy warnings — complex type in ``adx_all`` extracted + to ``AdxAllResult`` type alias; ``welford_step`` helper annotated with + ``#[allow(clippy::too_many_arguments)]``. + +*Performance* + +- **``target-cpu=native``**: new ``.cargo/config.toml`` enables native CPU instruction + set (AVX2, NEON, etc.) for all non-WASM targets. CI can override via ``RUSTFLAGS``. + +*Testing* + +- **Streaming unit tests**: 37 new tests in ``tests/unit/streaming/test_streaming.py`` + covering ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI`` — batch parity, warmup + NaN behavior, reset, edge cases, and large dataset numerical stability. +- **Edge case tests**: 31 new tests in ``tests/unit/test_edge_cases.py`` — empty arrays, + single elements, all-NaN input, NaN propagation, extreme values (1e300, 1e-300), + constant series, period boundary conditions, OHLCV edge cases, and dtype coercion + (float32, int64). +- **Property-based tests**: expanded Hypothesis tests for EMA, BBANDS, MACD, ATR, WMA, + and OBV with algebraic invariants (upper >= middle >= lower, histogram == macd - signal, + ATR non-negative, etc.). +- **Pandas/polars integration tests**: new ``test_dataframe_integration.py`` verifying + transparent ``pd.Series`` and ``polars.Series`` support across SMA, EMA, RSI, BBANDS, + MACD, and end-to-end DataFrame workflows. +- **Fuzzing**: expanded from 2 to 9 fuzz targets — added EMA, BBANDS, MACD, ATR, STOCH, + MFI, and WMA with output invariant assertions. +- **Test helpers**: new ``tests/unit/helpers.py`` consolidating duplicated assertion + patterns (``nan_count``, ``finite``, ``assert_nan_warmup``, ``assert_output_length``, + ``assert_range``, ``make_ohlcv``). + +*Documentation* + +- **README benchmarks**: updated to match actual artifact data — MFI 3.25x, WMA 2.20x, + BBANDS 1.97x, SMA 1.93x; corrected win count from 6 to 7 at 100k bars. +- **Rust doc comments**: added comprehensive ``///`` documentation to all public functions + in ``ferro_ta_core`` — overlap (SMA, EMA, WMA, BBANDS, MACD), momentum (RSI, STOCH, + ADX family), volatility (ATR, TRANGE), volume (OBV, MFI), statistic (STDDEV), and math + (sum, max, min, sliding_max, sliding_min). + +*Linting* + +- **Ruff clean**: fixed import sorting, unused imports, trailing whitespace, and + formatting across all Python files. +- **cargo fmt**: all Rust code formatted. + +1.1.0 (2026-03-28) +------------------ + +**Phase 1 — Simulation fidelity** + +- **Bid-ask spread model**: new ``CommissionModel.spread_bps`` field (basis points). + Half-spread is deducted per leg (entry and exit), modelling real market microstructure costs. +- **Breakeven stop**: new ``backtest_ohlcv_core`` parameter ``breakeven_pct`` and + ``BacktestEngine.with_breakeven_stop(pct)``. Once profit reaches ``pct``, the + effective stop-loss is moved to the entry price, guaranteeing at worst a breakeven exit. +- **Bracket order priority**: when both stop-loss and take-profit are breached on the + same bar, the level closer to the bar's open price fires first (previously SL always won). + +**Phase 2 — Portfolio & risk** + +- **Short borrow cost**: new ``CommissionModel.short_borrow_rate_annual`` field. + Accrued per bar for short positions at the specified annualised rate. +- **Leverage / margin modeling**: new ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)``. + Tracks margin usage and triggers a margin-call force-close when equity falls below + ``margin_call_pct × initial_margin``. +- **Loss circuit breakers**: new ``BacktestEngine.with_loss_limits(daily, total)``. + Halts all trading when a per-bar loss or total drawdown threshold is breached. +- **Portfolio constraints**: new ``BacktestEngine.with_portfolio_constraints(max_asset_weight, + max_gross_exposure, max_net_exposure)`` for multi-asset backtests. + +**Phase 3 — Data & UX** + +- **Bar aggregation** (``ferro_ta.analysis.resample``): ``resample_ohlcv()``, ``align_to_coarse()``, + ``resample_ohlcv_labels()`` — pure-NumPy OHLCV resampling from any fine TF to any coarser TF. +- **Multi-timeframe engine** (``ferro_ta.analysis.multitf``): ``MultiTimeframeEngine`` — compute + strategy signals on coarser bars and execute on finer bars, with automatic signal alignment. +- **Dividend/split adjustment** (``ferro_ta.analysis.adjust``): ``adjust_ohlcv()``, + ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted price series for + equity/index strategies. +- **Visualization** (``ferro_ta.analysis.plot``): ``plot_backtest()`` — interactive Plotly chart + with equity curve, drawdown panel, position panel, trade markers, and optional benchmark overlay. + +**Phase 4 — Differentiation** + +- **Regime detection** (``ferro_ta.analysis.regime``): ``detect_volatility_regime()``, + ``detect_trend_regime()``, ``detect_combined_regime()``, ``RegimeFilter`` — pure-NumPy + 6-state market regime labeling and signal filtering; no external ML dependencies. +- **Portfolio optimization** (``ferro_ta.analysis.optimize``): ``PortfolioOptimizer``, + ``mean_variance_optimize()``, ``risk_parity_optimize()``, ``max_sharpe_optimize()`` — + minimum-variance, risk-parity, and maximum-Sharpe portfolios via SLSQP (requires scipy). +- **Paper trading bridge** (``ferro_ta.analysis.live``): ``PaperTrader`` — event-driven + bar-by-bar simulator matching ``backtest_ohlcv_core`` logic exactly; supports streaming + data, live state inspection, and seamless strategy migration from backtesting to live. + +1.1.0 (2026-03-27) +------------------ + +**Advanced commission and fee model (Indian market support)** + +- New ``CommissionModel`` class (pure Rust in ``ferro_ta_core``, exposed via + PyO3 and WASM) replaces the broken flat ``commission_per_trade`` scalar. The + old code subtracted an absolute currency amount from a 1.0-normalised equity + curve — equivalent to a 2 000 % error on a ₹1 lakh account. The new model + correctly converts every charge to a fraction of ``initial_capital`` before + deducting it from the equity curve. +- ``CommissionModel`` supports: proportional brokerage (``rate_of_value``), + flat per-order fee (``flat_per_order``), per-lot fee (``per_lot``), brokerage + cap (``max_brokerage``), Securities Transaction Tax (``stt_rate`` with + configurable buy/sell sides), exchange transaction charges, SEBI regulatory + charges, 18 % GST on brokerage + exchange + regulatory levies, and stamp duty + on buy leg only. +- Built-in presets: ``CommissionModel.equity_delivery_india()``, + ``CommissionModel.equity_intraday_india()``, + ``CommissionModel.futures_india()``, ``CommissionModel.options_india()``, + ``CommissionModel.proportional(rate)``, ``CommissionModel.zero()``. +- JSON persistence: ``model.to_json()`` / ``CommissionModel.from_json(s)``, + ``model.save(path)`` / ``CommissionModel.load(path)``. +- ``BacktestEngine.with_commission_model(model)`` — pass a full + ``CommissionModel``; old ``with_commission(rate)`` kept as a shim. +- New ``initial_capital`` parameter (default ₹1,00,000) on both + ``backtest_core`` and ``backtest_ohlcv_core``; also exposed as + ``BacktestEngine.with_initial_capital(capital)``. + +**Currency system — INR default with lakh/crore formatting** + +- New ``Currency`` immutable descriptor in the Python layer with constants + ``INR``, ``USD``, ``EUR``, ``GBP``, ``JPY``, ``USDT``. +- ``INR`` is the default currency for ``BacktestEngine``; change via + ``engine.with_currency("USD")`` or ``engine.with_currency(EUR)``. +- ``currency.format(amount)`` produces Indian lakh/crore grouping for INR + (e.g. ``₹1,23,45,678.00``) and standard Western grouping for other + currencies. +- Module-level helper ``format_currency(amount, currency=INR)``. +- ``AdvancedBacktestResult`` gains ``currency``, ``initial_capital``, and + ``equity_abs`` (absolute currency equity curve) slots. +- ``summary()`` now includes ``initial_capital``, ``final_capital``, + ``absolute_pnl``, and ``currency`` keys. +- ``AdvancedBacktestResult.__repr__`` shows the final capital in the correct + currency symbol (e.g. ``final=₹1,23,450.00``). +- Trade log gains a ``pnl_abs`` column (PnL in absolute currency units). +- ``to_equity_dataframe()`` now includes an ``equity_abs`` column. + +**Trailing stop loss** + +- ``backtest_ohlcv_core`` (and ``BacktestEngine.with_trailing_stop(pct)``) + now supports a trailing stop implemented intrabar in Rust: the high-water + mark is updated each bar; the position is exited at + ``trail_high × (1 − pct)`` when ``low[i]`` crosses below it (long trades), + or ``trail_low × (1 + pct)`` for short trades. + +**Benchmark comparison metrics** + +- ``compute_performance_metrics`` accepts an optional ``benchmark_returns`` + array. When provided, ``summary()`` includes: ``benchmark_total_return``, + ``benchmark_cagr``, ``benchmark_annualized_vol``, ``benchmark_sharpe``, + ``alpha`` (active return), ``beta``, ``tracking_error``, and + ``information_ratio``. +- ``BacktestEngine.with_benchmark(close_array)`` — pass benchmark close prices. + +**Volatility-target position sizing** + +- New ``"volatility_target"`` method for ``with_position_sizing()``: + ``engine.with_position_sizing("volatility_target", target_vol=0.15, vol_window=20)``. + Signals are pre-scaled in Python by ``clip(target_vol / rolling_annualised_vol, 0, 3)`` + before the Rust core call, keeping the hot loop unchanged. + +**Backtesting engine v2 — full feature set** + +- ``BacktestEngine`` now supports true two-pass Kelly / half-Kelly position + sizing: a unit-signal pass computes win statistics, then the core engine + re-runs with signals scaled by the Kelly fraction. +- Added ``fixed_fractional`` position sizing method: + ``engine.with_position_sizing("fixed_fractional", fraction=0.5)``. +- New ``StreamingBacktest`` Rust class for bar-by-bar incremental backtesting + (no bulk arrays needed); exposes ``.on_bar()``, ``.summary()``, ``.reset()``. +- ``AdvancedBacktestResult.to_equity_dataframe(freq)`` — returns equity, + returns, and drawdown as a ``pd.DataFrame`` with a synthetic DatetimeIndex. +- ``AdvancedBacktestResult.summary()`` — concise dict of the 9 most commonly + cited metrics plus ``n_trades``. + +**Core indicator speedup** + +- ADX-family indicators (``adx_all`` public API): all six series (PDM, MDM, + +DI, -DI, DX, ADX) can now be computed from a single TR/PDM/MDM pass via + ``ferro_ta.adx_all()``, eliminating the 6× redundant computation that + occurred when callers fetched each series independently. +- ``adxr`` now reuses a single ``adx_inner`` call internally (was calling + ``adx()`` which re-ran the inner loop). + +1.0.6 (2026-03-24) +------------------ + +- Added a repo-managed pre-push gate so the core Rust, Python, docs, and WASM + checks can be run locally before release. +- Expanded Rust-backed analysis/data helpers, broadened the WASM exports, and + added cross-surface API manifest verification plus Node conformance checks. +- Refreshed benchmark coverage and perf artifacts, aligned Python CI with the + local tooling flow, and updated the locked security fixes needed for a clean + release pass. + +1.0.4 (2026-03-24) +------------------ + +- Expanded the optional MCP server from a small hand-written subset to the + broader public ferro-ta callable surface, including stateful class support + through stored-instance management tools. +- Split the root documentation so the full TA-Lib compatibility matrix lives in + ``TA_LIB_COMPATIBILITY.md`` while the README stays product-first and shorter. +- Refreshed MCP docs/tests and updated locked low-risk Python dependencies as + part of the release cleanup pass. +- Stopped tracking the stray ``.coverage`` artifact and aligned ignore rules + for local coverage outputs. + +1.0.3 (2026-03-24) +------------------ + +- Added top-level package metadata helpers such as ``ferro_ta.__version__``, + ``ferro_ta.about()``, and ``ferro_ta.methods()``. +- Added a standalone derivatives benchmark artifact for selected options + pricing, IV, Greeks, and Black-76 comparisons. +- Simplified release version bumps with a single script and updated release + guidance. +- Fixed Python CI/type-stub gaps around the new metadata API and corrected the + tag-driven GitHub Release workflow trigger used for publish automation. + +1.0.2 (2026-03-24) +------------------ + +- Improved rolling statistical kernels and several Python analysis hotspots. +- Added reproducible perf-contract artifacts, TA-Lib regression guards, and + updated benchmark tooling. +- Tightened the public benchmark documentation so claims, caveats, and evidence + live closer together. + +1.0.1 (2026-03-24) +------------------ + +- Improved release automation for PyPI, crates.io, and npm. +- Fixed CI workflow issues that caused otherwise healthy release jobs to fail. +- Ensured the published WASM package includes its built ``pkg/`` artifacts. + +1.0.0 (2026-03-23) +------------------ + +- First stable release of the Rust-backed Python technical analysis library. +- Shipped broad TA-Lib coverage, streaming APIs, extended indicators, and the + initial Sphinx documentation set. +- Added the benchmark suite, release playbook, and compatibility/testing + scaffolding for stable releases. + +For the canonical project changelog, including the full per-version details, +see `CHANGELOG.md `_. diff --git a/vendor/ferro-ta-main/docs/compatibility/finta.md b/vendor/ferro-ta-main/docs/compatibility/finta.md new file mode 100644 index 0000000..741eaa2 --- /dev/null +++ b/vendor/ferro-ta-main/docs/compatibility/finta.md @@ -0,0 +1,145 @@ +# ferro-ta ↔ finta Compatibility + +[finta](https://github.com/peerchemist/finta) implements over 80 financial +technical indicators as class methods on a single `TA` class, operating +entirely on Pandas DataFrames. + +--- + +## Key architectural differences + +| Aspect | ferro-ta | finta | +|--------|---------|-------| +| **Backend** | Rust/C + SIMD | Pure Pandas | +| **Input type** | NumPy array or list | OHLCV Pandas DataFrame (required) | +| **DatetimeIndex** | Not required | **Required** | +| **Column names** | Separate arrays | `open/high/low/close/volume` | +| **Output type** | NumPy array | Pandas Series or DataFrame | +| **NaN handling** | Pads warmup with NaN | Pads warmup with NaN | +| **Streaming** | Yes (StreamingXxx classes) | No | +| **Speed** | ~700× faster on ATR | Baseline (pure Pandas) | + +--- + +## Required DataFrame format + +finta requires a **Pandas DataFrame with a DatetimeIndex** and lowercase +column names: + +```python +import pandas as pd +import numpy as np + +df = pd.DataFrame({ + "open": open_prices, + "high": high_prices, + "low": low_prices, + "close": close_prices, + "volume": volume_data, # required for volume indicators +}, index=pd.date_range("2020-01-01", periods=len(close_prices), freq="D")) +``` + +ferro-ta accepts raw NumPy arrays or Python lists — no DataFrame needed. + +--- + +## Function signature mapping + +finta uses a class-method API: `TA.INDICATOR(ohlcv_df, period, ...)`. + +| Indicator | ferro-ta | finta | +|-----------|---------|-------| +| SMA | `SMA(close, timeperiod=20)` | `TA.SMA(df, 20)` | +| EMA | `EMA(close, timeperiod=20)` | `TA.EMA(df, 20)` | +| WMA | `WMA(close, timeperiod=14)` | `TA.WMA(df, 14)` | +| DEMA | `DEMA(close, timeperiod=30)` | `TA.DEMA(df, 30)` | +| TEMA | `TEMA(close, timeperiod=30)` | `TA.TEMA(df, 30)` | +| HMA | Not supported | `TA.HMA(df, 16)` | +| RSI | `RSI(close, timeperiod=14)` | `TA.RSI(df, 14)` | +| MACD | `MACD(close, 12, 26, 9)` → (macd, signal, hist) | `TA.MACD(df, 12, 26, 9)` → DataFrame with `MACD`/`SIGNAL` columns | +| BBANDS | `BBANDS(close, 20, 2.0, 2.0)` → (upper, mid, lower) | `TA.BBANDS(df, 20)` → DataFrame with `BB_UPPER`/`BB_MIDDLE`/`BB_LOWER` | +| ATR | `ATR(high, low, close, timeperiod=14)` | `TA.ATR(df, 14)` | +| TRUE RANGE | `TRANGE(high, low, close)` | `TA.TR(df)` | +| OBV | `OBV(close, volume)` | `TA.OBV(df)` | +| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `TA.MFI(df, 14)` | +| CCI | `CCI(high, low, close, timeperiod=14)` | `TA.CCI(df, 14)` | +| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `TA.STOCH(df, 14)` | +| WILLR | `WILLR(high, low, close, timeperiod=14)` | `TA.WILLIAMS(df, 14)` | +| ADX | `ADX(high, low, close, timeperiod=14)` | `TA.ADX(df, 14)` | +| AROON | `AROON(high, low, timeperiod=14)` → (up, down) | `TA.AROON(df, 14)` → DataFrame | + +--- + +## Numerical accuracy + +finta uses sample standard deviation (ddof=1) for Bollinger Bands while +ferro-ta follows the TA-Lib convention (population std, ddof=0). For a +window of 20 bars this creates a ~0.5% difference in band width. + +For EMA-based indicators, finta seeds with the first data point while ferro-ta +follows TA-Lib (SMA of first `timeperiod` bars). Values converge after +~3× the period. + +Cross-library correlation between ferro-ta and finta is ≥ 0.95 for all +indicators after discarding the warm-up period. + +--- + +## Speed comparison + +On 10,000 bars (median µs, Apple M-series): + +| Indicator | ferro-ta | finta | ferro-ta speedup | +|-----------|--------:|-------:|----------------:| +| SMA | 16.7 | 178.1 | **10.7×** | +| MACD | 70.4 | 383.9 | **5.5×** | +| ATR | 51.4 | 1,247 | **24×** | + +On 100,000 bars: + +| Indicator | ferro-ta | finta | ferro-ta speedup | +|-----------|--------:|--------:|----------------:| +| SMA | 126.2 | 699.7 | **5.6×** | +| MACD | 465.9 | 1,470.8 | **3.2×** | +| ATR | 478.5 | 6,782 | **14×** | + +finta's ATR scales especially poorly because it relies on Pandas `.apply()` +with a lambda, which cannot be vectorised. + +--- + +## Migration guide + +```python +# FROM finta +import pandas as pd +from finta import TA + +ohlcv = pd.DataFrame(...) # must have DatetimeIndex + open/high/low/close/volume +sma = TA.SMA(ohlcv, 20) # returns Pandas Series +macd_df = TA.MACD(ohlcv, 12, 26, 9) # returns DataFrame with MACD/SIGNAL cols +bb_df = TA.BBANDS(ohlcv, 20) # returns DataFrame with BB_UPPER/MIDDLE/LOWER + +# TO ferro-ta (NumPy arrays — no DataFrame required) +import ferro_ta +import numpy as np + +close = ohlcv["close"].values +sma = ferro_ta.SMA(close, timeperiod=20) + +macd, signal, hist = ferro_ta.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + +upper, middle, lower = ferro_ta.BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0) +``` + +--- + +## Known limitations + +- finta cannot process raw NumPy arrays — a properly formatted DataFrame with + DatetimeIndex is always required. +- `TA.MACD` only returns `MACD` and `SIGNAL` columns; the histogram must be + computed manually as `MACD - SIGNAL`. +- Several finta indicators use non-standard formulas that may not match TA-Lib + conventions (e.g. STOCH uses a fixed 14-period window regardless of the + `fastk_period` argument). diff --git a/vendor/ferro-ta-main/docs/compatibility/pandas_ta.md b/vendor/ferro-ta-main/docs/compatibility/pandas_ta.md new file mode 100644 index 0000000..d475266 --- /dev/null +++ b/vendor/ferro-ta-main/docs/compatibility/pandas_ta.md @@ -0,0 +1,108 @@ +# Compatibility: ferro-ta vs pandas-ta + +ferro-ta provides indicators that match [pandas-ta](https://github.com/twopirllc/pandas-ta) +results to within numerical tolerance. This guide explains how to migrate from +pandas-ta and how to run the cross-library validation tests. + +## Installation + +```bash +pip install ferro-ta +# Optional: install pandas-ta to run comparison tests +pip install pandas-ta +``` + +## API Comparison + +### pandas-ta style (accessor) + +```python +import pandas as pd +import pandas_ta as ta + +close = pd.Series([...]) +sma = close.ta.sma(length=20) +ema = close.ta.ema(length=14) +rsi = close.ta.rsi(length=14) +``` + +### ferro-ta equivalent + +```python +import numpy as np +import ferro_ta as ft + +close = np.array([...]) +sma = ft.SMA(close, timeperiod=20) +ema = ft.EMA(close, timeperiod=14) +rsi = ft.RSI(close, timeperiod=14) +``` + +> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass +> it directly — ferro-ta will convert it automatically. + +## Indicator Mapping + +| pandas-ta | ferro-ta | Notes | +|---|---|---| +| `ta.sma(length=N)` | `ft.SMA(close, timeperiod=N)` | Exact match | +| `ta.ema(length=N)` | `ft.EMA(close, timeperiod=N)` | Tail convergence within 1e-6 | +| `ta.wma(length=N)` | `ft.WMA(close, timeperiod=N)` | Exact match | +| `ta.rsi(length=N)` | `ft.RSI(close, timeperiod=N)` | Tail convergence | +| `ta.macd(fast, slow, signal)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence | +| `ta.bbands(length=N, std=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match | +| `ta.stoch(high, low, close)` | `ft.STOCH(high, low, close, ...)` | Tail convergence | +| `ta.cci(high, low, close, length=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match | +| `ta.mom(length=N)` | `ft.MOM(close, timeperiod=N)` | Exact match | +| `ta.roc(length=N)` | `ft.ROC(close, timeperiod=N)` | Exact match | +| `ta.trima(length=N)` | `ft.TRIMA(close, timeperiod=N)` | Exact match | +| `ta.hma(length=N)` | `ft.HT_MA(close, timeperiod=N)` | Hull MA variant | +| `ta.ichimoku(...)` | `ft.ICHIMOKU(high, low, close)` | Tenkan/Kijun match | +| `ta.kc(high, low, close, ...)` | `ft.KELTNER(high, low, close, ...)` | Tail convergence | + +## Batch Execution + +ferro-ta supports running many indicators at once via the batch API: + +```python +import numpy as np +import ferro_ta as ft + +data = np.random.randn(1000, 50) # 50 instruments × 1000 bars + +# Run SMA(20) across all 50 instruments in one call +results = ft.batch_compute(data, "SMA", timeperiod=20) +``` + +## Running the Cross-Library Tests + +Cross-library comparison tests live in `tests/integration/test_vs_pandas_ta.py`. +They are automatically **skipped** when pandas-ta is not installed. + +```bash +# Install pandas-ta first +pip install pandas-ta + +# Run comparison tests +pytest tests/integration/test_vs_pandas_ta.py -v +``` + +## Known Differences + +- **Seeding period**: EMA results during the first `timeperiod` bars may differ + due to different initialization strategies (SMA seed vs EMA seed). Results + converge after the seeding window. +- **MACD signal line**: The signal EMA is seeded from the first valid MACD value. + Exact match begins after 2× `slowperiod` bars. +- **STOCH smoothing**: ferro-ta defaults match TA-Lib (SMA slowk, SMA slowd). + pandas-ta uses different defaults; pass matching parameters explicitly. + +## Performance Comparison + +ferro-ta is 10–100× faster than pandas-ta for large arrays because the core +computation is written in Rust: + +```bash +# Run the benchmark +pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json +``` diff --git a/vendor/ferro-ta-main/docs/compatibility/ta.md b/vendor/ferro-ta-main/docs/compatibility/ta.md new file mode 100644 index 0000000..120fdf7 --- /dev/null +++ b/vendor/ferro-ta-main/docs/compatibility/ta.md @@ -0,0 +1,104 @@ +# Compatibility: ferro-ta vs ta (Bukosabino) + +ferro-ta provides indicators that match [ta](https://github.com/bukosabino/ta) +(Bukosabino's library) results to within numerical tolerance. This guide +explains how to migrate from `ta` and how to run the cross-library validation +tests. + +## Installation + +```bash +pip install ferro-ta +# Optional: install ta to run comparison tests +pip install ta +``` + +## API Comparison + +### ta style + +```python +import pandas as pd +from ta.momentum import RSIIndicator, StochasticOscillator +from ta.volatility import AverageTrueRange, BollingerBands +from ta.trend import SMAIndicator, EMAIndicator, MACD, CCIIndicator +from ta.volume import OnBalanceVolumeIndicator +from ta.others import DailyReturnIndicator + +close = pd.Series([...]) +high = pd.Series([...]) +low = pd.Series([...]) +volume = pd.Series([...]) + +rsi = RSIIndicator(close, window=14).rsi() +sma = SMAIndicator(close, window=20).sma_indicator() +ema = EMAIndicator(close, window=14).ema_indicator() +``` + +### ferro-ta equivalent + +```python +import numpy as np +import ferro_ta as ft + +close = np.array([...]) +high = np.array([...]) +low = np.array([...]) +volume = np.array([...]) + +rsi = ft.RSI(close, timeperiod=14) +sma = ft.SMA(close, timeperiod=20) +ema = ft.EMA(close, timeperiod=14) +``` + +> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass +> it directly — ferro-ta will convert it automatically. + +## Indicator Mapping + +| ta | ferro-ta | Notes | +|---|---|---| +| `SMAIndicator(close, window=N).sma_indicator()` | `ft.SMA(close, timeperiod=N)` | Exact match | +| `EMAIndicator(close, window=N).ema_indicator()` | `ft.EMA(close, timeperiod=N)` | Tail convergence | +| `BollingerBands(close, window=N, window_dev=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match | +| `RSIIndicator(close, window=N).rsi()` | `ft.RSI(close, timeperiod=N)` | Tail convergence | +| `MACD(close, window_slow, window_fast, window_sign)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence | +| `StochasticOscillator(high, low, close, window, smooth_window)` | `ft.STOCH(high, low, close, ...)` | Tail convergence | +| `AverageTrueRange(high, low, close, window=N)` | `ft.ATR(high, low, close, timeperiod=N)` | Tail convergence | +| `WilliamsRIndicator(high, low, close, lbp=N)` | `ft.WILLR(high, low, close, timeperiod=N)` | Exact match | +| `OnBalanceVolumeIndicator(close, volume)` | `ft.OBV(close, volume)` | Exact match | +| `CCIIndicator(high, low, close, window=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match | + +## Running the Cross-Library Tests + +Cross-library comparison tests live in `tests/integration/test_vs_ta.py`. +They are automatically **skipped** when `ta` is not installed. + +```bash +# Install ta first +pip install ta + +# Run comparison tests +pytest tests/integration/test_vs_ta.py -v +``` + +## Known Differences + +- **EMA seeding**: `ta` uses pandas `ewm` with `adjust=True` by default, which + produces different warm-up values. Results converge after `2 × timeperiod` bars. +- **ATR**: `ta` uses a simple rolling mean for ATR by default; ferro-ta uses + Wilder's smoothing (same as TA-Lib). Values converge after the warm-up window. +- **STOCH**: `ta` and ferro-ta use different default smoothing periods. Pass + matching `window` / `smooth_window` values to get tail convergence. + +## Performance Comparison + +ferro-ta is significantly faster than `ta` for large arrays because the core +computation is written in Rust: + +```bash +pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json +``` + +`ta` is a pure-Python/pandas library; ferro-ta processes 100k-bar arrays +in microseconds vs milliseconds for pandas-based implementations. diff --git a/vendor/ferro-ta-main/docs/compatibility/talib.md b/vendor/ferro-ta-main/docs/compatibility/talib.md new file mode 100644 index 0000000..d33d14b --- /dev/null +++ b/vendor/ferro-ta-main/docs/compatibility/talib.md @@ -0,0 +1,27 @@ +# Compatibility: ferro-ta vs TA-Lib + +See the full migration guide at [docs/migration_talib.rst](../migration_talib.rst). + +ferro-ta is designed as a **drop-in replacement** for TA-Lib (`talib` Python package) for the most commonly used indicators. + +## Quick Reference + +```python +# TA-Lib +import talib +result = talib.SMA(close, timeperiod=14) + +# ferro-ta (identical API) +import ferro_ta +result = ferro_ta.SMA(close, timeperiod=14) +``` + +Full migration guide including all indicator mappings, known differences, and step-by-step migration: [migration_talib.rst](../migration_talib.rst) + +## Running Cross-Library Tests + +```bash +# Requires TA-Lib C library + talib Python package +pip install TA-Lib +pytest tests/integration/test_vs_talib.py -v +``` diff --git a/vendor/ferro-ta-main/docs/compatibility/tulipy.md b/vendor/ferro-ta-main/docs/compatibility/tulipy.md new file mode 100644 index 0000000..d1ee421 --- /dev/null +++ b/vendor/ferro-ta-main/docs/compatibility/tulipy.md @@ -0,0 +1,140 @@ +# ferro-ta ↔ Tulipy Compatibility + +[Tulipy](https://github.com/cirla/tulipy) is the Python binding for +[Tulip Indicators](https://tulipindicators.org/) — 104 technical analysis +functions written in pure ANSI C99, designed for absolute speed with zero +external dependencies. + +--- + +## Key architectural differences + +| Aspect | ferro-ta | Tulipy | +|--------|---------|--------| +| **Backend** | Rust/C + SIMD | ANSI C99 | +| **Input type** | NumPy array or list | `np.float64` contiguous array | +| **Output length** | Same as input (NaN-padded) | Truncated (lookback bars shorter) | +| **NaN handling** | Pads warmup with NaN | Strips warmup entirely | +| **Multi-output** | Returns tuple | Returns tuple | +| **Pandas support** | Yes (via `ArrayLike`) | No | +| **Streaming** | Yes (StreamingXxx classes) | No | + +--- + +## Output length difference + +Tulipy **truncates** output instead of NaN-padding. When comparing results +you must align by the **trailing** elements: + +```python +import tulipy as ti +import ferro_ta +import numpy as np + +close = np.ascontiguousarray(np.random.randn(100).cumsum() + 100, dtype=np.float64) + +ti_sma = ti.sma(close, period=20) # len = 81 +ft_sma = ferro_ta.SMA(close, timeperiod=20) # len = 100 (19 leading NaN) + +# Align: compare last 81 values +n = len(ti_sma) +assert np.allclose(ti_sma, ft_sma[-n:][np.isfinite(ft_sma[-n:])], atol=1e-8) +``` + +--- + +## Function signature mapping + +Tulipy uses lowercase function names. The `period` argument is always a +positional-or-keyword integer. + +| Indicator | ferro-ta | Tulipy | +|-----------|---------|--------| +| SMA | `SMA(close, timeperiod=20)` | `sma(close, period=20)` | +| EMA | `EMA(close, timeperiod=20)` | `ema(close, period=20)` | +| WMA | `WMA(close, timeperiod=14)` | `wma(close, period=14)` | +| RSI | `RSI(close, timeperiod=14)` | `rsi(close, period=14)` | +| MACD | `MACD(close, 12, 26, 9)` | `macd(close, short_period=12, long_period=26, signal_period=9)` | +| BBANDS | `BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)` → (upper, mid, lower) | `bbands(close, period=20, stddev=2.0)` → (lower, mid, upper) ⚠️ reversed! | +| ATR | `ATR(high, low, close, timeperiod=14)` | `atr(high, low, close, period=14)` | +| OBV | `OBV(close, volume)` | `obv(close, volume)` | +| CCI | `CCI(high, low, close, timeperiod=14)` | `cci(high, low, close, period=14)` | +| WILLR | `WILLR(high, low, close, timeperiod=14)` | `willr(high, low, close, period=14)` | +| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `stoch(high, low, close, ...)` | +| HMA | Not supported | `hma(close, period=14)` | +| DEMA | `DEMA(close, timeperiod=30)` | `dema(close, period=30)` | +| TEMA | `TEMA(close, timeperiod=30)` | `tema(close, period=30)` | +| AROON | `AROONOSC(high, low, timeperiod=14)` | `aroonosc(high, low, period=14)` | +| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `mfi(high, low, close, volume, period=14)` | +| TRANGE | `TRANGE(high, low, close)` | `tr(high, low, close)` | + +⚠️ **BBANDS tuple order**: Tulipy returns `(lower, middle, upper)`; +ferro-ta and TA-Lib return `(upper, middle, lower)`. + +--- + +## Memory requirements + +Tulipy requires **strictly contiguous** `np.float64` arrays. Passing a +Pandas Series slice or a non-contiguous array causes an error: + +```python +# Wrong — may be a non-contiguous view +close = df["close"].values +ti.sma(close, period=20) # may raise ValueError + +# Correct — explicit contiguous cast +close = np.ascontiguousarray(df["close"].values, dtype=np.float64) +ti.sma(close, period=20) # always works +``` + +ferro-ta accepts any `ArrayLike` and handles the conversion internally. + +--- + +## Numerical accuracy + +Tulipy and ferro-ta agree closely for SMA, WMA, and other non-recursive +indicators (differences < 1e-8). For EMA-based indicators the first +`timeperiod` values differ due to initialisation seed choice: + +- **Tulipy**: uses the first data value as the EMA seed. +- **ferro-ta**: follows TA-Lib convention (SMA of first `timeperiod` bars). + +Values converge after approximately 2–3× the `timeperiod`. + +--- + +## Speed comparison + +On 10,000 bars (median µs, Apple M-series): + +| Indicator | ferro-ta | Tulipy | Winner | +|-----------|--------:|-------:|--------| +| SMA | 16.7 | 21.2 | ferro-ta | +| MACD | 70.4 | 30.2 | Tulipy | +| ATR | 51.4 | 27.6 | Tulipy | + +Tulipy's C99 implementation excels for recursive indicators (ATR, MACD). +ferro-ta is faster for sliding-window indicators (SMA) thanks to SIMD +vectorisation. + +--- + +## Migration guide + +```python +# FROM Tulipy +import tulipy as ti +import numpy as np + +close = np.ascontiguousarray(close_series.values, dtype=np.float64) +sma_values = ti.sma(close, period=20) # length: n - 19 + +# TO ferro-ta (drop-in, same numeric result in the tail) +import ferro_ta + +sma_values = ferro_ta.SMA(close, timeperiod=20) # length: n (19 leading NaN) +# Strip warmup if needed: +sma_values = sma_values[~np.isnan(sma_values)] +``` diff --git a/vendor/ferro-ta-main/docs/conf.py b/vendor/ferro-ta-main/docs/conf.py new file mode 100644 index 0000000..7d7d62b --- /dev/null +++ b/vendor/ferro-ta-main/docs/conf.py @@ -0,0 +1,98 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import os +import re +import sys +from pathlib import Path + +try: + import tomllib +except ImportError: # pragma: no cover + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + tomllib = None # type: ignore[assignment] + +# Add the python source directory so autodoc can import ferro_ta +# Only add if ferro_ta is not already installed (e.g. from a wheel in CI) +try: + import ferro_ta # noqa: F401 +except ImportError: + sys.path.insert(0, os.path.abspath("../python")) + +# -- Project information ------------------------------------------------------- +project = "ferro-ta" +copyright = "2024, pratikbhadane24" +author = "pratikbhadane24" + + +def _default_release() -> str: + if tomllib is None: + return "0+unknown" + pyproject_toml = Path(__file__).resolve().parents[1] / "pyproject.toml" + try: + if tomllib is not None: + with pyproject_toml.open("rb") as handle: + data = tomllib.load(handle) + return data.get("project", {}).get("version", "0+unknown") + text = pyproject_toml.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if match: + return match.group(1) + return "0+unknown" + except Exception: + return "0+unknown" + + +# Version from env (e.g. set in CI from git tag) or default to pyproject.toml +release = os.environ.get("FERRO_TA_VERSION", _default_release()) +version = release + +# -- General configuration ---------------------------------------------------- +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "sphinx.ext.napoleon", # Google / NumPy-style docstrings + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", +] + +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable", None), + "pandas": ("https://pandas.pydata.org/docs", None), +} + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# -- Options for HTML output -------------------------------------------------- +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +html_title = "ferro-ta Documentation" +html_short_title = "ferro-ta" + +# -- autodoc ------------------------------------------------------------------ +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} +autodoc_typehints = "description" +napoleon_google_docstring = False +napoleon_numpy_docstring = True + +# Suppress autodoc import warnings for modules that can't be loaded without +# the compiled Rust extension (_ferro_ta). These are expected when building +# docs without the wheel; the documented API is still accurate. +# Also suppress duplicate object descriptions that arise when Rust-backed +# streaming classes (defined in ferro_ta._ferro_ta) are re-exported through +# ferro_ta.streaming — autodoc sees them in both modules. +suppress_warnings = [ + "autodoc.import_object", + "ref.doc", + "py.duplicate", +] diff --git a/vendor/ferro-ta-main/docs/contributing.rst b/vendor/ferro-ta-main/docs/contributing.rst new file mode 100644 index 0000000..4ebf9ef --- /dev/null +++ b/vendor/ferro-ta-main/docs/contributing.rst @@ -0,0 +1,113 @@ +Contributing +============ + +Thank you for your interest in contributing to ferro-ta! + +This page summarises how to get started. The full details are in +`CONTRIBUTING.md `_ +at the repository root. + +.. contents:: + :local: + :depth: 2 + + +Development setup +----------------- + +Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``. + +.. code-block:: bash + + git clone https://github.com/pratikbhadane24/ferro-ta.git + cd ferro-ta + pip install maturin numpy pytest pytest-cov + maturin develop --release + pytest tests/ + + +Git hooks and pre-push checks +----------------------------- + +Install the repository-managed hooks after setting up the environment: + +.. code-block:: bash + + make hooks + +Run the same push gate manually with: + +.. code-block:: bash + + make prepush + +You can scope it to selected checks while iterating: + +.. code-block:: bash + + make prepush CHECKS="version changelog python_lint" + + +Adding a new indicator +----------------------- + +1. **Rust** — implement the function in the appropriate ``src//`` + directory (e.g. ``src/overlap/mod.rs`` and ``src/overlap/sma.rs``). Follow + the existing patterns: slice inputs, ``Vec`` output, leading NaN for + warm-up bars, a ``#[pyfunction]`` decorator, and registration in the + module's ``register(m)`` function. + +2. **Python** — add a thin wrapper in the matching ``python/ferro_ta/*.py`` + module using the ``_to_f64`` helper. Export it in ``__all__``. + +3. **Re-export** — add the function to ``python/ferro_ta/__init__.py``'s + ``__all__`` list and import block. + +4. **Type stub** — add a type annotation to ``python/ferro_ta/__init__.pyi``. + +5. **Tests** — add at least one test class in ``tests/test_ferro_ta.py`` + covering output length, NaN count, and a known-value check. + +6. **README** — add a row to the appropriate accuracy table. + + +Code style +---------- + +- Rust: ``cargo fmt`` (enforced in CI) and ``cargo clippy -- -D warnings`` +- Python: PEP 8; function names in UPPER_CASE to match TA-Lib convention. +- All public Python functions should have NumPy-style docstrings. + + +Running tests +------------- + +.. code-block:: bash + + # Python tests + pytest tests/ -v + + # Rust format check + cargo fmt --check + + # Rust lints + cargo clippy --release -- -D warnings + + # Optional: TA-Lib comparison tests (requires ta-lib installed) + pytest tests/test_vs_talib.py -v + + +Type checking +------------- + +The package is typed (PEP 561). To run mypy:: + + pip install mypy numpy + mypy python/ferro_ta --ignore-missing-imports + + +Questions +--------- + +Open a GitHub Issue or Discussion. For security vulnerabilities see +`SECURITY.md `_. diff --git a/vendor/ferro-ta-main/docs/derivatives-analytics.md b/vendor/ferro-ta-main/docs/derivatives-analytics.md new file mode 100644 index 0000000..ed86658 --- /dev/null +++ b/vendor/ferro-ta-main/docs/derivatives-analytics.md @@ -0,0 +1,226 @@ +# Derivatives Analytics + +`ferro-ta` ships a Rust-backed derivatives analytics layer focused on +research, simulation, and risk analysis. All functions are implemented in +Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen). + +--- + +## Modules + +### `ferro_ta.analysis.options` + +| Category | Functions | +|---|---| +| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` | +| **Greeks** | `greeks`, `extended_greeks` | +| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` | +| **Digital options** | `digital_option_price`, `digital_option_greeks` | +| **American options** | `american_option_price`, `early_exercise_premium` | +| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` | +| **Chain helpers** | `label_moneyness`, `select_strike` | +| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` | +| **Vol cone** | `vol_cone` | +| **Diagnostics** | `put_call_parity_deviation` | + +### `ferro_ta.analysis.futures` + +- Synthetic forwards and parity diagnostics +- Basis, annualized basis, implied carry, carry spread +- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted +- Curve analytics: calendar spreads, slope, contango summary + +### `ferro_ta.analysis.options_strategy` + +Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets +(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`), +risk controls, cost assumptions, and simulation limits. + +### `ferro_ta.analysis.derivatives_payoff` + +Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and +**stock** instrument types. + +| Function | Description | +|---|---| +| `option_leg_payoff` | Expiry P/L for a single option leg | +| `futures_leg_payoff` | Linear P/L for a futures leg | +| `stock_leg_payoff` | Linear P/L for a stock/equity leg | +| `strategy_payoff` | Aggregate expiry payoff across all legs | +| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy | +| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs | + +--- + +## Model conventions + +| Parameter | Convention | +|---|---| +| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield | +| `model="black76"` | Underlying is the forward price | +| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) | +| `time_to_expiry` | Years (e.g. `0.25` = 3 months) | + +--- + +## Quick examples + +### BSM pricing and Greeks + +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price + +price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call") +g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +print(price, iv, g.delta, g.gamma) +``` + +### Extended (second-order) Greeks + +```python +from ferro_ta.analysis.options import extended_greeks + +eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color) +``` + +### Digital options + +```python +from ferro_ta.analysis.options import digital_option_price, digital_option_greeks + +# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53 +price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20, + option_type="call", digital_type="cash_or_nothing") +g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20, + option_type="call", digital_type="cash_or_nothing") +print(price, g.delta, g.gamma, g.vega) +``` + +### American options (BAW approximation) + +```python +from ferro_ta.analysis.options import american_option_price, early_exercise_premium + +# American put — may have meaningful early exercise premium +american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +print(american, premium) +``` + +### Historical volatility estimators + +```python +import numpy as np +from ferro_ta.analysis.options import ( + close_to_close_vol, garman_klass_vol, parkinson_vol, + rogers_satchell_vol, yang_zhang_vol, +) + +# Assume daily OHLC arrays of length N +open_p, high_p, low_p, close_p = ... # numpy arrays + +ctc = close_to_close_vol(close_p, window=20) # close-only +park = parkinson_vol(high_p, low_p, window=20) # high-low +gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20) +rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20) +yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20) +``` + +### Volatility cone + +```python +from ferro_ta.analysis.options import vol_cone + +cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252)) +# Overlay current IV against the cone to gauge richness/cheapness +for w, med in zip(cone.windows, cone.median): + print(f"window={int(w):3d} median_rv={med:.1%}") +``` + +### Put-call parity check + +```python +from ferro_ta.analysis.options import option_price, put_call_parity_deviation + +call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put") +dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0) +# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes +``` + +### Expected move + +```python +from ferro_ta.analysis.options import expected_move + +lower, upper = expected_move(100.0, 0.20, days_to_expiry=30) +print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]") +``` + +### Multi-leg strategies with stock + +```python +import numpy as np +from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value + +# Covered Call: long 100 shares + short 1 OTM call +spot_grid = np.linspace(80, 130, 100) +legs = [ + PayoffLeg("stock", "long", entry_price=100.0), + PayoffLeg("option", "short", option_type="call", + strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25), +] + +# Expiry P/L +payoff = strategy_payoff(spot_grid, legs=legs) + +# Pre-expiry BSM value (T=3 months remaining) +value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20) +``` + +### Futures analytics + +```python +from ferro_ta.analysis.futures import basis, curve_summary + +print(basis(100.0, 103.0)) +print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0])) +``` + +--- + +## Instrument types in `PayoffLeg` / `StrategyLeg` + +| `instrument` | Required fields | Payoff | +|---|---|---| +| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(S−K), 0) − premium` | +| `"future"` | `entry_price` | `S − entry_price` | +| `"stock"` | `entry_price` | `S − entry_price` (identical to future, no margin) | + +--- + +## Volatility estimator efficiency comparison + +| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps | +|---|---|---| +| Close-to-close | 1× (baseline) | N/A (uses close only) | +| Parkinson | ~5× | No | +| Garman-Klass | ~7.4× | No | +| Rogers-Satchell | ~8× | No | +| Yang-Zhang | ~14× | Yes | + +*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson +or Garman-Klass for continuous trading sessions.* + +--- + +## Notes + +- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`, + `option_price`, etc.) are preserved — fully backward compatible. +- The derivatives layer is analytics-only: no broker connectivity, order routing, + or execution workflow. +- WASM: all functions in this layer are also exported as WebAssembly bindings + (see `wasm/src/lib.rs`). diff --git a/vendor/ferro-ta-main/docs/derivatives.rst b/vendor/ferro-ta-main/docs/derivatives.rst new file mode 100644 index 0000000..9ac93ef --- /dev/null +++ b/vendor/ferro-ta-main/docs/derivatives.rst @@ -0,0 +1,126 @@ +Derivatives Analytics +===================== + +``ferro-ta`` includes a Rust-backed derivatives layer for analytics, research, +and simulation workflows. The implementation is analytics-only: there is no +broker connectivity, order routing, or execution engine in this package. + +What Is Included +---------------- + +Options analytics +~~~~~~~~~~~~~~~~~ + +- Rolling IV helpers: ``iv_rank``, ``iv_percentile``, ``iv_zscore`` +- Black-Scholes-Merton pricing +- Black-76 pricing +- Greeks: delta, gamma, vega, theta, rho +- Implied volatility inversion +- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity +- Chain helpers: moneyness labels and strike selection by offset or delta + +Futures analytics +~~~~~~~~~~~~~~~~~ + +- Synthetic forwards and parity diagnostics +- Basis, annualized basis, implied carry, carry spread +- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted +- Curve analytics: calendar spreads, slope, contango/backwardation summary + +Strategy and payoff helpers +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +- Typed strategy schemas for expiry selectors, strike selectors, leg presets, + risk controls, and simulation limits +- Multi-leg payoff aggregation +- Greeks aggregation across option and futures legs + +Conventions +----------- + +- ``model="bsm"`` expects spot as the underlying input. +- ``model="black76"`` expects forward as the underlying input. +- Volatility uses decimal annualized units: ``0.20`` means 20%. +- Rates and carry use decimal annualized units: ``0.05`` means 5%. +- ``time_to_expiry`` is expressed in years. + +Options Example +--------------- + +.. code-block:: python + + from ferro_ta.analysis.options import greeks, implied_volatility, option_price + + price = option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.20, + option_type="call", + model="bsm", + ) + iv = implied_volatility( + price, + 100.0, + 100.0, + 0.05, + 1.0, + option_type="call", + model="bsm", + ) + g = greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.20, + option_type="call", + model="bsm", + ) + +Futures Example +--------------- + +.. code-block:: python + + from ferro_ta.analysis.futures import basis, curve_summary, synthetic_forward + + front_basis = basis(100.0, 103.0) + synthetic = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5) + curve = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]) + +Strategy and Payoff Example +--------------------------- + +.. code-block:: python + + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, aggregate_greeks, strategy_payoff + + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.20, + time_to_expiry=0.5, + ), + PayoffLeg( + instrument="future", + side="long", + entry_price=100.0, + ), + ] + + payoff = strategy_payoff([90.0, 100.0, 110.0], legs=legs) + portfolio_greeks = aggregate_greeks(100.0, legs=legs) + +Related Modules +--------------- + +- :mod:`ferro_ta.analysis.options` +- :mod:`ferro_ta.analysis.futures` +- :mod:`ferro_ta.analysis.options_strategy` +- :mod:`ferro_ta.analysis.derivatives_payoff` diff --git a/vendor/ferro-ta-main/docs/error_handling.rst b/vendor/ferro-ta-main/docs/error_handling.rst new file mode 100644 index 0000000..846ae75 --- /dev/null +++ b/vendor/ferro-ta-main/docs/error_handling.rst @@ -0,0 +1,79 @@ +Error Handling and Validation +============================= + +ferro-ta uses a consistent error model so you can catch and handle failures in a +predictable way. + +Exception hierarchy +------------------- + +All ferro-ta–specific exceptions inherit from :exc:`ferro_ta.FerroTAError` and the +corresponding built-in type so that existing ``except ValueError`` code keeps +working: + +- **FerroTAError** — base for all ferro-ta exceptions +- **FerroTAValueError** — invalid parameter values (e.g. ``timeperiod < 1``, + ``fastperiod >= slowperiod`` for MACD). Inherits from :exc:`ValueError`. +- **FerroTAInputError** — invalid input arrays (mismatched lengths, wrong shape, + or opt-in strict checks). Inherits from :exc:`ValueError`. + +Example: + +.. code-block:: python + + from ferro_ta import SMA, FerroTAValueError, FerroTAInputError + + try: + SMA(close, timeperiod=0) + except FerroTAValueError as e: + print(e) # "timeperiod must be >= 1, got 0" + + try: + SMA(open_arr, timeperiod=5) # if open_arr has different length + except FerroTAInputError as e: + print(e) + +Validation in wrappers +---------------------- + +Every indicator wrapper validates parameters and inputs before calling the Rust +engine: + +- **Period parameters** (e.g. ``timeperiod``, ``fastperiod``, ``slowperiod``) are + checked with :func:`ferro_ta.exceptions.check_timeperiod` and must be >= 1 + (or >= 2 where the algorithm requires it, e.g. MAVP ``minperiod``). +- **Multiple arrays** (e.g. open, high, low, close, volume) are checked with + :func:`ferro_ta.exceptions.check_equal_length` so all have the same length. + +Any error raised by the Rust extension (e.g. invalid value or bad array) is +re-raised as :exc:`FerroTAValueError` or :exc:`FerroTAInputError` with the same +message, so you can rely on the ferro-ta exception hierarchy. + +NaN and Inf +----------- + +By default, ferro-ta **propagates** NaN and Inf in input arrays: output values +that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is +raised for NaN or Inf in the input. + +If you need strict behaviour (no NaN/Inf), call +:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them to +an indicator. + +Empty and short arrays +---------------------- + +Indicators that require a minimum number of bars (e.g. SMA with ``timeperiod=5`` +needs at least 5 elements) may return an array of NaN or raise if the Rust layer +rejects the input. You can use :func:`ferro_ta.exceptions.check_min_length` to +enforce a minimum length before calling an indicator. + +Helper reference +---------------- + +- :func:`ferro_ta.exceptions.check_timeperiod` — raise if a period parameter is below minimum +- :func:`ferro_ta.exceptions.check_equal_length` — raise if supplied arrays have different lengths +- :func:`ferro_ta.exceptions.check_finite` — raise if an array contains NaN or Inf (opt-in strict) +- :func:`ferro_ta.exceptions.check_min_length` — raise if an array is shorter than required + +See the :mod:`ferro_ta.exceptions` API for full signatures and examples. diff --git a/vendor/ferro-ta-main/docs/extended.rst b/vendor/ferro-ta-main/docs/extended.rst new file mode 100644 index 0000000..5b9677d --- /dev/null +++ b/vendor/ferro-ta-main/docs/extended.rst @@ -0,0 +1,10 @@ +Extended Indicators +=================== + +Extended indicators go beyond the TA-Lib standard set. + +.. automodule:: ferro_ta.extended + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/gpu-backend.md b/vendor/ferro-ta-main/docs/gpu-backend.md new file mode 100644 index 0000000..c8e15cf --- /dev/null +++ b/vendor/ferro-ta-main/docs/gpu-backend.md @@ -0,0 +1,134 @@ +# GPU Backend (PyTorch) + +This document describes the optional GPU-accelerated backend for **ferro-ta** powered +by [PyTorch](https://pytorch.org/). + +--- + +## Goals + +- Offer a drop-in GPU path for a small subset of indicators (SMA, EMA, RSI) for users + who process very large arrays (millions of bars or thousands of symbols in parallel). +- Keep the default install CPU-only: no GPU dependency unless the user opts in. +- Maintain API transparency: `torch.Tensor` in → `torch.Tensor` out; + `numpy.ndarray` in → `numpy.ndarray` out. +- Support both **CUDA** (NVIDIA) and **MPS** (Apple Silicon). + +--- + +## Supported Indicators + +| Indicator | Module | Notes | +|---|---|---| +| `sma` | `ferro_ta.gpu` | cumsum-based O(n) rolling mean; native PyTorch | +| `ema` | `ferro_ta.gpu` | SMA-seeded; recurrence on CPU for numerical fidelity | +| `rsi` | `ferro_ta.gpu` | diffs on GPU; Wilder smoothing on CPU | + +All other ferro-ta indicators fall back to the CPU path automatically when called +through the top-level `ferro_ta` namespace. + +--- + +## Installation + +**Default (CPU-only):** + +```bash +pip install ferro-ta +``` + +**With GPU support (PyTorch):** + +```bash +pip install "ferro-ta[gpu]" +``` + +This installs `torch>=2.0`. For CUDA or MPS, install the appropriate PyTorch build +from [pytorch.org](https://pytorch.org/get-started/locally/): + +```bash +# CUDA 12.x (example) +pip install torch --index-url https://download.pytorch.org/whl/cu121 + +# Apple Silicon (MPS) — often included in default pip install +pip install torch +``` + +--- + +## Usage + +```python +import torch +from ferro_ta.gpu import sma, ema, rsi + +# Build a tensor on GPU (CUDA or MPS on Apple Silicon) +close_gpu = torch.tensor( + [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33], + device="cuda", # or device="mps" on Apple Silicon + dtype=torch.float64, +) + +# GPU-accelerated SMA — result is also a torch.Tensor +sma_out = sma(close_gpu, timeperiod=5) +print(type(sma_out)) # +print(sma_out.cpu().numpy()) # same values as CPU SMA + +# RSI on GPU +rsi_out = rsi(close_gpu, timeperiod=5) + +# Fall back to CPU automatically when input is numpy +import numpy as np +close_cpu = np.array([44.34, 44.09, 44.15, 43.61, 44.33]) +sma_cpu = sma(close_cpu, timeperiod=3) +print(type(sma_cpu)) # +``` + +--- + +## Limitations + +1. **Only 3 indicators supported.** SMA, EMA, RSI. The full set of 160+ indicators + falls back to the CPU path. Adding more GPU indicators is planned for future work. + +2. **Transfer overhead.** Moving data from CPU RAM to GPU memory and back dominates for + small arrays (< ~100k elements). The GPU path is faster only when data is already + on the device or for very large arrays. + +3. **float64.** PyTorch tensors are supported; dtype conversion is performed + automatically for integer inputs. + +4. **EMA and RSI recurrence is on CPU.** To guarantee exact Wilder-smoothing parity + with the CPU implementation, the recurrence loop runs on the CPU after computing + diffs/seeds on the GPU. A future release may implement a fully native GPU kernel. + +5. **No OOM handling.** For extremely large arrays the GPU may run out of memory; + no graceful fallback is implemented. + +--- + +## Benchmarks + +Measured on an NVIDIA RTX 3080 (10 GB VRAM) with CUDA 12.2, Python 3.11, +PyTorch 2.x. Array size: **1,000,000 elements**. + +| Indicator | CPU (NumPy/Rust) | GPU (PyTorch) | Speedup | Notes | +|---|---|---|---|---| +| `sma` (period 30) | 0.4 ms | 0.9 ms | 0.4× | Transfer overhead dominates | +| `ema` (period 30) | 0.6 ms | 1.2 ms | 0.5× | Recurrence on CPU; no GPU gain | +| `rsi` (period 14) | 1.1 ms | 1.4 ms | 0.8× | Diffs on GPU; recurrence on CPU | + +> **Key finding:** For 1M-element arrays, the GPU path is **not faster** than the +> optimised Rust/CPU path due to the cost of host↔device memory transfers. The GPU +> path is most useful when (a) data is already on the GPU, or (b) the same kernel +> is launched many times without re-transferring data. + +The benchmark script is in `benchmarks/bench_gpu.py`. + +--- + +## Future Work + +- Implement fully native GPU kernels for EMA and RSI to avoid CPU round-trips. +- Extend to batch operations (running 1000+ symbols in parallel on GPU). +- Add optional RAPIDS cuDF or Polars GPU integration for dataframe-level workflows. diff --git a/vendor/ferro-ta-main/docs/guides/dtw.md b/vendor/ferro-ta-main/docs/guides/dtw.md new file mode 100644 index 0000000..c7a825f --- /dev/null +++ b/vendor/ferro-ta-main/docs/guides/dtw.md @@ -0,0 +1,80 @@ +# Dynamic Time Warping + +ferro-ta ships three DTW entry points. Pick the one that matches your +workload — the distance-only path is measurably faster than the one that +reconstructs the warping path, and `BATCH_DTW` parallelises over rows. + +## Quick reference + +| Function | Returns | When to use | +|---|---|---| +| `DTW_DISTANCE(a, b, window=None)` | `float` | You only need the distance. Fastest. | +| `DTW(a, b, window=None)` | `(float, ndarray[N, 2])` | You need the alignment path for plotting or downstream analysis. | +| `BATCH_DTW(matrix, reference, window=None)` | `ndarray[N]` | You have N candidate series and one reference; uses rayon. | + +## Distance convention + +ferro-ta's DTW uses squared-Euclidean local cost accumulated along the +optimal path, with a single `sqrt()` applied at the end. This matches +`dtaidistance.dtw.distance()` to within floating-point tolerance (parity +tests assert numerical agreement, not bitwise identity). Example: + +```python +>>> import ferro_ta as fta +>>> fta.DTW_DISTANCE([0.0, 1.0, 2.0], [1.0, 2.0, 3.0]) +1.4142135623730951 # == sqrt(2), same as dtaidistance +``` + +If you are migrating from a library that uses absolute-difference local +cost without the final sqrt (e.g. `fastdtw`'s default), your numbers will +not line up. That is a choice ferro-ta made for parity with the +scientific-Python ecosystem. + +## Window constraint (Sakoe-Chiba band) + +Passing `window=w` constrains the DP to cells where `|i - j| < w`. This +turns the O(n·m) cost into O(n·w), which is typically a 5–20× speedup for +realistic `w`. A narrower band can only *increase* the distance, so +`window=` is safe to use whenever your series are roughly aligned. + +```python +# Unconstrained +fta.DTW_DISTANCE(a, b) + +# Constrained: warping may shift up to 5 positions +fta.DTW_DISTANCE(a, b, window=5) +``` + +## Batch usage + +`BATCH_DTW` compares each row of a 2-D matrix against one reference +series, in parallel: + +```python +import numpy as np +import ferro_ta as fta + +reference = np.random.random(500) +candidates = np.random.random((1000, 500)) + +distances = fta.BATCH_DTW(candidates, reference, window=20) +nearest = int(np.argmin(distances)) +``` + +Parallelism is via rayon; no thread-pool configuration is needed on the +Python side. For the sequence lengths ferro-ta targets (thousands of +bars, hundreds to low thousands of candidates), batch-parallel classic +DTW beats FastDTW-style approximations. + +## Edge cases + +- **Empty input:** raises `FerroTAInputError`. +- **NaN in input:** propagates to the output (matches IEEE 754). Call + `ferro_ta.core.exceptions.check_finite()` first if you want to fail + loudly instead. +- **Different-length series:** fully supported. The path array length + is bounded by `max(n, m) <= len(path) <= n + m - 1`. + +## See also + +- `tests/unit/indicators/test_statistic.py` — parity tests against `dtaidistance`. diff --git a/vendor/ferro-ta-main/docs/guides/simd.md b/vendor/ferro-ta-main/docs/guides/simd.md new file mode 100644 index 0000000..31fd46a --- /dev/null +++ b/vendor/ferro-ta-main/docs/guides/simd.md @@ -0,0 +1,92 @@ +# SIMD acceleration + +ferro-ta accelerates hot reductions with **runtime CPU-feature dispatch** +via the [`multiversion`](https://crates.io/crates/multiversion) crate. Each +dispatched function is compiled into several variants — baseline, SSE, +AVX2/FMA, AVX-512 on x86_64; NEON on aarch64 — and the fastest one the +**current** CPU supports is chosen at load time via CPUID. + +## Why dispatch instead of `-C target-cpu` + +A static `RUSTFLAGS=-C target-cpu=x86-64-v3` build *requires* AVX2 on the +running CPU; on an older chip it crashes with an illegal instruction +(SIGILL). Runtime dispatch instead ships every code path in one binary and +picks at runtime, so a single artifact: + +- runs on **any** CPU of the target architecture (no SIGILL on pre-AVX2 + hardware), and +- still uses wide vector units where the hardware has them. + +That property is what lets the **same** wheel / Docker image / crate run +across a heterogeneous fleet. + +## When it helps + +SIMD helps indicators whose inner loop is a reduction over contiguous +`f64` data — e.g. the initial window sum that seeds SMA, the `(T, S)` seed +for WMA, and similar fixed-window reductions. It does **not** help: + +- The O(n) streaming recurrences (`window_sum += new - old`): each step + depends on the previous one, so they are inherently sequential. +- Branchy inner loops (SAR, candlestick patterns). +- Streaming classes (a single-bar update is one or two ops). + +The shared primitives live in `crates/ferro_ta_core/src/simd.rs` +(`sum`, `wma_seed`). They accumulate into independent lanes before a final +horizontal combine — that lane independence is what allows the optimizer to +vectorize each CPU-feature variant. A consequence is that results differ +from a strict left-to-right sum by a few ULPs, well inside every +indicator's documented tolerance. + +## The `simd` feature + +Dispatch is gated behind the `simd` Cargo feature, which is **on by +default**: + +```bash +# default build — runtime dispatch enabled +cargo build -p ferro_ta_core --release + +# pure-scalar build (debugging / baseline benchmarking) +cargo build -p ferro_ta_core --release --no-default-features +``` + +For Python, wheels published to PyPI are built with the default features, +so `pip install ferro-ta` ships the dispatched fast path with no action on +your part. To build a pure-scalar extension from source: + +```bash +maturin develop --release --no-default-features +``` + +## Measured speedups + +The nightly `benchmarks/bench_simd.py` job (see +`.github/workflows/nightly-bench.yml`) builds the extension twice — once +with `--no-default-features` (pure scalar) and once with `--features simd` +(dispatch) — and reports the per-indicator delta. Numbers are regenerated +on every run and vary with hardware; treat any table in a PR as a snapshot, +not a contract. The dispatched kernels here target correctness-preserving +reductions, so gains are modest on the sliding-window indicators and larger +on full-array reductions. + +## Adding a SIMD-optimized indicator + +1. Write and test the **scalar** implementation first — it is the ground + truth. +2. If the hot path is a contiguous `f64` reduction, route it through a + `crate::simd` primitive, or wrap a new helper in + `#[multiversion::multiversion(targets = "simd")]` with the loop body + accumulating into independent lanes. +3. Add a parity test comparing the dispatched result against the strict + scalar reference within tolerance (see `simd.rs` tests for the pattern). +4. Benchmark scalar vs dispatch via `bench_simd.py`. Only keep the SIMD + path if it wins — alignment and tail-handling overhead can make a naive + vectorization *lose* to scalar. + +## See also + +- `crates/ferro_ta_core/src/simd.rs` — dispatched primitives and tests. +- `benches/indicators.rs` — criterion suite. +- `crates/ferro_ta_core/Cargo.toml` `[features] simd = ["dep:multiversion"]` + — the gate (default-on). diff --git a/vendor/ferro-ta-main/docs/index.rst b/vendor/ferro-ta-main/docs/index.rst new file mode 100644 index 0000000..d94da9a --- /dev/null +++ b/vendor/ferro-ta-main/docs/index.rst @@ -0,0 +1,108 @@ +ferro-ta Documentation +====================== + +.. toctree:: + :maxdepth: 2 + :caption: Core Library + + quickstart + migration_talib + support_matrix + pandas_api + error_handling + api/index + streaming + batch + extended + +.. toctree:: + :maxdepth: 2 + :caption: Evidence and Releases + + benchmarks + changelog + +.. toctree:: + :maxdepth: 2 + :caption: Adjacent and Experimental + + derivatives + adjacent_tooling + plugins + contributing + +Overview +-------- + +**ferro-ta** is a Rust-powered Python technical analysis library focused on a +TA-Lib-compatible API for NumPy-centered workloads. + +.. important:: + + Performance varies by indicator, array layout, warmup, build flags, and + machine. ferro-ta is often faster on selected indicators, not universally + faster. See :doc:`benchmarks` for the reproducible workflow, methodology + notes, and the indicators where TA-Lib still wins or ties in the current + checked-in artifact. + +Core library: + +- 160+ indicators covering all TA-Lib categories +- TA-Lib-style imports such as ``ferro_ta.SMA(close, timeperiod=20)`` +- Pre-built wheels for the supported Python/OS matrix +- Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency +- Batch execution API — run indicators on 2-D arrays of multiple series +- Streaming / bar-by-bar API for live trading +- Transparent pandas.Series support +- Type stubs (.pyi) for IDE auto-completion +- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, ...) + +Adjacent and experimental tooling: + +- **Backtesting engine** — OHLCV fill, 23 metrics, Monte Carlo, walk-forward, multi-asset — see :doc:`adjacent_tooling` +- Derivatives analytics — see :doc:`derivatives` +- Agentic workflow and LangChain tool wrappers — see `Agentic guide `_ +- MCP server for MCP-compatible clients — see `MCP guide `_ +- WASM, plugins, and other optional surfaces — see :doc:`adjacent_tooling` + +Installation +~~~~~~~~~~~~ + +.. code-block:: bash + + pip install ferro-ta + +Quick Start +~~~~~~~~~~~ + +.. code-block:: python + + import numpy as np + from ferro_ta import SMA, EMA, RSI, MACD, BBANDS + + close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5]) + print(SMA(close, timeperiod=3)) + + # Batch: run SMA on 5 symbols at once + from ferro_ta.batch import batch_sma + data = np.random.rand(100, 5) + result = batch_sma(data, timeperiod=10) + +Further Reading +~~~~~~~~~~~~~~~ + +- `Architecture `_ — Rust/Python layout, two-crate design, binding flow. +- `Performance Guide `_ — when to use raw numpy vs pandas/polars, batch notes, tips. +- `API Stability `_ — stability tiers, versioning, and deprecation policy. +- :doc:`support_matrix` — parity status, tested wheel targets, supported Python versions, and experimental modules. +- `Rust-First Policy `_ — all compute logic belongs in Rust; how to add new indicators. +- `Out-of-Core Execution `_ — chunked processing and Dask integration. +- :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers. +- :doc:`adjacent_tooling` — optional surfaces such as derivatives, MCP, WASM, GPU, plugins, and agent-oriented integrations. + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/vendor/ferro-ta-main/docs/mcp.md b/vendor/ferro-ta-main/docs/mcp.md new file mode 100644 index 0000000..35ff982 --- /dev/null +++ b/vendor/ferro-ta-main/docs/mcp.md @@ -0,0 +1,191 @@ +# MCP Server + +ferro-ta ships an optional MCP (Model Context Protocol) server built on the +official Python SDK's FastMCP layer. The server now exposes the broad public +ferro-ta callable surface instead of a tiny hand-picked subset. + +That means MCP clients can use: + +- Exact top-level ferro-ta exports such as `SMA`, `RSI`, `MACD`, `about`, + `methods`, `info`, `benchmark`, and `traced` +- Non-top-level public tools such as `compute_indicator`, `run_backtest`, + `check_cross`, `aggregate_ticks`, `TickAggregator`, and `AlertManager` +- Legacy lowercase convenience aliases: `sma`, `ema`, `rsi`, `macd`, + and `backtest` +- Generic instance tools for stateful classes and stored callables: + `list_instances`, `describe_instance`, `call_instance_method`, + `call_stored_callable`, and `delete_instance` + +--- + +## Installation + +Install the optional MCP extra: + +```bash +pip install "ferro-ta[mcp]" +``` + +If you are working from this repository, you can install the same extra into +the project environment with: + +```bash +uv sync --extra mcp +``` + +--- + +## Running the server + +Run the server over stdio: + +```bash +python -m ferro_ta.mcp +``` + +The command exits immediately with an install hint if the optional `mcp` +dependency is missing. + +--- + +## Connect in Cursor + +Add the server to Cursor's MCP settings: + +```json +{ + "mcpServers": { + "ferro-ta": { + "command": "python", + "args": ["-m", "ferro_ta.mcp"], + "description": "ferro-ta technical analysis tools" + } + } +} +``` + +You can place this in your user settings JSON or in a workspace-level +`.cursor/mcp.json`. + +--- + +## Tool naming + +The MCP server prefers the real ferro-ta API names. + +- Use exact public names when possible, for example `SMA`, `MACD`, + `compute_indicator`, `trade_stats`, `TickAggregator`, or `AlertManager` +- Use the legacy lowercase aliases only when you want the old MCP-friendly + shortcuts and result shapes +- Use `about`, `methods`, `indicators`, and `info` to discover what is + available from inside an MCP client + +--- + +## Stateful classes and object references + +Class tools return stored object references instead of plain text placeholders. +For example, calling `TickAggregator` or `AlertManager` returns a payload like: + +```json +{ + "instance_id": "tickaggregator-0001", + "type": "ferro_ta.data.aggregation.TickAggregator", + "repr": "TickAggregator(rule='tick:2')" +} +``` + +Use that `instance_id` with: + +- `describe_instance` to inspect the stored object and list public methods +- `call_instance_method` to call methods like `aggregate`, `update`, + `run_backtest`, or `to_dict` +- `delete_instance` to remove stored objects when you are done + +If a tool returns a stored callable, use `call_stored_callable`. + +--- + +## Callable references + +Some ferro-ta APIs accept other callables, for example `benchmark`, +`log_call`, `traced`, or `multi_timeframe(indicator=...)`. + +Pass public ferro-ta callables using: + +```json +{"callable": "SMA"} +``` + +Pass stored objects using: + +```json +{"instance_id": "function-0001"} +``` + +--- + +## Example prompts + +Once connected, you can ask an MCP-compatible client things like: + +> "Run `SMA` with `close=[100, 101, 102, 103, 104]` and `timeperiod=3`." + +> "Use `compute_indicator` to calculate `MACD` for this close series." + +> "Call `about` and summarize the current ferro-ta API surface." + +> "Create a `TickAggregator` with `rule='tick:50'`, aggregate this tick data, +> then delete the instance." + +> "Benchmark `SMA` over this price series using a callable reference." + +--- + +## Programmatic use + +Use the server entrypoint: + +```python +from ferro_ta.mcp import create_server + +server = create_server() +# server.run(transport="stdio") +``` + +Or call the handlers directly without starting the server: + +```python +from ferro_ta.mcp import handle_call_tool, handle_list_tools +import json + +tools = handle_list_tools() +print(len(tools["tools"])) + +close = [100, 101, 102, 103, 104] +result = handle_call_tool("SMA", {"close": close, "timeperiod": 3}) +print(json.loads(result["content"][0]["text"])) + +aggregator = json.loads( + handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"] +) +bars = handle_call_tool( + "call_instance_method", + { + "instance_id": aggregator["instance_id"], + "method": "aggregate", + "args": [{"price": [1, 2, 3, 4], "size": [1, 1, 1, 1]}], + }, +) +print(json.loads(bars["content"][0]["text"])) +``` + +--- + +## See also + +- `python -m ferro_ta.mcp` - stdio MCP entrypoint +- `ferro_ta.mcp.create_server()` - FastMCP server factory +- `ferro_ta.tools.api_info` - API discovery helpers used by the MCP catalog +- `ferro_ta.tools` - stable wrappers such as `compute_indicator` +- `docs/agentic.md` - workflow and agent integration notes diff --git a/vendor/ferro-ta-main/docs/migration_talib.rst b/vendor/ferro-ta-main/docs/migration_talib.rst new file mode 100644 index 0000000..8e9aec5 --- /dev/null +++ b/vendor/ferro-ta-main/docs/migration_talib.rst @@ -0,0 +1,168 @@ +Migration from TA-Lib +===================== + +ferro-ta is designed as a drop-in replacement for `ta-lib` (the Python +`talib` package) for the most-commonly used indicators. This guide explains +the differences so you can migrate existing code with confidence. + +.. contents:: + :local: + :depth: 2 + + +Import changes +-------------- + +TA-Lib uses a single flat namespace:: + + import talib + result = talib.SMA(close, timeperiod=14) + +ferro-ta exposes the same names at the top level **and** in sub-modules:: + + # Option A — top-level (most concise, mirrors talib) + from ferro_ta import SMA, EMA, RSI + result = SMA(close, timeperiod=14) + + # Option B — sub-modules + from ferro_ta.overlap import SMA + from ferro_ta.momentum import RSI + +Multi-output functions return a **tuple** in both libraries:: + + # talib + upper, middle, lower = talib.BBANDS(close) + + # ferro_ta + upper, middle, lower = ferro_ta.BBANDS(close) + + +Input / output conventions +-------------------------- + +Both libraries accept NumPy ``float64`` arrays. ferro-ta also accepts any +array-like (Python list, ``float32``, pandas Series) and converts +automatically. + +- **Leading NaN values** — both libraries emit ``NaN`` for the "warm-up" + period at the start of an array. The number of ``NaN`` values is identical + for all indicators marked **Exact** or **Close** in the accuracy table. +- **Output length** — always equal to input length, matching TA-Lib. +- **Pandas Series** — ferro-ta transparently preserves the original index when + a ``pd.Series`` is passed as input. + + +Accuracy levels +--------------- + +.. list-table:: + :header-rows: 1 + + * - Symbol + - Meaning + * - ✅ **Exact** + - Values match TA-Lib to floating-point precision. + * - ✅ **Close** + - Values converge to TA-Lib after the warm-up window (EMA-seed + differences resolve within ~50 bars for typical periods). + * - ⚠️ **Corr** + - Strong correlation (> 0.95) but not numerically identical (e.g. + MAMA uses the same algorithm but slightly different initialization). + * - ⚠️ **Shape** + - Same output shape and NaN structure; absolute values differ (e.g. SAR + reversal history can diverge due to floating-point accumulation). + +All overlap, momentum, volume, volatility, statistic, and price-transform +functions are **Exact** or **Close**. The only remaining **Corr / Shape** +functions are MAMA, SAR, SAREXT, and the six HT_* cycle indicators — see +the roadmap for details. + + +Known behavioural differences +------------------------------ + +EMA / DEMA / TEMA / T3 / MACD +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +TA-Lib seeds the first EMA value with a simple moving average. ferro-ta uses +the same seeding, so values converge after the warm-up period. For a 14-period +EMA on typical market data, convergence is complete by bar ~60. + +RSI +~~~ + +ferro-ta uses the same Wilder smoothing seed as TA-Lib (SMA seed for the first +``timeperiod`` bars) and produces **Exact** results. + +SAR / SAREXT +~~~~~~~~~~~~ + +Parabolic SAR reversal history can diverge in rare edge-cases due to +floating-point accumulation differences. Output shapes (NaN count, length) +match exactly. + +HT_* cycle indicators +~~~~~~~~~~~~~~~~~~~~~ + +The Hilbert Transform cycle indicators (``HT_DCPERIOD``, ``HT_DCPHASE``, +``HT_PHASOR``, ``HT_SINE``, ``HT_TRENDLINE``, ``HT_TRENDMODE``) use the +same Ehlers algorithm as TA-Lib but may differ slightly in floating-point +accumulation. All six share a 63-bar lookback matching TA-Lib. + +OBV +~~~ + +ferro-ta OBV starts accumulation from zero at bar 0 (same as TA-Lib for most +data sets). If your TA-Lib OBV shows an offset this is usually due to a +starting volume difference in the input data. + + +Before / after example +----------------------- + +.. code-block:: python + + # --- Before (ta-lib) --- + import numpy as np + import talib + + close = np.random.rand(200).cumsum() + 100.0 + high = close + 0.5 + low = close - 0.5 + + sma = talib.SMA(close, timeperiod=14) + ema = talib.EMA(close, timeperiod=14) + rsi = talib.RSI(close, timeperiod=14) + upper, mid, lower = talib.BBANDS(close, timeperiod=20) + macd, signal, hist = talib.MACD(close) + atr = talib.ATR(high, low, close, timeperiod=14) + + # --- After (ferro_ta) --- + import numpy as np + from ferro_ta import SMA, EMA, RSI, BBANDS, MACD, ATR + + close = np.random.rand(200).cumsum() + 100.0 + high = close + 0.5 + low = close - 0.5 + + sma = SMA(close, timeperiod=14) + ema = EMA(close, timeperiod=14) + rsi = RSI(close, timeperiod=14) + upper, mid, lower = BBANDS(close, timeperiod=20) + macd, signal, hist = MACD(close) + atr = ATR(high, low, close, timeperiod=14) + +Only the import line changes for the most common indicators. + + +Extended (non-TA-Lib) indicators +--------------------------------- + +ferro-ta additionally provides indicators not in TA-Lib:: + + from ferro_ta import ( + VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, + KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX, + ) + +See :doc:`extended` for full API documentation. diff --git a/vendor/ferro-ta-main/docs/options-volatility.md b/vendor/ferro-ta-main/docs/options-volatility.md new file mode 100644 index 0000000..6cdf3b9 --- /dev/null +++ b/vendor/ferro-ta-main/docs/options-volatility.md @@ -0,0 +1,79 @@ +# Options and Implied Volatility + +`ferro-ta` exposes options analytics from `ferro_ta.analysis.options`. + +## Scope + +The module now covers both classic IV-series helpers and model-based option +analytics: + +- `iv_rank`, `iv_percentile`, `iv_zscore` +- Black-Scholes-Merton pricing +- Black-76 pricing +- Delta, gamma, vega, theta, rho +- Implied volatility inversion +- Smile metrics and chain helpers + +Heavy computation runs in Rust through the `_ferro_ta` extension. + +## IV-series helpers + +The original rolling helpers remain available and keep their public names: + +```python +import numpy as np +from ferro_ta.analysis.options import iv_rank, iv_percentile, iv_zscore + +iv = np.array([18.5, 22.3, 19.1, 25.0, 30.2, 27.8, 21.4, 19.0]) +rank = iv_rank(iv, window=5) +pct = iv_percentile(iv, window=5) +z = iv_zscore(iv, window=5) +``` + +These helpers accept a 1-D IV series and return rolling statistics with +`NaN` during the warmup period. + +## Pricing and Greeks + +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price + +price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call") +g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") +``` + +Conventions: + +- Volatility is decimal annualized volatility: `0.20` means 20%. +- Rates are decimal annualized rates: `0.05` means 5%. +- `time_to_expiry` is measured in years. +- `model="bsm"` uses spot as the underlying input. +- `model="black76"` uses forward as the underlying input. + +## Smile and chain helpers + +```python +from ferro_ta.analysis.options import label_moneyness, select_strike, smile_metrics + +strikes = [80, 90, 100, 110, 120] +vols = [0.30, 0.25, 0.20, 0.22, 0.27] + +metrics = smile_metrics(strikes, vols, 100.0, 0.5) +labels = label_moneyness(strikes, 100.0, option_type="call") +atm = select_strike(strikes, 100.0, selector="ATM") +delta_strike = select_strike( + strikes, + 100.0, + selector="DELTA0.25", + option_type="call", + volatilities=vols, + time_to_expiry=0.5, +) +``` + +## Related futures analytics + +See `ferro_ta.analysis.futures` and +[`docs/derivatives-analytics.md`](./derivatives-analytics.md) for synthetic +forwards, basis, carry, curve, and roll analytics. diff --git a/vendor/ferro-ta-main/docs/out-of-core.md b/vendor/ferro-ta-main/docs/out-of-core.md new file mode 100644 index 0000000..9999ac9 --- /dev/null +++ b/vendor/ferro-ta-main/docs/out-of-core.md @@ -0,0 +1,169 @@ +# Out-of-Core and Distributed Execution + +ferro-ta is designed to work efficiently on large datasets that do not fit +in memory by supporting **chunked execution** with warm-up overlap. This +document explains the problem, the recommended approach, and current +limitations. + +--- + +## Problem statement + +Technical analysis indicators are typically stateful: they require a +look-back window of historical bars to produce a valid value. When a price +dataset is larger than available memory (e.g. tick data, multiple years of +1-second bars), or when it needs to be processed in a distributed cluster +(Spark, Dask), the data must be split into chunks. + +The challenges are: + +1. **Warm-up / border effects** — the first `period - 1` bars of each chunk + will produce NaN because the indicator has not yet accumulated enough + history. +2. **Partition stitching** — after computing an indicator on each partition + independently, the partial results must be assembled into a single + coherent output. +3. **Indicators that need full history** — some indicators (e.g. Hilbert + Transform cycle indicators) cannot be decomposed into partitions; they + require the full series. + +--- + +## Chunk boundaries and warm-up overlap + +The `ferro_ta.chunked` module provides Rust-backed helpers for chunk-based +execution: + +```python +from ferro_ta.chunked import make_chunk_ranges, trim_overlap, stitch_chunks, chunk_apply +from ferro_ta import SMA + +import numpy as np + +data = np.random.rand(1_000_000) # large price series +period = 20 +overlap = period - 1 # warm-up bars needed + +ranges = make_chunk_ranges(len(data), chunk_size=50_000, overlap=overlap) +chunks_out = [] +for start, end in ranges: + chunk = data[start:end] + out = SMA(chunk, timeperiod=period) + chunks_out.append(out) + +result = stitch_chunks(chunks_out, overlap=overlap) +``` + +### Key concepts + +| Concept | Description | +|---------|-------------| +| `chunk_size` | Number of bars per chunk (excluding overlap). | +| `overlap` | Warm-up bars prepended to each chunk from the previous chunk. | +| `trim_overlap` | Strips the warm-up prefix from a chunk result. | +| `stitch_chunks` | Concatenates trimmed chunk results into the final output. | +| `chunk_apply` | Convenience wrapper: runs a callable on each chunk and stitches. | + +--- + +## Options for distributed / out-of-core execution + +### Option A: Chunked pandas with overlap (single-machine, recommended) + +Use `chunk_apply` or `make_chunk_ranges` + manual loop. Suitable for +datasets up to ~10 GB that fit on a single machine with streaming reads. + +```python +from ferro_ta.chunked import chunk_apply +from ferro_ta import EMA + +result = chunk_apply(data, EMA, chunk_size=100_000, overlap=50, timeperiod=50) +``` + +### Option B: Dask `map_partitions` (distributed) + +Dask can partition a large array and apply a function to each partition. +To handle warm-up correctly, use overlapping partitions via +`dask.array.overlap.overlap`: + +```python +import dask.array as da +from dask.array.overlap import overlap as da_overlap +from ferro_ta import SMA + +x = da.from_array(price_array, chunks=100_000) +depth = 20 - 1 # warm-up depth + +x_ov = da_overlap(x, depth={0: depth}, boundary={0: "none"}) +result = x_ov.map_blocks(lambda blk: SMA(blk, timeperiod=20)) +# trim overlap from each block +result_trimmed = da.map_blocks( + lambda blk: blk[depth:], + result, + dtype=float, +) +``` + +### Option C: Apache Spark (brief) + +Spark does not natively support overlapping windows for time-series +indicators. You would need to: + +1. Repartition data by time range with explicit padding. +2. Apply the indicator via a Pandas UDF. +3. Filter out warm-up rows in a post-processing step. + +This approach is feasible but complex. For most use-cases, Dask +(Option B) is simpler. + +--- + +## Recommended path + +| Scale | Recommendation | +|-------|---------------| +| Single machine, fits in RAM | Use ferro_ta directly on the full array. | +| Single machine, does not fit in RAM | `chunk_apply` with overlap (Option A). | +| Multi-machine cluster | Dask `map_partitions` with `dask.array.overlap` (Option B). | + +--- + +## Which indicators are safe for partition-wise execution + +Indicators that depend only on a fixed-length window are **safe** for +chunked/partition-wise execution (with correct overlap): + +- All overlap studies: SMA, EMA, WMA, DEMA, TEMA, BBANDS, etc. +- Momentum: RSI, MACD, STOCH, ADX, CCI, WILLR, etc. +- Volatility: ATR, NATR. +- Most volume indicators: OBV, AD (cumulative; use `stitch_chunks` carefully). + +Indicators that are **not safe** for partition-wise execution without +special handling: + +- Hilbert Transform cycle indicators (`HT_*`) — require full history. +- Adaptive indicators with unbounded look-back (e.g. KAMA with long + adaptation period). +- Streaming state-machine indicators when state must be preserved across + chunks (use `ferro_ta.streaming` classes instead). + +--- + +## Limitations + +- **Volume-weighted indicators** (e.g. VWAP, OBV) accumulate across all + bars; resetting at chunk boundaries changes their semantics. Use + `streaming.StreamingVWAP` for bar-by-bar accumulation instead. +- **SAR and MAMA** have path-dependent state; chunk results will differ + from full-series results unless the prior state is passed across chunks. +- Current `chunk_apply` does not propagate indicator state across chunks; + all indicators restart at each chunk boundary (modulo the overlap + warm-up). + +--- + +## See also + +- `ferro_ta.chunked` — API reference for chunk helpers. +- `ferro_ta.streaming` — Stateful streaming classes for live bar-by-bar use. +- Dask documentation: diff --git a/vendor/ferro-ta-main/docs/pandas_api.rst b/vendor/ferro-ta-main/docs/pandas_api.rst new file mode 100644 index 0000000..0d731dc --- /dev/null +++ b/vendor/ferro-ta-main/docs/pandas_api.rst @@ -0,0 +1,46 @@ +Pandas API contract +=================== + +**Contract** + +- All indicators accept ``pandas.Series`` (or 1-D DataFrame columns) and return + ``pandas.Series`` — or a **tuple of Series** for multi-output functions (e.g. MACD, BBANDS) + — with the **original index preserved**. +- Default OHLCV column names for DataFrames are ``open``, ``high``, ``low``, ``close``, ``volume``. +- To use different column names, use :func:`ferro_ta.utils.get_ohlcv` to extract arrays/Series + with configurable column names, then call the indicator. + +**Single Series** + +.. code-block:: python + + import pandas as pd + from ferro_ta import SMA, RSI + close = pd.Series([44.34, 44.09, 44.15], index=pd.date_range("2024-01-01", periods=3)) + sma = SMA(close, timeperiod=2) # returns pd.Series with same index + +**DataFrame with OHLCV (configurable column names)** + +.. code-block:: python + + import pandas as pd + from ferro_ta import ATR, RSI + from ferro_ta.utils import get_ohlcv + + df = pd.DataFrame({ + "Open": [1, 2, 3], "High": [1.1, 2.1, 3.1], + "Low": [0.9, 1.9, 2.9], "Close": [1.05, 2.05, 3.05], + }, index=pd.date_range("2024-01-01", periods=3, freq="D")) + + o, h, l, c, v = get_ohlcv(df, open_col="Open", high_col="High", + low_col="Low", close_col="Close", volume_col=None) + atr = ATR(h, l, c, timeperiod=2) # index preserved + rsi = RSI(c, timeperiod=2) # index preserved + +**Multi-output** + +Functions like ``MACD`` and ``BBANDS`` return a tuple of ``pandas.Series``, all with the same index as the input. + +**See also** + +- :mod:`ferro_ta.utils` — :func:`get_ohlcv` for DataFrame OHLCV extraction. diff --git a/vendor/ferro-ta-main/docs/performance.md b/vendor/ferro-ta-main/docs/performance.md new file mode 100644 index 0000000..f23bc0d --- /dev/null +++ b/vendor/ferro-ta-main/docs/performance.md @@ -0,0 +1,397 @@ +# Performance Guide + +This document explains the performance characteristics of **ferro-ta** and gives +practical advice on how to get the best speed from the library. + +--- + +## Quick Summary + +| Use case | Recommended API | Notes | +|---------------------------------------|----------------------------------------|----------------------------------------| +| Fast path — NumPy arrays | Pass `np.ndarray` (float64, C-order) | Zero overhead; no conversion needed | +| pandas users | Pass `pd.Series`; result is `pd.Series`| Small overhead for index wrapping | +| polars users | Pass `pl.Series`; result is `pl.Series`| Small overhead for type conversion | +| Raw Rust access (expert) | `from ferro_ta._ferro_ta import sma` | Bypasses all Python wrappers | +| Multiple series at once | `batch_sma`, `batch_ema`, `batch_rsi` | One Python call for all columns | +| Many indicators on same arrays | `compute_many` | Amortizes Python→Rust overhead | + +**Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked +in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark +inputs/results and methodology, use [benchmarks/README.md](../benchmarks/README.md) +and regenerate with `python benchmarks/bench_vs_talib.py --json benchmark_vs_talib.json`. + +--- + +## The Rust Core Is Fast; Overhead Is in Python + +The Rust extension (`_ferro_ta`) is compiled with full optimisations and is very +fast. The bottlenecks for most users are in the Python wrapping layer: + +1. **Array conversion** — `_to_f64` converts any array-like to a contiguous + `float64` NumPy array. If your input is already a C-contiguous `float64` + ndarray the fast path returns it without any copy or allocation. + +2. **pandas wrapping** — `pandas_wrap` extracts the NumPy array from a + `pd.Series`, calls the Rust function, and wraps the result back into a + `pd.Series` with the original index. The wrapping itself is cheap but adds + a small constant overhead per call. + +3. **polars wrapping** — `polars_wrap` converts a `pl.Series` to NumPy and back. + The result is now built from the NumPy buffer directly (`pl.Series(name, + np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of + earlier versions. + +4. **Batch / grouped execution** — `batch_sma`/`batch_ema`/`batch_rsi` use + Rust-side batch functions for 2-D input (single GIL release for all + columns). `compute_many(...)` groups supported 1-D indicator bundles into + one Rust call, which helps most on medium-to-large workloads. The generic + `batch_apply` still runs a Python loop over columns; use it only when there + is no dedicated fast path. + +--- + +## The Fast Path: Pass Contiguous float64 NumPy Arrays + +The cheapest way to call any indicator is to pass a C-contiguous `float64` +NumPy array. `_to_f64` detects this case and returns the array as-is: + +```python +import numpy as np +from ferro_ta import SMA + +# Already float64 and C-contiguous — _to_f64 is a no-op (zero copy) +close = np.random.rand(10_000).astype(np.float64) +result = SMA(close, timeperiod=20) +``` + +If your array is in a different dtype or order, `_to_f64` will create a new +array. You can force the fast path once and reuse the result: + +```python +close_f64 = np.ascontiguousarray(close, dtype=np.float64) # one-time conversion +result = SMA(close_f64, timeperiod=20) # no copy inside _to_f64 +``` + +--- + +## Raw Numpy-Only API (No Wrapper Overhead) + +If you want zero Python overhead — no pandas/polars wrapping, no validation — +you can import functions directly from the compiled extension: + +```python +from ferro_ta._ferro_ta import sma, ema, rsi # raw Rust functions + +import numpy as np +close = np.random.rand(10_000).astype(np.float64) +result = sma(close, 20) # returns a NumPy array (PyArray1 from PyO3) +``` + +> **Warning:** The raw `_ferro_ta` API is internal and may change between +> versions. It does *not* validate inputs — passing an empty array or a wrong +> type will raise an obscure error from PyO3. Use it only if you have +> profiled a bottleneck and need the absolute minimum overhead. + +For a stable raw API with the same functions, use the `ferro_ta.raw` submodule +(no pandas/polars wrapping or validation). + +--- + +## pandas Series + +```python +import pandas as pd +from ferro_ta import SMA + +s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=pd.date_range("2024-01-01", periods=5)) +result = SMA(s, timeperiod=3) +# result is a pd.Series with the same DatetimeIndex +``` + +Overhead compared to a raw numpy call: one `pd.Series.to_numpy()` call (cheap) +plus one `pd.Series(result, index=...)` call (cheap). For large arrays this +is negligible; for very tight loops (millions of calls per second) prefer numpy. + +--- + +## polars Series + +```python +import polars as pl +from ferro_ta import SMA + +s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0]) +result = SMA(s, timeperiod=3) +# result is a pl.Series named "close" +``` + +Overhead: one `.cast(Float64).to_numpy()` call plus one `pl.Series(name, +np.asarray(result))` call. The result is built from the numpy buffer +(zero-copy where polars allows it) rather than going through `.tolist()`. + +--- + +## Batch Execution + +Use the batch API when you have many series (e.g., one column per symbol): + +```python +import numpy as np +from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply + +data = np.random.rand(252, 500).astype(np.float64) # 252 bars × 500 symbols +sma_out = batch_sma(data, timeperiod=20) # shape (252, 500) +rsi_out = batch_rsi(data, timeperiod=14) +``` + +`batch_apply` lets you run any indicator on a 2-D array: + +```python +from ferro_ta import ATR +from ferro_ta.batch import batch_apply + +ohlcv = np.random.rand(252, 100, 3).astype(np.float64) # not directly supported +# For indicators that take multiple arrays use a manual loop instead +``` + +For 2-D input, `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch +functions (single GIL release for all columns). Use `batch_apply` for other +indicators that do not have a dedicated Rust batch implementation. + +When you have several indicators over the same 1-D arrays, use `compute_many`: + +```python +from ferro_ta.batch import compute_many + +results = compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, +) +``` + +Supported grouped paths currently cover common close-only indicators plus a +small HLC bundle (`ATR`, `NATR`, `ADX`, `ADXR`, `CCI`, `WILLR`). Unsupported +parameter shapes fall back to the normal registry path automatically. + +--- + +## Streaming (Bar-by-Bar) + +```python +from ferro_ta.streaming import StreamingSMA + +sma = StreamingSMA(period=20) +for bar in live_feed: + value = sma.update(bar.close) + if value is not None: + print(f"SMA(20) = {value:.4f}") +``` + +The streaming classes are implemented in Rust (PyO3 `#[pyclass]` in +`_ferro_ta`) and re-exported from `ferro_ta.streaming`. They are suitable for +live trading at typical bar rates with minimal Python overhead. + +--- + +## Extended Indicators + +`VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`, +`HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, and `CHOPPINESS_INDEX` are implemented in +Rust (`src/extended/mod.rs`). The Python module `ferro_ta/extended.py` is a thin +wrapper with validation and `_to_f64`; all computation runs in the extension. + +--- + +## Tips for Best Performance + +1. **Pre-convert once.** If you call multiple indicators on the same array, + convert it to `float64` + C-contiguous once: + ```python + close = np.ascontiguousarray(raw_close, dtype=np.float64) + ``` + +2. **Avoid repeated dtype conversions.** Passing a `float32` or `int` array + triggers a copy every call. + +3. **Use batch functions for multiple symbols.** For SMA, EMA, and RSI use + `batch_sma`/`batch_ema`/`batch_rsi` (Rust-side loop, single GIL release). + The generic `batch_apply` runs a Python loop over columns; use it only for + indicators that do not have a dedicated Rust batch. + +4. **Avoid wrapping in very tight loops.** If you call an indicator millions + of times per second (e.g., in a simulation) use the raw `_ferro_ta` API + and manage conversion yourself. + +5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the + actual bottleneck before assuming a particular layer is slow. + +6. **Use the perf-contract scripts for evidence.** `benchmarks/run_perf_contract.py` + and `benchmarks/profile_runtime_hotspots.py` record timings with git/runtime + metadata so you can compare apples to apples across machines and commits. + +## Backtesting Performance + +ferro-ta's backtesting engine is the fastest in the Python ecosystem for +vectorized single- and multi-asset scenarios. + +| Library | 100k bars | vs ferro-ta | +|---------|-----------|-------------| +| ferro-ta `backtest_core` | **0.29 ms** | — | +| ferro-ta `backtest_ohlcv_core` | **0.33 ms** | ~same | +| NumPy vectorized | 0.46 ms | 1.6× slower | +| vectorbt | 2.90 ms | 10× slower | +| backtesting.py | 319 ms | 1,117× slower | +| backtrader | ~50,000 ms (est.) | >15,000× slower | + +Additional capabilities measured at 100k bars: + +| Capability | Time | +|---|---| +| Monte Carlo 1,000 sims (parallel) | 50 ms — 12× faster than NumPy loop | +| 23 performance metrics | 2.8 ms (0.12 ms/metric) | +| Multi-asset 100 symbols, parallel | 43 ms — 2× vs serial | +| Walk-forward index generation | 0.3 µs | + +## Benchmark Tooling + +The benchmark suite now includes a small set of machine-readable scripts for +performance work beyond the full pytest benchmark table: + +- `python benchmarks/bench_batch.py --json batch_benchmark.json` +- `python benchmarks/bench_streaming.py --json streaming_benchmark.json` +- `python benchmarks/bench_backtest.py --json bench_backtest_results.json` +- `python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json` +- `python benchmarks/bench_simd.py --json simd_benchmark.json` +- `python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest` +- `python benchmarks/check_hotspot_regression.py --input runtime_hotspots.json` + +The WASM bindings also ship with a Node benchmark: + +- `cd wasm && wasm-pack build --target nodejs --out-dir pkg` +- `node bench.js --json ../wasm_benchmark.json` + +## SIMD And Build Flags + +Distributable wheels should stay on the portable release profile: + +- `cargo`/`maturin` release build +- `lto = true` +- `codegen-units = 1` +- no architecture-specific `target-cpu=native` in shipped artifacts + +For local source builds, there are two opt-in tuning levers: + +```bash +# Portable SIMD-enabled local build +uv run maturin develop --release --features simd + +# Maximum local tuning for your current machine only +RUSTFLAGS="-C target-cpu=native" uv run maturin develop --release --features simd +``` + +Policy: + +- Ship portable wheels with the default release settings. +- Use `--features simd` for measured local/source wins. +- Reserve `target-cpu=native` for developer workstations or private deploys, + because those binaries are not portable across CPU families. + +--- + +## Performance Improvements (implemented) + +The following improvements are already in place. See +[docs/plans/2026-03-08-production-grade.md](plans/2026-03-08-production-grade.md) +for history and commits. + +| Area | Improvement | Where | +|-------------|----------------------------------------------------------------|-------| +| **Utils** | `_to_f64` fast path: no copy for 1-D C-contiguous float64 | `python/ferro_ta/_utils.py` (lines 34–39) | +| **Utils** | Polars result: `pl.Series(name, result)` from NumPy buffer (no `.tolist()`) | `python/ferro_ta/_utils.py` (e.g. 254–258) | +| **Raw API** | `ferro_ta.raw` — bypass pandas/polars and validation | `python/ferro_ta/raw.py` | +| **Batch** | Rust batch for SMA/EMA/RSI — single GIL release for 2-D | `src/batch/mod.rs`, `python/ferro_ta/batch.py` | +| **Streaming** | All streaming classes in Rust (PyO3) | `src/streaming/mod.rs` | +| **Extended** | All extended indicators (incl. SUPERTREND) in Rust | `src/extended/mod.rs`, `python/ferro_ta/extended.py` wraps Rust | + +--- + +## Known Bottlenecks and Possible Improvements + +Maintainer-facing list of slower paths and optional improvements. Update as +bottlenecks are fixed or deferred. + +**Backtest** (`python/ferro_ta/analysis/backtest.py`): +- Core signal→equity loop is fully in Rust (`backtest_core`, `backtest_ohlcv_core`). +- Commission and slippage applied inside Rust; no Python loop on the hot path. +- `compute_performance_metrics` computes all 23 metrics in a single Rust pass. +- Monte Carlo runs in parallel Rayon threads with LCG seeding (GIL released). + +**Batch** (`python/ferro_ta/batch.py`): +- `batch_apply` runs a Python loop over columns (one Python call per column). + Use `batch_sma`/`batch_ema`/`batch_rsi` when possible. +- No fast path for already 2-D C-contiguous float64 in batch_sma/ema/rsi + (unlike `_to_f64` for 1-D); could avoid a potential copy. + +**Derivatives analytics** (`python/ferro_ta/analysis/options.py`): +- `iv_rank`, `iv_percentile`, and `iv_zscore` now delegate to Rust. +- The Python layer mostly performs broadcasting and result shaping; the hot + path is in Rust. +- Model-based implied-volatility inversion is much faster now, but still more + expensive than direct pricing or Greeks due to root-finding. + +**Features** (`python/ferro_ta/features.py`): +- `nan_policy="fill"` is vectorized now. +- `feature_matrix(...)` uses `compute_many(...)`, but grouped HLC bundles are + still only near parity on medium workloads and are best on larger arrays. + +**Signals** (`python/ferro_ta/signals.py`): +- `compose(..., method="rank")` now uses a one-call Rust rank-composition + path, but its gains are moderate rather than dramatic. Keep measuring before + treating it as a major optimization lever. + +**Other**: +- **dsl.py**: Some code paths use Python loops over bars. +- **gpu.py**: Fallback SMA/EMA/RSI use Python loops when GPU is not used. +- **tools.py / viz.py**: `.tolist()` for JSON/Plotly; acceptable for I/O. +- **Validation**: `check_equal_length`, `check_timeperiod` run in Python; + cost is small; moving to Rust is deferred (see production-grade plan). +- **pandas_wrap / polars_wrap**: Per-call overhead; use `ferro_ta.raw` when + minimising overhead. + +--- + +## Benchmarking and comparison + +For cross-library speed, run: +`pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`. + +To convert benchmark JSON into a markdown table: +`python benchmarks/benchmark_table.py`. + +For focused TA-Lib comparison on the same data/parameters, run +`python benchmarks/bench_vs_talib.py` (requires `pip install ta-lib`). +Results are reported as speedup = TA-Lib time / ferro_ta time (values > 1 mean +ferro_ta is faster). Speedup depends on indicator and data size. + +--- + +## Related Documents + +- [`docs/architecture.md`](architecture.md) — how the Rust/Python layers are + organised and how they communicate. +- [`benchmarks/test_speed.py`](../benchmarks/test_speed.py) — + Authoritative cross-library speed benchmarks (pytest-benchmark). +- [`benchmarks/benchmark_table.py`](../benchmarks/benchmark_table.py) — + Render speed tables from `benchmarks/results.json`. +- [`crates/ferro_ta_core/benches/indicators.rs`](../crates/ferro_ta_core/benches/indicators.rs) — + Rust Criterion benchmarks for the pure core (run with `cargo bench -p ferro_ta_core`). +- [`benchmarks/bench_vs_talib.py`](../benchmarks/bench_vs_talib.py) — speed comparison vs + TA-Lib (same data and parameters); run with `python benchmarks/bench_vs_talib.py` (requires + `ta-lib`). See README “Performance vs TA-Lib” for methodology and a comparison table. +- [`benchmarks/check_vs_talib_regression.py`](../benchmarks/check_vs_talib_regression.py) — + CI guardrail script for detecting severe benchmark regressions from JSON artifacts. diff --git a/vendor/ferro-ta-main/docs/plugin-catalog.md b/vendor/ferro-ta-main/docs/plugin-catalog.md new file mode 100644 index 0000000..f9d3ca6 --- /dev/null +++ b/vendor/ferro-ta-main/docs/plugin-catalog.md @@ -0,0 +1,85 @@ +# Plugin Catalog + +A curated list of ferro-ta plugins and community extensions. + +> **Note:** This catalog is community-maintained and provided on a best-effort +> basis. Plugins are not endorsed or audited by the ferro-ta maintainers. +> Verify each plugin before use in production. + +--- + +## How to Add Your Plugin + +1. Verify that your plugin works with the current ferro-ta release. +2. Open a pull request adding a row to the table below. Include: + - **Name**: package name (PyPI or GitHub) + - **Description**: one-line summary of what the plugin adds + - **Install**: `pip install ...` command + - **Link**: GitHub or PyPI URL + +**Listing criteria:** +- Has a README or documentation describing what it does. +- Works with the current or previous minor version of ferro-ta. +- Is publicly available (PyPI or GitHub). + +--- + +## Known Plugins + +| Name | Description | Install | Link | +|------|-------------|---------|------| +| *(none yet — be the first!)* | | | | + +--- + +## Reference Implementation + +The `examples/custom_indicator.py` file in the ferro-ta repository serves as +the canonical reference for building a plugin. See [Writing a plugin](plugins.rst) +for the full guide. + +```python +# Minimal plugin example +from ferro_ta.registry import register +from ferro_ta import RSI, SMA + +def SMOOTH_RSI(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI of RSI values.""" + return SMA(RSI(close, timeperiod=timeperiod), timeperiod=smooth) + +register("SMOOTH_RSI", SMOOTH_RSI) +``` + +--- + +## Publishing Your Plugin to PyPI + +1. **Implement** your indicator(s) following the [plugin contract](plugins.rst). +2. **Package** with pyproject.toml using the `ferro_ta.plugins` entry point: + +```toml +[project] +name = "ferro-ta-myplugin" +version = "1.0.0" +dependencies = ["ferro_ta>=1.0.0"] + +[project.entry-points."ferro_ta.plugins"] +auto_register = "ferro_ta_myplugin:register_all" +``` + +3. **Publish** to PyPI: + +```bash +pip install build twine +python -m build +twine upload dist/* +``` + +4. **Submit a PR** to add your plugin to this catalog. + +--- + +## Removal Requests + +If you are the maintainer of a listed plugin and want it removed, open a +GitHub issue with the title "Plugin catalog removal: ". diff --git a/vendor/ferro-ta-main/docs/plugins.rst b/vendor/ferro-ta-main/docs/plugins.rst new file mode 100644 index 0000000..055f3bf --- /dev/null +++ b/vendor/ferro-ta-main/docs/plugins.rst @@ -0,0 +1,91 @@ +Writing a plugin +================ + +The plugin registry lets you register custom indicator functions and call them by name +alongside built-in indicators. This page describes the **plugin contract**, how to +register and run plugins, and a full example. + +Plugin contract +--------------- + +A plugin is a **callable** (function or callable object) that satisfies: + +1. **Signature** + - At least one positional argument that is array-like (e.g. ``close``, ``high``, ``low``). + - Optional ``*args`` and ``**kwargs`` for parameters (e.g. ``timeperiod=14``). + - :func:`ferro_ta.registry.run` forwards all ``*args`` and ``**kwargs`` to the callable. + +2. **Return type** + - A single ``numpy.ndarray``, or + - A tuple of ``numpy.ndarray`` (for multi-output indicators). + - Output length should match input length (same number of bars); document any exception. + +3. **Behaviour** + - The callable may use ``ferro_ta`` internally (e.g. call :func:`ferro_ta.RSI` and then apply another transformation). + - Plugins run with the caller's privileges; there is no sandboxing. + +Validation +---------- + +:func:`ferro_ta.registry.register` checks that the provided object is callable. If not, +it raises ``TypeError``. No strict signature check is performed at registration time +so that valid plugins (e.g. with default arguments) are not rejected. + +Step-by-step +------------ + +1. **Write a function** that accepts at least one array-like and returns one or more + arrays of the same length as the first argument. + +2. **Register it** with :func:`ferro_ta.registry.register`: + + .. code-block:: python + + from ferro_ta.registry import register + register("MY_INDICATOR", my_indicator_function) + +3. **Call it by name** with :func:`ferro_ta.registry.run`: + + .. code-block:: python + + from ferro_ta.registry import run + result = run("MY_INDICATOR", close, timeperiod=14) + +4. **List all indicators** (built-in and registered) with :func:`ferro_ta.registry.list_indicators`. + +Full example +------------ + +The following plugin computes a smoothed RSI (RSI of RSI, or "double RSI") and is +included in the repo as ``examples/custom_indicator.py``: + +.. code-block:: python + + """Example plugin: smoothed RSI (RSI applied to RSI values).""" + import numpy as np + from ferro_ta.registry import register, run, list_indicators + from ferro_ta import RSI, SMA + + def SMOOTH_RSI(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI then SMA of the RSI series.""" + rsi = RSI(close, timeperiod=timeperiod) + return SMA(rsi, timeperiod=smooth) + + if __name__ == "__main__": + register("SMOOTH_RSI", SMOOTH_RSI) + close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33]) + out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2) + print("SMOOTH_RSI:", out) + assert "SMOOTH_RSI" in list_indicators() + +API reference +------------- + +- :func:`ferro_ta.registry.register` — Register a callable under a name. +- :func:`ferro_ta.registry.unregister` — Remove a registered indicator. +- :func:`ferro_ta.registry.get` — Return the callable for a name. +- :func:`ferro_ta.registry.run` — Look up by name and call with given args/kwargs. +- :func:`ferro_ta.registry.list_indicators` — Sorted list of all registered names. +- :exc:`ferro_ta.registry.FerroTARegistryError` — Raised when a name is not found. + +See :mod:`ferro_ta.registry` for full docstrings. diff --git a/vendor/ferro-ta-main/docs/quickstart.rst b/vendor/ferro-ta-main/docs/quickstart.rst new file mode 100644 index 0000000..04d9d00 --- /dev/null +++ b/vendor/ferro-ta-main/docs/quickstart.rst @@ -0,0 +1,119 @@ +Quick Start +=========== + +Installation +------------ + +.. code-block:: bash + + pip install ferro-ta + + # For Pandas support: + pip install ferro-ta pandas + + # For benchmarks: + pip install ferro-ta pytest-benchmark + +Basic Usage +----------- + +All functions accept NumPy arrays and return NumPy arrays: + +.. code-block:: python + + import numpy as np + from ferro_ta import SMA, EMA, RSI, MACD, BBANDS, ATR + + close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33]) + high = close + 0.5 + low = close - 0.5 + + # Single output + sma = SMA(close, timeperiod=5) + ema = EMA(close, timeperiod=5) + rsi = RSI(close, timeperiod=5) + atr = ATR(high, low, close, timeperiod=5) + + # Multi output + upper, middle, lower = BBANDS(close, timeperiod=5) + macd_line, signal, histogram = MACD(close) + +Pandas Integration +------------------ + +All functions transparently accept ``pandas.Series`` and preserve the index: + +.. code-block:: python + + import pandas as pd + from ferro_ta import SMA, BBANDS + + idx = pd.date_range("2024-01-01", periods=10, freq="D") + close = pd.Series([44.34, 44.09, 44.15, 43.61, 44.33, + 44.83, 45.10, 45.15, 43.61, 44.33], index=idx) + + sma = SMA(close, timeperiod=3) # → pd.Series, same index + upper, mid, lower = BBANDS(close, timeperiod=3) # → tuple of pd.Series + +Streaming / Live Trading +------------------------ + +Use the :mod:`ferro_ta.streaming` module for bar-by-bar processing: + +.. code-block:: python + + from ferro_ta.streaming import StreamingSMA, StreamingRSI, StreamingATR + + sma = StreamingSMA(period=5) + rsi = StreamingRSI(period=14) + atr = StreamingATR(period=14) + + for bar in live_feed: + current_sma = sma.update(bar.close) + current_rsi = rsi.update(bar.close) + current_atr = atr.update(bar.high, bar.low, bar.close) + +Extended Indicators +------------------- + +.. code-block:: python + + from ferro_ta import VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS + import numpy as np + + high = np.array([...]) + low = np.array([...]) + close = np.array([...]) + vol = np.array([...]) + + # VWAP + vwap = VWAP(high, low, close, vol) + rolling_vwap = VWAP(high, low, close, vol, timeperiod=14) + + # Supertrend + st_line, direction = SUPERTREND(high, low, close, timeperiod=7, multiplier=3.0) + + # Ichimoku Cloud + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(high, low, close) + + # Donchian Channels + dc_upper, dc_mid, dc_lower = DONCHIAN(high, low, timeperiod=20) + + # Pivot Points + pivot, r1, s1, r2, s2 = PIVOT_POINTS(high, low, close, method="classic") + +Derivatives Analytics +--------------------- + +.. code-block:: python + + from ferro_ta.analysis.options import greeks, option_price + from ferro_ta.analysis.futures import basis + + call_price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + call_greeks = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + front_basis = basis(100.0, 103.0) + +See :doc:`derivatives` for the full analytics surface, including implied +volatility inversion, smile metrics, strike selection, futures curve tools, +strategy schemas, and multi-leg payoff helpers. diff --git a/vendor/ferro-ta-main/docs/rust_first.md b/vendor/ferro-ta-main/docs/rust_first.md new file mode 100644 index 0000000..7b99132 --- /dev/null +++ b/vendor/ferro-ta-main/docs/rust_first.md @@ -0,0 +1,213 @@ +# Rust-First Architecture Policy + +> **Rule:** All non-trivial computation and processing logic MUST be +> implemented in Rust and exposed to Python via PyO3. Python is the +> **interface layer** only. + +--- + +## Rationale + +ferro-ta is built on the insight that Python is excellent as a glue layer +(validation, type dispatch, pandas/polars wrapping) but poor as a compute +engine (GIL, interpreter overhead, per-call allocation). Every Python loop +over data is a performance regression. + +This policy formalises what the codebase already does for standard TA-Lib +indicators and extends it to all new and existing indicators. + +--- + +## The Boundary + +``` +Python layer (thin) Rust layer (thick) +───────────────────────────── ──────────────────────────────────── +ferro_ta/overlap.py ────▶ src/overlap/mod.rs +ferro_ta/momentum.py ────▶ src/momentum/mod.rs +ferro_ta/streaming.py ────▶ src/streaming/mod.rs (PyO3 classes) +ferro_ta/extended.py ────▶ src/extended/mod.rs +ferro_ta/math_ops.py ────▶ src/math_ops/mod.rs +ferro_ta/batch.py ────▶ src/batch/mod.rs +ferro_ta/pattern.py ────▶ src/pattern/mod.rs +... ────▶ ... +``` + +**Python layer responsibilities (ONLY):** +- Input validation (`check_equal_length`, `check_timeperiod`) +- `_to_f64()` conversion (already has fast path for contiguous float64) +- pandas/polars wrapping (via `pandas_wrap` / `polars_wrap` decorators) +- Re-exporting and documentation + +**Rust layer responsibilities (EVERYTHING ELSE):** +- All loops over data +- All rolling window computations +- All stateful streaming state machines +- All mathematical transformations applied bar-by-bar +- All batch operations + +--- + +## Implementation Rules + +### Rule 1: New indicators go in Rust first + +When adding a new indicator: + +1. Implement the algorithm in `src//mod.rs` (or a new category + module if the category does not exist). +2. Register the function in `src/lib.rs` via `::register(m)?`. +3. Write a thin Python wrapper in `python/ferro_ta/.py` that: + - Validates inputs + - Calls `_to_f64()` on array arguments + - Calls the Rust function + - Wraps the result for pandas/polars if the output is a `np.ndarray` +4. Export from `python/ferro_ta/__init__.py` via the usual `__all__` + + `pandas_wrap` / `polars_wrap` pattern. + +**Do not write the algorithm in Python first and port it later.** Porting is +expensive; getting it right in Rust first is cheaper. + +### Rule 2: Porting Python algorithms to Rust + +If you find a Python loop that iterates over data (e.g., `for i in range(n):`) +or a pure-Python rolling window computation, it is a porting candidate. +Priority order: +1. Hot paths called from batch or streaming contexts. +2. Any loop where `n` can be 10,000+. +3. Loops inside extended indicators. + +When porting: +- The Python function becomes a thin wrapper that calls the Rust function. +- There is no Python fallback; the extension must be built. If the Rust call + fails, the function is allowed to fail (no silent fallback to Python). + +### Rule 3: No raw NumPy loops in indicator logic + +The following patterns are **forbidden** in indicator implementation code: + +```python +# ❌ Forbidden: Python loop over data +for i in range(n): + result[i] = compute(data[i - period : i]) + +# ❌ Forbidden: nested Python loop in rolling window +for i in range(timeperiod - 1, n): + result[i] = data[i + 1 - timeperiod : i + 1].max() +``` + +The following are **allowed** in Python wrappers only: +```python +# ✓ Allowed: vectorised NumPy (no loop) +result = np.cumsum(data) + +# ✓ Allowed: scalar operations (no loop over n) +tp = (high + low + close) / 3.0 +``` + +### Rule 4: Streaming classes are Rust PyO3 classes + +Streaming (bar-by-bar stateful) classes **must** be `#[pyclass]` types +implemented in `src/streaming/mod.rs`. Python should import and re-export +them — never re-implement them. + +Template for a new streaming class: +```rust +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingMyIndicator { + period: usize, + // ... state fields +} + +#[pymethods] +impl StreamingMyIndicator { + #[new] + pub fn new(period: usize) -> PyResult { ... } + pub fn update(&mut self, value: f64) -> f64 { ... } + pub fn reset(&mut self) { ... } + #[getter] + pub fn period(&self) -> usize { self.period } +} +``` + +Then in `src/streaming/mod.rs::register()`: +```rust +m.add_class::()?; +``` + +And in `python/ferro_ta/streaming.py`: +```python +from ferro_ta._ferro_ta import StreamingMyIndicator # noqa: F401 +``` + +### Rule 5: Batch operations are Rust functions + +Batch functions that process multiple time-series at once must be implemented +in `src/batch/mod.rs`. They accept 2-D numpy arrays and loop over columns +entirely in Rust (one GIL release covers all columns). + +### Rule 6: Document the Rust location + +Every Python wrapper docstring must note that the algorithm is in Rust: + +```python +def MY_INDICATOR(close, timeperiod=14): + """My Indicator. + ... + Notes + ----- + Implemented in Rust — see ``src/my_category/my_indicator.rs``. + """ +``` + +--- + +## What Belongs in Python Only + +Some things are **intentionally** in Python and should stay there: + +| Thing | Why it stays in Python | +|---|---| +| `pandas_wrap` / `polars_wrap` decorators | Pandas/polars are Python libraries; zero-copy Rust wrappers are not practical here | +| `_to_f64` fast path check | One Python branch beats a PyO3 round-trip for the already-valid case | +| `check_equal_length`, `check_timeperiod` | Negligible overhead vs indicator computation; keeps Rust functions focused | +| `Pipeline`, `Config` | Orchestration logic — Python is appropriate | +| `gpu.py` (CuPy PoC) | CuPy is Python-native; Rust cannot talk to GPU without CUDA bindings | +| `backtest.py` helpers | High-level orchestration | + +--- + +## Current Status (as of 2026-03-08) + +| Module | Logic location | +|---|---| +| `overlap.py` | ✅ Rust (`src/overlap/`) | +| `momentum.py` | ✅ Rust (`src/momentum/`) | +| `volatility.py` | ✅ Rust (`src/volatility/`) | +| `statistic.py` | ✅ Rust (`src/statistic/`) | +| `volume.py` | ✅ Rust (`src/volume/`) | +| `price_transform.py` | ✅ Rust (`src/price_transform/`) | +| `pattern.py` | ✅ Rust (`src/pattern/`) | +| `cycle.py` | ✅ Rust (`src/cycle/`) | +| `batch.py` | ✅ Rust (`src/batch/`) | +| `streaming.py` | ✅ Rust (`src/streaming/`) — all 9 classes | +| `extended.py` | ✅ Rust (`src/extended/`) — all 10 indicators | +| `math_ops.py` (rolling) | ✅ Rust (`src/math_ops/`) — SUM/MAX/MIN/MAXINDEX/MININDEX | +| `math_ops.py` (element-wise) | ✅ NumPy wrappers (no loops — vectorised by NumPy's C core) | +| `gpu.py` | ⚠️ CuPy (Python/CUDA — intentional, see above) | +| `pipeline.py` | ✅ Orchestration only (no indicator loops) | +| `config.py` | ✅ Configuration only | +| `backtest.py` | ✅ Orchestration only | + +--- + +## Checklist for New Indicator PRs + +- [ ] Algorithm implemented in `src//mod.rs` +- [ ] `cargo fmt --check` passes +- [ ] `cargo clippy --release -- -D warnings` passes +- [ ] Python wrapper is **thin** (validation + `_to_f64` + Rust call) +- [ ] No Python loops over data +- [ ] Docstring notes "Implemented in Rust" +- [ ] Registered in `src/lib.rs` and exported from `__init__.py` +- [ ] Tests added in `tests/` diff --git a/vendor/ferro-ta-main/docs/stability.md b/vendor/ferro-ta-main/docs/stability.md new file mode 100644 index 0000000..9a21f15 --- /dev/null +++ b/vendor/ferro-ta-main/docs/stability.md @@ -0,0 +1,104 @@ +# API Stability Policy + +This document describes which parts of **ferro-ta** are considered stable, which +are experimental, and what the deprecation process is. + +--- + +## Stability Tiers + +### Stable + +The following are considered **stable** and will not change in incompatible ways +without a major version bump (i.e., following [Semantic Versioning 2.0.0]): + +- All indicator functions exported from `ferro_ta.*` by name (e.g. `ferro_ta.SMA`, + `ferro_ta.RSI`, `ferro_ta.BBANDS`). +- Sub-module imports: `from ferro_ta.overlap import SMA` etc. +- Function signatures: positional array arguments and `timeperiod` / other keyword + arguments documented in the docstrings. +- Return types: single `np.ndarray` or tuple of `np.ndarray` as documented. +- Exception classes: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`. +- Utility helpers: `ferro_ta.utils.get_ohlcv`, `ferro_ta._utils.get_ohlcv`. +- `pandas_wrap` / `polars_wrap` behaviour: `pd.Series` in → `pd.Series` out; + `pl.Series` in → `pl.Series` out. +- Registry API: `ferro_ta.registry.register`, `run`, `get`, `list_indicators`. +- Pipeline API: `ferro_ta.pipeline.Pipeline`, `make_pipeline`. +- Config API: `ferro_ta.config.set_default`, `ferro_ta.config.Config`. + +### Experimental + +The following are **experimental** and may change in minor releases: + +- **`ferro_ta.raw`** — direct access to the compiled Rust extension; function + signatures follow the Rust layer and may change when the Rust layer changes. +- **`ferro_ta.batch`** internals — the Python↔Rust dispatch logic may change as + the Rust batch API evolves. +- **`ferro_ta.streaming`** — the streaming class API (especially the `reset()` + method and internal buffer access) may evolve; the `update()` method signature + is stable. +- **`ferro_ta.extended`** — extended indicators (VWAP, SUPERTREND, etc.) are + considered stable in return shape and semantics, but implementation details + (e.g. whether computation is in Python or Rust) may change. +- **`ferro_ta.backtest`** — the backtest helpers are convenience utilities and + may be refactored. +- **`ferro_ta.gpu`** — the CuPy GPU backend is an experimental proof-of-concept. + +### Internal / Private + +Names prefixed with `_` (e.g. `_ferro_ta`, `_utils`, `_to_f64`) are internal +and may change at any time without notice. Do not rely on them in user code. + +--- + +## Versioning + +ferro-ta follows [Semantic Versioning 2.0.0]: + +| Change type | Version bump | +|----------------------------------------|--------------| +| Breaking API change (removed indicator, renamed parameter, changed return type) | **MAJOR** | +| New indicators, new sub-modules, new features (backward-compatible) | **MINOR** | +| Bug fixes, performance improvements, docs, dependency bumps | **PATCH** | + +The current version (`1.x`) is stable. Breaking changes to stable APIs are +reserved for future **major** releases. + +--- + +## Deprecation Policy + +Before removing or renaming any **stable** API: + +1. The deprecated name/function is kept until the next **major release** after + the deprecation notice. +2. A `DeprecationWarning` is raised when the deprecated API is used. +3. The deprecation and removal are documented in `CHANGELOG.md` under + `### Deprecated` and `### Removed`. + +Example timeline: + +- `1.1.0` — `OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available. +- `2.0.0` — `OLD_NAME` removed. + +--- + +## What is NOT covered + +- The Rust ABI of the compiled extension (`_ferro_ta.so` / `_ferro_ta.pyd`). + Only the Python-level API is covered by this policy. +- Numerical precision beyond what is documented (exact TA-Lib matches for listed + indicators, "correlated" for Wilder-seeded indicators). +- Performance characteristics — we may change the implementation to be faster + (e.g. moving a Python loop to Rust) without a version bump. + +--- + +## Requesting Stability Guarantees + +If you depend on an experimental API and would like it promoted to stable, please +open an issue on GitHub explaining your use case. We will consider promoting +experimental APIs to stable when they have been in use long enough to be confident +in their design. + +[Semantic Versioning 2.0.0]: https://semver.org/ diff --git a/vendor/ferro-ta-main/docs/streaming.rst b/vendor/ferro-ta-main/docs/streaming.rst new file mode 100644 index 0000000..332de5b --- /dev/null +++ b/vendor/ferro-ta-main/docs/streaming.rst @@ -0,0 +1,12 @@ +Streaming API +============= + +The :mod:`ferro_ta.streaming` module provides stateful, bar-by-bar indicator computation +for live/real-time trading. Each class maintains an internal buffer and returns ``NaN`` +during the warmup period. + +.. automodule:: ferro_ta.streaming + :no-index: + :members: + :undoc-members: + :show-inheritance: diff --git a/vendor/ferro-ta-main/docs/support_matrix.rst b/vendor/ferro-ta-main/docs/support_matrix.rst new file mode 100644 index 0000000..da41485 --- /dev/null +++ b/vendor/ferro-ta-main/docs/support_matrix.rst @@ -0,0 +1,190 @@ +Support Matrix +============== + +The primary product is the Python technical analysis library: TA-Lib-style +indicator calls backed by a Rust implementation. + +Indicator compatibility +----------------------- + +.. list-table:: + :header-rows: 1 + + * - Status + - Scope + - Notes + * - Exact parity + - Common TA-Lib-compatible indicators such as ``SMA``, ``WMA``, + ``BBANDS``, ``RSI``, ``ATR``, ``NATR``, ``CCI``, ``STOCH``, + ``STOCHRSI``, and most candlestick patterns + - Matches TA-Lib numerically within floating-point tolerance in the + current comparison suite. + * - Approximate parity + - EMA-family indicators (``EMA``, ``DEMA``, ``TEMA``, ``T3``, ``MACD``), + ``MAMA`` / ``FAMA``, ``SAR`` / ``SAREXT``, and ``HT_*`` cycle + indicators + - Same API and intended use, with convergence-window or floating-point + differences documented in the migration guide. + * - Intentionally different + - ferro-ta-only indicators such as ``VWAP``, ``SUPERTREND``, + ``ICHIMOKU``, ``DONCHIAN``, ``KELTNER_CHANNELS``, ``HULL_MA``, + ``CHANDELIER_EXIT``, ``VWMA``, and ``CHOPPINESS_INDEX`` + - These extend the library beyond TA-Lib and are not parity claims. + +For migration details and known indicator-specific differences, see +:doc:`migration_talib`. + +Module status +------------- + +.. list-table:: + :header-rows: 1 + + * - Surface + - Status + - Notes + * - Top-level indicators and category submodules + - Stable core + - This is the main supported surface of the project. + * - ``ferro_ta.batch`` + - Supported + - Public API is supported; internal dispatch may evolve. + * - ``ferro_ta.streaming`` + - Supported, still evolving + - Suitable for live workflows; some API details are still marked + experimental in the stability policy. + * - ``ferro_ta.extended`` + - Supported extension + - Useful indicators beyond TA-Lib, but not part of drop-in parity claims. + * - ``ferro_ta.analysis.*`` + - Adjacent tooling + - Useful analytics helpers, but not the primary product story. + * - ``ferro_ta.analysis.resample`` + - Supported (v1.1.0) + - ``resample_ohlcv()``, ``align_to_coarse()``, ``resample_ohlcv_labels()`` — pure-NumPy + OHLCV bar aggregation across timeframes. + * - ``ferro_ta.analysis.multitf`` + - Supported (v1.1.0) + - ``MultiTimeframeEngine`` — multi-timeframe signal generation with automatic alignment. + * - ``ferro_ta.analysis.adjust`` + - Supported (v1.1.0) + - ``adjust_ohlcv()``, ``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted + price series for equity/index strategies. + * - ``ferro_ta.analysis.plot`` + - Supported (v1.1.0) + - ``plot_backtest()`` — interactive Plotly backtest visualization (requires plotly). + * - ``ferro_ta.analysis.regime`` + - Supported (v1.1.0) + - ``detect_volatility_regime()``, ``detect_trend_regime()``, ``detect_combined_regime()``, + ``RegimeFilter`` — pure-NumPy 6-state market regime labeling; no ML dependencies. + * - ``ferro_ta.analysis.optimize`` + - Supported (v1.1.0) + - ``PortfolioOptimizer``, ``mean_variance_optimize()``, ``risk_parity_optimize()``, + ``max_sharpe_optimize()`` — portfolio optimization via SLSQP (requires scipy). + * - ``ferro_ta.analysis.live`` + - Supported (v1.1.0) + - ``PaperTrader`` — event-driven paper trading bridge matching backtest logic exactly. + * - MCP, WASM, GPU, plugin, and agent-oriented tooling + - Experimental or adjacent + - Evaluate these independently from the core indicator library. + +Backtesting engine features +--------------------------- + +.. list-table:: + :header-rows: 1 + + * - Feature + - Status + - Notes + * - Flat/proportional commission + - Supported + - Via ``CommissionModel`` presets and ``BacktestEngine.with_commission_model()``. + * - Bid-ask spread model (``spread_bps``) + - Supported (v1.1.0) + - New ``CommissionModel.spread_bps`` field; half-spread deducted per leg. + * - Short borrow cost (``short_borrow_rate_annual``) + - Supported (v1.1.0) + - New ``CommissionModel.short_borrow_rate_annual`` field; accrued per bar for short positions. + * - Trailing stop loss + - Supported + - ``BacktestEngine.with_trailing_stop(pct)`` — intrabar high-water mark tracking. + * - Breakeven stop (``breakeven_pct``) + - Supported (v1.1.0) + - ``BacktestEngine.with_breakeven_stop(pct)`` — moves stop to entry once profit reaches ``pct``. + * - Bracket order priority + - Supported (v1.1.0) + - When both SL and TP are breached on the same bar, the level closer to open fires first. + * - Leverage / margin modeling + - Supported (v1.1.0) + - ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)`` — tracks margin and + triggers force-close on margin call. + * - Loss circuit breakers + - Supported (v1.1.0) + - ``BacktestEngine.with_loss_limits(daily, total)`` — halts trading on drawdown breach. + * - Portfolio constraints + - Supported (v1.1.0) + - ``BacktestEngine.with_portfolio_constraints(max_asset_weight, max_gross_exposure, + max_net_exposure)`` for multi-asset backtests. + * - Volatility-target position sizing + - Supported + - ``BacktestEngine.with_position_sizing("volatility_target", ...)``. + * - Walk-forward / Monte Carlo + - Supported + - Available via ``BacktestEngine`` higher-level methods. + * - Benchmark comparison + - Supported + - ``BacktestEngine.with_benchmark(close_array)`` — alpha, beta, information ratio. + +Supported Python versions +------------------------- + +.. list-table:: + :header-rows: 1 + + * - Python + - Status + * - 3.13 + - Supported and tested in CI + * - 3.12 + - Supported and tested in CI + * - 3.11 + - Supported and tested in CI + * - 3.10 + - Supported and tested in CI + * - < 3.10 + - Not supported + +Tested wheel targets +-------------------- + +.. list-table:: + :header-rows: 1 + + * - OS + - Architecture + - Wheel status + * - Linux + - ``x86_64`` (manylinux2014 / ``manylinux_2_17``) + - Tested wheel target + * - macOS + - ``universal2`` + - Tested wheel target for Intel and Apple Silicon + * - Windows + - ``x86_64`` + - Tested wheel target + +For source builds, packaging details, and platform notes, see +`PLATFORMS.md `_. + +Release status +-------------- + +These docs track package version ``1.2.0``. + +- Release notes by version: :doc:`changelog` +- Canonical project changelog: `CHANGELOG.md `_ +- Stability policy: `docs/stability.md `_ + +If the package version, docs version, or support matrix disagree, treat that as +a documentation bug. diff --git a/vendor/ferro-ta-main/examples/README.md b/vendor/ferro-ta-main/examples/README.md new file mode 100644 index 0000000..d71dedb --- /dev/null +++ b/vendor/ferro-ta-main/examples/README.md @@ -0,0 +1,31 @@ +# ferro-ta Examples + +Jupyter notebooks demonstrating key ferro-ta features. + +## Notebooks + +| Notebook | Description | +|---|---| +| [`quickstart.ipynb`](quickstart.ipynb) | Core API: moving averages, RSI, MACD, Bollinger Bands, batch API, pipeline, pandas integration | +| [`streaming.ipynb`](streaming.ipynb) | Streaming bar-by-bar API: StreamingSMA, StreamingRSI, StreamingBBands, StreamingMACD, StreamingATR | +| [`backtesting.ipynb`](backtesting.ipynb) | Backtesting harness, indicator pipeline for feature engineering, config defaults | +| [`features_21_30.ipynb`](features_21_30.ipynb) | Multi-timeframe, resampling, portfolio analytics, strategy DSL, feature matrix, viz, adapters | + +## Running the Notebooks + +```bash +# Install dependencies +pip install ferro-ta jupyter numpy + +# Optional: pandas and polars integration +pip install "ferro-ta[pandas]" "ferro_ta[polars]" + +# Start Jupyter +jupyter notebook examples/ +``` + +## Links + +- [ferro-ta README](../README.md) +- [API documentation](../docs/) +- [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/vendor/ferro-ta-main/examples/backtesting.ipynb b/vendor/ferro-ta-main/examples/backtesting.ipynb new file mode 100644 index 0000000..b0eac7f --- /dev/null +++ b/vendor/ferro-ta-main/examples/backtesting.ipynb @@ -0,0 +1,200 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Backtesting with ferro-ta\n", + "\n", + "This notebook demonstrates the minimal backtesting harness and the\n", + "indicator pipeline, together with the configuration defaults API.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ferro_ta.config as config\n", + "import numpy as np\n", + "from ferro_ta.backtest import backtest\n", + "from ferro_ta.pipeline import Pipeline\n", + "\n", + "from ferro_ta import BBANDS, EMA, RSI, SMA\n", + "\n", + "# Synthetic data\n", + "np.random.seed(42)\n", + "n = 300\n", + "close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "volume = np.random.randint(1000, 10000, n).astype(float)\n", + "print(f\"Generated {n} bars, final price: {close[-1]:.2f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RSI 30/70 Strategy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = backtest(close, strategy=\"rsi_30_70\", timeperiod=14)\n", + "print(\"Strategy: RSI 30/70\")\n", + "print(f\"Final equity: {result.final_equity:.4f}\")\n", + "print(f\"Number of trades: {result.n_trades}\")\n", + "print(f\"Return: {(result.final_equity - 1.0) * 100:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## SMA Crossover Strategy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result2 = backtest(close, strategy=\"sma_crossover\", fast=10, slow=30)\n", + "print(\"Strategy: SMA Crossover (10/30)\")\n", + "print(f\"Final equity: {result2.final_equity:.4f}\")\n", + "print(f\"Number of trades: {result2.n_trades}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Configuration Defaults\n", + "\n", + "Set global defaults for indicator parameters to avoid repeating them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Set global defaults\n", + "config.set_default(\"timeperiod\", 20) # global default for all indicators\n", + "config.set_default(\"RSI.timeperiod\", 14) # RSI-specific override\n", + "\n", + "print(\"Current defaults:\", config.list_defaults())\n", + "print(\"RSI defaults:\", config.get_defaults_for(\"RSI\"))\n", + "print(\"SMA defaults:\", config.get_defaults_for(\"SMA\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Context manager for temporary overrides\n", + "with config.Config(timeperiod=5):\n", + " temp_default = config.get_default(\"timeperiod\")\n", + " print(f\"Inside context: timeperiod={temp_default}\")\n", + "\n", + "print(f\"After context: timeperiod={config.get_default('timeperiod')}\") # back to 20\n", + "\n", + "# Clean up\n", + "config.reset()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multi-indicator Pipeline for Feature Engineering" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pipe = (\n", + " Pipeline()\n", + " .add(\"sma_10\", SMA, timeperiod=10)\n", + " .add(\"sma_30\", SMA, timeperiod=30)\n", + " .add(\"ema_10\", EMA, timeperiod=10)\n", + " .add(\"rsi_14\", RSI, timeperiod=14)\n", + " .add(\n", + " \"bb\",\n", + " BBANDS,\n", + " output_keys=[\"bb_upper\", \"bb_mid\", \"bb_lower\"],\n", + " timeperiod=20,\n", + " nbdevup=2.0,\n", + " nbdevdn=2.0,\n", + " )\n", + ")\n", + "\n", + "features = pipe.run(close)\n", + "print(\"Feature columns:\", list(features.keys()))\n", + "\n", + "# Build a simple feature matrix (last 5 complete rows)\n", + "valid_start = 30 # warmup\n", + "feature_matrix = np.column_stack([v[valid_start:] for v in features.values()])\n", + "print(f\"Feature matrix shape: {feature_matrix.shape}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Simple Manual Backtest Using the Pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Signal: long when RSI < 40 AND close > SMA_30; flat otherwise\n", + "rsi_vals = features[\"rsi_14\"]\n", + "sma30_vals = features[\"sma_30\"]\n", + "\n", + "signal = np.where((rsi_vals < 40) & (close > sma30_vals), 1.0, 0.0)\n", + "position = np.roll(signal, 1) # trade on next bar open\n", + "position[0] = 0.0\n", + "\n", + "returns = np.diff(close) / close[:-1]\n", + "strategy_returns = returns * position[1:]\n", + "\n", + "equity = np.cumprod(1 + strategy_returns)\n", + "print(f\"Final equity: {equity[-1]:.4f}\")\n", + "print(f\"Number of signal bars: {int(signal.sum())}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/vendor/ferro-ta-main/examples/custom_indicator.py b/vendor/ferro-ta-main/examples/custom_indicator.py new file mode 100644 index 0000000..246119d --- /dev/null +++ b/vendor/ferro-ta-main/examples/custom_indicator.py @@ -0,0 +1,53 @@ +""" +Example plugin: smoothed RSI (SMA of RSI). + +Run this file to verify the plugin contract: + python examples/custom_indicator.py + +Registers "SMOOTH_RSI" and runs it on sample data. +""" + +from __future__ import annotations + +import numpy as np + +from ferro_ta import RSI, SMA +from ferro_ta.core.registry import list_indicators, register, run + + +def smooth_rsi(close, timeperiod=14, smooth=3): + """Smoothed RSI: RSI then SMA of the RSI series. + + Parameters + ---------- + close : array-like + Close prices. + timeperiod : int + RSI period (default 14). + smooth : int + SMA period applied to RSI (default 3). + + Returns + ------- + numpy.ndarray + Smoothed RSI values; same length as close. + """ + rsi = RSI(close, timeperiod=timeperiod) + return SMA(rsi, timeperiod=smooth) + + +def main(): + register("SMOOTH_RSI", smooth_rsi) + close = np.array( + [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33] + ) + out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2) + print("SMOOTH_RSI:", out) + assert "SMOOTH_RSI" in list_indicators(), ( + "SMOOTH_RSI should be in list_indicators()" + ) + print("OK: plugin registered and run successfully.") + + +if __name__ == "__main__": + main() diff --git a/vendor/ferro-ta-main/examples/quickstart.ipynb b/vendor/ferro-ta-main/examples/quickstart.ipynb new file mode 100644 index 0000000..25f1c5f --- /dev/null +++ b/vendor/ferro-ta-main/examples/quickstart.ipynb @@ -0,0 +1,234 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ferro-ta Quick Start\n", + "\n", + "This notebook demonstrates the core ferro-ta API.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from ferro_ta import BBANDS, EMA, MACD, RSI, SMA\n", + "\n", + "# Synthetic OHLCV data\n", + "np.random.seed(42)\n", + "n = 200\n", + "close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "high = close * (1 + np.abs(np.random.randn(n)) * 0.005)\n", + "low = close * (1 - np.abs(np.random.randn(n)) * 0.005)\n", + "volume = np.random.randint(1_000, 10_000, n).astype(float)\n", + "\n", + "print(f\"Generated {n} bars\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Moving Averages" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma_20 = SMA(close, timeperiod=20)\n", + "ema_20 = EMA(close, timeperiod=20)\n", + "\n", + "print(\"SMA(20):\", sma_20[-5:])\n", + "print(\"EMA(20):\", ema_20[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RSI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rsi = RSI(close, timeperiod=14)\n", + "print(\"RSI(14):\", rsi[-5:])\n", + "print(f\"RSI range: [{np.nanmin(rsi):.2f}, {np.nanmax(rsi):.2f}]\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## MACD" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "macd_line, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)\n", + "print(\"MACD line: \", macd_line[-5:])\n", + "print(\"Signal: \", signal[-5:])\n", + "print(\"Histogram: \", hist[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bollinger Bands" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "upper, middle, lower = BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)\n", + "print(\"Upper band: \", upper[-5:])\n", + "print(\"Middle band:\", middle[-5:])\n", + "print(\"Lower band: \", lower[-5:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Batch API — multiple symbols at once" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.batch import batch_rsi, batch_sma\n", + "\n", + "# Simulate 5 symbols\n", + "data = np.random.default_rng(0).random((200, 5)) * 100 + 50\n", + "sma_result = batch_sma(data, timeperiod=20)\n", + "rsi_result = batch_rsi(data, timeperiod=14)\n", + "\n", + "print(\"Batch SMA shape:\", sma_result.shape) # (200, 5)\n", + "print(\"Batch RSI shape:\", rsi_result.shape) # (200, 5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pipeline API — compose multiple indicators" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.pipeline import Pipeline\n", + "\n", + "pipe = (\n", + " Pipeline()\n", + " .add(\"sma_20\", SMA, timeperiod=20)\n", + " .add(\"ema_20\", EMA, timeperiod=20)\n", + " .add(\"rsi_14\", RSI, timeperiod=14)\n", + " .add(\n", + " \"bb\",\n", + " BBANDS,\n", + " output_keys=[\"bb_upper\", \"bb_mid\", \"bb_lower\"],\n", + " timeperiod=20,\n", + " nbdevup=2.0,\n", + " nbdevdn=2.0,\n", + " )\n", + ")\n", + "\n", + "results = pipe.run(close)\n", + "print(\"Pipeline outputs:\", list(results.keys()))\n", + "print(\"SMA last 3:\", results[\"sma_20\"][-3:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pandas Integration" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " import pandas as pd\n", + "\n", + " s = pd.Series(close, name=\"close\")\n", + " sma_pd = SMA(s, timeperiod=20)\n", + " print(\"Result type:\", type(sma_pd)) # pandas.Series\n", + " print(\"Index preserved:\", list(sma_pd.index[:3]))\n", + "except ImportError:\n", + " print(\"pandas not installed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Error Handling" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from ferro_ta.exceptions import check_timeperiod\n", + "\n", + "from ferro_ta import FerroTAValueError\n", + "\n", + "try:\n", + " check_timeperiod(0)\n", + "except FerroTAValueError as e:\n", + " print(\"Caught:\", e)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/vendor/ferro-ta-main/examples/streaming.ipynb b/vendor/ferro-ta-main/examples/streaming.ipynb new file mode 100644 index 0000000..faa1d0d --- /dev/null +++ b/vendor/ferro-ta-main/examples/streaming.ipynb @@ -0,0 +1,206 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Streaming API — bar-by-bar live trading\n", + "\n", + "The `ferro_ta.streaming` module provides stateful classes that process\n", + "data bar-by-bar, suitable for real-time feeds and live trading.\n", + "\n", + "Install:\n", + "```bash\n", + "pip install ferro-ta\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from ferro_ta.streaming import (\n", + " StreamingATR,\n", + " StreamingBBands,\n", + " StreamingEMA,\n", + " StreamingMACD,\n", + " StreamingRSI,\n", + " StreamingSMA,\n", + ")\n", + "\n", + "# Simulate incoming bars\n", + "np.random.seed(42)\n", + "n = 50\n", + "closes = np.cumprod(1 + np.random.randn(n) * 0.01) * 100\n", + "highs = closes * 1.005\n", + "lows = closes * 0.995\n", + "\n", + "print(f\"Simulated {n} bars\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingSMA and StreamingEMA" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma = StreamingSMA(period=5)\n", + "ema = StreamingEMA(period=5)\n", + "\n", + "sma_values = [sma.update(c) for c in closes]\n", + "ema_values = [ema.update(c) for c in closes]\n", + "\n", + "print(\n", + " \"SMA last 5:\", [f\"{v:.4f}\" if not np.isnan(v) else \"NaN\" for v in sma_values[-5:]]\n", + ")\n", + "print(\n", + " \"EMA last 5:\", [f\"{v:.4f}\" if not np.isnan(v) else \"NaN\" for v in ema_values[-5:]]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingRSI" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "rsi_stream = StreamingRSI(period=14)\n", + "\n", + "rsi_values = [rsi_stream.update(c) for c in closes]\n", + "finite = [(i, v) for i, v in enumerate(rsi_values) if not np.isnan(v)]\n", + "print(f\"First valid RSI at bar {finite[0][0]}: {finite[0][1]:.2f}\")\n", + "print(\"RSI last 3:\", [f\"{v:.2f}\" for _, v in finite[-3:]])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingBBands" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "bbands = StreamingBBands(period=20)\n", + "\n", + "bb_results = [bbands.update(c) for c in closes]\n", + "# Each result is (upper, middle, lower) or (nan, nan, nan) during warmup\n", + "valid_bb = [\n", + " (i, u, m, lower) for i, (u, m, lower) in enumerate(bb_results) if not np.isnan(m)\n", + "]\n", + "if valid_bb:\n", + " i, u, m, lower = valid_bb[-1]\n", + " print(f\"Latest Bollinger Bands at bar {i}:\")\n", + " print(f\" Upper: {u:.4f}\")\n", + " print(f\" Middle: {m:.4f}\")\n", + " print(f\" Lower: {lower:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingMACD" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "macd_stream = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9)\n", + "\n", + "macd_results = [macd_stream.update(c) for c in closes]\n", + "# Each result is (macd_line, signal, histogram)\n", + "valid_macd = [\n", + " (i, m, s, h) for i, (m, s, h) in enumerate(macd_results) if not np.isnan(m)\n", + "]\n", + "if valid_macd:\n", + " i, m, s, h = valid_macd[-1]\n", + " print(f\"Latest MACD at bar {i}:\")\n", + " print(f\" MACD line: {m:.6f}\")\n", + " print(f\" Signal: {s:.6f}\")\n", + " print(f\" Histogram: {h:.6f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## StreamingATR" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "atr_stream = StreamingATR(period=14)\n", + "\n", + "atr_values = [atr_stream.update(h, low, c) for h, low, c in zip(highs, lows, closes)]\n", + "finite_atr = [v for v in atr_values if not np.isnan(v)]\n", + "if finite_atr:\n", + " print(f\"Latest ATR: {finite_atr[-1]:.4f}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reset and reuse\n", + "\n", + "All streaming classes support `reset()` to clear internal state and start fresh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sma.reset()\n", + "print(\"After reset, SMA(5.0):\", sma.update(5.0)) # NaN — warm-up restarted\n", + "sma.update(6.0)\n", + "sma.update(7.0)\n", + "sma.update(8.0)\n", + "print(\"SMA after 4 bars:\", sma.update(9.0)) # 7.0 = mean of [5,6,7,8,9]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/vendor/ferro-ta-main/fuzz/Cargo.toml b/vendor/ferro-ta-main/fuzz/Cargo.toml new file mode 100644 index 0000000..5688b12 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "ferro_ta_fuzz" +version = "0.0.1" +edition = "2021" +publish = false + +# Exclude from the root workspace so cargo doesn't reject it as an unlisted member +[workspace] + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +ferro_ta_core = { path = "../crates/ferro_ta_core" } + +[[bin]] +name = "fuzz_sma" +path = "fuzz_targets/fuzz_sma.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_rsi" +path = "fuzz_targets/fuzz_rsi.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_ema" +path = "fuzz_targets/fuzz_ema.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_bbands" +path = "fuzz_targets/fuzz_bbands.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_macd" +path = "fuzz_targets/fuzz_macd.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_atr" +path = "fuzz_targets/fuzz_atr.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_stoch" +path = "fuzz_targets/fuzz_stoch.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_mfi" +path = "fuzz_targets/fuzz_mfi.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_wma" +path = "fuzz_targets/fuzz_wma.rs" +test = false +doc = false +bench = false + +[profile.release] +debug = 1 diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs new file mode 100644 index 0000000..37243ab --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_atr.rs @@ -0,0 +1,48 @@ +/*! +Fuzz target for `ferro_ta_core::volatility::atr`. + +Verifies that ATR never panics, output length matches input, and all +finite values are non-negative (ATR is always >= 0). +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::volatility; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Need 3 f64s per bar (high, low, close) + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + let n_bars = n_floats / 3; + if n_bars == 0 { + return; + } + + let all_floats: Vec = (0..n_bars * 3) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let high = &all_floats[..n_bars]; + let low = &all_floats[n_bars..n_bars * 2]; + let close = &all_floats[n_bars * 2..n_bars * 3]; + + let result = volatility::atr(high, low, close, timeperiod); + assert_eq!(result.len(), high.len(), "ATR output length mismatch"); + + // ATR values should be non-negative when finite + for (i, &v) in result.iter().enumerate() { + if v.is_finite() { + assert!(v >= 0.0, "ATR result[{i}] = {v} is negative"); + } + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs new file mode 100644 index 0000000..e62b5dc --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_bbands.rs @@ -0,0 +1,60 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::bbands`. + +Verifies that BBANDS never panics and that the three output vectors +(upper, middle, lower) always have the same length as the input. +When finite, upper >= middle >= lower must hold. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + if data.len() < 3 { + return; + } + + let timeperiod = ((data[0] as usize) % 64) + 1; + // Use second byte for deviation multipliers (1.0 - 4.0 range) + let nbdevup = 1.0 + (data[1] as f64 / 255.0) * 3.0; + let nbdevdn = 1.0 + (data[2] as f64 / 255.0) * 3.0; + + let float_bytes = &data[3..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let (upper, middle, lower) = overlap::bbands(&close, timeperiod, nbdevup, nbdevdn); + + assert_eq!(upper.len(), close.len(), "BBANDS upper length mismatch"); + assert_eq!(middle.len(), close.len(), "BBANDS middle length mismatch"); + assert_eq!(lower.len(), close.len(), "BBANDS lower length mismatch"); + + // When all three are finite, upper >= middle >= lower + for i in 0..close.len() { + if upper[i].is_finite() && middle[i].is_finite() && lower[i].is_finite() { + assert!( + upper[i] >= middle[i], + "BBANDS upper[{i}] ({}) < middle[{i}] ({})", + upper[i], + middle[i] + ); + assert!( + middle[i] >= lower[i], + "BBANDS middle[{i}] ({}) < lower[{i}] ({})", + middle[i], + lower[i] + ); + } + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs new file mode 100644 index 0000000..32153d1 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_ema.rs @@ -0,0 +1,35 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::ema`. + +Verifies that EMA never panics for any input and that the output length +always matches the input length. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + let timeperiod = ((data[0] as usize) % 64) + 1; + + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let result = overlap::ema(&close, timeperiod); + assert_eq!(result.len(), close.len(), "EMA output length mismatch"); +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs new file mode 100644 index 0000000..ee4d0d0 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_macd.rs @@ -0,0 +1,41 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::macd`. + +Verifies that MACD never panics and that all three output vectors +(macd, signal, histogram) match the input length. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + if data.len() < 4 { + return; + } + + // Extract periods from first 3 bytes (1-64 range each) + let fastperiod = ((data[0] as usize) % 32) + 1; + let slowperiod = ((data[1] as usize) % 32) + fastperiod + 1; // slow > fast + let signalperiod = ((data[2] as usize) % 32) + 1; + + let float_bytes = &data[3..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let (macd, signal, hist) = overlap::macd(&close, fastperiod, slowperiod, signalperiod); + + assert_eq!(macd.len(), close.len(), "MACD line length mismatch"); + assert_eq!(signal.len(), close.len(), "MACD signal length mismatch"); + assert_eq!(hist.len(), close.len(), "MACD histogram length mismatch"); +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs new file mode 100644 index 0000000..1ad8cb8 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_mfi.rs @@ -0,0 +1,51 @@ +/*! +Fuzz target for `ferro_ta_core::volume::mfi`. + +Verifies that MFI never panics, output length matches input, and finite +values lie in [0, 100]. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::volume; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Need 4 f64s per bar (high, low, close, volume) + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + let n_bars = n_floats / 4; + if n_bars == 0 { + return; + } + + let all_floats: Vec = (0..n_bars * 4) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let high = &all_floats[..n_bars]; + let low = &all_floats[n_bars..n_bars * 2]; + let close = &all_floats[n_bars * 2..n_bars * 3]; + let vol = &all_floats[n_bars * 3..n_bars * 4]; + + let result = volume::mfi(high, low, close, vol, timeperiod); + assert_eq!(result.len(), high.len(), "MFI output length mismatch"); + + for (i, &v) in result.iter().enumerate() { + if v.is_finite() { + assert!( + v >= 0.0 && v <= 100.0, + "MFI result[{i}] = {v} is out of [0, 100]" + ); + } + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs new file mode 100644 index 0000000..9d9b705 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_rsi.rs @@ -0,0 +1,52 @@ +/*! +Fuzz target for `ferro_ta_core::momentum::rsi`. + +Generates arbitrary f64 slices (via raw bytes) and arbitrary timeperiods, +verifying that RSI never panics and that all finite output values lie in +the range [0, 100]. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::momentum; + +fuzz_target!(|data: &[u8]| { + // Need at least 1 byte for timeperiod + 8 bytes for one f64 + if data.len() < 2 { + return; + } + + // Extract timeperiod from first byte (1-64) + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Interpret remaining bytes as f64 values + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + // Must not panic + let result = momentum::rsi(&close, timeperiod); + + // Result length must match input + assert_eq!(result.len(), close.len(), "RSI output length mismatch"); + + // All finite output values must be in [0, 100] + for (i, &v) in result.iter().enumerate() { + if v.is_finite() { + assert!( + v >= 0.0 && v <= 100.0, + "RSI result[{i}] = {v} is out of [0, 100]" + ); + } + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs new file mode 100644 index 0000000..6f4ceb7 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_sma.rs @@ -0,0 +1,51 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::sma`. + +The fuzzer generates arbitrary byte sequences and interprets them as +`f64` values plus a `timeperiod`. The invariant under test is that the +function **never panics** for any input — it may return `NaN`, `Inf`, or +an all-NaN slice, but it must not crash. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + // Need at least 1 byte for timeperiod + 8 bytes for one f64 + if data.len() < 2 { + return; + } + + // Extract timeperiod from first byte (1-64 to keep runs fast) + let timeperiod = ((data[0] as usize) % 64) + 1; + + // Interpret remaining bytes as f64 values (skip incomplete trailing bytes) + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + // Must not panic for any input + let result = overlap::sma(&close, timeperiod); + + // Result length must match input length + assert_eq!(result.len(), close.len(), "SMA output length mismatch"); + + // The first (timeperiod - 1) values must be NaN + for i in 0..(timeperiod.min(close.len()).saturating_sub(1)) { + assert!( + result[i].is_nan(), + "SMA result[{i}] should be NaN (warm-up period)" + ); + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs new file mode 100644 index 0000000..178669c --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_stoch.rs @@ -0,0 +1,63 @@ +/*! +Fuzz target for `ferro_ta_core::momentum::stoch`. + +Verifies that STOCH never panics, output lengths match, and finite +values lie in [0, 100]. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::momentum; + +fuzz_target!(|data: &[u8]| { + if data.len() < 4 { + return; + } + + let fastk_period = ((data[0] as usize) % 32) + 1; + let slowk_period = ((data[1] as usize) % 16) + 1; + let slowd_period = ((data[2] as usize) % 16) + 1; + + // Need 3 f64s per bar (high, low, close) + let float_bytes = &data[3..]; + let n_floats = float_bytes.len() / 8; + let n_bars = n_floats / 3; + if n_bars == 0 { + return; + } + + let all_floats: Vec = (0..n_bars * 3) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let high = &all_floats[..n_bars]; + let low = &all_floats[n_bars..n_bars * 2]; + let close = &all_floats[n_bars * 2..n_bars * 3]; + + let (slowk, slowd) = momentum::stoch(high, low, close, fastk_period, slowk_period, slowd_period); + + assert_eq!(slowk.len(), high.len(), "STOCH slowk length mismatch"); + assert_eq!(slowd.len(), high.len(), "STOCH slowd length mismatch"); + + // Finite values should be in [0, 100] + for (i, &v) in slowk.iter().enumerate() { + if v.is_finite() { + assert!( + v >= 0.0 && v <= 100.0, + "STOCH slowk[{i}] = {v} is out of [0, 100]" + ); + } + } + for (i, &v) in slowd.iter().enumerate() { + if v.is_finite() { + assert!( + v >= 0.0 && v <= 100.0, + "STOCH slowd[{i}] = {v} is out of [0, 100]" + ); + } + } +}); diff --git a/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs new file mode 100644 index 0000000..32ce226 --- /dev/null +++ b/vendor/ferro-ta-main/fuzz/fuzz_targets/fuzz_wma.rs @@ -0,0 +1,35 @@ +/*! +Fuzz target for `ferro_ta_core::overlap::wma`. + +Verifies that WMA never panics and that the output length always +matches the input length. +*/ + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use ferro_ta_core::overlap; + +fuzz_target!(|data: &[u8]| { + if data.len() < 2 { + return; + } + + let timeperiod = ((data[0] as usize) % 64) + 1; + + let float_bytes = &data[1..]; + let n_floats = float_bytes.len() / 8; + if n_floats == 0 { + return; + } + + let close: Vec = (0..n_floats) + .map(|i| { + let chunk: [u8; 8] = float_bytes[i * 8..(i + 1) * 8].try_into().unwrap(); + f64::from_le_bytes(chunk) + }) + .collect(); + + let result = overlap::wma(&close, timeperiod); + assert_eq!(result.len(), close.len(), "WMA output length mismatch"); +}); diff --git a/vendor/ferro-ta-main/perf-contract/batch.json b/vendor/ferro-ta-main/perf-contract/batch.json new file mode 100644 index 0000000..77f8bf0 --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/batch.json @@ -0,0 +1,93 @@ +{ + "metadata": { + "suite": "batch", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:04.010216+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.4" + }, + "dataset": { + "n_samples": 20000, + "n_series": 32, + "total_bars": 640000, + "seed": 42 + } + }, + "results": [ + { + "indicator": "SMA", + "parallel_ms": 7.9306, + "sequential_ms": 2.4832, + "loop_ms": 1.0238, + "parallel_speedup_vs_loop": 0.1291, + "sequential_speedup_vs_loop": 0.4123 + }, + { + "indicator": "RSI", + "parallel_ms": 3.9307, + "sequential_ms": 5.0938, + "loop_ms": 3.6883, + "parallel_speedup_vs_loop": 0.9383, + "sequential_speedup_vs_loop": 0.7241 + }, + { + "indicator": "ATR", + "parallel_ms": 9.2546, + "sequential_ms": 7.768, + "loop_ms": 5.2669, + "parallel_speedup_vs_loop": 0.5691, + "sequential_speedup_vs_loop": 0.678 + }, + { + "indicator": "ADX", + "parallel_ms": 9.5489, + "sequential_ms": 9.1403, + "loop_ms": 7.1578, + "parallel_speedup_vs_loop": 0.7496, + "sequential_speedup_vs_loop": 0.7831 + } + ], + "grouped_results": [ + { + "case": "close_bundle_3", + "grouped_ms": 0.466, + "separate_ms": 0.2003, + "speedup_vs_separate": 0.4298 + }, + { + "case": "hlc_bundle_3", + "grouped_ms": 1.2538, + "separate_ms": 0.6999, + "speedup_vs_separate": 0.5582 + } + ] +} diff --git a/vendor/ferro-ta-main/perf-contract/indicator_latency.json b/vendor/ferro-ta-main/perf-contract/indicator_latency.json new file mode 100644 index 0000000..1d9e3b2 --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/indicator_latency.json @@ -0,0 +1,185 @@ +{ + "metadata": { + "suite": "indicator_latency", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:02.973728+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.4" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "dataset": { + "fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "bars": 2000, + "rounds": 5 + } + }, + "results": [ + { + "name": "VAR_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0233 + }, + { + "name": "WILLR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0207 + }, + { + "name": "STOCH", + "inputs": "hlc", + "kwargs": {}, + "elapsed_ms": 0.0202 + }, + { + "name": "ADX_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0171 + }, + { + "name": "CCI_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0164 + }, + { + "name": "MACD", + "inputs": "close", + "kwargs": {}, + "elapsed_ms": 0.015 + }, + { + "name": "ATR_14", + "inputs": "hlc", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0116 + }, + { + "name": "RSI_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.011 + }, + { + "name": "STDDEV_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0103 + }, + { + "name": "BETA_5", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 5 + }, + "elapsed_ms": 0.0091 + }, + { + "name": "CORREL_30", + "inputs": "pair_hl", + "kwargs": { + "timeperiod": 30 + }, + "elapsed_ms": 0.0078 + }, + { + "name": "BBANDS_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0062 + }, + { + "name": "TSF_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0056 + }, + { + "name": "EMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0055 + }, + { + "name": "LINEARREG_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.0054 + }, + { + "name": "LINEARREG_SLOPE_14", + "inputs": "close", + "kwargs": { + "timeperiod": 14 + }, + "elapsed_ms": 0.005 + }, + { + "name": "SMA_20", + "inputs": "close", + "kwargs": { + "timeperiod": 20 + }, + "elapsed_ms": 0.0032 + } + ] +} diff --git a/vendor/ferro-ta-main/perf-contract/manifest.json b/vendor/ferro-ta-main/perf-contract/manifest.json new file mode 100644 index 0000000..01b5fe6 --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/manifest.json @@ -0,0 +1,69 @@ +{ + "metadata": { + "suite": "perf_contract", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:08.787230+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.4" + }, + "fixtures": [ + { + "path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz", + "size_bytes": 75586, + "sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c" + } + ], + "output_dir": "perf-contract" + }, + "artifacts": { + "indicator_latency": { + "path": "perf-contract/indicator_latency.json", + "size_bytes": 4041, + "sha256": "564027c7abed7ecd4ae2ac1720217d96e1e31807f8ac7d5c5393c8fd974f13ed" + }, + "batch": { + "path": "perf-contract/batch.json", + "size_bytes": 2507, + "sha256": "c50f242a138a0c358a6fa86c420954b4b11a2200598ca2942bcd0fd2bc456fb2" + }, + "streaming": { + "path": "perf-contract/streaming.json", + "size_bytes": 2766, + "sha256": "d271d3219ec098e443e84ef648ab4220cd2a38e6dc998bc6c9fc545d7fc77716" + }, + "runtime_hotspots": { + "path": "perf-contract/runtime_hotspots.json", + "size_bytes": 3197, + "sha256": "96eafc9a61ac410e47fcdfee6a9db1123cdee0ec05f49a85c631a3975063deed" + } + } +} diff --git a/vendor/ferro-ta-main/perf-contract/runtime_hotspots.json b/vendor/ferro-ta-main/perf-contract/runtime_hotspots.json new file mode 100644 index 0000000..2e8e2df --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/runtime_hotspots.json @@ -0,0 +1,118 @@ +{ + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:08.521805+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.4" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 32.5847, + "reference_ms": 990.4233, + "speedup_vs_reference": 30.3954, + "share_of_suite_pct": 69.27 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 12.2974, + "reference_ms": 233.0671, + "speedup_vs_reference": 18.9525, + "share_of_suite_pct": 26.14 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9511, + "reference_ms": 90.5663, + "speedup_vs_reference": 95.2202, + "share_of_suite_pct": 2.02 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.6619, + "reference_ms": 0.6155, + "speedup_vs_reference": 0.9299, + "share_of_suite_pct": 1.41 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.3175, + "reference_ms": 0.2437, + "speedup_vs_reference": 0.7675, + "share_of_suite_pct": 0.67 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0742, + "reference_ms": 188.6048, + "speedup_vs_reference": 2541.5695, + "share_of_suite_pct": 0.16 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0638, + "reference_ms": 215.973, + "speedup_vs_reference": 3387.8115, + "share_of_suite_pct": 0.14 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0441, + "reference_ms": 49.6108, + "speedup_vs_reference": 1125.3954, + "share_of_suite_pct": 0.09 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.044, + "reference_ms": 57.9675, + "speedup_vs_reference": 1318.7009, + "share_of_suite_pct": 0.09 + } + ] +} diff --git a/vendor/ferro-ta-main/perf-contract/simd.json b/vendor/ferro-ta-main/perf-contract/simd.json new file mode 100644 index 0000000..b220833 --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/simd.json @@ -0,0 +1,285 @@ +{ + "metadata": { + "suite": "simd", + "runtime": { + "generated_at_utc": "2026-03-23T21:09:13.028473+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + }, + "variants": [ + "portable_release", + "simd_release" + ] + }, + "results": [ + { + "name": "compute_many_close", + "category": "ffi_grouping", + "portable_ms": 0.1711, + "simd_ms": 0.1618, + "speedup_simd_vs_portable": 1.0575 + }, + { + "name": "iv_percentile", + "category": "python_analysis", + "portable_ms": 0.9126, + "simd_ms": 0.9096, + "speedup_simd_vs_portable": 1.0033 + }, + { + "name": "iv_zscore", + "category": "python_analysis", + "portable_ms": 29.0039, + "simd_ms": 28.9123, + "speedup_simd_vs_portable": 1.0032 + }, + { + "name": "iv_rank", + "category": "python_analysis", + "portable_ms": 10.8629, + "simd_ms": 10.862, + "speedup_simd_vs_portable": 1.0001 + }, + { + "name": "BETA", + "category": "rust_kernel", + "portable_ms": 0.0696, + "simd_ms": 0.0696, + "speedup_simd_vs_portable": 1.0 + }, + { + "name": "CORREL", + "category": "rust_kernel", + "portable_ms": 0.0555, + "simd_ms": 0.0556, + "speedup_simd_vs_portable": 0.9982 + }, + { + "name": "TSF", + "category": "rust_kernel", + "portable_ms": 0.0414, + "simd_ms": 0.0415, + "speedup_simd_vs_portable": 0.9976 + }, + { + "name": "LINEARREG", + "category": "rust_kernel", + "portable_ms": 0.0413, + "simd_ms": 0.0416, + "speedup_simd_vs_portable": 0.9928 + }, + { + "name": "feature_matrix", + "category": "ffi_grouping", + "portable_ms": 0.2543, + "simd_ms": 0.2634, + "speedup_simd_vs_portable": 0.9655 + } + ], + "reports": { + "portable_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T21:08:38.698373+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 29.0039, + "reference_ms": 903.5384, + "speedup_vs_reference": 31.1523, + "share_of_suite_pct": 70.04 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 10.8629, + "reference_ms": 201.621, + "speedup_vs_reference": 18.5606, + "share_of_suite_pct": 26.23 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9126, + "reference_ms": 80.0849, + "speedup_vs_reference": 87.7523, + "share_of_suite_pct": 2.2 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2543, + "reference_ms": 0.2222, + "speedup_vs_reference": 0.874, + "share_of_suite_pct": 0.61 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1711, + "reference_ms": 0.1413, + "speedup_vs_reference": 0.8257, + "share_of_suite_pct": 0.41 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0696, + "reference_ms": 162.9168, + "speedup_vs_reference": 2341.2971, + "share_of_suite_pct": 0.17 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0555, + "reference_ms": 163.8589, + "speedup_vs_reference": 2950.1793, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0414, + "reference_ms": 49.2846, + "speedup_vs_reference": 1189.9901, + "share_of_suite_pct": 0.1 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0413, + "reference_ms": 47.5241, + "speedup_vs_reference": 1149.7853, + "share_of_suite_pct": 0.1 + } + ] + }, + "simd_release": { + "metadata": { + "suite": "runtime_hotspots", + "runtime": { + "generated_at_utc": "2026-03-23T21:08:57.755423+00:00", + "python_version": "3.12.11", + "platform": "macOS-26.3.1-arm64-arm-64bit", + "machine": "arm64", + "processor": "arm" + }, + "git": { + "commit": "2d5000262f0f1439546bd4872235aae0333880a4", + "dirty": true, + "branch": "feat/performace-1.0.2" + }, + "dataset": { + "price_bars": 20000, + "iv_bars": 50000, + "window": 252 + } + }, + "results": [ + { + "category": "python_analysis", + "name": "iv_zscore", + "fast_ms": 28.9123, + "reference_ms": 909.2586, + "speedup_vs_reference": 31.4489, + "share_of_suite_pct": 69.98 + }, + { + "category": "python_analysis", + "name": "iv_rank", + "fast_ms": 10.862, + "reference_ms": 198.2076, + "speedup_vs_reference": 18.2478, + "share_of_suite_pct": 26.29 + }, + { + "category": "python_analysis", + "name": "iv_percentile", + "fast_ms": 0.9096, + "reference_ms": 78.1232, + "speedup_vs_reference": 85.889, + "share_of_suite_pct": 2.2 + }, + { + "category": "ffi_grouping", + "name": "feature_matrix", + "fast_ms": 0.2634, + "reference_ms": 0.2219, + "speedup_vs_reference": 0.8425, + "share_of_suite_pct": 0.64 + }, + { + "category": "ffi_grouping", + "name": "compute_many_close", + "fast_ms": 0.1618, + "reference_ms": 0.155, + "speedup_vs_reference": 0.9583, + "share_of_suite_pct": 0.39 + }, + { + "category": "rust_kernel", + "name": "BETA", + "fast_ms": 0.0696, + "reference_ms": 161.4576, + "speedup_vs_reference": 2320.3601, + "share_of_suite_pct": 0.17 + }, + { + "category": "rust_kernel", + "name": "CORREL", + "fast_ms": 0.0556, + "reference_ms": 162.6189, + "speedup_vs_reference": 2923.4861, + "share_of_suite_pct": 0.13 + }, + { + "category": "rust_kernel", + "name": "LINEARREG", + "fast_ms": 0.0416, + "reference_ms": 48.029, + "speedup_vs_reference": 1155.0143, + "share_of_suite_pct": 0.1 + }, + { + "category": "rust_kernel", + "name": "TSF", + "fast_ms": 0.0415, + "reference_ms": 47.55, + "speedup_vs_reference": 1145.783, + "share_of_suite_pct": 0.1 + } + ] + } + } +} diff --git a/vendor/ferro-ta-main/perf-contract/streaming.json b/vendor/ferro-ta-main/perf-contract/streaming.json new file mode 100644 index 0000000..239adce --- /dev/null +++ b/vendor/ferro-ta-main/perf-contract/streaming.json @@ -0,0 +1,95 @@ +{ + "metadata": { + "suite": "streaming", + "runtime": { + "generated_at_utc": "2026-03-24T09:13:04.351797+00:00", + "python_version": "3.13.5", + "python_implementation": "CPython", + "python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "system": "Darwin", + "release": "25.3.0", + "machine": "arm64", + "processor": "arm", + "cpu_model": "Apple M3 Max", + "cpu_count_logical": 14, + "total_memory_bytes": 38654705664 + }, + "git": { + "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8", + "cargo": "cargo 1.93.1 (083ac5135 2025-12-15)", + "cargo_release_profile": { + "lto": true, + "codegen-units": 1 + }, + "rustflags": null, + "cargo_build_rustflags": null, + "maturin_flags": null + }, + "packages": { + "numpy": "2.2.6", + "ferro-ta": "1.0.4" + }, + "dataset": { + "n_bars": 20000, + "seed": 2026 + } + }, + "results": [ + { + "indicator": "StreamingSMA", + "inputs": "close", + "stream_total_ms": 0.9927, + "batch_total_ms": 0.0166, + "stream_ns_per_update": 49.63, + "batch_ns_per_bar": 0.83, + "updates_per_second": 20147763.43, + "stream_over_batch_ratio": 59.7092 + }, + { + "indicator": "StreamingEMA", + "inputs": "close", + "stream_total_ms": 0.9459, + "batch_total_ms": 0.0435, + "stream_ns_per_update": 47.3, + "batch_ns_per_bar": 2.18, + "updates_per_second": 21143503.9, + "stream_over_batch_ratio": 21.7247 + }, + { + "indicator": "StreamingRSI", + "inputs": "close", + "stream_total_ms": 1.0059, + "batch_total_ms": 0.0991, + "stream_ns_per_update": 50.3, + "batch_ns_per_bar": 4.95, + "updates_per_second": 19882356.06, + "stream_over_batch_ratio": 10.1523 + }, + { + "indicator": "StreamingATR", + "inputs": "hlc", + "stream_total_ms": 2.1574, + "batch_total_ms": 0.0992, + "stream_ns_per_update": 107.87, + "batch_ns_per_bar": 4.96, + "updates_per_second": 9270525.53, + "stream_over_batch_ratio": 21.746 + }, + { + "indicator": "StreamingVWAP", + "inputs": "hlcv", + "stream_total_ms": 2.6935, + "batch_total_ms": 0.0252, + "stream_ns_per_update": 134.68, + "batch_ns_per_bar": 1.26, + "updates_per_second": 7425170.07, + "stream_over_batch_ratio": 106.8484 + } + ] +} diff --git a/vendor/ferro-ta-main/pyproject.toml b/vendor/ferro-ta-main/pyproject.toml new file mode 100644 index 0000000..01d14b3 --- /dev/null +++ b/vendor/ferro-ta-main/pyproject.toml @@ -0,0 +1,167 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "ferro-ta" +version = "1.2.0" +description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +keywords = [ + "technical-analysis", "trading", "finance", "rust", "pyo3", + "ta-lib", "indicators", "candlestick", "pandas", "numpy", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Financial and Insurance Industry", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Rust", + "Topic :: Office/Business :: Financial", + "Topic :: Scientific/Engineering :: Mathematics", + "Typing :: Typed", +] +dependencies = ["numpy>=1.20"] + +[project.optional-dependencies] +test = ["pytest>=7.0", "hypothesis>=6.0"] +benchmark = ["pytest>=7.0", "pytest-benchmark>=4.0"] +pandas = ["pandas>=1.0"] +polars = ["polars>=0.19"] +docs = ["sphinx>=7.0", "sphinx-rtd-theme>=1.3"] +comparison = [ + "pytest>=7.0", + "ta-lib>=0.4", + "pandas-ta>=0.3; python_version >= '3.12'", + "ta>=0.10", + "pandas>=1.0", + "vectorbt>=0.28", + "backtrader>=1.9", + "backtesting>=0.6", + "quantstats>=0.0.81", +] +gpu = ["torch>=2.0"] +options = [] +mcp = ["mcp>=1.0"] +all = ["pandas>=1.0", "polars>=0.19", "pytest>=7.0", "pytest-benchmark>=4.0"] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "hypothesis>=6.0", + "pandas>=1.0", + "polars>=0.19", + "pre-commit>=3.0", + "ruff>=0.3", + "mypy>=1.0", + "pyright>=1.1", + "maturin>=1.0,<2.0", + "pyyaml>=6.0", + "matplotlib>=3.5", + "fastapi>=0.135.1", + "httpx>=0.24", + "scipy>=1.10", +] + +[project.urls] +Homepage = "https://github.com/pratikbhadane24/ferro-ta" +Repository = "https://github.com/pratikbhadane24/ferro-ta" +"Bug Tracker" = "https://github.com/pratikbhadane24/ferro-ta/issues" + +[tool.pytest.ini_options] +testpaths = ["tests/unit", "tests/integration"] + +[tool.coverage.run] +source = ["ferro_ta"] +omit = ["*/_ferro_ta*", "*/mcp/*"] + +[tool.coverage.report] +show_missing = true +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ ==", +] + +[tool.maturin] +python-source = "python" +module-name = "ferro_ta._ferro_ta" +features = ["pyo3/extension-module"] +# Include the PEP 561 py.typed marker so type checkers recognize this package +include = [{ path = "python/ferro_ta/py.typed", format = "wheel" }] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +ignore_missing_imports = true +# Strictness: step toward strict; fix critical issues first +disallow_untyped_defs = false +check_untyped_defs = true +# Allow for now; tighten once missing-return and no-any-return are fixed +disable_error_code = ["return", "no-any-return"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["python", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501", "UP006", "UP045", "UP007"] + +# Tests: allow underscore in class names (TestCHANDELIER_EXIT), late imports (E402), uppercase helpers (N802) +# Stubs: public API names are uppercase (SMA, RSI, etc.); Tuple used for compatibility +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"tests/integration/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] +"benchmarks/*" = ["E402", "E741"] +"python/ferro_ta/*.py" = ["N802"] # Public API: SMA, RSI, ATR, etc. +"python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design +"python/ferro_ta/__init__.pyi" = ["N802", "E402"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +reportMissingModuleSource = false + +[tool.uv] +# Use uv for dependency resolution, locking, and running commands. +# Install: pip install uv (or curl -Lsf https://astral.sh/uv/install.sh | sh) +# Sync: uv sync --extra dev +# Tests: uv run pytest tests/ +# Build: uv run maturin build --release --out dist +constraint-dependencies = [ + "pygments>=2.20.0", + "requests>=2.33.0", +] + +[dependency-groups] +dev = [ + "pytest>=7.0", + "hypothesis>=6.0", + "pandas>=1.0", + "polars>=0.19", + "pre-commit>=3.0", + "ruff>=0.3", + "mypy>=1.0", + "pyright>=1.1", + "maturin>=1.0,<2.0", + "pyyaml>=6.0", + "pandas-ta>=0.3; python_version >= '3.12'", + "ta>=0.10", + "scipy>=1.15.3", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/__init__.py new file mode 100644 index 0000000..8fc10be --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/__init__.py @@ -0,0 +1,674 @@ +""" +ferro_ta — A fast Technical Analysis library powered by Rust and PyO3. + +Drop-in alternative to TA-Lib with pre-compiled wheels for all platforms. + +Indicators are organized into sub-modules matching TA-Lib's category structure, +and are also importable directly from this top-level package for convenience. + +Sub-packages +------------ +* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended) +* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw) +* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters) +* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff) +* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu) + +Sub-modules (also accessible via sub-packages above) +----------------------------------------------------- +* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …) +* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …) +* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV) +* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE) +* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …) +* :mod:`ferro_ta.indicators.price_transform` — Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE) +* :mod:`ferro_ta.indicators.pattern` — Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …) +* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE) +* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, MULT, DIV, SUM, MAX, MIN, ACOS, SIN, …) +* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +* :mod:`ferro_ta.data.streaming` — Streaming / Incremental API (bar-by-bar stateful classes for live trading) +* :mod:`ferro_ta.data.batch` — Batch Execution API (run SMA/EMA/RSI on 2-D arrays of multiple series) +* :mod:`ferro_ta.data.resampling` — OHLCV resampling and multi-timeframe API +* :mod:`ferro_ta.data.aggregation` — Tick/trade aggregation pipeline +* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL +* :mod:`ferro_ta.analysis.signals` — Signal composition and screening +* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics +* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength +* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness +* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics +* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics +* :mod:`ferro_ta.tools.viz` — Charting and visualisation API +* :mod:`ferro_ta.data.adapters` — Market data adapters + +Usage +----- +>>> import numpy as np +>>> from ferro_ta import SMA, EMA, RSI, MACD, BBANDS +>>> close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5]) +>>> SMA(close, timeperiod=3) +array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...]) + +>>> # Or import from sub-packages: +>>> from ferro_ta.indicators.overlap import SMA, BBANDS +>>> from ferro_ta.indicators.momentum import RSI, ADX +>>> from ferro_ta.indicators.volatility import ATR +>>> from ferro_ta.indicators.cycle import HT_TRENDLINE, HT_DCPERIOD +>>> # Backward-compat flat imports still work: +>>> from ferro_ta.overlap import SMA # noqa: F401 (stub) +""" + +from __future__ import annotations + +import re as _re +import sys as _sys +from importlib.metadata import PackageNotFoundError as _PackageNotFoundError +from importlib.metadata import version as _dist_version +from pathlib import Path as _Path + +try: + import tomllib as _tomllib +except ImportError: # pragma: no cover + try: + import tomli as _tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + _tomllib = None # type: ignore[assignment] + + +def _detect_version() -> str: + try: + return _dist_version("ferro-ta") + except _PackageNotFoundError: + pass + + if _tomllib is not None: + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + with pyproject_toml.open("rb") as handle: + data = _tomllib.load(handle) + return data.get("project", {}).get("version", "0+unknown") + except Exception: + pass + + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + text = pyproject_toml.read_text(encoding="utf-8") + match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE) + if match: + return match.group(1) + except Exception: + pass + + return "0+unknown" + + +__version__ = _detect_version() + +# --------------------------------------------------------------------------- +# Exceptions — exported at the top level for convenient catching +# --------------------------------------------------------------------------- +from ferro_ta.core.exceptions import ( # noqa: F401 + FerroTAError, + FerroTaError, + FerroTAInputError, + FerroTAValueError, + InsufficientDataError, + InvalidInputError, + InvalidPeriodError, + LengthMismatchError, + NumericConvergenceError, +) + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.cycle import ( # noqa: F401 + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + HT_TRENDLINE, + HT_TRENDMODE, +) + +# --------------------------------------------------------------------------- +# Math Operators & Math Transforms +# --------------------------------------------------------------------------- +from ferro_ta.indicators.math_ops import ( # noqa: F401 + ACOS, + ADD, + ASIN, + ATAN, + CEIL, + COS, + COSH, + DIV, + EXP, + FLOOR, + LN, + LOG10, + MAX, + MAXINDEX, + MIN, + MININDEX, + MULT, + SIN, + SINH, + SQRT, + SUB, + SUM, + TAN, + TANH, +) + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.momentum import ( # noqa: F401 + ADX, + ADXR, + APO, + AROON, + AROONOSC, + BOP, + CCI, + CMO, + DX, + MFI, + MINUS_DI, + MINUS_DM, + MOM, + PLUS_DI, + PLUS_DM, + PPO, + ROC, + ROCP, + ROCR, + ROCR100, + RSI, + STOCH, + STOCHF, + STOCHRSI, + TRANGE, + TRIX, + ULTOSC, + WILLR, +) + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- +from ferro_ta.indicators.overlap import ( # noqa: F401 + BBANDS, + DEMA, + EMA, + KAMA, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MIDPOINT, + MIDPRICE, + SAR, + SAREXT, + SMA, + T3, + TEMA, + TRIMA, + WMA, +) + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- +from ferro_ta.indicators.pattern import ( # noqa: F401 + CDL2CROWS, + CDL3BLACKCROWS, + CDL3INSIDE, + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLEVENINGSTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLMORNINGSTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSPINNINGTOP, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, +) + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- +from ferro_ta.indicators.price_transform import ( # noqa: F401 + AVGPRICE, + MEDPRICE, + TYPPRICE, + WCLPRICE, +) + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- +from ferro_ta.indicators.statistic import ( # noqa: F401 + BETA, + CORREL, + LINEARREG, + LINEARREG_ANGLE, + LINEARREG_INTERCEPT, + LINEARREG_SLOPE, + STDDEV, + TSF, + VAR, +) + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.volatility import ( # noqa: F401 + ATR, + NATR, +) + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- +from ferro_ta.indicators.volume import ( # noqa: F401 + AD, + ADOSC, + OBV, +) + +__all__ = [ + "__version__", + # Overlap Studies + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "TRIMA", + "KAMA", + "T3", + "BBANDS", + "MACD", + "MACDFIX", + "MACDEXT", + "SAR", + "SAREXT", + "MA", + "MAVP", + "MAMA", + "MIDPOINT", + "MIDPRICE", + # Momentum + "RSI", + "MOM", + "ROC", + "ROCP", + "ROCR", + "ROCR100", + "WILLR", + "AROON", + "AROONOSC", + "CCI", + "MFI", + "BOP", + "STOCHF", + "STOCH", + "STOCHRSI", + "APO", + "PPO", + "CMO", + "PLUS_DM", + "MINUS_DM", + "PLUS_DI", + "MINUS_DI", + "DX", + "ADX", + "ADXR", + "TRIX", + "ULTOSC", + "TRANGE", + # Volume + "AD", + "ADOSC", + "OBV", + # Volatility + "ATR", + "NATR", + # Statistics + "STDDEV", + "VAR", + "LINEARREG", + "LINEARREG_SLOPE", + "LINEARREG_INTERCEPT", + "LINEARREG_ANGLE", + "TSF", + "BETA", + "CORREL", + # Price transforms + "AVGPRICE", + "MEDPRICE", + "TYPPRICE", + "WCLPRICE", + # Patterns + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", + # Cycle + "HT_TRENDLINE", + "HT_DCPERIOD", + "HT_DCPHASE", + "HT_PHASOR", + "HT_SINE", + "HT_TRENDMODE", + # Math Operators + "ADD", + "SUB", + "MULT", + "DIV", + "SUM", + "MAX", + "MIN", + "MAXINDEX", + "MININDEX", + # Math Transforms + "ACOS", + "ASIN", + "ATAN", + "CEIL", + "COS", + "COSH", + "EXP", + "FLOOR", + "LN", + "LOG10", + "SIN", + "SINH", + "SQRT", + "TAN", + "TANH", + # Extended Indicators + "VWAP", + "SUPERTREND", + "ICHIMOKU", + "DONCHIAN", + "PIVOT_POINTS", + "KELTNER_CHANNELS", + "HULL_MA", + "CHANDELIER_EXIT", + "VWMA", + "CHOPPINESS_INDEX", + # API discovery + "about", + "indicators", + "methods", + "info", + # Logging utilities + "enable_debug", + "disable_debug", + "debug_mode", + "get_logger", + "log_call", + "benchmark", + "traced", +] + +# --------------------------------------------------------------------------- +# Extended Indicators +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Pandas API — apply transparent pandas.Series / DataFrame support to every +# public indicator function exported from this module. +# --------------------------------------------------------------------------- +from ferro_ta._utils import pandas_wrap as _pandas_wrap # noqa: E402 +from ferro_ta._utils import polars_wrap as _polars_wrap # noqa: E402 +from ferro_ta.analysis.attribution import ( # noqa: F401, E402 + TradeStats, + attribution_by_month, + attribution_by_signal, + from_backtest, + trade_stats, +) +from ferro_ta.analysis.crypto import ( # noqa: F401, E402 + continuous_bar_labels, + funding_pnl, + resample_continuous, + session_boundaries, +) +from ferro_ta.analysis.regime import ( # noqa: F401, E402 + detect_breaks_cusum, + regime, + regime_adx, + regime_combined, + rolling_variance_break, + structural_breaks, +) +from ferro_ta.core import exceptions as exceptions # noqa: F401, E402 + +# --------------------------------------------------------------------------- +# Logging utilities — ferro_ta.enable_debug() / ferro_ta.benchmark() +# --------------------------------------------------------------------------- +from ferro_ta.core.logging_utils import ( # noqa: F401, E402 + benchmark, + debug_mode, + disable_debug, + enable_debug, + get_logger, + log_call, + traced, +) +from ferro_ta.data import batch as batch # noqa: F401, E402 +from ferro_ta.data import streaming as streaming # noqa: F401, E402 + +# --------------------------------------------------------------------------- +# Batch API (not in __all__ — use directly from ferro_ta.batch) +# Import: from ferro_ta.batch import batch_sma, batch_ema, batch_rsi +# --------------------------------------------------------------------------- +from ferro_ta.data.batch import ( # noqa: F401, E402 + batch_apply, + batch_ema, + batch_rsi, + batch_sma, + compute_many, +) +from ferro_ta.data.chunked import ( # noqa: F401, E402 + chunk_apply, + make_chunk_ranges, + stitch_chunks, + trim_overlap, +) + +# --------------------------------------------------------------------------- +# Streaming / Incremental API (not in __all__ — these are classes, not funcs) +# Import directly: from ferro_ta.streaming import StreamingSMA, ... +# --------------------------------------------------------------------------- +from ferro_ta.data.streaming import ( # noqa: F401, E402 # type: ignore[assignment] + StreamingATR, # type: ignore[attr-defined] + StreamingBBands, # type: ignore[attr-defined] + StreamingEMA, # type: ignore[attr-defined] + StreamingMACD, # type: ignore[attr-defined] + StreamingRSI, # type: ignore[attr-defined] + StreamingSMA, # type: ignore[attr-defined] + StreamingStoch, # type: ignore[attr-defined] + StreamingSupertrend, # type: ignore[attr-defined] + StreamingVWAP, # type: ignore[attr-defined] +) +from ferro_ta.indicators import cycle as cycle # noqa: F401, E402 +from ferro_ta.indicators import extended as extended # noqa: F401, E402 +from ferro_ta.indicators import math_ops as math_ops # noqa: F401, E402 +from ferro_ta.indicators import momentum as momentum # noqa: F401, E402 +from ferro_ta.indicators import overlap as overlap # noqa: F401, E402 +from ferro_ta.indicators import pattern as pattern # noqa: F401, E402 +from ferro_ta.indicators import price_transform as price_transform # noqa: F401, E402 +from ferro_ta.indicators import statistic as statistic # noqa: F401, E402 +from ferro_ta.indicators import volatility as volatility # noqa: F401, E402 +from ferro_ta.indicators import volume as volume # noqa: F401, E402 +from ferro_ta.indicators.extended import ( # noqa: F401, E402 + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + DONCHIAN, + HULL_MA, + ICHIMOKU, + KELTNER_CHANNELS, + PIVOT_POINTS, + SUPERTREND, + VWAP, + VWMA, +) + +# --------------------------------------------------------------------------- +# Additional modules (not in __all__ — access via submodule) +# --------------------------------------------------------------------------- +from ferro_ta.tools.alerts import ( # noqa: F401, E402 + AlertManager, + check_cross, + check_threshold, + collect_alert_bars, +) + +# --------------------------------------------------------------------------- +# API discovery helpers — ferro_ta.about(), ferro_ta.methods(), +# ferro_ta.indicators(), and ferro_ta.info() +# --------------------------------------------------------------------------- +from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402 + +_ALIASED_SUBMODULES = { + "batch": batch, + "cycle": cycle, + "exceptions": exceptions, + "extended": extended, + "math_ops": math_ops, + "momentum": momentum, + "overlap": overlap, + "pattern": pattern, + "price_transform": price_transform, + "statistic": statistic, + "streaming": streaming, + "volatility": volatility, + "volume": volume, +} + +for _module_name, _module in _ALIASED_SUBMODULES.items(): + setattr(_sys.modules[__name__], _module_name, _module) + _sys.modules[f"{__name__}.{_module_name}"] = _module + +_g = globals() +for _name in __all__: + _fn = _g.get(_name) + if callable(_fn) and not getattr(_fn, "_pandas_wrapped", False): + _g[_name] = _pandas_wrap(_fn) + _fn = _g.get(_name) + if callable(_fn) and not getattr(_fn, "_polars_wrapped", False): + _g[_name] = _polars_wrap(_fn) +del _ALIASED_SUBMODULES, _g, _module, _module_name, _name, _fn, _sys diff --git a/vendor/ferro-ta-main/python/ferro_ta/__init__.pyi b/vendor/ferro-ta-main/python/ferro_ta/__init__.pyi new file mode 100644 index 0000000..e4b707b --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/__init__.pyi @@ -0,0 +1,765 @@ +""" +type stubs for ferro_ta. +Generated for IDE auto-completion and static type checking. +""" + +import logging +from collections.abc import Callable +from contextlib import AbstractContextManager +from typing import Any, TypeVar + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +_F = TypeVar("_F", bound=Callable[..., Any]) + +__version__: str + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + +def SMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def EMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def WMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def DEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def TEMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def TRIMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def KAMA(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def T3( + real: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7 +) -> NDArray[np.float64]: ... +def BBANDS( + real: ArrayLike, + timeperiod: int = 5, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACD( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACDFIX( + real: ArrayLike, + signalperiod: int = 9, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def MACDEXT( + real: ArrayLike, + fastperiod: int = 12, + fastmatype: int = 0, + slowperiod: int = 26, + slowmatype: int = 0, + signalperiod: int = 9, + signalmatype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def SAR( + high: ArrayLike, + low: ArrayLike, + acceleration: float = 0.02, + maximum: float = 0.2, +) -> NDArray[np.float64]: ... +def SAREXT( + high: ArrayLike, + low: ArrayLike, + startvalue: float = 0.0, + offsetonreverse: float = 0.0, + accelerationinitlong: float = 0.02, + accelerationlong: float = 0.02, + accelerationmaxlong: float = 0.2, + accelerationinitshort: float = 0.02, + accelerationshort: float = 0.02, + accelerationmaxshort: float = 0.2, +) -> NDArray[np.float64]: ... +def MA( + real: ArrayLike, timeperiod: int = 30, matype: int = 0 +) -> NDArray[np.float64]: ... +def MAVP( + real: ArrayLike, + periods: ArrayLike, + minperiod: int = 2, + maxperiod: int = 30, + matype: int = 0, +) -> NDArray[np.float64]: ... +def MAMA( + real: ArrayLike, + fastlimit: float = 0.5, + slowlimit: float = 0.05, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def MIDPOINT(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def MIDPRICE( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + +def RSI(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def MOM(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROC(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCP(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCR(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def ROCR100(real: ArrayLike, timeperiod: int = 10) -> NDArray[np.float64]: ... +def WILLR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def AROON( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def AROONOSC( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def CCI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MFI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def BOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... +def STOCHF( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + fastd_period: int = 3, + fastd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def STOCH( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + slowk_period: int = 3, + slowk_matype: int = 0, + slowd_period: int = 3, + slowd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def STOCHRSI( + real: ArrayLike, + timeperiod: int = 14, + fastk_period: int = 5, + fastd_period: int = 3, + fastd_matype: int = 0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def APO( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + matype: int = 0, +) -> NDArray[np.float64]: ... +def PPO( + real: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + matype: int = 0, +) -> NDArray[np.float64]: ... +def CMO(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def PLUS_DM( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MINUS_DM( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def PLUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def MINUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def DX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def ADX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def ADXR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def TRIX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def ULTOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod1: int = 7, + timeperiod2: int = 14, + timeperiod3: int = 28, +) -> NDArray[np.float64]: ... +def TRANGE( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + +def AD( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, +) -> NDArray[np.float64]: ... +def ADOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + fastperiod: int = 3, + slowperiod: int = 10, +) -> NDArray[np.float64]: ... +def OBV( + real: ArrayLike, + volume: ArrayLike, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + +def ATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... +def NATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- + +def STDDEV( + real: ArrayLike, + timeperiod: int = 5, + nbdev: float = 1.0, +) -> NDArray[np.float64]: ... +def VAR( + real: ArrayLike, + timeperiod: int = 5, + nbdev: float = 1.0, +) -> NDArray[np.float64]: ... +def LINEARREG(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def LINEARREG_SLOPE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def LINEARREG_INTERCEPT( + real: ArrayLike, timeperiod: int = 14 +) -> NDArray[np.float64]: ... +def LINEARREG_ANGLE(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def TSF(real: ArrayLike, timeperiod: int = 14) -> NDArray[np.float64]: ... +def BETA( + real0: ArrayLike, + real1: ArrayLike, + timeperiod: int = 5, +) -> NDArray[np.float64]: ... +def CORREL( + real0: ArrayLike, + real1: ArrayLike, + timeperiod: int = 30, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Price Transforms +# --------------------------------------------------------------------------- + +def AVGPRICE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> NDArray[np.float64]: ... +def MEDPRICE(high: ArrayLike, low: ArrayLike) -> NDArray[np.float64]: ... +def TYPPRICE( + high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.float64]: ... +def WCLPRICE( + high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- + +def HT_TRENDLINE(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_DCPERIOD(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_DCPHASE(real: ArrayLike) -> NDArray[np.float64]: ... +def HT_PHASOR(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def HT_SINE(real: ArrayLike) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def HT_TRENDMODE(real: ArrayLike) -> NDArray[np.int32]: ... + +# --------------------------------------------------------------------------- +# Math Operators +# --------------------------------------------------------------------------- + +def ADD(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def SUB(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def MULT(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def DIV(real0: ArrayLike, real1: ArrayLike) -> NDArray[np.float64]: ... +def SUM(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MAX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MIN(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.float64]: ... +def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ... +def MININDEX(real: ArrayLike, timeperiod: int = 30) -> NDArray[np.int32]: ... + +# Math Transforms +def ACOS(real: ArrayLike) -> NDArray[np.float64]: ... +def ASIN(real: ArrayLike) -> NDArray[np.float64]: ... +def ATAN(real: ArrayLike) -> NDArray[np.float64]: ... +def CEIL(real: ArrayLike) -> NDArray[np.float64]: ... +def COS(real: ArrayLike) -> NDArray[np.float64]: ... +def COSH(real: ArrayLike) -> NDArray[np.float64]: ... +def EXP(real: ArrayLike) -> NDArray[np.float64]: ... +def FLOOR(real: ArrayLike) -> NDArray[np.float64]: ... +def LN(real: ArrayLike) -> NDArray[np.float64]: ... +def LOG10(real: ArrayLike) -> NDArray[np.float64]: ... +def SIN(real: ArrayLike) -> NDArray[np.float64]: ... +def SINH(real: ArrayLike) -> NDArray[np.float64]: ... +def SQRT(real: ArrayLike) -> NDArray[np.float64]: ... +def TAN(real: ArrayLike) -> NDArray[np.float64]: ... +def TANH(real: ArrayLike) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Extended Indicators (Phase 8 + 9) +# --------------------------------------------------------------------------- + +def VWAP( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 0, +) -> NDArray[np.float64]: ... +def SUPERTREND( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 7, + multiplier: float = 3.0, +) -> tuple[NDArray[np.float64], NDArray[np.int8]]: ... +def ICHIMOKU( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + tenkan_period: int = 9, + kijun_period: int = 26, + senkou_b_period: int = 52, + displacement: int = 26, +) -> tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +]: ... +def DONCHIAN( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 20, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def PIVOT_POINTS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + method: str = "classic", +) -> tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +]: ... +def KELTNER_CHANNELS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 20, + atr_period: int = 10, + multiplier: float = 2.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: ... +def HULL_MA( + close: ArrayLike, + timeperiod: int = 16, +) -> NDArray[np.float64]: ... +def CHANDELIER_EXIT( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 22, + multiplier: float = 3.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: ... +def VWMA( + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 20, +) -> NDArray[np.float64]: ... +def CHOPPINESS_INDEX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> NDArray[np.float64]: ... + +# --------------------------------------------------------------------------- +# Streaming / Incremental API (Phase 3) +# --------------------------------------------------------------------------- + +class StreamingSMA: + period: int + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingEMA: + period: int + def __init__(self, period: int) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingRSI: + period: int + def __init__(self, period: int = 14) -> None: ... + def update(self, value: float) -> float: ... + def reset(self) -> None: ... + +class StreamingATR: + period: int + def __init__(self, period: int = 14) -> None: ... + def update(self, high: float, low: float, close: float) -> float: ... + def reset(self) -> None: ... + +class StreamingBBands: + period: int + def __init__( + self, + period: int = 20, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + ) -> None: ... + def update(self, value: float) -> tuple[float, float, float]: ... + def reset(self) -> None: ... + +class StreamingMACD: + def __init__( + self, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, + ) -> None: ... + def update(self, value: float) -> tuple[float, float, float]: ... + def reset(self) -> None: ... + +class StreamingStoch: + def __init__( + self, + fastk_period: int = 5, + slowk_period: int = 3, + slowd_period: int = 3, + ) -> None: ... + def update(self, high: float, low: float, close: float) -> tuple[float, float]: ... + def reset(self) -> None: ... + +class StreamingVWAP: + def __init__(self) -> None: ... + def update(self, high: float, low: float, close: float, volume: float) -> float: ... + def reset(self) -> None: ... + +class StreamingSupertrend: + period: int + def __init__(self, period: int = 7, multiplier: float = 3.0) -> None: ... + def update(self, high: float, low: float, close: float) -> tuple[float, int]: ... + def reset(self) -> None: ... + +# --------------------------------------------------------------------------- +# Candlestick Patterns +# --------------------------------------------------------------------------- + +def CDL2CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3BLACKCROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3INSIDE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3LINESTRIKE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3OUTSIDE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3STARSINSOUTH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDL3WHITESOLDIERS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLABANDONEDBABY( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLADVANCEBLOCK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLBELTHOLD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLBREAKAWAY( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCLOSINGMARUBOZU( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCONCEALBABYSWALL( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLCOUNTERATTACK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDARKCLOUDCOVER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLDRAGONFLYDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLENGULFING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLEVENINGDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLEVENINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLGAPSIDESIDEWHITE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLGRAVESTONEDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHAMMER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHANGINGMAN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHARAMI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHARAMICROSS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIGHWAVE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIKKAKE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHIKKAKEMOD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLHOMINGPIGEON( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLIDENTICAL3CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLINNECK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLINVERTEDHAMMER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLKICKING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLKICKINGBYLENGTH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLADDERBOTTOM( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLONGLEGGEDDOJI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLLONGLINE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMARUBOZU( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMATCHINGLOW( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMATHOLD( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMORNINGDOJISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLMORNINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLONNECK( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLPIERCING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLRICKSHAWMAN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLRISEFALL3METHODS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSEPARATINGLINES( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSHOOTINGSTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSHORTLINE( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSPINNINGTOP( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSTALLEDPATTERN( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLSTICKSANDWICH( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTAKURI( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTASUKIGAP( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTHRUSTING( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLTRISTAR( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLUNIQUE3RIVER( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLUPSIDEGAP2CROWS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... +def CDLXSIDEGAP3METHODS( + open: ArrayLike, high: ArrayLike, low: ArrayLike, close: ArrayLike +) -> NDArray[np.int32]: ... + +# --------------------------------------------------------------------------- +# Batch API +# --------------------------------------------------------------------------- + +from ferro_ta.batch import batch_apply as batch_apply +from ferro_ta.batch import batch_ema as batch_ema +from ferro_ta.batch import batch_rsi as batch_rsi +from ferro_ta.batch import batch_sma as batch_sma +from ferro_ta.batch import compute_many as compute_many + +# --------------------------------------------------------------------------- +# Exception hierarchy (re-exported from ferro_ta.exceptions) +# --------------------------------------------------------------------------- + +class FerroTAError(Exception): + code: str + suggestion: str | None + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + ) -> None: ... + +class FerroTAValueError(FerroTAError, ValueError): + code: str + suggestion: str | None + +class FerroTAInputError(FerroTAError, ValueError): + code: str + suggestion: str | None + +# --------------------------------------------------------------------------- +# API discovery (ferro_ta.api_info) +# --------------------------------------------------------------------------- + +def about() -> dict[str, Any]: ... +def indicators(category: str | None = None) -> list[dict[str, Any]]: ... +def info(func_or_name: Callable[..., Any] | str) -> dict[str, Any]: ... +def methods(category: str | None = None) -> list[dict[str, Any]]: ... + +# --------------------------------------------------------------------------- +# Logging utilities (ferro_ta.logging_utils) +# --------------------------------------------------------------------------- + +def get_logger() -> logging.Logger: ... +def enable_debug(fmt: str = ...) -> None: ... +def disable_debug() -> None: ... +def debug_mode(fmt: str = ...) -> AbstractContextManager[logging.Logger]: ... +def log_call(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: ... +def benchmark( + func: Callable[..., Any], + *args: Any, + n: int = 100, + warmup: int = 5, + **kwargs: Any, +) -> dict[str, float]: ... +def traced(func: _F) -> _F: ... diff --git a/vendor/ferro-ta-main/python/ferro_ta/_binding.py b/vendor/ferro-ta-main/python/ferro_ta/_binding.py new file mode 100644 index 0000000..9997c08 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/_binding.py @@ -0,0 +1,93 @@ +""" +Data-driven binding layer — generic wrapper for Rust indicator calls. + +This module provides a single helper that performs validation, array conversion +(_to_f64), Rust call, and error normalization. Indicator modules can use it to +reduce repetitive wrapper code; a manifest (see _indicator_manifest.yaml) describes +each indicator so that wrappers or code generation can be driven from data. + +Usage (manual wrapper): + from ferro_ta._binding import binding_call + def SMA(close, timeperiod=30): + return binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=close, + timeperiod=timeperiod, + ) + +Future: A code generator can read the manifest and emit either full wrapper +functions or binding_call(...) invocations so that ~6000 lines of repetitive +wrapper code are generated from the manifest. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import ( + _normalize_rust_error, + check_equal_length, + check_timeperiod, +) + + +def binding_call( + rust_fn: Callable[..., Any], + *, + array_params: list[str], + timeperiod_param: Optional[str] = None, + timeperiod_min: int = 1, + equal_length_groups: Optional[list[list[str]]] = None, + **kwargs: Any, +) -> Any: + """Call a Rust indicator with validation and array conversion. + + Parameters + ---------- + rust_fn : callable + The Rust function from _ferro_ta (e.g. _sma). + array_params : list of str + Names of keyword arguments that are array-like; they are converted + with _to_f64 and passed in order as positional args to rust_fn. + timeperiod_param : str, optional + If set, the value of kwargs[timeperiod_param] is validated with + check_timeperiod(..., minimum=timeperiod_min). + timeperiod_min : int + Minimum allowed value for timeperiod_param (default 1). + equal_length_groups : list of list of str, optional + Each inner list is a group of param names that must have equal length; + check_equal_length is called with that group. + **kwargs + Keyword arguments to pass. Array params are converted and passed + positionally; non-array params are passed as keyword arguments to + rust_fn (caller must ensure rust_fn signature matches). + + Returns + ------- + Result of rust_fn(...). Typically numpy.ndarray or tuple of ndarray. + + Raises + ------ + FerroTAValueError, FerroTAInputError + Via check_timeperiod / check_equal_length or _normalize_rust_error. + """ + if timeperiod_param is not None and timeperiod_param in kwargs: + check_timeperiod( + kwargs[timeperiod_param], + name=timeperiod_param, + minimum=timeperiod_min, + ) + if equal_length_groups is not None: + for group in equal_length_groups: + check_equal_length(**{k: kwargs[k] for k in group if k in kwargs}) + # Build positional args for rust_fn in array_params order, then remaining kwargs + pos_args = [_to_f64(kwargs[p]) for p in array_params if p in kwargs] + rest_kw = {k: v for k, v in kwargs.items() if k not in array_params} + try: + return rust_fn(*pos_args, **rest_kw) + except ValueError as e: + _normalize_rust_error(e) diff --git a/vendor/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml b/vendor/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml new file mode 100644 index 0000000..a08c0e8 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/_indicator_manifest.yaml @@ -0,0 +1,364 @@ +# Indicator binding manifest — data-driven description of Rust indicators. +# +# Used by scripts/generate_bindings.py to generate Python wrappers. +# Schema (per indicator): +# rust_fn: name of the function in _ferro_ta +# array_params: list of parameter names that are array-like (passed to _to_f64) +# timeperiod_param: optional; name of period parameter to validate +# timeperiod_min: optional; minimum value (default 1) +# equal_length_groups: optional; list of groups of param names that must have equal length +# defaults: optional; map of param name -> default value for function signature +# extra_params: optional; list of param names (after array_params and timeperiod) for signature/call +# +# Indicators with multiple period params or custom logic (MACD, MA, MAVP, MAMA, SAR, SAREXT, MACDEXT) +# are listed here for reference but are not generated (hand-written wrappers in overlap.py). + +overlap: + SMA: + rust_fn: sma + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + EMA: + rust_fn: ema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + WMA: + rust_fn: wma + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + DEMA: + rust_fn: dema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + TEMA: + rust_fn: tema + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + TRIMA: + rust_fn: trima + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + KAMA: + rust_fn: kama + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + T3: + rust_fn: t3 + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, vfactor: 0.7 } + extra_params: [vfactor] + BBANDS: + rust_fn: bbands + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdevup: 2.0, nbdevdn: 2.0 } + extra_params: [nbdevup, nbdevdn] + MIDPOINT: + rust_fn: midpoint + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + MIDPRICE: + rust_fn: midprice + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + MACDFIX: + rust_fn: macdfix + array_params: [close] + timeperiod_param: signalperiod + defaults: { signalperiod: 9 } + # Below: documented in manifest but use hand-written wrappers (multiple periods or custom validation) + MACD: + rust_fn: macd + array_params: [close] + custom: true + SAR: + rust_fn: sar + array_params: [high, low] + custom: true + MA: + rust_fn: ma + array_params: [close] + custom: true + MAVP: + rust_fn: mavp + array_params: [close, periods] + custom: true + MAMA: + rust_fn: mama + array_params: [close] + custom: true + SAREXT: + rust_fn: sarext + array_params: [high, low] + custom: true + MACDEXT: + rust_fn: macdext + array_params: [close] + custom: true + +# --------------------------------------------------------------------------- +# volume +# --------------------------------------------------------------------------- +volume: + AD: + rust_fn: ad + array_params: [high, low, close, volume] + equal_length_groups: [[high, low, close, volume]] + ADOSC: + rust_fn: adosc + array_params: [high, low, close, volume] + equal_length_groups: [[high, low, close, volume]] + defaults: { fastperiod: 3, slowperiod: 10 } + extra_params: [fastperiod, slowperiod] + custom: true # two period params (fastperiod < slowperiod) + OBV: + rust_fn: obv + array_params: [close, volume] + equal_length_groups: [[close, volume]] + +# --------------------------------------------------------------------------- +# volatility +# --------------------------------------------------------------------------- +volatility: + ATR: + rust_fn: atr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + NATR: + rust_fn: natr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + TRANGE: + rust_fn: trange + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + +# --------------------------------------------------------------------------- +# statistic +# --------------------------------------------------------------------------- +statistic: + STDDEV: + rust_fn: stddev + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdev: 1.0 } + extra_params: [nbdev] + VAR: + rust_fn: var + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 5, nbdev: 1.0 } + extra_params: [nbdev] + LINEARREG: + rust_fn: linearreg + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_SLOPE: + rust_fn: linearreg_slope + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_INTERCEPT: + rust_fn: linearreg_intercept + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + LINEARREG_ANGLE: + rust_fn: linearreg_angle + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + TSF: + rust_fn: tsf + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + BETA: + rust_fn: beta + array_params: [real0, real1] + timeperiod_param: timeperiod + equal_length_groups: [[real0, real1]] + defaults: { timeperiod: 5 } + CORREL: + rust_fn: correl + array_params: [real0, real1] + timeperiod_param: timeperiod + equal_length_groups: [[real0, real1]] + defaults: { timeperiod: 30 } + +# --------------------------------------------------------------------------- +# momentum (single-period or simple equal-length; multi-period / tuple-return marked custom) +# --------------------------------------------------------------------------- +momentum: + RSI: + rust_fn: rsi + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + MOM: + rust_fn: mom + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROC: + rust_fn: roc + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCP: + rust_fn: rocp + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCR: + rust_fn: rocr + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + ROCR100: + rust_fn: rocr100 + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 10 } + WILLR: + rust_fn: willr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + AROON: + rust_fn: aroon + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + AROONOSC: + rust_fn: aroonosc + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + CCI: + rust_fn: cci + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + MFI: + rust_fn: mfi + array_params: [high, low, close, volume] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close, volume]] + defaults: { timeperiod: 14 } + BOP: + rust_fn: bop + array_params: [open, high, low, close] + equal_length_groups: [[open, high, low, close]] + STOCHF: + rust_fn: stochf + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { fastk_period: 5, fastd_period: 3 } + extra_params: [fastk_period, fastd_period] + custom: true + STOCH: + rust_fn: stoch + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { fastk_period: 5, slowk_period: 3, slowd_period: 3 } + extra_params: [fastk_period, slowk_period, slowd_period] + custom: true + STOCHRSI: + rust_fn: stochrsi + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14, fastk_period: 5, fastd_period: 3 } + extra_params: [fastk_period, fastd_period] + custom: true + APO: + rust_fn: apo + array_params: [close] + defaults: { fastperiod: 12, slowperiod: 26 } + extra_params: [fastperiod, slowperiod] + custom: true + PPO: + rust_fn: ppo + array_params: [close] + defaults: { fastperiod: 12, slowperiod: 26, signalperiod: 9 } + extra_params: [fastperiod, slowperiod, signalperiod] + custom: true + CMO: + rust_fn: cmo + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 14 } + PLUS_DM: + rust_fn: plus_dm + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + MINUS_DM: + rust_fn: minus_dm + array_params: [high, low] + timeperiod_param: timeperiod + equal_length_groups: [[high, low]] + defaults: { timeperiod: 14 } + PLUS_DI: + rust_fn: plus_di + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + MINUS_DI: + rust_fn: minus_di + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + DX: + rust_fn: dx + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + ADX: + rust_fn: adx + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + ADXR: + rust_fn: adxr + array_params: [high, low, close] + timeperiod_param: timeperiod + equal_length_groups: [[high, low, close]] + defaults: { timeperiod: 14 } + TRIX: + rust_fn: trix + array_params: [close] + timeperiod_param: timeperiod + defaults: { timeperiod: 30 } + ULTOSC: + rust_fn: ultosc + array_params: [high, low, close] + equal_length_groups: [[high, low, close]] + defaults: { timeperiod1: 7, timeperiod2: 14, timeperiod3: 28 } + extra_params: [timeperiod1, timeperiod2, timeperiod3] + custom: true diff --git a/vendor/ferro-ta-main/python/ferro_ta/_utils.py b/vendor/ferro-ta-main/python/ferro_ta/_utils.py new file mode 100644 index 0000000..296bc95 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/_utils.py @@ -0,0 +1,291 @@ +""" +Shared utility helpers for ferro_ta Python wrappers. +""" + +from __future__ import annotations + +import functools +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +# Default OHLCV column names for DataFrame contract +DEFAULT_OHLCV_COLUMNS = { + "open": "open", + "high": "high", + "low": "low", + "close": "close", + "volume": "volume", +} + + +@functools.lru_cache(maxsize=1) +def _optional_pandas_module(): + """Import pandas lazily once and cache absence for low-overhead hot paths.""" + try: + import pandas as pd + except ImportError: + return None + return pd + + +@functools.lru_cache(maxsize=1) +def _optional_polars_module(): + """Import polars lazily once and cache absence for low-overhead hot paths.""" + try: + import polars as pl + except ImportError: + return None + return pl + + +def _to_f64(data: ArrayLike) -> np.ndarray: + """Convert any array-like to a contiguous 1-D float64 NumPy array. + + Transparently accepts ``pandas.Series`` and ``polars.Series`` — the values + are extracted and the index/metadata is discarded (use :func:`pandas_wrap` + or :func:`polars_wrap` to preserve it). + + Fast path: if *data* is already a 1-D C-contiguous ``float64`` NumPy array + it is returned as-is without any copy or allocation. + """ + # Fast path: already a 1-D contiguous float64 numpy array — no copy needed. + if ( + isinstance(data, np.ndarray) + and data.dtype == np.float64 + and data.ndim == 1 + and data.flags["C_CONTIGUOUS"] + ): + return data + # Accept pandas Series/DataFrame without requiring pandas at import time + if hasattr(data, "to_numpy"): + try: + data = data.to_numpy(dtype=np.float64) # type: ignore[union-attr] + except TypeError: + # Some libraries (e.g. polars) have to_numpy() but don't accept dtype + data = np.asarray(data.to_numpy(), dtype=np.float64) # type: ignore[union-attr] + # Accept polars Series via to_numpy() (available since polars 0.13) + elif hasattr(data, "to_list") and type(data).__name__ == "Series": + # polars Series doesn't have to_numpy with dtype kwarg; use cast+to_numpy + try: + data = data.cast(float).to_numpy() # type: ignore[union-attr] + except Exception: + data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr] + arr = np.ascontiguousarray(data, dtype=np.float64) + if arr.ndim != 1: + from ferro_ta.core.exceptions import FerroTAInputError + + raise FerroTAInputError( + f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.", + suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.", + ) + return arr + + +def get_ohlcv( + df: Any, + open_col: str = "open", + high_col: str = "high", + low_col: str = "low", + close_col: str = "close", + volume_col: Optional[str] = "volume", +) -> tuple[Any, Any, Any, Any, Any]: + """Extract OHLCV arrays or Series from a DataFrame with configurable column names. + + Use this when you have a single DataFrame with OHLCV columns (possibly with + different names) and want to call indicators that expect separate arrays. + Index is preserved when the input is a pandas DataFrame. + + Parameters + ---------- + df : pandas.DataFrame + DataFrame with at least columns for open, high, low, close (and optionally volume). + open_col, high_col, low_col, close_col, volume_col : str + Column names to use. Defaults are ``'open'``, ``'high'``, ``'low'``, + ``'close'``, ``'volume'``. + + Returns + ------- + tuple of (open, high, low, close, volume) + Each element is a 1-D array or pandas Series (same type as DataFrame columns) + with the same index as ``df``. Missing columns raise KeyError. + + Examples + -------- + >>> import pandas as pd + >>> from ferro_ta import ATR, RSI + >>> from ferro_ta._utils import get_ohlcv + >>> df = pd.DataFrame({ + ... 'Open': [1, 2, 3], 'High': [1.1, 2.1, 3.1], + ... 'Low': [0.9, 1.9, 2.9], 'Close': [1.05, 2.05, 3.05] + ... }) + >>> o, h, l, c, v = get_ohlcv(df, open_col='Open', high_col='High', + ... low_col='Low', close_col='Close', volume_col=None) + >>> atr = ATR(h, l, c, timeperiod=2) # index preserved if pandas + """ + try: + import pandas as pd + except ImportError: + raise ImportError("get_ohlcv requires pandas. Install with: pip install pandas") + + if not isinstance(df, pd.DataFrame): + raise TypeError("get_ohlcv expects a pandas.DataFrame") + + def _get(name: Optional[str]) -> Any: + if name is None: + return np.full(len(df), np.nan) + if name not in df.columns: + raise KeyError( + f"Column '{name}' not found in DataFrame. Columns: {list(df.columns)}" + ) + return df[name] + + vol_col = volume_col if (volume_col and volume_col in df.columns) else None + return ( + _get(open_col), + _get(high_col), + _get(low_col), + _get(close_col), + _get(vol_col) if vol_col else np.full(len(df), np.nan), + ) + + +def pandas_wrap(func): + """Decorator — transparent ``pandas.Series`` / ``DataFrame`` support. + + When at least one positional argument is a ``pandas.Series`` or + ``pandas.DataFrame`` column, the wrapper: + + 1. Extracts the NumPy arrays from all pandas inputs. + 2. Captures the index from the *first* pandas input. + 3. Calls the original function with plain NumPy arrays. + 4. Wraps every ``numpy.ndarray`` in the result back into a + ``pandas.Series`` (or tuple of Series) with the captured index. + + If ``pandas`` is not installed the decorator is a no-op pass-through so + the NumPy API is unaffected. + + Parameters that are already NumPy arrays (or lists) are passed through + unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always + passed through unchanged. + + Examples + -------- + >>> import pandas as pd, numpy as np + >>> from ferro_ta import SMA + >>> s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0]) + >>> result = SMA(s, timeperiod=3) + >>> isinstance(result, pd.Series) + True + >>> list(result.index) == list(s.index) + True + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + pd = _optional_pandas_module() + if pd is None: + return func(*args, **kwargs) + + pd_index = None + new_args: list[Any] = [] + + for arg in args: + if isinstance(arg, pd.Series): + if pd_index is None: + pd_index = arg.index + new_args.append(arg.to_numpy(dtype=np.float64)) + elif isinstance(arg, pd.DataFrame): + if pd_index is None: + pd_index = arg.index + # Pass each column as a 1-D array (single-column DataFrames) + if arg.shape[1] == 1: + new_args.append(arg.iloc[:, 0].to_numpy(dtype=np.float64)) + else: + new_args.append(arg) + else: + new_args.append(arg) + + result = func(*new_args, **kwargs) + + if pd_index is not None: + if isinstance(result, tuple): + return tuple( + pd.Series(r, index=pd_index) if isinstance(r, np.ndarray) else r + for r in result + ) + elif isinstance(result, np.ndarray): + return pd.Series(result, index=pd_index) + + return result + + # Mark so callers can detect wrapped functions + wrapper._pandas_wrapped = True # type: ignore[attr-defined] + return wrapper + + +def polars_wrap(func): + """Decorator — transparent ``polars.Series`` support. + + When at least one positional argument is a ``polars.Series``, the wrapper: + + 1. Converts all polars Series inputs to NumPy arrays. + 2. Captures the name from the *first* polars input (used as the result + series name). + 3. Calls the original function with plain NumPy arrays. + 4. Wraps every ``numpy.ndarray`` in the result back into a + ``polars.Series`` with the same name. + + If ``polars`` is not installed the decorator is a no-op pass-through so + the NumPy API is unaffected. + + Parameters that are already NumPy arrays (or lists) are passed through + unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always + passed through unchanged. + + Examples + -------- + >>> import polars as pl + >>> from ferro_ta import SMA + >>> s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0]) + >>> result = SMA(s, timeperiod=3) + >>> isinstance(result, pl.Series) + True + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + pl = _optional_polars_module() + if pl is None: + return func(*args, **kwargs) + + pl_name: Optional[str] = None + new_args: list[Any] = [] + + for arg in args: + if isinstance(arg, pl.Series): + if pl_name is None: + pl_name = arg.name + try: + new_args.append(arg.cast(pl.Float64).to_numpy()) + except Exception: + new_args.append(np.array(arg.to_list(), dtype=np.float64)) + else: + new_args.append(arg) + + result = func(*new_args, **kwargs) + + if pl_name is not None: + if isinstance(result, tuple): + return tuple( + pl.Series(pl_name, r) if isinstance(r, np.ndarray) else r + for r in result + ) + elif isinstance(result, np.ndarray): + return pl.Series(pl_name, result) + + return result + + wrapper._polars_wrapped = True # type: ignore[attr-defined] + return wrapper diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/__init__.py new file mode 100644 index 0000000..5b52d30 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/__init__.py @@ -0,0 +1,62 @@ +""" +ferro_ta.analysis — Portfolio analytics, strategy analysis, and financial modelling. + + +Sub-modules +----------- +* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics +* :mod:`ferro_ta.analysis.backtest` — Vectorised back-testing helpers +* :mod:`ferro_ta.analysis.regime` — Market regime detection +* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative-strength analysis +* :mod:`ferro_ta.analysis.attribution` — Return attribution +* :mod:`ferro_ta.analysis.signals` — Signal composition and screening +* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers +* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers +* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics +* :mod:`ferro_ta.analysis.futures` — Futures basis, curve, roll, and synthetic analytics +* :mod:`ferro_ta.analysis.options_strategy` — Typed derivatives strategy schemas +* :mod:`ferro_ta.analysis.derivatives_payoff` — Multi-leg payoff and Greeks aggregation +* :mod:`ferro_ta.analysis.resample` — OHLCV bar aggregation utilities +* :mod:`ferro_ta.analysis.multitf` — Multi-timeframe signal utilities +* :mod:`ferro_ta.analysis.adjust` — Corporate action price adjustment utilities +* :mod:`ferro_ta.analysis.plot` — Plotly-based backtest visualization + +Example usage:: + + from ferro_ta.analysis.portfolio import portfolio_returns + from ferro_ta.analysis.backtest import backtest + from ferro_ta.analysis.resample import resample_ohlcv, align_to_coarse, resample_ohlcv_labels + from ferro_ta.analysis.multitf import MultiTimeframeEngine + from ferro_ta.analysis.adjust import adjust_ohlcv, adjust_for_splits, adjust_for_dividends + from ferro_ta.analysis.plot import plot_backtest +""" + +import importlib as _importlib + +_LAZY_IMPORTS: dict[str, tuple[str, str]] = { + "detect_volatility_regime": ( + "ferro_ta.analysis.regime", + "detect_volatility_regime", + ), + "detect_trend_regime": ("ferro_ta.analysis.regime", "detect_trend_regime"), + "detect_combined_regime": ("ferro_ta.analysis.regime", "detect_combined_regime"), + "RegimeFilter": ("ferro_ta.analysis.regime", "RegimeFilter"), + "PortfolioOptimizer": ("ferro_ta.analysis.optimize", "PortfolioOptimizer"), + "mean_variance_optimize": ("ferro_ta.analysis.optimize", "mean_variance_optimize"), + "risk_parity_optimize": ("ferro_ta.analysis.optimize", "risk_parity_optimize"), + "max_sharpe_optimize": ("ferro_ta.analysis.optimize", "max_sharpe_optimize"), + "PaperTrader": ("ferro_ta.analysis.live", "PaperTrader"), + "BarResult": ("ferro_ta.analysis.live", "BarResult"), + "TradeRecord": ("ferro_ta.analysis.live", "TradeRecord"), +} + + +def __getattr__(name: str): + """Lazy imports for heavy sub-modules to avoid startup cost.""" + if name in _LAZY_IMPORTS: + module_path, attr = _LAZY_IMPORTS[name] + mod = _importlib.import_module(module_path) + obj = getattr(mod, attr) + globals()[name] = obj # cache so subsequent access skips __getattr__ + return obj + raise AttributeError(f"module 'ferro_ta.analysis' has no attribute {name!r}") diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/adjust.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/adjust.py new file mode 100644 index 0000000..46f3afa --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/adjust.py @@ -0,0 +1,194 @@ +""" +Corporate action price adjustment utilities. + +adjust_for_splits(close, split_factors, split_indices) + Apply split adjustments to a close price series (backward-adjusted). + +adjust_for_dividends(close, dividends, ex_dates) + Apply dividend adjustments to a close price series (backward-adjusted). + +adjust_ohlcv(open_, high, low, close, volume, split_factors=None, split_indices=None, + dividends=None, ex_date_indices=None) + Apply both split and dividend adjustments to a full OHLCV dataset. + Returns (adj_open, adj_high, adj_low, adj_close, adj_volume). +""" + +from typing import Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = ["adjust_for_splits", "adjust_for_dividends", "adjust_ohlcv"] + + +def adjust_for_splits( + close: ArrayLike, + split_factors: ArrayLike, # e.g. [2.0, 3.0] means 2-for-1 then 3-for-1 + split_indices: ArrayLike, # bar indices of each split (must be sorted ascending) +) -> NDArray: + """Backward-adjust close prices for stock splits. + + All prices BEFORE a split are divided by the split factor. + e.g. a 2-for-1 split at bar 100: prices[0:100] are halved. + + Parameters + ---------- + close : array-like + Raw close prices. + split_factors : array-like + Split factor for each split event (e.g. 2.0 for a 2-for-1 split). + split_indices : array-like + Bar index of each split event (0-based, must be sorted ascending). + + Returns + ------- + NDArray of adjusted close prices. + """ + c = np.asarray(close, dtype=np.float64).copy() + factors = np.asarray(split_factors, dtype=np.float64) + indices = np.asarray(split_indices, dtype=np.intp) + + # Process splits in chronological order; apply backward adjustment + # (all bars before the split are divided by the factor) + for idx, factor in zip(indices, factors): + if factor <= 0: + raise ValueError(f"split_factor must be > 0, got {factor}") + c[:idx] /= factor + + return c + + +def adjust_for_dividends( + close: ArrayLike, + dividends: ArrayLike, # dividend amount per ex-date + ex_date_indices: ArrayLike, # bar indices of ex-dividend dates +) -> NDArray: + """Backward-adjust close prices for cash dividends (proportional method). + + Adjustment factor at ex-date i = (close[i-1] - dividend) / close[i-1]. + All bars before ex-date are multiplied by the cumulative adjustment. + + Parameters + ---------- + close : array-like + Raw close prices. + dividends : array-like + Dividend amount (in currency units) at each ex-dividend date. + ex_date_indices : array-like + Bar index of each ex-dividend date (0-based, sorted ascending). + + Returns + ------- + NDArray of adjusted close prices. + """ + c = np.asarray(close, dtype=np.float64).copy() + divs = np.asarray(dividends, dtype=np.float64) + indices = np.asarray(ex_date_indices, dtype=np.intp) + + # Process in chronological order + for idx, div in zip(indices, divs): + if idx == 0: + # No prior bar; skip adjustment (nothing to adjust) + continue + prev_close = c[idx - 1] + if prev_close <= 0: + continue + adj_factor = (prev_close - div) / prev_close + if adj_factor <= 0: + continue + # All prices before ex-date are multiplied by adj_factor + c[:idx] *= adj_factor + + return c + + +def adjust_ohlcv( + open_: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + split_factors: Optional[ArrayLike] = None, + split_indices: Optional[ArrayLike] = None, + dividends: Optional[ArrayLike] = None, + ex_date_indices: Optional[ArrayLike] = None, +) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]: + """Apply split and dividend adjustments to full OHLCV data. + + Price arrays are multiplied by cumulative adjustment factor. + Volume is divided by split factors (shares outstanding adjust inversely). + Returns (adj_open, adj_high, adj_low, adj_close, adj_volume). + + Parameters + ---------- + open_, high, low, close : array-like + Raw OHLCV price arrays. + volume : array-like + Raw volume array. + split_factors : array-like, optional + Split factors for each split event. + split_indices : array-like, optional + Bar indices of split events (required if split_factors provided). + dividends : array-like, optional + Dividend amounts for each ex-date. + ex_date_indices : array-like, optional + Bar indices of ex-dividend dates (required if dividends provided). + + Returns + ------- + (adj_open, adj_high, adj_low, adj_close, adj_volume) + """ + o = np.asarray(open_, dtype=np.float64).copy() + h = np.asarray(high, dtype=np.float64).copy() + low_arr = np.asarray(low, dtype=np.float64).copy() + c = np.asarray(close, dtype=np.float64).copy() + v = np.asarray(volume, dtype=np.float64).copy() + + n = len(c) + + # Build a per-bar cumulative adjustment factor for prices (starts at 1.0) + price_adj = np.ones(n, dtype=np.float64) + # Separate inverse adjustment for volume (splits only) + vol_adj = np.ones(n, dtype=np.float64) + + # ----------------------------------------------------------------------- + # Apply split adjustments + # ----------------------------------------------------------------------- + if split_factors is not None and split_indices is not None: + sf = np.asarray(split_factors, dtype=np.float64) + si = np.asarray(split_indices, dtype=np.intp) + for idx, factor in zip(si, sf): + if factor <= 0: + raise ValueError(f"split_factor must be > 0, got {factor}") + # Prices before split are divided by factor + price_adj[:idx] /= factor + # Volume before split is multiplied by factor (more shares pre-split) + vol_adj[:idx] *= factor + + # ----------------------------------------------------------------------- + # Apply dividend adjustments (prices only) + # ----------------------------------------------------------------------- + if dividends is not None and ex_date_indices is not None: + divs = np.asarray(dividends, dtype=np.float64) + ei = np.asarray(ex_date_indices, dtype=np.intp) + # We need the split-adjusted close at (idx-1) for each dividend event. + # Instead of recomputing the full array each iteration, read the single + # element we need: c[idx-1] * price_adj[idx-1]. + for idx, div in zip(ei, divs): + if idx == 0: + continue + prev_close = c[idx - 1] * price_adj[idx - 1] + if prev_close <= 0: + continue + adj_factor = (prev_close - div) / prev_close + if adj_factor <= 0: + continue + price_adj[:idx] *= adj_factor + + adj_open = o * price_adj + adj_high = h * price_adj + adj_low = low_arr * price_adj + adj_close = c * price_adj + adj_volume = v * vol_adj + + return adj_open, adj_high, adj_low, adj_close, adj_volume diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/attribution.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/attribution.py new file mode 100644 index 0000000..f131bae --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/attribution.py @@ -0,0 +1,329 @@ +""" +ferro_ta.attribution — Performance attribution and trade analysis. +================================================================= + +Compute trade-level statistics and attribute equity-curve performance to +individual signals or time periods. Designed to work with the output of +``ferro_ta.backtest.backtest()``. + +Functions +--------- +trade_stats(pnl, hold_bars) + Compute win rate, avg win/loss, profit factor, and avg hold duration. + +from_backtest(result) + Extract the trade list (PnL per trade, hold duration) from a + :class:`~ferro_ta.backtest.BacktestResult`. + +attribution_by_month(bar_returns, timestamps) + Attribute per-bar returns to calendar months. + +attribution_by_signal(bar_returns, signal_labels) + Attribute per-bar returns to signal labels. + +TradeStats + Named-tuple-style result container returned by ``trade_stats``. + +Rust backend +------------ + ferro_ta._ferro_ta.trade_stats + ferro_ta._ferro_ta.monthly_contribution + ferro_ta._ferro_ta.signal_attribution +""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + extract_trades as _rust_extract_trades, +) +from ferro_ta._ferro_ta import ( + monthly_contribution as _rust_monthly_contribution, +) +from ferro_ta._ferro_ta import ( + signal_attribution as _rust_signal_attribution, +) +from ferro_ta._ferro_ta import ( + trade_stats as _rust_trade_stats, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "TradeStats", + "trade_stats", + "from_backtest", + "attribution_by_month", + "attribution_by_signal", +] + + +# --------------------------------------------------------------------------- +# TradeStats container +# --------------------------------------------------------------------------- + + +class TradeStats: + """Container for trade-level statistics. + + Attributes + ---------- + win_rate : float — fraction of trades with PnL > 0 + avg_win : float — mean PnL of winning trades (0 if none) + avg_loss : float — mean PnL of losing trades (negative; 0 if none) + profit_factor : float — gross profit / |gross loss| (inf if no losses) + avg_hold_bars : float — mean hold duration in bars + n_trades : int — total number of trades + """ + + __slots__ = ( + "win_rate", + "avg_win", + "avg_loss", + "profit_factor", + "avg_hold_bars", + "n_trades", + ) + + def __init__( + self, + win_rate: float, + avg_win: float, + avg_loss: float, + profit_factor: float, + avg_hold_bars: float, + n_trades: int, + ) -> None: + self.win_rate = win_rate + self.avg_win = avg_win + self.avg_loss = avg_loss + self.profit_factor = profit_factor + self.avg_hold_bars = avg_hold_bars + self.n_trades = n_trades + + def __repr__(self) -> str: + return ( + f"TradeStats(n_trades={self.n_trades}, " + f"win_rate={self.win_rate:.2%}, " + f"profit_factor={self.profit_factor:.2f}, " + f"avg_hold={self.avg_hold_bars:.1f} bars)" + ) + + def to_dict(self) -> dict[str, Any]: + """Return stats as a plain dict.""" + return { + "n_trades": self.n_trades, + "win_rate": self.win_rate, + "avg_win": self.avg_win, + "avg_loss": self.avg_loss, + "profit_factor": self.profit_factor, + "avg_hold_bars": self.avg_hold_bars, + } + + +# --------------------------------------------------------------------------- +# trade_stats +# --------------------------------------------------------------------------- + + +def trade_stats( + pnl: ArrayLike, + hold_bars: Optional[ArrayLike] = None, +) -> TradeStats: + """Compute trade-level performance statistics. + + Parameters + ---------- + pnl : array-like — per-trade PnL (positive = win, negative = loss) + hold_bars : array-like, optional — hold duration in bars for each trade. + If ``None``, defaults to an array of ones (hold duration unknown). + + Returns + ------- + :class:`TradeStats` + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import trade_stats + >>> pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0]) + >>> hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0]) + >>> ts = trade_stats(pnl, hold) + >>> print(ts) + TradeStats(n_trades=6, win_rate=50.00%, profit_factor=...) + """ + p = _to_f64(pnl) + n = len(p) + if n == 0: + raise ValueError("pnl must be non-empty") + if hold_bars is None: + h = np.ones(n, dtype=np.float64) + else: + h = _to_f64(hold_bars) + + win_rate, avg_win, avg_loss, profit_factor, avg_hold = _rust_trade_stats(p, h) + return TradeStats( + win_rate=win_rate, + avg_win=avg_win, + avg_loss=avg_loss, + profit_factor=profit_factor, + avg_hold_bars=avg_hold, + n_trades=n, + ) + + +# --------------------------------------------------------------------------- +# from_backtest +# --------------------------------------------------------------------------- + + +def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Extract per-trade PnL and hold durations from a BacktestResult. + + Scans the ``positions`` and ``strategy_returns`` arrays of *result* to + find trade entries and exits, then computes per-trade PnL and duration. + + Parameters + ---------- + result : :class:`~ferro_ta.backtest.BacktestResult` + + Returns + ------- + tuple ``(pnl, hold_bars)`` — 1-D float64 arrays of length n_trades. + + Notes + ----- + A "trade" is defined as a continuous run of non-zero position. PnL is + the sum of ``strategy_returns`` during that period. Hold duration is + the number of bars in the run. + """ + pos = np.asarray(result.positions, dtype=np.float64) + ret = np.asarray(result.strategy_returns, dtype=np.float64) + pnl, hold = _rust_extract_trades(pos, ret) + return ( + np.asarray(pnl, dtype=np.float64), + np.asarray(hold, dtype=np.float64), + ) + + +# --------------------------------------------------------------------------- +# attribution_by_month +# --------------------------------------------------------------------------- + + +def attribution_by_month( + bar_returns: ArrayLike, + timestamps: Optional[ArrayLike] = None, +) -> dict[str, float]: + """Attribute per-bar returns to calendar months. + + Parameters + ---------- + bar_returns : array-like — per-bar strategy returns + timestamps : array-like of int64, optional — UTC timestamps in + nanoseconds (e.g. ``pandas.DatetimeIndex.astype('int64')``). + If ``None``, bars are grouped into calendar-agnostic monthly buckets + of 21 bars (approximate trading month). + + Returns + ------- + dict mapping month label (str ``'YYYY-MM'`` or ``'period_N'``) to + total return for that month. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import attribution_by_month + >>> rng = np.random.default_rng(0) + >>> ret = rng.normal(0, 0.01, 252) + >>> contrib = attribution_by_month(ret) + >>> list(contrib.keys())[:3] + ['period_0', 'period_1', 'period_2'] + """ + ret = _to_f64(bar_returns) + n = len(ret) + + if timestamps is not None: + # Convert ns timestamps → month index + ts = np.asarray(timestamps, dtype=np.int64) + # Month = year*12 + month_of_year (0-based) + # ns → seconds → datetime calculation (fast path without pandas) + try: + import pandas as pd + + dti = pd.to_datetime(ts, unit="ns", utc=True) + month_idx = (dti.year * 12 + dti.month - 1).astype(np.int64) # type: ignore[union-attr] + offset = int(month_idx[0]) + month_idx = (month_idx - offset).values.astype(np.int64) + except ImportError: + # Fallback: 21-bar buckets + month_idx = np.arange(n, dtype=np.int64) // 21 + else: + month_idx = np.arange(n, dtype=np.int64) // 21 + + months_arr, contrib_arr = _rust_monthly_contribution(ret, month_idx) + months = np.asarray(months_arr, dtype=np.int64) + contribs = np.asarray(contrib_arr, dtype=np.float64) + + if timestamps is not None: + try: + import pandas as pd + + ts = np.asarray(timestamps, dtype=np.int64) + dti = pd.to_datetime(ts, unit="ns", utc=True) + month_idx_full = (dti.year * 12 + dti.month - 1).astype(np.int64).values # type: ignore[union-attr] + offset = int(month_idx_full[0]) + labels = {} + for m, c in zip(months, contribs): + abs_month = int(m) + offset + year = abs_month // 12 + month_of_year = abs_month % 12 + 1 + labels[f"{year:04d}-{month_of_year:02d}"] = float(c) + return labels + except ImportError: + pass + + return {f"period_{int(m)}": float(c) for m, c in zip(months, contribs)} + + +# --------------------------------------------------------------------------- +# attribution_by_signal +# --------------------------------------------------------------------------- + + +def attribution_by_signal( + bar_returns: ArrayLike, + signal_labels: ArrayLike, +) -> dict[str, float]: + """Attribute per-bar returns to signal labels. + + Parameters + ---------- + bar_returns : array-like — per-bar strategy returns + signal_labels : array-like of int — signal label per bar. + Use ``-1`` for flat (no position) bars. + + Returns + ------- + dict mapping signal label (str) to total attributed return. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.attribution import attribution_by_signal + >>> rng = np.random.default_rng(0) + >>> ret = rng.normal(0, 0.01, 100) + >>> labels = np.where(np.arange(100) < 50, 0, 1) # signal 0 or signal 1 + >>> contrib = attribution_by_signal(ret, labels) + >>> sorted(contrib.keys()) + ['signal_0', 'signal_1'] + """ + ret = _to_f64(bar_returns) + lbl = np.asarray(signal_labels, dtype=np.int64) + labels_arr, contrib_arr = _rust_signal_attribution(ret, lbl) + labels = np.asarray(labels_arr, dtype=np.int64) + contribs = np.asarray(contrib_arr, dtype=np.float64) + return {f"signal_{int(lbl)}": float(c) for lbl, c in zip(labels, contribs)} diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/backtest.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/backtest.py new file mode 100644 index 0000000..4ce368a --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/backtest.py @@ -0,0 +1,1535 @@ +""" +Minimal Backtesting Harness +============================ + +A lightweight vectorized backtester that uses ferro_ta indicators as the engine. + +**Scope** (minimal harness): +- Vectorized approach: compute indicators once, then apply a signal function over bars. +- Single-asset, long-only or long/short, no leverage. +- Optional **commission** (per trade) and **slippage** (basis points) for more realistic equity. +- Returns a :class:`BacktestResult` with signals, positions, and equity curve. + +For production backtesting consider `backtrader`, `zipline`, or `vectorbt`. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.analysis.backtest import backtest, rsi_strategy +>>> +>>> # Generate synthetic OHLCV data +>>> np.random.seed(42) +>>> n = 100 +>>> close = np.cumprod(1 + np.random.randn(n) * 0.01) * 100 +>>> volume = np.random.randint(1_000, 10_000, n).astype(float) +>>> +>>> result = backtest(close, volume=volume, strategy="rsi_30_70") +>>> print(result) # BacktestResult(bars=100, trades=…, final_equity=…) + +API +--- +backtest(close, *, high=None, low=None, open=None, volume=None, + strategy="rsi_30_70", commission_per_trade=0, slippage_bps=0, **kwargs) + Run the backtester and return a :class:`BacktestResult`. Optional + commission (subtracted from equity on each position change) and slippage + (basis points; applied as a cost on the bar where position changes). + +rsi_strategy(close, timeperiod=14, oversold=30, overbought=70) + Built-in RSI oversold/overbought strategy; returns a signal array. + +sma_crossover_strategy(close, fast=10, slow=30) + Built-in SMA crossover strategy; returns a signal array. + +macd_crossover_strategy(close, fastperiod=12, slowperiod=26, signalperiod=9) + Built-in MACD line/signal crossover strategy; returns a signal array. + +BacktestResult + Dataclass-like container with signals, positions, returns, equity arrays. +""" + +from __future__ import annotations + +import dataclasses +import warnings +from collections import Counter +from collections.abc import Callable +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import CommissionModel +from ferro_ta._ferro_ta import Currency as _RustCurrency +from ferro_ta._ferro_ta import backtest_core as _rust_backtest_core +from ferro_ta._ferro_ta import ( + backtest_multi_asset_core as _rust_backtest_multi_asset_core, +) +from ferro_ta._ferro_ta import backtest_ohlcv_core as _rust_backtest_ohlcv_core +from ferro_ta._ferro_ta import compute_performance_metrics as _rust_compute_perf_metrics +from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown_series +from ferro_ta._ferro_ta import extract_trades_ohlcv as _rust_extract_trades +from ferro_ta._ferro_ta import kelly_fraction as _rust_kelly_fraction +from ferro_ta._ferro_ta import macd_crossover_signals as _rust_macd_crossover_signals +from ferro_ta._ferro_ta import monte_carlo_bootstrap as _rust_monte_carlo_bootstrap +from ferro_ta._ferro_ta import rsi_threshold_signals as _rust_rsi_threshold_signals +from ferro_ta._ferro_ta import sma_crossover_signals as _rust_sma_crossover_signals +from ferro_ta._ferro_ta import walk_forward_indices as _rust_walk_forward_indices +from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError + +# --------------------------------------------------------------------------- +# Currency system (backed by Rust via ferro_ta._ferro_ta.Currency) +# --------------------------------------------------------------------------- + +# Re-export the Rust-backed Currency class as the public API. + +Currency = _RustCurrency + +# Built-in currency constants +INR: _RustCurrency = Currency.INR() +USD: _RustCurrency = Currency.USD() +EUR: _RustCurrency = Currency.EUR() +GBP: _RustCurrency = Currency.GBP() +JPY: _RustCurrency = Currency.JPY() +USDT: _RustCurrency = Currency.USDT() + +_CURRENCIES: dict[str, _RustCurrency] = { + "INR": INR, + "USD": USD, + "EUR": EUR, + "GBP": GBP, + "JPY": JPY, + "USDT": USDT, +} + + +def format_currency(amount: float, currency: _RustCurrency | None = None) -> str: + """Format *amount* using *currency*'s display style. + + Uses Indian lakh/crore grouping for INR, standard grouping for others. + + >>> format_currency(123456.78) + '₹1,23,456.78' + >>> format_currency(1234567.89, USD) + '$1,234,567.89' + """ + effective: _RustCurrency = currency if currency is not None else INR + return effective.format(amount) + + +# --------------------------------------------------------------------------- +# BacktestResult +# --------------------------------------------------------------------------- + + +class BacktestResult: + """Container for backtesting output. + + Attributes + ---------- + signals : NDArray[np.float64] + Array of +1 (long), -1 (short), or 0 (flat) for every bar. + positions : NDArray[np.float64] + Lagged signals — position held *during* each bar (shift by 1 to + avoid look-ahead bias). + bar_returns : NDArray[np.float64] + Per-bar return of the underlying close price (pct change). + strategy_returns : NDArray[np.float64] + ``positions * bar_returns`` — strategy return at each bar. + equity : NDArray[np.float64] + Cumulative equity curve starting at 1.0. + n_trades : int + Number of position changes. + final_equity : float + Terminal equity value. + """ + + __slots__ = ( + "signals", + "positions", + "bar_returns", + "strategy_returns", + "equity", + "n_trades", + "final_equity", + ) + + def __init__( + self, + signals: NDArray[np.float64], + positions: NDArray[np.float64], + bar_returns: NDArray[np.float64], + strategy_returns: NDArray[np.float64], + equity: NDArray[np.float64], + ) -> None: + self.signals = signals + self.positions = positions + self.bar_returns = bar_returns + self.strategy_returns = strategy_returns + self.equity = equity + self.n_trades = int(np.sum(np.diff(positions) != 0)) + self.final_equity = float(equity[-1]) if len(equity) > 0 else 1.0 + + def __repr__(self) -> str: # pragma: no cover + return ( + f"BacktestResult(" + f"bars={len(self.signals)}, " + f"trades={self.n_trades}, " + f"final_equity={self.final_equity:.4f})" + ) + + +# --------------------------------------------------------------------------- +# Built-in strategies +# --------------------------------------------------------------------------- + + +def rsi_strategy( + close: ArrayLike, + timeperiod: int = 14, + oversold: float = 30.0, + overbought: float = 70.0, +) -> NDArray[np.float64]: + """RSI oversold / overbought signal generator. + + Returns + ------- + signals : ndarray of float64 + +1 where RSI <= oversold (buy signal), -1 where RSI >= overbought + (sell signal), 0 otherwise. NaN during the RSI warm-up period. + + Parameters + ---------- + close : array-like + Close prices. + timeperiod : int + RSI look-back period (default 14). + oversold : float + RSI level below which a long (+1) signal is generated (default 30). + overbought : float + RSI level above which a short (-1) signal is generated (default 70). + """ + if timeperiod < 1: + raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}") + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_rsi_threshold_signals( + c, int(timeperiod), float(oversold), float(overbought) + ), + dtype=np.float64, + ) + + +def sma_crossover_strategy( + close: ArrayLike, + fast: int = 10, + slow: int = 30, +) -> NDArray[np.float64]: + """SMA fast/slow crossover strategy. + + Returns + ------- + signals : ndarray of float64 + +1 when fast SMA > slow SMA (uptrend), -1 when fast SMA < slow SMA + (downtrend), NaN during the warm-up window. + + Parameters + ---------- + close : array-like + Close prices. + fast : int + Fast SMA period (default 10). + slow : int + Slow SMA period (default 30). + """ + if fast < 1: + raise FerroTAValueError(f"fast must be >= 1, got {fast}") + if slow < 1: + raise FerroTAValueError(f"slow must be >= 1, got {slow}") + if fast >= slow: + raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})") + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_sma_crossover_signals(c, int(fast), int(slow)), + dtype=np.float64, + ) + + +def macd_crossover_strategy( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> NDArray[np.float64]: + """MACD line / signal line crossover strategy. + + Returns + ------- + signals : ndarray of float64 + +1 when MACD line > signal line (uptrend), -1 when MACD line < signal line + (downtrend), NaN during the MACD warm-up window. + + Parameters + ---------- + close : array-like + Close prices. + fastperiod : int + Fast EMA period (default 12). + slowperiod : int + Slow EMA period (default 26). + signalperiod : int + Signal line EMA period (default 9). + """ + if fastperiod < 1 or slowperiod < 1 or signalperiod < 1: + raise FerroTAValueError("MACD periods must be >= 1") + if fastperiod >= slowperiod: + raise FerroTAValueError( + f"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})" + ) + + c = np.asarray(close, dtype=np.float64) + return np.asarray( + _rust_macd_crossover_signals( + c, int(fastperiod), int(slowperiod), int(signalperiod) + ), + dtype=np.float64, + ) + + +# --------------------------------------------------------------------------- +# Built-in strategy registry +# --------------------------------------------------------------------------- + +_BENCHMARK_METRICS = frozenset( + ( + "alpha", + "beta", + "tracking_error", + "information_ratio", + "benchmark_cagr", + "benchmark_sharpe", + ) +) + +_BUILTIN_STRATEGIES: dict[str, Callable[..., NDArray[np.float64]]] = { + "rsi_30_70": rsi_strategy, + "sma_crossover": sma_crossover_strategy, + "macd_crossover": macd_crossover_strategy, +} + + +# --------------------------------------------------------------------------- +# Main backtest entry point +# --------------------------------------------------------------------------- + + +def backtest( + close: ArrayLike, + *, + high: Optional[ArrayLike] = None, + low: Optional[ArrayLike] = None, + open: Optional[ArrayLike] = None, + volume: Optional[ArrayLike] = None, + strategy: Union[str, Callable[..., NDArray[np.float64]]] = "rsi_30_70", + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + **strategy_kwargs: object, +) -> BacktestResult: + """Run a vectorized backtest on *close* prices using *strategy*. + + Parameters + ---------- + close : array-like + Close prices (required). + high, low, open, volume : array-like, optional + Additional OHLCV data. Passed to the strategy function if it accepts + them (via ``**strategy_kwargs``); currently unused by the built-in + strategies. + strategy : str or callable + Either a name of a built-in strategy (``"rsi_30_70"``, + ``"sma_crossover"``, or ``"macd_crossover"``) or a callable with + signature ``(close, **kwargs) -> ndarray`` that returns a signal array. + commission_per_trade : float, optional + Fixed commission deducted from equity on each position change (default 0). + slippage_bps : float, optional + Slippage in basis points (1 bps = 0.01%) applied as a cost on the bar + where the position changes (default 0). + **strategy_kwargs + Extra keyword arguments forwarded to the strategy function + (e.g. ``timeperiod=14``, ``oversold=30``). + + Returns + ------- + BacktestResult + Container with signals, positions, equity curve, and trade count. + + Raises + ------ + FerroTAValueError + If a named strategy is unknown. + FerroTAInputError + If ``close`` is too short (< 2 bars) or contains non-finite values. + + Notes + ----- + Commission is subtracted from equity immediately after each position change. + Slippage is applied by reducing the strategy return on the bar where the + position changes by ``slippage_bps / 10000`` (one-way). + """ + c = np.asarray(close, dtype=np.float64) + if c.ndim != 1: + raise FerroTAInputError("close must be a 1-D array.") + if len(c) < 2: + raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.") + + # ------------------------------------------------------------------ + # Resolve strategy & compute signals + # ------------------------------------------------------------------ + strategy_fn = _resolve_strategy(strategy) + signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64) + positions, bar_returns, strategy_returns, equity = _rust_backtest_core( + c, + signals, + commission_per_trade=float(commission_per_trade), + slippage_bps=float(slippage_bps), + ) + + return BacktestResult( + signals=signals, + positions=np.asarray(positions, dtype=np.float64), + bar_returns=np.asarray(bar_returns, dtype=np.float64), + strategy_returns=np.asarray(strategy_returns, dtype=np.float64), + equity=np.asarray(equity, dtype=np.float64), + ) + + +# =========================================================================== +# Advanced API — AdvancedBacktestResult, BacktestEngine, walk_forward, monte_carlo +# =========================================================================== + + +class AdvancedBacktestResult(BacktestResult): + """Extended backtest result with full metrics, trade log, and drawdown series. + + All ``BacktestResult`` attributes are preserved (``isinstance`` checks work). + + Additional Attributes + --------------------- + metrics : dict[str, float] + Full performance metrics: cagr, sharpe, sortino, calmar, max_drawdown, + avg_drawdown, max_drawdown_duration_bars, ulcer_index, omega_ratio, + win_rate, profit_factor, r_expectancy, tail_ratio, skewness, kurtosis, etc. + trades : Any + Trade log as ``pd.DataFrame`` (if pandas is installed) with columns: + entry_bar, exit_bar, direction, entry_price, exit_price, pnl_pct, + duration_bars, mae, mfe. None if no trades were extracted. + drawdown_series : NDArray[np.float64] + Per-bar drawdown (always <= 0). + fill_prices : NDArray[np.float64] + Actual fill prices per bar (NaN when flat). NaN array in close-only mode. + """ + + __slots__ = BacktestResult.__slots__ + ( + "metrics", + "trades", + "drawdown_series", + "fill_prices", + "currency", + "initial_capital", + "equity_abs", + ) + + def __init__( + self, + signals: NDArray, + positions: NDArray, + bar_returns: NDArray, + strategy_returns: NDArray, + equity: NDArray, + metrics: dict, + trades: Any, + drawdown_series: NDArray, + fill_prices: NDArray, + currency: _RustCurrency = INR, + initial_capital: float = 100_000.0, + ) -> None: + super().__init__(signals, positions, bar_returns, strategy_returns, equity) + self.metrics = metrics + self.trades = trades + self.drawdown_series = drawdown_series + self.fill_prices = fill_prices + self.currency = currency + self.initial_capital = float(initial_capital) + self.equity_abs = equity * self.initial_capital + + def __repr__(self) -> str: # pragma: no cover + m = self.metrics + final = ( + float(self.equity_abs[-1]) + if len(self.equity_abs) > 0 + else self.initial_capital + ) + return ( + f"AdvancedBacktestResult(" + f"bars={len(self.signals)}, " + f"trades={self.n_trades}, " + f"sharpe={m.get('sharpe', float('nan')):.3f}, " + f"max_dd={m.get('max_drawdown', float('nan')):.1%}, " + f"final={self.currency.format(final)})" + ) + + def to_equity_dataframe(self, freq: str = "B") -> Any: + """Return equity and drawdown as a ``pd.DataFrame`` indexed by date. + + Parameters + ---------- + freq : str + pandas date-offset alias for the synthetic DatetimeIndex (default ``"B"``). + + Returns + ------- + pd.DataFrame with columns ``equity``, ``equity_abs``, ``strategy_returns``, + ``drawdown``, indexed by a synthetic ``pd.DatetimeIndex`` starting 2000-01-03. + Raises ``ImportError`` if pandas is not installed. + """ + try: + import pandas as pd + except ImportError as exc: + raise ImportError("pandas is required for to_equity_dataframe()") from exc + n = len(self.equity) + idx = pd.date_range("2000-01-03", periods=n, freq=freq) + return pd.DataFrame( + { + "equity": self.equity, + "equity_abs": self.equity_abs, + "strategy_returns": self.strategy_returns, + "drawdown": self.drawdown_series, + }, + index=idx, + ) + + def summary(self) -> dict: + """Return a concise performance summary dict. + + Includes the 9 most commonly cited metrics plus n_trades, + initial_capital, final_capital, absolute_pnl, and currency. + """ + m = self.metrics + keys = ( + "total_return", + "cagr", + "annualized_vol", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "win_rate", + "profit_factor", + ) + result = {k: m.get(k, float("nan")) for k in keys} + result["n_trades"] = self.n_trades + result["initial_capital"] = self.initial_capital + final_capital = ( + float(self.equity_abs[-1]) + if len(self.equity_abs) > 0 + else self.initial_capital + ) + result["final_capital"] = final_capital + result["absolute_pnl"] = final_capital - self.initial_capital + result["currency"] = self.currency.code + # Include benchmark metrics if available + for key in _BENCHMARK_METRICS: + if key in m: + result[key] = m[key] + return result + + +def _resolve_strategy( + strategy: Union[str, Callable], +) -> Callable[..., NDArray]: + if isinstance(strategy, str): + if strategy not in _BUILTIN_STRATEGIES: + raise FerroTAValueError( + f"Unknown strategy '{strategy}'. " + f"Available: {sorted(_BUILTIN_STRATEGIES)}" + ) + return _BUILTIN_STRATEGIES[strategy] + elif callable(strategy): + return strategy + raise FerroTAValueError("strategy must be a string name or a callable.") + + +def _pct_change(arr: NDArray) -> NDArray: + """Percentage change with zero-price guard. Returns array of length len(arr)-1.""" + return np.diff(arr) / np.where(arr[:-1] != 0, arr[:-1], 1.0) + + +def _kelly_stats(strategy_returns: NDArray) -> tuple[float, float, float]: + """Extract (win_rate, avg_win, avg_loss) from strategy returns. + + Returns (0, 0, 0) when there are no active trades. + """ + active = strategy_returns[np.isfinite(strategy_returns) & (strategy_returns != 0.0)] + if len(active) == 0: + return 0.0, 0.0, 0.0 + wins = active[active > 0.0] + losses = active[active < 0.0] + win_rate = len(wins) / len(active) + avg_win = float(wins.mean()) if len(wins) > 0 else 0.0 + avg_loss = float(np.abs(losses).mean()) if len(losses) > 0 else 0.0 + return win_rate, avg_win, avg_loss + + +def _build_trades_df( + positions: NDArray, + fill_prices: NDArray, + high: NDArray, + low: NDArray, + initial_capital: float = 100_000.0, +) -> Any: + """Extract trade log; returns pd.DataFrame if pandas available, else None.""" + try: + import pandas as pd + except ImportError: + return None + eb, xb, d, ep, xp, pnl, dur, mae, mfe = _rust_extract_trades( + positions, fill_prices, high, low + ) + if len(eb) == 0: + return pd.DataFrame( + columns=[ # type: ignore[arg-type] + "entry_bar", + "exit_bar", + "direction", + "entry_price", + "exit_price", + "pnl_pct", + "pnl_abs", + "duration_bars", + "mae", + "mfe", + ] + ) + df = pd.DataFrame( + { + "entry_bar": eb, + "exit_bar": xb, + "direction": d, + "entry_price": ep, + "exit_price": xp, + "pnl_pct": pnl, + "duration_bars": dur, + "mae": mae, + "mfe": mfe, + } + ) + df["pnl_abs"] = df["pnl_pct"] * initial_capital + return df + + +class BacktestEngine: + """Composable backtesting engine with a fluent builder interface. + + Example + ------- + >>> import numpy as np + >>> from ferro_ta.analysis.backtest import BacktestEngine + >>> close = np.cumprod(1 + np.random.randn(200) * 0.01) * 100 + >>> high = close * 1.01; low = close * 0.99; open_ = close * 0.999 + >>> result = ( + ... BacktestEngine() + ... .with_commission(0.001) + ... .with_slippage(5.0) + ... .with_ohlcv(high=high, low=low, open_=open_) + ... .with_stop_loss(0.03) + ... .run(close, strategy="rsi_30_70") + ... ) + >>> print(result.metrics["sharpe"]) + """ + + def __init__(self) -> None: + self._commission: float = 0.0 + self._commission_model: Optional[CommissionModel] = None + self._currency: _RustCurrency = INR + self._initial_capital: float = 100_000.0 + self._slippage_bps: float = 0.0 + self._slippage_pct_range: float = 0.0 + self._position_sizing: str = "fixed" + self._fixed_fraction: float = 1.0 + self._vol_window: int = 20 + self._target_vol: float = 0.10 + self._high: Optional[NDArray] = None + self._low: Optional[NDArray] = None + self._open: Optional[NDArray] = None + self._stop_loss_pct: float = 0.0 + self._take_profit_pct: float = 0.0 + self._trailing_stop_pct: float = 0.0 + self._fill_mode: str = "market_open" + self._periods_per_year: float = 252.0 + self._risk_free_rate: float = 0.0 + self._benchmark_close: Optional[NDArray] = None + self._limit_prices: Optional[NDArray] = None + self._max_hold_bars: int = 0 + self._breakeven_pct: float = 0.0 + # Phase 2: Portfolio & Risk + self._margin_ratio: float = 0.0 + self._margin_call_pct: float = 0.5 + self._daily_loss_limit: float = 0.0 + self._total_loss_limit: float = 0.0 + self._max_asset_weight: float = 1.0 + self._max_gross_exposure: float = 0.0 + self._max_net_exposure: float = 0.0 + + def with_commission(self, rate: float) -> BacktestEngine: + """Backward-compat: set a flat per-order fee (in base currency units).""" + self._commission = float(rate) + return self + + def with_commission_model(self, model: CommissionModel) -> BacktestEngine: + """Set a full commission+tax model (takes precedence over ``with_commission``).""" + self._commission_model = model + return self + + def with_currency( + self, currency: str | _RustCurrency | None = None + ) -> BacktestEngine: + """Set display currency (default: INR).""" + if currency is None: + currency = INR + if isinstance(currency, str): + try: + currency = Currency.from_code(currency) + except Exception: + raise FerroTAValueError( + f"Unknown currency code '{currency}'. " + f"Supported: {sorted(_CURRENCIES)}" + ) + self._currency = currency + return self + + def with_initial_capital(self, capital: float) -> BacktestEngine: + """Set starting capital in base currency (default: ₹1,00,000).""" + self._initial_capital = float(capital) + return self + + def with_benchmark(self, benchmark_close: ArrayLike) -> BacktestEngine: + """Set benchmark close prices for alpha/beta/tracking error computation.""" + self._benchmark_close = np.asarray(benchmark_close, dtype=np.float64) + return self + + def with_trailing_stop(self, pct: float) -> BacktestEngine: + """Set trailing stop distance as a fraction (e.g. 0.02 = 2%). 0 = disabled.""" + self._trailing_stop_pct = float(pct) + return self + + def with_slippage(self, bps: float) -> BacktestEngine: + self._slippage_bps = float(bps) + return self + + def with_ohlcv( + self, + *, + high: ArrayLike, + low: ArrayLike, + open_: ArrayLike, + ) -> BacktestEngine: + self._high = np.asarray(high, dtype=np.float64) + self._low = np.asarray(low, dtype=np.float64) + self._open = np.asarray(open_, dtype=np.float64) + return self + + def with_stop_loss(self, pct: float) -> BacktestEngine: + self._stop_loss_pct = float(pct) + return self + + def with_take_profit(self, pct: float) -> BacktestEngine: + self._take_profit_pct = float(pct) + return self + + def with_fill_mode(self, mode: str) -> BacktestEngine: + if mode not in ("market_open", "market_close"): + raise FerroTAValueError("fill_mode must be 'market_open' or 'market_close'") + self._fill_mode = mode + return self + + def with_position_sizing( + self, + method: str, + fraction: float = 1.0, + vol_window: int = 20, + target_vol: float = 0.10, + ) -> BacktestEngine: + valid = ( + "fixed", + "kelly", + "half_kelly", + "fixed_fractional", + "volatility_target", + ) + if method not in valid: + raise FerroTAValueError(f"position_sizing must be one of {valid}") + if method == "fixed_fractional" and not (0.0 < fraction <= 1.0): + raise FerroTAValueError("fixed_fractional fraction must be in (0, 1]") + self._vol_window = int(vol_window) + self._target_vol = float(target_vol) + self._position_sizing = method + self._fixed_fraction = float(fraction) + return self + + def with_calendar(self, periods_per_year: float) -> BacktestEngine: + self._periods_per_year = float(periods_per_year) + return self + + def with_risk_free_rate(self, rate: float) -> BacktestEngine: + self._risk_free_rate = float(rate) + return self + + def with_limit_orders(self, prices: ArrayLike) -> BacktestEngine: + """Set limit prices for entry/exit orders (requires OHLCV data via with_ohlcv). + + Parameters + ---------- + prices : array-like, shape (n_bars,) + Limit price for each signal bar. NaN (or 0) entries use market-order fill. + Buy limit: fill only when bar low <= limit_price (execute at limit_price). + Sell limit: fill only when bar high >= limit_price (execute at limit_price). + """ + self._limit_prices = np.asarray(prices, dtype=np.float64) + return self + + def with_max_hold(self, n_bars: int) -> BacktestEngine: + """Force exit after *n_bars* bars in trade regardless of signal (requires OHLCV). + + 0 = disabled (default). Useful for mean-reversion strategies. + """ + if int(n_bars) < 0: + raise FerroTAValueError("max_hold n_bars must be >= 0") + self._max_hold_bars = int(n_bars) + return self + + def with_slippage_pct_range(self, pct: float) -> BacktestEngine: + """Set slippage as a fraction of the bar's high-low range (requires OHLCV). + + Overrides ``with_slippage`` when both are set. Typical values: 0.05–0.20. + Example: pct=0.10 means slippage = 10% of bar's (high - low). + """ + self._slippage_pct_range = float(pct) + return self + + def with_breakeven_stop(self, pct: float) -> BacktestEngine: + """Move stop to entry price once profit reaches *pct* fraction (e.g. 0.02 = 2%). 0 = disabled.""" + self._breakeven_pct = float(pct) + return self + + def with_leverage( + self, margin_ratio: float, margin_call_pct: float = 0.5 + ) -> BacktestEngine: + """Enable margin/leverage modeling. margin_ratio=0.2 means 20% margin (5x leverage). + margin_call_pct=0.5 triggers a margin call when equity falls to 50% of initial margin.""" + self._margin_ratio = float(margin_ratio) + self._margin_call_pct = float(margin_call_pct) + return self + + def with_loss_limits( + self, daily: float = 0.0, total: float = 0.0 + ) -> BacktestEngine: + """Set circuit breakers. daily=0.02 halts after a 2% per-bar loss. total=0.20 halts after 20% drawdown.""" + self._daily_loss_limit = float(daily) + self._total_loss_limit = float(total) + return self + + def with_portfolio_constraints( + self, + max_asset_weight: float = 1.0, + max_gross_exposure: float = 0.0, + max_net_exposure: float = 0.0, + ) -> BacktestEngine: + """Set portfolio-level constraints for multi-asset backtests.""" + self._max_asset_weight = float(max_asset_weight) + self._max_gross_exposure = float(max_gross_exposure) + self._max_net_exposure = float(max_net_exposure) + return self + + def run( + self, + close: ArrayLike, + strategy: Union[str, Callable] = "rsi_30_70", + **strategy_kwargs: object, + ) -> AdvancedBacktestResult: + """Run the backtest and return an AdvancedBacktestResult.""" + c = np.asarray(close, dtype=np.float64) + if c.ndim != 1: + raise FerroTAInputError("close must be a 1-D array.") + if len(c) < 2: + raise FerroTAInputError(f"close must have at least 2 bars, got {len(c)}.") + + strategy_fn = _resolve_strategy(strategy) + signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64) + + cm = self._commission_model + commission_scalar = self._commission if cm is None else 0.0 + ic = self._initial_capital + + if self._position_sizing == "fixed_fractional": + signals = signals * self._fixed_fraction + + if self._position_sizing == "volatility_target": + proxy_rets = _pct_change(c) + w = self._vol_window + # Naive rolling window is O(n·w); cumsum-of-squares is O(n) with no per-bar allocation. + # Safe for financial returns (centred near zero → no catastrophic cancellation). + cs = np.cumsum(proxy_rets) + cs2 = np.cumsum(proxy_rets**2) + pad = np.zeros(1) + s1 = cs[w - 1 :] - np.concatenate([pad, cs[: len(cs) - w]]) + s2 = cs2[w - 1 :] - np.concatenate([pad, cs2[: len(cs2) - w]]) + var = np.maximum(s2 / w - (s1 / w) ** 2, 0.0) + rolling_vol = np.concatenate([np.full(w, np.nan), np.sqrt(var)]) * np.sqrt( + self._periods_per_year + ) + rolling_vol = np.concatenate([[np.nan], rolling_vol[: len(signals) - 1]]) + with np.errstate(divide="ignore", invalid="ignore"): + # NaN positions (warm-up) have rolling_vol<=0 → else-branch produces 1.0 + scale = np.where( + rolling_vol > 0, + np.clip(self._target_vol / rolling_vol, 0.0, 3.0), + 1.0, + ) + signals = signals * scale + + use_ohlcv = ( + self._high is not None and self._low is not None and self._open is not None + ) + + def _execute_run(sigs: NDArray) -> tuple: + if use_ohlcv: + pos, fp, br, sr, eq = _rust_backtest_ohlcv_core( + self._open, + self._high, + self._low, + c, + sigs, + self._fill_mode, + self._stop_loss_pct, + self._take_profit_pct, + self._trailing_stop_pct, + cm, + self._slippage_bps, + ic, + commission_scalar, + self._limit_prices, + self._max_hold_bars, + self._slippage_pct_range, + self._breakeven_pct, + self._periods_per_year, + self._margin_ratio, + self._margin_call_pct, + self._daily_loss_limit, + self._total_loss_limit, + ) + return ( + np.asarray(pos), + np.asarray(fp), + np.asarray(br), + np.asarray(sr), + np.asarray(eq), + ) + pos, br, sr, eq = _rust_backtest_core( + c, + sigs, + cm, + self._slippage_bps, + ic, + commission_scalar, + ) + return ( + np.asarray(pos), + np.full(len(c), np.nan, dtype=np.float64), + np.asarray(br), + np.asarray(sr), + np.asarray(eq), + ) + + bench_returns_arr = None + if self._benchmark_close is not None and len(self._benchmark_close) == len(c): + bc = self._benchmark_close + bench_returns_arr = np.concatenate([[0.0], _pct_change(bc)]) + + def _compute_metrics(sr: NDArray, eq: NDArray) -> dict: + return dict( + _rust_compute_perf_metrics( + sr, + eq, + self._periods_per_year, + self._risk_free_rate, + bench_returns_arr, + ) + ) + + # Kelly / half-Kelly: estimate fraction from a preliminary run, then re-run scaled + _kelly_kf: float = 0.0 + if self._position_sizing in ("kelly", "half_kelly"): + positions, fill_prices, bar_returns, strategy_returns, equity = ( + _execute_run(signals) + ) + wr, aw, al = _kelly_stats(strategy_returns) + if aw > 0.0: + try: + _kelly_kf = _rust_kelly_fraction(wr, aw, al) + fraction = ( + _kelly_kf + if self._position_sizing == "kelly" + else _kelly_kf / 2.0 + ) + signals = signals * fraction + except Exception as exc: + warnings.warn( + f"Kelly sizing failed, falling back to unit signals: {exc}", + stacklevel=2, + ) + + positions, fill_prices, bar_returns, strategy_returns, equity = _execute_run( + signals + ) + metrics = _compute_metrics(strategy_returns, equity) + + # Annotate Kelly info (reuse pre-computed fraction, avoid re-scanning returns) + if _kelly_kf > 0.0 and "kelly_fraction" not in metrics: + metrics["kelly_fraction"] = _kelly_kf + metrics["half_kelly_fraction"] = _kelly_kf / 2.0 + metrics["position_size_fraction"] = ( + _kelly_kf if self._position_sizing == "kelly" else _kelly_kf / 2.0 + ) + + high_arr: NDArray = self._high if use_ohlcv and self._high is not None else c + low_arr: NDArray = self._low if use_ohlcv and self._low is not None else c + trades = _build_trades_df(positions, fill_prices, high_arr, low_arr, ic) + + dd_arr, _ = _rust_drawdown_series(equity) + drawdown_series = np.asarray(dd_arr) + + return AdvancedBacktestResult( + signals=signals, + positions=positions, + bar_returns=bar_returns, + strategy_returns=strategy_returns, + equity=equity, + metrics=metrics, + trades=trades, + drawdown_series=drawdown_series, + fill_prices=fill_prices, + currency=self._currency, + initial_capital=ic, + ) + + +# --------------------------------------------------------------------------- +# Additional built-in strategies +# --------------------------------------------------------------------------- + + +def adx_trend_follow_strategy( + close: ArrayLike, + high: Optional[ArrayLike] = None, + low: Optional[ArrayLike] = None, + adx_period: int = 14, + adx_threshold: float = 25.0, + sma_period: int = 50, + **kwargs: object, +) -> NDArray: + """ADX trend-following: +1 when ADX>threshold AND close>SMA, else -1.""" + from ferro_ta._ferro_ta import adx as _adx + from ferro_ta._ferro_ta import sma as _sma + + c = np.asarray(close, dtype=np.float64) + h = np.asarray(high, dtype=np.float64) if high is not None else c * 1.001 + low_arr = np.asarray(low, dtype=np.float64) if low is not None else c * 0.999 + + adx_vals = np.asarray(_adx(h, low_arr, c, adx_period), dtype=np.float64) + sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64) + + out = np.where( + np.isnan(adx_vals) | np.isnan(sma_vals), + np.nan, + np.where((adx_vals > adx_threshold) & (c > sma_vals), 1.0, -1.0), + ) + return out + + +def bb_mean_revert_strategy( + close: ArrayLike, + timeperiod: int = 20, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, + **kwargs: object, +) -> NDArray: + """Bollinger Band mean reversion: +1 near lower band, -1 near upper band.""" + from ferro_ta._ferro_ta import bbands as _bbands + + c = np.asarray(close, dtype=np.float64) + upper, middle, lower = _bbands(c, timeperiod, nbdevup, nbdevdn) + upper = np.asarray(upper, dtype=np.float64) + lower = np.asarray(lower, dtype=np.float64) + + out = np.where( + np.isnan(upper) | np.isnan(lower), + np.nan, + np.where(c <= lower, 1.0, np.where(c >= upper, -1.0, 0.0)), + ) + return out + + +def rsi_sma_combo_strategy( + close: ArrayLike, + rsi_period: int = 14, + oversold: float = 30.0, + overbought: float = 70.0, + sma_period: int = 50, + **kwargs: object, +) -> NDArray: + """RSI signal filtered by SMA trend: RSI oversold/overbought only in trend direction.""" + from ferro_ta._ferro_ta import sma as _sma + + c = np.asarray(close, dtype=np.float64) + rsi_signals = rsi_strategy(c, rsi_period, oversold, overbought) + sma_vals = np.asarray(_sma(c, sma_period), dtype=np.float64) + + trend = np.where(np.isnan(sma_vals), np.nan, np.where(c > sma_vals, 1.0, -1.0)) + # Only take RSI long signals in uptrend, RSI short signals in downtrend + out = np.where( + np.isnan(rsi_signals) | np.isnan(trend), + np.nan, + np.where( + (rsi_signals == 1.0) & (trend == 1.0), + 1.0, + np.where((rsi_signals == -1.0) & (trend == -1.0), -1.0, 0.0), + ), + ) + return out + + +# Register additional built-in strategies +_BUILTIN_STRATEGIES["adx_trend_follow"] = adx_trend_follow_strategy +_BUILTIN_STRATEGIES["bb_mean_revert"] = bb_mean_revert_strategy +_BUILTIN_STRATEGIES["rsi_sma_combo"] = rsi_sma_combo_strategy + + +# --------------------------------------------------------------------------- +# WalkForwardResult + walk_forward() +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class WalkForwardResult: + """Results from walk-forward analysis. + + Attributes + ---------- + fold_results : list[AdvancedBacktestResult] + Out-of-sample backtest result for each fold. + fold_indices : NDArray[np.int64] + Shape (n_folds, 4): [train_start, train_end, test_start, test_end]. + best_params_per_fold : list[dict] + Parameter dict that scored highest in each fold's training period. + oos_equity : NDArray[np.float64] + Concatenated out-of-sample equity curve (chained, not spliced raw). + oos_metrics : dict[str, float] + Performance metrics computed on the full OOS equity curve. + param_stability : dict[str, Any] + For each param name, the most-chosen value and its selection frequency. + """ + + fold_results: list + fold_indices: NDArray + best_params_per_fold: list + oos_equity: NDArray + oos_metrics: dict + param_stability: dict + + +def walk_forward( + close: ArrayLike, + strategy_fn: Callable, + param_grid: list, + train_bars: int, + test_bars: int, + *, + metric: str = "sharpe", + anchored: bool = False, + step_bars: int = 0, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + periods_per_year: float = 252.0, +) -> WalkForwardResult: + """Walk-forward analysis with grid search on each training fold. + + Parameters + ---------- + close : array-like + Full close price series. + strategy_fn : callable + Signal-generating function ``(close, **params) -> signals``. + param_grid : list[dict] + List of parameter dicts to test on the training set. + train_bars : int + Number of bars in each training window. + test_bars : int + Number of bars in each test (out-of-sample) window. + metric : str + Metric name from ``compute_performance_metrics`` to optimise (default "sharpe"). + anchored : bool + If True, training window always starts from bar 0 (expanding window). + step_bars : int + Step between folds. 0 → non-overlapping (step = test_bars). + commission_per_trade, slippage_bps : float + Applied in both training (for metric computation) and test. + periods_per_year : float + Annualisation factor for metrics (default 252). + + Returns + ------- + WalkForwardResult + """ + if metric in _BENCHMARK_METRICS: + raise FerroTAValueError( + f"metric '{metric}' requires a benchmark and is not supported in walk_forward(). " + f"Use a non-benchmark metric such as 'sharpe', 'cagr', or 'sortino'." + ) + + c = np.asarray(close, dtype=np.float64) + n = len(c) + + fold_idx = np.asarray( + _rust_walk_forward_indices(n, train_bars, test_bars, anchored, step_bars), + dtype=np.int64, + ) + + fold_results: list = [] + best_params_per_fold: list = [] + oos_returns_parts: list = [] + + engine_base = ( + BacktestEngine() + .with_commission(commission_per_trade) + .with_slippage(slippage_bps) + .with_calendar(periods_per_year) + ) + + for fold in fold_idx: + tr_start, tr_end, te_start, te_end = ( + int(fold[0]), + int(fold[1]), + int(fold[2]), + int(fold[3]), + ) + c_train = c[tr_start:tr_end] + c_test = c[te_start:te_end] + + # Grid search on training set + best_params: dict = param_grid[0] if param_grid else {} + best_score = float("-inf") + + for params in param_grid: + try: + signals_train = np.asarray( + strategy_fn(c_train, **params), dtype=np.float64 + ) + _, _, sr_train, eq_train = _rust_backtest_core( + c_train, + signals_train, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + ) + sr_train = np.asarray(sr_train, dtype=np.float64) + eq_train = np.asarray(eq_train, dtype=np.float64) + fold_metrics = dict( + _rust_compute_perf_metrics( + sr_train, eq_train, periods_per_year, 0.0 + ) + ) + score = fold_metrics.get(metric, float("-inf")) + if score > best_score: + best_score = score + best_params = params + except Exception as exc: + warnings.warn( + f"walk_forward: training fold param evaluation failed: {exc}", + stacklevel=2, + ) + continue + + best_params_per_fold.append(best_params) + + # Test with best params + try: + test_result = engine_base.run(c_test, strategy_fn, **best_params) + except Exception as exc: + warnings.warn( + f"walk_forward: test fold failed, using flat equity: {exc}", + stacklevel=2, + ) + dummy = np.ones(len(c_test)) + test_result = AdvancedBacktestResult( + signals=dummy, + positions=dummy, + bar_returns=dummy, + strategy_returns=np.zeros(len(c_test)), + equity=dummy, + metrics={}, + trades=None, + drawdown_series=np.zeros(len(c_test)), + fill_prices=np.full(len(c_test), np.nan), + ) + + fold_results.append(test_result) + oos_returns_parts.append(test_result.strategy_returns) + + # Chain OOS equity curves from per-fold equity (preserves commission deductions) + if fold_results: + oos_equity_parts: list[NDArray] = [] + oos_returns = np.concatenate(oos_returns_parts) + cumulative = 1.0 + for fr in fold_results: + fold_eq = np.asarray(fr.equity, dtype=np.float64) + # Renormalize: fold equity starts at 1.0, scale to chain from prior fold + oos_equity_parts.append(fold_eq * cumulative) + cumulative *= float(fold_eq[-1]) if len(fold_eq) > 0 else 1.0 + oos_equity = np.concatenate(oos_equity_parts) + else: + oos_returns = np.array([0.0]) + oos_equity = np.array([1.0]) + + # OOS metrics on full concatenated curve + try: + oos_metrics = dict( + _rust_compute_perf_metrics(oos_returns, oos_equity, periods_per_year, 0.0) + ) + except Exception: + oos_metrics = {} + + # Parameter stability: how often each param value was chosen + param_stability: dict = {} + if best_params_per_fold: + all_keys = set().union(*[p.keys() for p in best_params_per_fold]) + for key in all_keys: + vals = [p.get(key) for p in best_params_per_fold if key in p] + counts = Counter(vals) + most_common_val, most_common_count = counts.most_common(1)[0] + param_stability[key] = { + "most_chosen": most_common_val, + "frequency": most_common_count / len(best_params_per_fold), + "counts": dict(counts), + } + + return WalkForwardResult( + fold_results=fold_results, + fold_indices=fold_idx, + best_params_per_fold=best_params_per_fold, + oos_equity=oos_equity, + oos_metrics=oos_metrics, + param_stability=param_stability, + ) + + +# --------------------------------------------------------------------------- +# MonteCarloResult + monte_carlo() +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class MonteCarloResult: + """Results from Monte Carlo bootstrap simulation. + + Attributes + ---------- + equity_curves : NDArray[np.float64] + Shape (n_sims, n_bars) — simulated equity curves. + terminal_equity : NDArray[np.float64] + Shape (n_sims,) — final equity value per simulation. + confidence_lower : NDArray[np.float64] + Lower confidence band per bar. + confidence_upper : NDArray[np.float64] + Upper confidence band per bar. + median_curve : NDArray[np.float64] + Median equity curve across simulations. + var : float + Value-at-Risk: worst ``(1-confidence)`` percentile of terminal equity. + cvar : float + Conditional VaR: mean of worst ``(1-confidence)`` fraction of terminal equity. + prob_profit : float + Fraction of simulations where terminal equity > 1.0. + n_sims : int + confidence : float + """ + + equity_curves: NDArray + terminal_equity: NDArray + confidence_lower: NDArray + confidence_upper: NDArray + median_curve: NDArray + var: float + cvar: float + prob_profit: float + n_sims: int + confidence: float + + +def monte_carlo( + result_or_returns: Union[BacktestResult, NDArray], + n_sims: int = 1000, + confidence: float = 0.95, + seed: int = 42, + block_size: int = 1, +) -> MonteCarloResult: + """Run Monte Carlo bootstrap simulation on strategy returns. + + Parameters + ---------- + result_or_returns : BacktestResult or array-like + Either a ``BacktestResult`` (uses its ``strategy_returns``) or + a 1-D array of returns directly. + n_sims : int + Number of bootstrap simulations (default 1000). + confidence : float + Confidence level for bands and VaR (default 0.95). + seed : int + Random seed for reproducibility. + block_size : int + Block size for stationary block bootstrap (1 = IID resample). + + Returns + ------- + MonteCarloResult + """ + if isinstance(result_or_returns, BacktestResult): + returns = np.asarray(result_or_returns.strategy_returns, dtype=np.float64) + else: + returns = np.asarray(result_or_returns, dtype=np.float64) + + equity_curves = np.asarray( + _rust_monte_carlo_bootstrap(returns, int(n_sims), int(seed), int(block_size)), + dtype=np.float64, + ) + + terminal_equity = equity_curves[:, -1] + + lower_pct = (1.0 - confidence) / 2.0 * 100.0 + upper_pct = (1.0 + confidence) / 2.0 * 100.0 + + pct_results = np.percentile(equity_curves, [lower_pct, upper_pct, 50.0], axis=0) + confidence_lower = pct_results[0] + confidence_upper = pct_results[1] + median_curve = pct_results[2] + + var_threshold = np.percentile(terminal_equity, (1.0 - confidence) * 100.0) + tail = terminal_equity[terminal_equity <= var_threshold] + cvar = float(np.mean(tail)) if len(tail) > 0 else float(var_threshold) + + prob_profit = float(np.mean(terminal_equity > 1.0)) + + return MonteCarloResult( + equity_curves=equity_curves, + terminal_equity=terminal_equity, + confidence_lower=confidence_lower, + confidence_upper=confidence_upper, + median_curve=median_curve, + var=float(var_threshold), + cvar=cvar, + prob_profit=prob_profit, + n_sims=int(n_sims), + confidence=float(confidence), + ) + + +# --------------------------------------------------------------------------- +# Portfolio backtest +# --------------------------------------------------------------------------- + + +def backtest_portfolio( + close_2d: ArrayLike, + weights_2d: ArrayLike, + *, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + periods_per_year: float = 252.0, + parallel: bool = True, + max_asset_weight: float = 1.0, + max_gross_exposure: float = 0.0, + max_net_exposure: float = 0.0, +) -> PortfolioBacktestResult: + """Backtest a portfolio of N assets in parallel. + + Parameters + ---------- + close_2d : array-like, shape (n_bars, n_assets) + Close prices for each asset. + weights_2d : array-like, shape (n_bars, n_assets) + Desired position per asset per bar (lagged internally like signals). + commission_per_trade : float + Per-position-change commission (default 0). + slippage_bps : float + Slippage in basis points (default 0). + periods_per_year : float + Annualisation factor for metrics (default 252). + parallel : bool + Use rayon parallelism (default True). + + Returns + ------- + PortfolioBacktestResult + """ + c2d = np.ascontiguousarray(close_2d, dtype=np.float64) + w2d = np.ascontiguousarray(weights_2d, dtype=np.float64) + + asset_returns, portfolio_returns, portfolio_equity = ( + _rust_backtest_multi_asset_core( + c2d, + w2d, + commission_per_trade, + slippage_bps, + parallel, + max_asset_weight, + max_gross_exposure, + max_net_exposure, + ) + ) + asset_returns = np.asarray(asset_returns, dtype=np.float64) + portfolio_returns = np.asarray(portfolio_returns, dtype=np.float64) + portfolio_equity = np.asarray(portfolio_equity, dtype=np.float64) + + metrics = dict( + _rust_compute_perf_metrics( + portfolio_returns, portfolio_equity, periods_per_year, 0.0 + ) + ) + + return PortfolioBacktestResult( + asset_returns=asset_returns, + portfolio_returns=portfolio_returns, + portfolio_equity=portfolio_equity, + metrics=metrics, + ) + + +@dataclasses.dataclass +class PortfolioBacktestResult: + """Result from a multi-asset portfolio backtest. + + Attributes + ---------- + asset_returns : NDArray[np.float64] + Shape (n_bars, n_assets) — per-asset strategy returns. + portfolio_returns : NDArray[np.float64] + Shape (n_bars,) — combined portfolio returns. + portfolio_equity : NDArray[np.float64] + Shape (n_bars,) — cumulative portfolio equity. + metrics : dict[str, float] + Full performance metrics on the portfolio equity curve. + """ + + asset_returns: NDArray + portfolio_returns: NDArray + portfolio_equity: NDArray + metrics: dict diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py new file mode 100644 index 0000000..d79bd86 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/cross_asset.py @@ -0,0 +1,237 @@ +""" +ferro_ta.cross_asset — Cross-asset and relative strength analytics. + +Provides helpers for relative value and pair-trading workflows: +- relative_strength(asset_returns, benchmark_returns) +- spread(a, b, hedge=1.0) +- ratio(a, b) +- zscore(x, window) +- rolling_beta(a, b, window) + +Compute-intensive work delegates to Rust (via ferro_ta._ferro_ta). + +Functions +--------- +relative_strength(asset_returns, benchmark_returns) + Cumulative-return ratio (asset / benchmark), starting at 1. + +spread(a, b, hedge=1.0) + Spread series: a - hedge * b. + +ratio(a, b) + Ratio series: a / b. + +zscore(x, window) + Rolling Z-score of series *x* over a sliding window. + +rolling_beta(a, b, window) + Rolling beta (hedge ratio) of series *a* vs *b*. + +Rust backend +------------ + ferro_ta._ferro_ta.relative_strength + ferro_ta._ferro_ta.spread + ferro_ta._ferro_ta.zscore_series + ferro_ta._ferro_ta.rolling_beta +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ratio as _rust_ratio +from ferro_ta._ferro_ta import relative_strength as _rust_rel_strength +from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta +from ferro_ta._ferro_ta import spread as _rust_spread +from ferro_ta._ferro_ta import zscore_series as _rust_zscore +from ferro_ta._utils import _to_f64 + +__all__ = [ + "relative_strength", + "spread", + "ratio", + "zscore", + "rolling_beta", +] + + +# --------------------------------------------------------------------------- +# relative_strength +# --------------------------------------------------------------------------- + + +def relative_strength( + asset_returns: ArrayLike, + benchmark_returns: ArrayLike, +) -> NDArray[np.float64]: + """Compute relative strength of an asset versus a benchmark. + + Returns the ratio of cumulative returns:: + + RS[i] = (1 + r_asset[0]) * … * (1 + r_asset[i]) / + ((1 + r_bench[0]) * … * (1 + r_bench[i])) + + starting from RS[0] ≈ 1. + + Parameters + ---------- + asset_returns, benchmark_returns : array-like + Fractional returns per bar (e.g. 0.01 for +1%). Equal length. + + Returns + ------- + numpy.ndarray of same length — relative strength series. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import relative_strength + >>> r_a = np.array([0.01, 0.02, -0.01, 0.005]) + >>> r_b = np.array([0.005, 0.01, -0.005, 0.002]) + >>> rs = relative_strength(r_a, r_b) + >>> rs[0] > 1 # asset outperformed at bar 0 + True + """ + a = _to_f64(asset_returns) + b = _to_f64(benchmark_returns) + return _rust_rel_strength(a, b) + + +# --------------------------------------------------------------------------- +# spread +# --------------------------------------------------------------------------- + + +def spread( + a: ArrayLike, + b: ArrayLike, + hedge: float = 1.0, +) -> NDArray[np.float64]: + """Compute the spread between two series. + + ``spread[i] = a[i] - hedge * b[i]`` + + Parameters + ---------- + a, b : array-like (equal length) + hedge : float — hedge ratio (default 1.0) + + Returns + ------- + numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import spread + >>> a = np.array([10.0, 11.0, 12.0]) + >>> b = np.array([9.0, 10.0, 11.0]) + >>> list(spread(a, b)) + [1.0, 1.0, 1.0] + """ + return _rust_spread(_to_f64(a), _to_f64(b), float(hedge)) + + +# --------------------------------------------------------------------------- +# ratio +# --------------------------------------------------------------------------- + + +def ratio( + a: ArrayLike, + b: ArrayLike, +) -> NDArray[np.float64]: + """Compute the ratio of two series: a / b. + + Zeros in *b* produce ``NaN`` in the result. + + Parameters + ---------- + a, b : array-like (equal length) + + Returns + ------- + numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import ratio + >>> a = np.array([10.0, 12.0, 15.0]) + >>> b = np.array([5.0, 4.0, 5.0]) + >>> list(ratio(a, b)) + [2.0, 3.0, 3.0] + """ + return _rust_ratio(_to_f64(a), _to_f64(b)) + + +# --------------------------------------------------------------------------- +# zscore +# --------------------------------------------------------------------------- + + +def zscore( + x: ArrayLike, + window: int, +) -> NDArray[np.float64]: + """Compute the rolling Z-score of series *x*. + + ``z[i] = (x[i] - mean(x[i-window+1..i])) / std(x[i-window+1..i])`` + + Parameters + ---------- + x : array-like + window : int — must be >= 2 + + Returns + ------- + numpy.ndarray — NaN for first ``window-1`` positions. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import zscore + >>> x = np.array([1.0, 2.0, 3.0, 2.0, 1.0]) + >>> z = zscore(x, window=3) + >>> np.isnan(z[0]) and np.isnan(z[1]) + True + """ + return _rust_zscore(_to_f64(x), int(window)) + + +# --------------------------------------------------------------------------- +# rolling_beta +# --------------------------------------------------------------------------- + + +def rolling_beta( + a: ArrayLike, + b: ArrayLike, + window: int, +) -> NDArray[np.float64]: + """Compute rolling beta (hedge ratio) of series *a* vs *b*. + + Parameters + ---------- + a, b : array-like (equal length) + window : int — rolling window size (must be >= 2) + + Returns + ------- + numpy.ndarray — NaN for first ``window-1`` positions. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.cross_asset import rolling_beta + >>> rng = np.random.default_rng(42) + >>> b = rng.normal(0, 1, 50) + >>> a = 0.8 * b + rng.normal(0, 0.1, 50) + >>> rb = rolling_beta(a, b, window=20) + >>> np.isnan(rb[18]) + True + >>> abs(rb[-1] - 0.8) < 0.3 + True + """ + return _rust_rolling_beta(_to_f64(a), _to_f64(b), int(window)) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/crypto.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/crypto.py new file mode 100644 index 0000000..0668371 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/crypto.py @@ -0,0 +1,232 @@ +""" +ferro_ta.crypto — Crypto and 24/7 market helpers. +================================================= + +Helpers designed for continuous (24/7) markets such as cryptocurrency or FX. + +Functions +--------- +funding_pnl(position_size, funding_rate) + Compute the cumulative PnL from periodic funding rate payments. + +continuous_bar_labels(n_bars, period_bars) + Assign integer period labels to bars without calendar-based sessions. + +session_boundaries(timestamps_ns) + Return bar indices at the start of each UTC-day session boundary. + +resample_continuous(ohlcv, period_bars) + Resample a continuous OHLCV series by grouping every *period_bars* input + bars into one output bar (no session filtering). + +Rust backend +------------ + ferro_ta._ferro_ta.funding_cumulative_pnl + ferro_ta._ferro_ta.continuous_bar_labels + ferro_ta._ferro_ta.mark_session_boundaries +""" + +from __future__ import annotations + +from typing import Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + continuous_bar_labels as _rust_continuous_bar_labels, +) +from ferro_ta._ferro_ta import ( + funding_cumulative_pnl as _rust_funding_cumulative_pnl, +) +from ferro_ta._ferro_ta import ( + mark_session_boundaries as _rust_mark_session_boundaries, +) +from ferro_ta._ferro_ta import ( + ohlcv_agg as _rust_ohlcv_agg, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "funding_pnl", + "continuous_bar_labels", + "session_boundaries", + "resample_continuous", +] + +# type alias +OHLCVTuple = tuple[ + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], + NDArray[np.float64], +] + + +def funding_pnl( + position_size: ArrayLike, + funding_rate: ArrayLike, +) -> NDArray[np.float64]: + """Compute cumulative PnL from periodic funding rate payments. + + Crypto perpetual contracts charge a periodic funding rate to position + holders. A long position pays when the funding rate is positive; a short + position receives. + + PnL at period *i* = ``-position_size[i] * funding_rate[i]`` + Returned array is the cumulative sum of those per-period PnLs. + + Parameters + ---------- + position_size : array-like — signed position size per funding period. + Positive = long, negative = short. + funding_rate : array-like — periodic funding rate in decimal notation + (e.g. 0.0001 = 0.01%). Must have the same length as *position_size*. + + Returns + ------- + numpy.ndarray of float64 — cumulative funding PnL. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.crypto import funding_pnl + >>> pos = np.ones(5) # long 1 contract + >>> rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001]) + >>> pnl = funding_pnl(pos, rate) + >>> pnl.round(6) + array([-0.0001, -0.0003, 0. , -0.0001, -0.0002]) + """ + return np.asarray( + _rust_funding_cumulative_pnl(_to_f64(position_size), _to_f64(funding_rate)), + dtype=np.float64, + ) + + +def continuous_bar_labels( + n_bars: int, + period_bars: int, +) -> NDArray[np.int64]: + """Assign sequential integer labels to bars in equal-size buckets. + + Useful for grouping continuous data (no session gaps) into periods without + relying on calendar logic. Bars 0…(period_bars-1) get label 0, + bars period_bars…(2·period_bars-1) get label 1, etc. + + Parameters + ---------- + n_bars : int — total number of bars + period_bars : int — number of bars per period (e.g. 24 for hourly → daily) + + Returns + ------- + numpy.ndarray of int64 — period label per bar. + + Examples + -------- + >>> from ferro_ta.analysis.crypto import continuous_bar_labels + >>> continuous_bar_labels(10, 3) + array([0, 0, 0, 1, 1, 1, 2, 2, 2, 3]) + """ + return np.asarray( + _rust_continuous_bar_labels(int(n_bars), int(period_bars)), + dtype=np.int64, + ) + + +def session_boundaries( + timestamps_ns: ArrayLike, +) -> NDArray[np.int64]: + """Return bar indices at the start of each UTC-day boundary. + + Intended for 24/7 data where no exchange session gaps exist. Useful for + building daily OHLCV bars from intraday continuous data. + + Parameters + ---------- + timestamps_ns : array-like of int64 — UTC timestamps in nanoseconds + (e.g. ``pandas.DatetimeIndex.astype('int64')``). + + Returns + ------- + numpy.ndarray of int64 — indices of the first bar in each UTC day + (always includes index 0). + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.crypto import session_boundaries + >>> # Two UTC days of hourly bars: day 0 = bars 0-23, day 1 = bars 24-47 + >>> base_ns = np.int64(1_700_000_000_000_000_000) # some UTC timestamp + >>> ns_per_hour = np.int64(3_600_000_000_000) + >>> ts = base_ns + np.arange(48, dtype=np.int64) * ns_per_hour + >>> bounds = session_boundaries(ts) + """ + ts = np.asarray(timestamps_ns, dtype=np.int64) + return np.asarray( + _rust_mark_session_boundaries(ts), + dtype=np.int64, + ) + + +def resample_continuous( + ohlcv: Union[ + tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike], + object, # pandas.DataFrame + ], + period_bars: int, +) -> OHLCVTuple: + """Resample a continuous OHLCV series by grouping *period_bars* input bars. + + Unlike time-based resampling, this function requires no calendar or + session information. Every *period_bars* consecutive input bars are + aggregated into one output bar. Ideal for 24/7 crypto data. + + Parameters + ---------- + ohlcv : tuple ``(open, high, low, close, volume)`` of array-like, + **or** a ``pandas.DataFrame`` with columns ``open/high/low/close/volume`` + (case-insensitive). + period_bars : int — number of input bars per output bar (must be >= 1). + + Returns + ------- + tuple ``(open, high, low, close, volume)`` of numpy.ndarray — resampled bars. + + Notes + ----- + The last output bar may aggregate fewer than *period_bars* input bars if + ``len(close) % period_bars != 0``. + """ + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr] + o = _to_f64(ohlcv[cols["open"]].values) # type: ignore[index] + h = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index] + lo = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index] + c = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index] + v = _to_f64(ohlcv[cols["volume"]].values) # type: ignore[index] + else: + o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + except ImportError: + o, h, lo, c, v = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + + n = len(c) + if period_bars < 1: + raise ValueError("period_bars must be >= 1") + # Build bar-group labels + labels = np.asarray( + _rust_continuous_bar_labels(n, int(period_bars)), + dtype=np.int64, + ) + ro, rh, rl, rc, rv = _rust_ohlcv_agg(o, h, lo, c, v, labels) + return ( + np.asarray(ro, dtype=np.float64), + np.asarray(rh, dtype=np.float64), + np.asarray(rl, dtype=np.float64), + np.asarray(rc, dtype=np.float64), + np.asarray(rv, dtype=np.float64), + ) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py new file mode 100644 index 0000000..0d3ff26 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/derivatives_payoff.py @@ -0,0 +1,357 @@ +""" +ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs +from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense +from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs +from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense +from ferro_ta.analysis.options import OptionGreeks +from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg +from ferro_ta.core.exceptions import ( + FerroTAInputError, + FerroTAValueError, + _normalize_rust_error, +) + +__all__ = [ + "PayoffLeg", + "option_leg_payoff", + "futures_leg_payoff", + "stock_leg_payoff", + "strategy_payoff", + "strategy_value", + "aggregate_greeks", +] + + +@dataclass(frozen=True) +class PayoffLeg: + instrument: str + side: str + quantity: float = 1.0 + option_type: str | None = None + strike: float | None = None + premium: float = 0.0 + entry_price: float | None = None + volatility: float | None = None + time_to_expiry: float | None = None + rate: float = 0.0 + carry: float = 0.0 + multiplier: float = 1.0 + + def __post_init__(self) -> None: + if self.instrument not in {"option", "future", "stock"}: + raise FerroTAValueError( + "instrument must be 'option', 'future', or 'stock'." + ) + if self.side not in {"long", "short"}: + raise FerroTAValueError("side must be 'long' or 'short'.") + if self.instrument == "option": + if self.option_type not in {"call", "put"}: + raise FerroTAValueError( + "option legs require option_type='call' or 'put'." + ) + if self.strike is None: + raise FerroTAValueError("option legs require strike.") + if self.instrument in {"future", "stock"} and self.entry_price is None: + raise FerroTAValueError(f"{self.instrument} legs require entry_price.") + + +def _side_sign(side: str) -> float: + return 1.0 if side == "long" else -1.0 + + +def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]: + grid = np.asarray(spot_grid, dtype=np.float64) + if grid.ndim != 1: + raise FerroTAInputError("spot_grid must be a 1-D array.") + return np.ascontiguousarray(grid) + + +def option_leg_payoff( + spot_grid: ArrayLike, + *, + strike: float, + premium: float = 0.0, + option_type: str = "call", + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """Expiry payoff for a single option leg.""" + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + if option_type not in {"call", "put"}: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([0], dtype=np.int64), # option + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([1 if option_type == "call" else -1], dtype=np.int64), + np.array([float(strike)], dtype=np.float64), + np.array([float(premium)], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def futures_leg_payoff( + spot_grid: ArrayLike, + *, + entry_price: float, + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """P/L profile for a futures leg.""" + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([1], dtype=np.int64), # future + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([-1], dtype=np.int64), + np.array([0.0], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(entry_price)], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def stock_leg_payoff( + spot_grid: ArrayLike, + *, + entry_price: float, + side: str = "long", + quantity: float = 1.0, + multiplier: float = 1.0, +) -> NDArray[np.float64]: + """P/L profile for a single stock (equity) leg over a spot grid. + + Payoff is linear:: + + P/L = sign(side) × quantity × multiplier × (spot − entry_price) + + Mathematically equivalent to a futures leg — no optionality. Use this + leg type when modelling strategies that hold the underlying equity: + Covered Call, Protective Put, Collar, Covered Strangle, etc. + + Parameters + ---------- + spot_grid: + 1-D array of spot prices at which to evaluate the P/L. + entry_price: + Purchase (or short-sale) price of the stock. + side: + ``"long"`` (default) or ``"short"``. + quantity: + Number of shares / contracts (default 1). + multiplier: + Contract multiplier (default 1.0). + + Returns + ------- + NDArray[float64] + P/L at each grid point, same shape as *spot_grid*. + """ + grid = _coerce_spot_grid(spot_grid) + _side_sign(side) + return np.asarray( + _rust_strategy_payoff_dense( + grid, + np.array([2], dtype=np.int64), # stock + np.array([1 if side == "long" else -1], dtype=np.int64), + np.array([-1], dtype=np.int64), + np.array([0.0], dtype=np.float64), + np.array([0.0], dtype=np.float64), + np.array([float(entry_price)], dtype=np.float64), + np.array([float(quantity)], dtype=np.float64), + np.array([float(multiplier)], dtype=np.float64), + ), + dtype=np.float64, + ) + + +def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg: + return PayoffLeg(**mapping) + + +def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg: + return PayoffLeg( + instrument=leg.instrument, + side=leg.side, + quantity=float(leg.quantity), + option_type=leg.option_type, + strike=leg.strike_selector.explicit_strike + if leg.strike_selector is not None + else None, + ) + + +def _normalize_legs( + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + *, + strategy: DerivativesStrategy | None = None, +) -> tuple[PayoffLeg, ...]: + if strategy is not None: + return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs) + if legs is None: + raise FerroTAInputError("Provide either legs or strategy.") + normalized: list[PayoffLeg] = [] + for leg in legs: + normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg)) + return tuple(normalized) + + +def strategy_payoff( + spot_grid: ArrayLike, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + strategy: DerivativesStrategy | None = None, +) -> NDArray[np.float64]: + """Aggregate expiry payoff across option and futures legs.""" + grid = _coerce_spot_grid(spot_grid) + normalized = _normalize_legs(legs, strategy=strategy) + if len(normalized) == 0: + return np.zeros_like(grid) + + try: + return np.asarray( + _rust_strategy_payoff_legs(grid, normalized), dtype=np.float64 + ) + except ValueError as err: + _normalize_rust_error(err) + + +def aggregate_greeks( + spot: float, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None, + strategy: DerivativesStrategy | None = None, +) -> OptionGreeks: + """Aggregate Greeks across option and futures legs.""" + normalized = _normalize_legs(legs, strategy=strategy) + if len(normalized) == 0: + return OptionGreeks(0.0, 0.0, 0.0, 0.0, 0.0) + + try: + delta, gamma, vega, theta, rho = _rust_aggregate_greeks_legs( + float(spot), normalized + ) + except ValueError as err: + _normalize_rust_error(err) + + return OptionGreeks( + float(delta), + float(gamma), + float(vega), + float(theta), + float(rho), + ) + + +def strategy_value( + spot_grid: ArrayLike, + *, + legs: Sequence[PayoffLeg | Mapping[str, Any]], + time_to_expiry: float, + volatility: float, + rate: float = 0.0, + carry: float = 0.0, +) -> NDArray[np.float64]: + """Current BSM mid-price value of a multi-leg strategy over a spot grid. + + Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry), + this uses live BSM pricing for option legs so the result reflects the + pre-expiry value including time value. + + Parameters + ---------- + spot_grid: + Array of spot prices to evaluate. + legs: + Sequence of :class:`PayoffLeg` (or dicts). Option legs must have + ``strike`` and ``premium`` set; future/stock legs must have + ``entry_price`` set. + time_to_expiry: + Shared time-to-expiry (years) applied to all option legs. + volatility: + Shared implied vol applied to all option legs. + rate: + Risk-free rate applied to all legs. + carry: + Carry / dividend yield applied to all option legs. + """ + grid = _coerce_spot_grid(spot_grid) + normalized: tuple[PayoffLeg, ...] = tuple( + leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs + ) + if len(normalized) == 0: + return np.zeros_like(grid) + + n_legs = len(normalized) + instruments = np.empty(n_legs, dtype=np.int64) + sides = np.empty(n_legs, dtype=np.int64) + option_types = np.empty(n_legs, dtype=np.int64) + strikes = np.zeros(n_legs, dtype=np.float64) + premiums = np.zeros(n_legs, dtype=np.float64) + entry_prices = np.zeros(n_legs, dtype=np.float64) + quantities = np.ones(n_legs, dtype=np.float64) + multipliers = np.ones(n_legs, dtype=np.float64) + ttes = np.full(n_legs, time_to_expiry, dtype=np.float64) + vols = np.full(n_legs, volatility, dtype=np.float64) + rates = np.full(n_legs, rate, dtype=np.float64) + carries = np.full(n_legs, carry, dtype=np.float64) + + _inst_map = {"option": 0, "future": 1, "stock": 2} + for i, leg in enumerate(normalized): + instruments[i] = _inst_map[leg.instrument] + sides[i] = 1 if leg.side == "long" else -1 + option_types[i] = 1 if leg.option_type == "call" else -1 + if leg.strike is not None: + strikes[i] = float(leg.strike) + premiums[i] = float(leg.premium) + if leg.entry_price is not None: + entry_prices[i] = float(leg.entry_price) + quantities[i] = float(leg.quantity) + multipliers[i] = float(leg.multiplier) + + try: + return np.asarray( + _rust_strategy_value_dense( + grid, + instruments, + sides, + option_types, + strikes, + premiums, + entry_prices, + quantities, + multipliers, + ttes, + vols, + rates, + carries, + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/features.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/features.py new file mode 100644 index 0000000..e0bad2a --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/features.py @@ -0,0 +1,184 @@ +""" +ferro_ta.features — Feature matrix and ML readiness. + +Exports a feature matrix (indicators as columns, bars as rows) suitable for +sklearn or other ML pipelines. + +Functions +--------- +feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...) + Compute all requested indicators on the OHLCV data and return a single + DataFrame with bars as rows and indicator names as columns. + +Rust backend +------------ +Individual indicator calls delegate to existing Rust-backed ferro_ta functions +via the registry. +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import NDArray + +from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan +from ferro_ta._utils import _to_f64 +from ferro_ta.data.batch import compute_many + +__all__ = [ + "feature_matrix", +] + + +def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]: + return np.asarray( + _rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64)) + ) + + +# --------------------------------------------------------------------------- +# feature_matrix +# --------------------------------------------------------------------------- + + +def feature_matrix( + ohlcv: Any, + indicators: list[Union[str, tuple[str, dict[str, Any]]]], + *, + nan_policy: str = "keep", + close_col: str = "close", + high_col: str = "high", + low_col: str = "low", + open_col: str = "open", + volume_col: str = "volume", +) -> Any: + """Compute multiple indicators on OHLCV data and return a feature matrix. + + Parameters + ---------- + ohlcv : pandas.DataFrame or dict of arrays + OHLCV data. Must contain at least a ``close`` column/key. + indicators : list of (str | tuple) + Each element is either: + - A string indicator name (e.g. ``'RSI'``), using default params. + - A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``. + - A ``(name, kwargs, output_key)`` 3-tuple to name a specific output + of a multi-output indicator (0-indexed int or output key). + + The column name in the output matrix is ```` for single-output + indicators or ``_`` for multi-output ones. + + nan_policy : str + How to handle NaN values (warmup rows): + - ``'keep'`` (default) — keep NaN rows as-is. + - ``'drop'`` — drop any row that contains at least one NaN. + - ``'fill'`` — forward-fill NaN values. + + close_col, high_col, low_col, open_col, volume_col : str + Column names when *ohlcv* is a DataFrame. + + Returns + ------- + pandas.DataFrame or dict of numpy arrays + If pandas is available, returns a DataFrame with one column per + indicator. Otherwise returns a dict {name: array}. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.features import feature_matrix + >>> rng = np.random.default_rng(0) + >>> n = 50 + >>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 + >>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99, + ... "open": close, "volume": np.ones(n) * 1000} + >>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}), + ... ("RSI", {"timeperiod": 14})]) + >>> list(fm.keys()) + ['SMA', 'RSI'] + """ + + # --- Extract arrays --- + def _get(col: str) -> Optional[NDArray[np.float64]]: + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None + except ImportError: + pass + if isinstance(ohlcv, dict): + return _to_f64(ohlcv[col]) if col in ohlcv else None + return None + + close = _get(close_col) + high = _get(high_col) + low = _get(low_col) + _open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators + volume = _get(volume_col) + + if close is None: + raise ValueError(f"close column '{close_col}' not found in ohlcv") + + n = len(close) + columns: dict[str, NDArray[np.float64]] = {} + + results = compute_many( + indicators, + close=close, + high=high if high is not None else None, + low=low if low is not None else None, + volume=volume if volume is not None else None, + ) + + for spec, result in zip(indicators, results): + if isinstance(spec, str): + name = spec + out_key: Optional[Any] = None + elif len(spec) == 2: + name, _ = spec # type: ignore[misc] + out_key = None + else: + name, _, out_key = spec # type: ignore[misc] + + if isinstance(result, tuple): + if out_key is not None: + if isinstance(out_key, int): + col_name = f"{name}_{out_key}" + columns[col_name] = np.asarray(result[out_key], dtype=np.float64) + else: + col_name = f"{name}_{out_key}" + columns[col_name] = np.asarray( + result[int(out_key)], dtype=np.float64 + ) + else: + for ki, arr in enumerate(result): + columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64) + else: + columns[name] = np.asarray(result, dtype=np.float64) + + # --- NaN policy --- + try: + import pandas as pd + + index = None + if isinstance(ohlcv, pd.DataFrame): + index = ohlcv.index + df = pd.DataFrame(columns, index=index) + if nan_policy == "drop": + df = df.dropna() + elif nan_policy == "fill": + df = df.ffill() + return df + except ImportError: + if nan_policy == "drop": + mask = np.ones(n, dtype=bool) + for arr in columns.values(): + mask &= ~np.isnan(arr) + return {k: v[mask] for k, v in columns.items()} + elif nan_policy == "fill": + for key, arr in columns.items(): + columns[key] = _forward_fill_nan(arr) + return columns diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/futures.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/futures.py new file mode 100644 index 0000000..6b38643 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/futures.py @@ -0,0 +1,230 @@ +""" +ferro_ta.analysis.futures — Futures and forward-curve analytics. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis +from ferro_ta._ferro_ta import ( + back_adjusted_continuous_contract as _rust_back_adjusted, +) +from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads +from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread +from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope +from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary +from ferro_ta._ferro_ta import futures_basis as _rust_basis +from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate +from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap +from ferro_ta._ferro_ta import ( + ratio_adjusted_continuous_contract as _rust_ratio_adjusted, +) +from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield +from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward +from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot +from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + +__all__ = [ + "CurveSummary", + "synthetic_forward", + "synthetic_spot", + "parity_gap", + "basis", + "annualized_basis", + "implied_carry_rate", + "carry_spread", + "weighted_continuous_contract", + "back_adjusted_continuous_contract", + "ratio_adjusted_continuous_contract", + "roll_yield", + "calendar_spreads", + "curve_slope", + "curve_summary", +] + + +@dataclass(frozen=True) +class CurveSummary: + front_basis: float + average_basis: float + slope: float + is_contango: bool + + def to_dict(self) -> dict[str, float | bool]: + return { + "front_basis": self.front_basis, + "average_basis": self.average_basis, + "slope": self.slope, + "is_contango": self.is_contango, + } + + +def synthetic_forward( + call_price: float, + put_price: float, + strike: float, + rate: float, + time_to_expiry: float, +) -> float: + return float( + _rust_synthetic_forward( + float(call_price), + float(put_price), + float(strike), + float(rate), + float(time_to_expiry), + ) + ) + + +def synthetic_spot( + call_price: float, + put_price: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + return float( + _rust_synthetic_spot( + float(call_price), + float(put_price), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + + +def parity_gap( + call_price: float, + put_price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + return float( + _rust_parity_gap( + float(call_price), + float(put_price), + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + + +def basis(spot: float, future: float) -> float: + return float(_rust_basis(float(spot), float(future))) + + +def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float: + return float( + _rust_annualized_basis(float(spot), float(future), float(time_to_expiry)) + ) + + +def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float: + return float( + _rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry)) + ) + + +def carry_spread( + spot: float, future: float, rate: float, time_to_expiry: float +) -> float: + return float( + _rust_carry_spread( + float(spot), float(future), float(rate), float(time_to_expiry) + ) + ) + + +def weighted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_weighted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def back_adjusted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_back_adjusted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def ratio_adjusted_continuous_contract( + front: ArrayLike, + next_contract: ArrayLike, + next_weights: ArrayLike, +) -> NDArray[np.float64]: + try: + return np.asarray( + _rust_ratio_adjusted( + _to_f64(front), _to_f64(next_contract), _to_f64(next_weights) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float: + return float( + _rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry)) + ) + + +def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]: + return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64) + + +def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float: + try: + return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices))) + except ValueError as err: + _normalize_rust_error(err) + + +def curve_summary( + spot: float, tenors: ArrayLike, futures_prices: ArrayLike +) -> CurveSummary: + try: + front_basis, average_basis, slope, is_contango = _rust_curve_summary( + float(spot), _to_f64(tenors), _to_f64(futures_prices) + ) + except ValueError as err: + _normalize_rust_error(err) + return CurveSummary(front_basis, average_basis, slope, is_contango) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/live.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/live.py new file mode 100644 index 0000000..8ad9b58 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/live.py @@ -0,0 +1,544 @@ +""" +Paper trading bridge — event-driven bar-by-bar simulation. + +PaperTrader + Simulates live order execution using the same logic as the backtester, + but processes one bar at a time. Maintains live state (position, equity, trades). + +Usage: + from ferro_ta.analysis.live import PaperTrader + + trader = PaperTrader(initial_capital=100_000) + for bar in streaming_bars: + signal = my_strategy(bar) + result = trader.on_bar( + open_=bar.open, high=bar.high, low=bar.low, close=bar.close, + signal=signal + ) + if result.filled: + print(f"Order filled at {result.fill_price}") +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class BarResult: + """Result of processing one bar through PaperTrader.""" + + bar_index: int + filled: bool # whether an order was executed this bar + fill_price: float # NaN if no fill + position: float # position after this bar + equity: float # equity after this bar (normalized, initial = 1.0) + equity_abs: float # absolute equity in currency units + pnl_bar: float # P&L this bar as fraction of initial capital + regime: Optional[int] = None # regime label if regime detection is enabled + + +@dataclass +class TradeRecord: + """Record of a completed round-trip trade.""" + + entry_bar: int + exit_bar: int + entry_price: float + exit_price: float + position: float # +1 long, -1 short + pnl_pct: float # P&L as fraction of initial capital + pnl_abs: float # P&L in currency units + + +class PaperTrader: + """Event-driven paper trading simulator. + + Processes bars one at a time, maintaining live state. + Supports stop-loss, take-profit, trailing stop, and breakeven stop. + + Parameters + ---------- + initial_capital : float + Starting capital in base currency. + stop_loss_pct : float + Stop-loss distance from entry (fraction). 0 = disabled. + take_profit_pct : float + Take-profit distance from entry (fraction). 0 = disabled. + trailing_stop_pct : float + Trailing stop distance (fraction). 0 = disabled. + breakeven_pct : float + Move stop to breakeven when this profit is reached. 0 = disabled. + slippage_bps : float + Slippage in basis points per fill. + commission_model : optional CommissionModel + Full commission model. None = zero commission. + """ + + def __init__( + self, + initial_capital: float = 100_000.0, + stop_loss_pct: float = 0.0, + take_profit_pct: float = 0.0, + trailing_stop_pct: float = 0.0, + breakeven_pct: float = 0.0, + slippage_bps: float = 0.0, + commission_model=None, + ) -> None: + self.initial_capital = float(initial_capital) + self.stop_loss_pct = float(stop_loss_pct) + self.take_profit_pct = float(take_profit_pct) + self.trailing_stop_pct = float(trailing_stop_pct) + self.breakeven_pct = float(breakeven_pct) + self.slippage_bps = float(slippage_bps) + self.commission_model = commission_model + + # Live state + self._position: float = 0.0 + self._entry_price: float = float("nan") + self._equity: float = 1.0 # normalized + self._prev_close: float = float("nan") + self._bar_index: int = 0 + self._trail_high: float = float("nan") + self._trail_low: float = float("nan") + self._breakeven_activated: bool = False + self._breakeven_stop: float = float("nan") + self._trades: list[TradeRecord] = [] + self._equity_history: list[float] = [] + + # One-bar-lag signal state + self._pending_signal: float = 0.0 + self._first_bar: bool = True + + def _close_position(self) -> None: + """Reset all trade-tracking state to flat (mirrors Rust OhlcvState.close_position).""" + self._position = 0.0 + self._entry_price = float("nan") + self._trail_high = float("nan") + self._trail_low = float("nan") + self._breakeven_activated = False + self._breakeven_stop = float("nan") + + def _commission_cost(self, fill_price: float, pos_size: float) -> float: + """Compute commission cost as fraction of initial capital.""" + if self.commission_model is None: + return 0.0 + try: + trade_value = abs(pos_size) * fill_price * self.initial_capital + if hasattr(self.commission_model, "cost_fraction"): + return self.commission_model.cost_fraction( + trade_value, 1.0, pos_size > 0, self.initial_capital + ) + except Exception: + pass + return 0.0 + + def on_bar( + self, + open_: float, + high: float, + low: float, + close: float, + signal: float, + ) -> BarResult: + """Process one bar and return a BarResult. + + signal : float + Desired position (+1, -1, or 0). Applied next bar (standard bar-by-bar logic). + For this bar, the signal from the PREVIOUS bar is acted upon. + """ + nan = float("nan") + slip = self.slippage_bps / 10_000.0 + + bar_idx = self._bar_index + self._bar_index += 1 + + # On the very first bar: record signal, no action (no prev signal yet) + if self._first_bar: + self._pending_signal = signal + self._first_bar = False + self._prev_close = close + self._equity_history.append(self._equity) + return BarResult( + bar_index=bar_idx, + filled=False, + fill_price=nan, + position=self._position, + equity=self._equity, + equity_abs=self._equity * self.initial_capital, + pnl_bar=0.0, + ) + + # The signal to act on this bar is from the previous call + desired_pos = ( + self._pending_signal if not math.isnan(self._pending_signal) else 0.0 + ) + # Store current bar's signal for next bar + self._pending_signal = signal + + prev_close = self._prev_close + self._prev_close = close + + strategy_return = 0.0 + fill_price_this_bar = nan + filled = False + forced_close = False + + # ---- Update trailing stop water marks ---- + if self.trailing_stop_pct > 0.0: + if self._position > 0.0 and not math.isnan(self._trail_high): + self._trail_high = max(self._trail_high, high) + if self._position < 0.0 and not math.isnan(self._trail_low): + self._trail_low = min(self._trail_low, low) + + close_ret = (close - prev_close) / prev_close if prev_close != 0.0 else 0.0 + + # ---- Trailing stop check ---- + if ( + self.trailing_stop_pct > 0.0 + and self._position != 0.0 + and not math.isnan(self._entry_price) + ): + if self._position > 0.0 and not math.isnan(self._trail_high): + trail_stop = self._trail_high * (1.0 - self.trailing_stop_pct) + if low <= trail_stop: + stop_ret = ( + (trail_stop - prev_close) / prev_close + if prev_close != 0.0 + else -self.trailing_stop_pct + ) + comm = self._commission_cost(trail_stop, self._position) + strategy_return = self._position * stop_ret - slip - comm + fill_price_this_bar = trail_stop + filled = True + self._record_trade(bar_idx, trail_stop) + self._close_position() + forced_close = True + + elif self._position < 0.0 and not math.isnan(self._trail_low): + trail_stop = self._trail_low * (1.0 + self.trailing_stop_pct) + if high >= trail_stop: + stop_ret = ( + (trail_stop - prev_close) / prev_close + if prev_close != 0.0 + else self.trailing_stop_pct + ) + comm = self._commission_cost(trail_stop, self._position) + strategy_return = self._position * stop_ret - slip - comm + fill_price_this_bar = trail_stop + filled = True + self._record_trade(bar_idx, trail_stop) + self._close_position() + forced_close = True + + # ---- Breakeven stop activation ---- + if ( + self.breakeven_pct > 0.0 + and self._position != 0.0 + and not math.isnan(self._entry_price) + and not self._breakeven_activated + ): + if self._position > 0.0 and high >= self._entry_price * ( + 1.0 + self.breakeven_pct + ): + self._breakeven_activated = True + self._breakeven_stop = self._entry_price + elif self._position < 0.0 and low <= self._entry_price * ( + 1.0 - self.breakeven_pct + ): + self._breakeven_activated = True + self._breakeven_stop = self._entry_price + + # ---- SL/TP combined bracket check ---- + if ( + not forced_close + and self._position != 0.0 + and not math.isnan(self._entry_price) + ): + entry = self._entry_price + has_stop = self._breakeven_activated or self.stop_loss_pct > 0.0 + stop_long = ( + self._breakeven_stop + if self._breakeven_activated + else entry * (1.0 - self.stop_loss_pct) + ) + stop_short = ( + self._breakeven_stop + if self._breakeven_activated + else entry * (1.0 + self.stop_loss_pct) + ) + has_tp = self.take_profit_pct > 0.0 + tp_long = entry * (1.0 + self.take_profit_pct) + tp_short = entry * (1.0 - self.take_profit_pct) + + if self._position > 0.0: + sl_triggered = has_stop and low <= stop_long + tp_triggered = has_tp and high >= tp_long + + if sl_triggered and tp_triggered: + sl_dist = abs(open_ - stop_long) + tp_dist = abs(tp_long - open_) + if sl_dist <= tp_dist: + # SL first + sr = ( + (stop_long - prev_close) / prev_close + if prev_close != 0.0 + else -self.stop_loss_pct + ) + comm = self._commission_cost(stop_long, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = stop_long + else: + sr = ( + (tp_long - prev_close) / prev_close + if prev_close != 0.0 + else self.take_profit_pct + ) + comm = self._commission_cost(tp_long, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = tp_long + filled = True + self._record_trade(bar_idx, fill_price_this_bar) + self._close_position() + forced_close = True + + elif sl_triggered: + sr = ( + (stop_long - prev_close) / prev_close + if prev_close != 0.0 + else -self.stop_loss_pct + ) + comm = self._commission_cost(stop_long, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = stop_long + filled = True + self._record_trade(bar_idx, stop_long) + self._close_position() + forced_close = True + + elif tp_triggered: + sr = ( + (tp_long - prev_close) / prev_close + if prev_close != 0.0 + else self.take_profit_pct + ) + comm = self._commission_cost(tp_long, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = tp_long + filled = True + self._record_trade(bar_idx, tp_long) + self._close_position() + forced_close = True + + elif self._position < 0.0: + sl_triggered = has_stop and high >= stop_short + tp_triggered = has_tp and low <= tp_short + + if sl_triggered and tp_triggered: + sl_dist = abs(stop_short - open_) + tp_dist = abs(open_ - tp_short) + if sl_dist <= tp_dist: + sr = ( + (stop_short - prev_close) / prev_close + if prev_close != 0.0 + else self.stop_loss_pct + ) + comm = self._commission_cost(stop_short, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = stop_short + else: + sr = ( + (tp_short - prev_close) / prev_close + if prev_close != 0.0 + else -self.take_profit_pct + ) + comm = self._commission_cost(tp_short, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = tp_short + filled = True + self._record_trade(bar_idx, fill_price_this_bar) + self._close_position() + forced_close = True + + elif sl_triggered: + sr = ( + (stop_short - prev_close) / prev_close + if prev_close != 0.0 + else self.stop_loss_pct + ) + comm = self._commission_cost(stop_short, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = stop_short + filled = True + self._record_trade(bar_idx, stop_short) + self._close_position() + forced_close = True + + elif tp_triggered: + sr = ( + (tp_short - prev_close) / prev_close + if prev_close != 0.0 + else -self.take_profit_pct + ) + comm = self._commission_cost(tp_short, self._position) + strategy_return = self._position * sr - slip - comm + fill_price_this_bar = tp_short + filled = True + self._record_trade(bar_idx, tp_short) + self._close_position() + forced_close = True + + # ---- Normal signal execution ---- + if not forced_close: + pos_changed = abs(desired_pos - self._position) > 1e-12 + # Fill at open (market_open mode, same as Rust default) + base_fill = open_ + if desired_pos > self._position: + actual_fill = base_fill * (1.0 + slip) + elif desired_pos < self._position: + actual_fill = base_fill * (1.0 - slip) + else: + actual_fill = base_fill + + if pos_changed: + fill_price_this_bar = actual_fill + filled = True + + old_pos = self._position + + if desired_pos != 0.0 and old_pos == 0.0: + r = ( + desired_pos * (close - actual_fill) / actual_fill + if actual_fill != 0.0 + else 0.0 + ) + comm = self._commission_cost(actual_fill, desired_pos) + strategy_return = r - comm + self._set_entry(bar_idx, actual_fill, desired_pos) + elif desired_pos == 0.0: + r = ( + old_pos * (actual_fill - prev_close) / prev_close + if prev_close != 0.0 + else 0.0 + ) + comm = self._commission_cost(actual_fill, old_pos) + strategy_return = r - comm + self._record_trade(bar_idx, actual_fill) + self._close_position() + else: + exit_r = ( + old_pos * (actual_fill - prev_close) / prev_close + if prev_close != 0.0 + else 0.0 + ) + entry_r = ( + desired_pos * (close - actual_fill) / actual_fill + if actual_fill != 0.0 + else 0.0 + ) + exit_comm = self._commission_cost(actual_fill, old_pos) + entry_comm = self._commission_cost(actual_fill, desired_pos) + strategy_return = exit_r + entry_r - exit_comm - entry_comm + if old_pos != 0.0: + self._record_trade(bar_idx, actual_fill) + self._set_entry(bar_idx, actual_fill, desired_pos) + + self._position = desired_pos + + else: + # Hold: full bar return (close-to-close on existing position) + strategy_return = self._position * close_ret + + # Update equity + prev_equity = self._equity + self._equity = self._equity * (1.0 + strategy_return) + pnl_bar = self._equity - prev_equity + + self._equity_history.append(self._equity) + + return BarResult( + bar_index=bar_idx, + filled=filled, + fill_price=fill_price_this_bar, + position=self._position, + equity=self._equity, + equity_abs=self._equity * self.initial_capital, + pnl_bar=pnl_bar, + ) + + def _record_trade(self, exit_bar: int, exit_price: float) -> None: + """Record a completed round-trip trade.""" + if math.isnan(self._entry_price): + return + entry_price = self._entry_price + pos = self._position + # P&L = position * (exit - entry) / entry as fraction + if entry_price != 0.0: + pnl_pct = pos * (exit_price - entry_price) / entry_price + else: + pnl_pct = 0.0 + pnl_abs = pnl_pct * self.initial_capital + + self._trades.append( + TradeRecord( + entry_bar=getattr(self, "_trade_entry_bar", 0), + exit_bar=exit_bar, + entry_price=entry_price, + exit_price=exit_price, + position=pos, + pnl_pct=pnl_pct, + pnl_abs=pnl_abs, + ) + ) + + def _set_entry(self, bar_idx: int, fill_price: float, pos: float) -> None: + """Set entry state — call after position changes to new non-zero position.""" + self._entry_price = fill_price + self._trade_entry_bar = bar_idx + self._trail_high = fill_price if pos > 0.0 else float("nan") + self._trail_low = fill_price if pos < 0.0 else float("nan") + self._breakeven_activated = False + self._breakeven_stop = float("nan") + + @property + def position(self) -> float: + """Current open position.""" + return self._position + + @property + def equity(self) -> float: + """Current normalized equity.""" + return self._equity + + @property + def equity_abs(self) -> float: + """Current absolute equity in base currency.""" + return self._equity * self.initial_capital + + @property + def trades(self) -> list[TradeRecord]: + """List of completed trades.""" + return list(self._trades) + + @property + def equity_curve(self) -> list[float]: + """Equity history (normalized).""" + return list(self._equity_history) + + def reset(self) -> None: + """Reset all state to initial values.""" + self._position = 0.0 + self._entry_price = float("nan") + self._equity = 1.0 + self._prev_close = float("nan") + self._bar_index = 0 + self._trail_high = float("nan") + self._trail_low = float("nan") + self._breakeven_activated = False + self._breakeven_stop = float("nan") + self._trades = [] + self._equity_history = [] + self._pending_signal = 0.0 + self._first_bar = True diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/multitf.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/multitf.py new file mode 100644 index 0000000..2f2d46c --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/multitf.py @@ -0,0 +1,185 @@ +""" +Multi-timeframe signal utilities. + +MultiTimeframeEngine wraps BacktestEngine with a higher-timeframe signal computation step. + +Usage: + from ferro_ta.analysis.multitf import MultiTimeframeEngine + + result = ( + MultiTimeframeEngine(factor=4) # 4 fine bars per coarse bar + .with_htf_strategy("rsi_30_70") # strategy runs on coarse bars + .with_ohlcv(high=h, low=l, open_=o) + .with_stop_loss(0.02) + .run(close_fine) + ) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta.analysis.backtest import AdvancedBacktestResult, BacktestEngine +from ferro_ta.analysis.resample import align_to_coarse, resample_ohlcv + +__all__ = ["MultiTimeframeEngine"] + + +class MultiTimeframeEngine: + """Backtests using signals computed on a higher timeframe (coarser bars). + + Parameters + ---------- + factor : int + Number of fine-resolution bars per coarse bar. + """ + + def __init__(self, factor: int) -> None: + if factor < 1: + raise ValueError(f"factor must be >= 1, got {factor}") + self._factor = factor + self._htf_strategy = "rsi_30_70" + self._inner = BacktestEngine() + + # Store OHLCV separately so we can resample them + self._high: np.ndarray | None = None + self._low: np.ndarray | None = None + self._open: np.ndarray | None = None + + def with_htf_strategy(self, strategy) -> MultiTimeframeEngine: + """Set the strategy function or name used on coarse bars.""" + self._htf_strategy = strategy + return self + + def with_ohlcv(self, *, high, low, open_) -> MultiTimeframeEngine: + """Store OHLCV data for resampling and pass to inner engine after resampling.""" + self._high = np.asarray(high, dtype=np.float64) + self._low = np.asarray(low, dtype=np.float64) + self._open = np.asarray(open_, dtype=np.float64) + return self + + def with_stop_loss(self, pct: float) -> MultiTimeframeEngine: + self._inner.with_stop_loss(pct) + return self + + def with_take_profit(self, pct: float) -> MultiTimeframeEngine: + self._inner.with_take_profit(pct) + return self + + def with_trailing_stop(self, pct: float) -> MultiTimeframeEngine: + self._inner.with_trailing_stop(pct) + return self + + def with_commission(self, rate: float) -> MultiTimeframeEngine: + self._inner.with_commission(rate) + return self + + def with_commission_model(self, model) -> MultiTimeframeEngine: + self._inner.with_commission_model(model) + return self + + def with_slippage(self, bps: float) -> MultiTimeframeEngine: + self._inner.with_slippage(bps) + return self + + def with_initial_capital(self, capital: float) -> MultiTimeframeEngine: + self._inner.with_initial_capital(capital) + return self + + def with_fill_mode(self, mode: str) -> MultiTimeframeEngine: + self._inner.with_fill_mode(mode) + return self + + def with_leverage( + self, margin_ratio: float, margin_call_pct: float = 0.5 + ) -> MultiTimeframeEngine: + self._inner.with_leverage(margin_ratio, margin_call_pct) + return self + + def with_loss_limits( + self, daily: float = 0.0, total: float = 0.0 + ) -> MultiTimeframeEngine: + self._inner.with_loss_limits(daily, total) + return self + + def run( + self, close_fine: ArrayLike, **htf_strategy_kwargs + ) -> AdvancedBacktestResult: + """Run multi-timeframe backtest. + + 1. Resample close_fine (and stored OHLCV) to coarse bars + 2. Run htf_strategy on coarse close to get coarse signals + 3. Align coarse signals back to fine resolution (repeat each coarse signal `factor` times) + 4. Run BacktestEngine on fine bars with aligned signals + + Parameters + ---------- + close_fine : array-like + Fine-resolution close prices. + **htf_strategy_kwargs + Extra keyword arguments passed to the HTF strategy. + + Returns + ------- + AdvancedBacktestResult + """ + c_fine = np.asarray(close_fine, dtype=np.float64) + n_fine = len(c_fine) + factor = self._factor + + # ------------------------------------------------------------------ + # 1. Resample close to coarse resolution + # ------------------------------------------------------------------ + # Build dummy OHLCV if OHLCV not provided + if self._high is not None and self._low is not None and self._open is not None: + coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv( + self._open, + self._high, + self._low, + c_fine, + np.ones(n_fine), # volume placeholder + factor, + ) + else: + coarse_o, coarse_h, coarse_l, coarse_c, _ = resample_ohlcv( + c_fine, + c_fine, + c_fine, + c_fine, + np.ones(n_fine), + factor, + ) + + # ------------------------------------------------------------------ + # 2. Compute coarse-bar signals via htf_strategy + # ------------------------------------------------------------------ + from ferro_ta.analysis.backtest import _resolve_strategy + + strategy_fn = _resolve_strategy(self._htf_strategy) + # Ensure the coarse close array is C-contiguous (required by Rust kernels) + coarse_c = np.ascontiguousarray(coarse_c, dtype=np.float64) + coarse_signals = np.asarray( + strategy_fn(coarse_c, **htf_strategy_kwargs), dtype=np.float64 + ) + + # ------------------------------------------------------------------ + # 3. Align coarse signals back to fine resolution + # ------------------------------------------------------------------ + aligned_signals = align_to_coarse(coarse_signals, factor, n_fine) + + # ------------------------------------------------------------------ + # 4. Set up OHLCV on inner engine if provided and run + # ------------------------------------------------------------------ + if self._high is not None and self._low is not None and self._open is not None: + self._inner.with_ohlcv( + high=self._high, + low=self._low, + open_=self._open, + ) + + # Use a passthrough lambda so the already-computed aligned_signals are used + return self._inner.run( + c_fine, + strategy=lambda c, **kw: aligned_signals, + ) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/optimize.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/optimize.py new file mode 100644 index 0000000..1981c1e --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/optimize.py @@ -0,0 +1,318 @@ +""" +Portfolio optimization utilities. + +mean_variance_optimize(returns, target_return=None, allow_short=False) + Minimum-variance portfolio (or target-return portfolio on efficient frontier). + Uses scipy.optimize.minimize with SLSQP. + Returns weight array summing to 1. + +risk_parity_optimize(returns, risk_budget=None) + Equal risk contribution portfolio (or custom risk budget). + Each asset contributes equally to total portfolio volatility. + Returns weight array summing to 1. + +max_sharpe_optimize(returns, risk_free_rate=0.0) + Maximize Sharpe ratio portfolio. + Returns weight array. + +PortfolioOptimizer + Fluent builder that wraps the above functions and integrates with + BacktestEngine for portfolio-level signal generation. +""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def mean_variance_optimize( + returns: ArrayLike, + target_return: Optional[float] = None, + allow_short: bool = False, + risk_free_rate: float = 0.0, +) -> NDArray: + """Compute minimum variance (or target return) portfolio weights. + + Parameters + ---------- + returns : (T, N) array of asset returns + target_return : float or None + If None, return minimum-variance portfolio. + If float, return minimum-variance portfolio with this expected return. + allow_short : bool + If False, weights are constrained to [0, 1]. + risk_free_rate : float + Not used directly here (kept for API symmetry with max_sharpe). + + Returns + ------- + weights : (N,) array summing to 1.0 + """ + try: + from scipy.optimize import minimize + except ImportError: + raise ImportError( + "scipy is required for portfolio optimization: pip install scipy" + ) + + r = np.asarray(returns, dtype=np.float64) + if r.ndim == 1: + r = r[:, np.newaxis] + n_assets = r.shape[1] + + if n_assets == 1: + return np.array([1.0]) + + mu = r.mean(axis=0) + cov = np.cov(r, rowvar=False) + # Regularize to handle near-singular covariance matrices + cov += 1e-8 * np.eye(n_assets) + + # Objective: minimize portfolio variance w^T @ cov @ w + def portfolio_variance(w: np.ndarray) -> float: + return float(w @ cov @ w) + + def portfolio_variance_grad(w: np.ndarray) -> np.ndarray: + return 2.0 * cov @ w + + # Constraints: weights sum to 1 + constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}] + + # Optional target return constraint + if target_return is not None: + constraints.append( + {"type": "eq", "fun": lambda w, mu=mu, tr=target_return: float(w @ mu) - tr} + ) + + # Bounds + bounds = None if allow_short else [(0.0, 1.0)] * n_assets + + # Initial guess: equal weights + w0 = np.ones(n_assets) / n_assets + + result = minimize( + portfolio_variance, + w0, + jac=portfolio_variance_grad, + method="SLSQP", + bounds=bounds, + constraints=constraints, + options={"ftol": 1e-12, "maxiter": 1000}, + ) + + weights = result.x + # Normalize to ensure exact sum=1 (numerical noise) + weights = weights / weights.sum() + if not allow_short: + weights = np.maximum(weights, 0.0) + s = weights.sum() + if s > 0: + weights /= s + return weights + + +def risk_parity_optimize( + returns: ArrayLike, + risk_budget: Optional[ArrayLike] = None, +) -> NDArray: + """Compute risk parity weights (equal risk contribution). + + Parameters + ---------- + returns : (T, N) array of asset returns + risk_budget : (N,) array or None + Target risk contribution per asset (normalized internally). None = equal. + + Returns + ------- + weights : (N,) array summing to 1.0 + """ + try: + from scipy.optimize import minimize + except ImportError: + raise ImportError( + "scipy is required for portfolio optimization: pip install scipy" + ) + + r = np.asarray(returns, dtype=np.float64) + if r.ndim == 1: + r = r[:, np.newaxis] + n_assets = r.shape[1] + + if n_assets == 1: + return np.array([1.0]) + + cov = np.cov(r, rowvar=False) + cov += 1e-8 * np.eye(n_assets) + + if risk_budget is None: + budget = np.ones(n_assets) / n_assets + else: + budget = np.asarray(risk_budget, dtype=np.float64) + budget = budget / budget.sum() + + def risk_contribution(w: np.ndarray) -> np.ndarray: + """Return marginal risk contribution of each asset.""" + sigma = np.sqrt(w @ cov @ w) + if sigma < 1e-12: + return np.zeros(n_assets) + mrc = cov @ w / sigma + return w * mrc + + def objective(w: np.ndarray) -> float: + """Minimize squared deviation from target risk budget.""" + rc = risk_contribution(w) + total_rc = rc.sum() + if total_rc < 1e-12: + return float(np.sum((rc - budget) ** 2)) + rc_normalized = rc / total_rc + return float(np.sum((rc_normalized - budget) ** 2)) + + constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}] + bounds = [(1e-6, 1.0)] * n_assets # risk parity requires positive weights + w0 = np.ones(n_assets) / n_assets + + result = minimize( + objective, + w0, + method="SLSQP", + bounds=bounds, + constraints=constraints, + options={"ftol": 1e-12, "maxiter": 2000}, + ) + + weights = result.x + weights = np.maximum(weights, 0.0) + s = weights.sum() + if s > 0: + weights /= s + return weights + + +def max_sharpe_optimize( + returns: ArrayLike, + risk_free_rate: float = 0.0, + allow_short: bool = False, +) -> NDArray: + """Compute maximum Sharpe ratio portfolio weights. + + Returns + ------- + weights : (N,) array summing to 1.0 + """ + try: + from scipy.optimize import minimize + except ImportError: + raise ImportError( + "scipy is required for portfolio optimization: pip install scipy" + ) + + r = np.asarray(returns, dtype=np.float64) + if r.ndim == 1: + r = r[:, np.newaxis] + n_assets = r.shape[1] + + if n_assets == 1: + return np.array([1.0]) + + mu = r.mean(axis=0) + cov = np.cov(r, rowvar=False) + cov += 1e-8 * np.eye(n_assets) + + # Maximize Sharpe = minimize negative Sharpe + def neg_sharpe(w: np.ndarray) -> float: + port_return = float(w @ mu) + port_vol = float(np.sqrt(w @ cov @ w)) + if port_vol < 1e-12: + return 0.0 + return -(port_return - risk_free_rate) / port_vol + + constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}] + bounds = None if allow_short else [(0.0, 1.0)] * n_assets + w0 = np.ones(n_assets) / n_assets + + result = minimize( + neg_sharpe, + w0, + method="SLSQP", + bounds=bounds, + constraints=constraints, + options={"ftol": 1e-12, "maxiter": 1000}, + ) + + weights = result.x + weights = weights / weights.sum() + if not allow_short: + weights = np.maximum(weights, 0.0) + s = weights.sum() + if s > 0: + weights /= s + return weights + + +class PortfolioOptimizer: + """Fluent interface for portfolio weight optimization. + + Example + ------- + weights = ( + PortfolioOptimizer() + .with_method("risk_parity") + .with_lookback(252) + .optimize(returns_matrix) + ) + """ + + def __init__(self) -> None: + self._method: str = "min_variance" + self._lookback: Optional[int] = None + self._allow_short: bool = False + self._risk_free_rate: float = 0.0 + self._target_return: Optional[float] = None + self._risk_budget: Optional[NDArray] = None + + def with_method(self, method: str) -> PortfolioOptimizer: + """Method: 'min_variance', 'risk_parity', 'max_sharpe'.""" + valid = ("min_variance", "risk_parity", "max_sharpe") + if method not in valid: + raise ValueError(f"method must be one of {valid}") + self._method = method + return self + + def with_lookback(self, n_bars: int) -> PortfolioOptimizer: + """Use only the last n_bars for covariance estimation.""" + self._lookback = int(n_bars) + return self + + def with_short_selling(self, allow: bool = True) -> PortfolioOptimizer: + self._allow_short = allow + return self + + def with_risk_free_rate(self, rate: float) -> PortfolioOptimizer: + self._risk_free_rate = float(rate) + return self + + def with_target_return(self, target: float) -> PortfolioOptimizer: + self._target_return = float(target) + return self + + def with_risk_budget(self, budget: ArrayLike) -> PortfolioOptimizer: + self._risk_budget = np.asarray(budget, dtype=np.float64) + return self + + def optimize(self, returns: ArrayLike) -> NDArray: + """Run optimization and return weight array.""" + r = np.asarray(returns, dtype=np.float64) + if self._lookback is not None: + r = r[-self._lookback :] + if self._method == "min_variance": + return mean_variance_optimize( + r, self._target_return, self._allow_short, self._risk_free_rate + ) + elif self._method == "risk_parity": + return risk_parity_optimize(r, self._risk_budget) + else: + return max_sharpe_optimize(r, self._risk_free_rate, self._allow_short) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/options.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/options.py new file mode 100644 index 0000000..5920e8b --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/options.py @@ -0,0 +1,1595 @@ +""" +ferro_ta.analysis.options — Rust-backed derivatives analytics for options. + +This module preserves the legacy IV-series helpers and expands them with +pricing, Greeks, implied-volatility inversion, smile analytics, and strike +selection helpers suitable for research and simulation workflows. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + black76_price as _rust_black76_price, +) +from ferro_ta._ferro_ta import ( + black76_price_batch as _rust_black76_price_batch, +) +from ferro_ta._ferro_ta import ( + bsm_price as _rust_bsm_price, +) +from ferro_ta._ferro_ta import ( + bsm_price_batch as _rust_bsm_price_batch, +) +from ferro_ta._ferro_ta import ( + expected_move as _rust_expected_move, +) +from ferro_ta._ferro_ta import ( + extended_greeks as _rust_extended_greeks, +) +from ferro_ta._ferro_ta import ( + extended_greeks_batch as _rust_extended_greeks_batch, +) +from ferro_ta._ferro_ta import ( + implied_volatility as _rust_implied_volatility, +) +from ferro_ta._ferro_ta import ( + implied_volatility_batch as _rust_implied_volatility_batch, +) +from ferro_ta._ferro_ta import ( + iv_percentile as _rust_iv_percentile, +) +from ferro_ta._ferro_ta import ( + iv_rank as _rust_iv_rank, +) +from ferro_ta._ferro_ta import ( + iv_zscore as _rust_iv_zscore, +) +from ferro_ta._ferro_ta import ( + moneyness_labels as _rust_moneyness_labels, +) +from ferro_ta._ferro_ta import ( + option_greeks as _rust_option_greeks, +) +from ferro_ta._ferro_ta import ( + option_greeks_batch as _rust_option_greeks_batch, +) +from ferro_ta._ferro_ta import ( + put_call_parity_deviation as _rust_put_call_parity_deviation, +) +from ferro_ta._ferro_ta import ( + select_strike_delta as _rust_select_strike_delta, +) +from ferro_ta._ferro_ta import ( + select_strike_offset as _rust_select_strike_offset, +) +from ferro_ta._ferro_ta import ( + smile_metrics as _rust_smile_metrics, +) +from ferro_ta._ferro_ta import ( + term_structure_slope as _rust_term_structure_slope, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import ( + FerroTAInputError, + FerroTAValueError, + _normalize_rust_error, +) + +ScalarOrArray: TypeAlias = float | NDArray[np.float64] + +__all__ = [ + "OptionGreeks", + "ExtendedGreeks", + "SmileMetrics", + "VolCone", + "black_scholes_price", + "black_76_price", + "option_price", + "greeks", + "extended_greeks", + "implied_volatility", + "smile_metrics", + "term_structure_slope", + "label_moneyness", + "select_strike", + "iv_rank", + "iv_percentile", + "iv_zscore", + "put_call_parity_deviation", + "expected_move", + "digital_option_price", + "digital_option_greeks", + "american_option_price", + "early_exercise_premium", + "close_to_close_vol", + "parkinson_vol", + "garman_klass_vol", + "rogers_satchell_vol", + "yang_zhang_vol", + "vol_cone", +] + + +@dataclass(frozen=True) +class ExtendedGreeks: + """Container for second-order and cross Greeks.""" + + vanna: ScalarOrArray + volga: ScalarOrArray + charm: ScalarOrArray + speed: ScalarOrArray + color: ScalarOrArray + + def to_dict(self) -> dict[str, ScalarOrArray]: + return { + "vanna": self.vanna, + "volga": self.volga, + "charm": self.charm, + "speed": self.speed, + "color": self.color, + } + + +@dataclass(frozen=True) +class VolCone: + """Historical realized vol distribution across window lengths.""" + + windows: NDArray[np.float64] + min: NDArray[np.float64] + p25: NDArray[np.float64] + median: NDArray[np.float64] + p75: NDArray[np.float64] + max: NDArray[np.float64] + + def to_dict(self) -> dict[str, NDArray[np.float64]]: + return { + "windows": self.windows, + "min": self.min, + "p25": self.p25, + "median": self.median, + "p75": self.p75, + "max": self.max, + } + + +@dataclass(frozen=True) +class OptionGreeks: + """Container for first-order Greeks.""" + + delta: ScalarOrArray + gamma: ScalarOrArray + vega: ScalarOrArray + theta: ScalarOrArray + rho: ScalarOrArray + + def to_dict(self) -> dict[str, ScalarOrArray]: + return { + "delta": self.delta, + "gamma": self.gamma, + "vega": self.vega, + "theta": self.theta, + "rho": self.rho, + } + + +@dataclass(frozen=True) +class SmileMetrics: + """Summary metrics for a single smile slice.""" + + atm_iv: float + risk_reversal_25d: float + butterfly_25d: float + skew_slope: float + convexity: float + + def to_dict(self) -> dict[str, float]: + return { + "atm_iv": self.atm_iv, + "risk_reversal_25d": self.risk_reversal_25d, + "butterfly_25d": self.butterfly_25d, + "skew_slope": self.skew_slope, + "convexity": self.convexity, + } + + +def _validate_option_type(option_type: str) -> str: + value = option_type.lower() + if value not in {"call", "put"}: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + return value + + +def _validate_model(model: str) -> str: + value = model.lower() + aliases = { + "bsm": "bsm", + "black_scholes": "bsm", + "black-scholes": "bsm", + "blackscholes": "bsm", + "black76": "black76", + "black_76": "black76", + "black-76": "black76", + } + if value not in aliases: + raise FerroTAValueError( + "model must be one of 'bsm', 'black_scholes', or 'black76'." + ) + return aliases[value] + + +def _coerce_1d(data: ArrayLike | float, *, name: str) -> tuple[np.ndarray, bool]: + arr = np.asarray(data, dtype=np.float64) + if arr.ndim > 1: + raise FerroTAInputError(f"{name} must be a scalar or 1-D array.") + return np.ascontiguousarray(arr.reshape(-1)), arr.ndim == 0 + + +def _broadcast_inputs( + **kwargs: ArrayLike | float, +) -> tuple[dict[str, np.ndarray], bool]: + arrays: dict[str, np.ndarray] = {} + scalar_flags: list[bool] = [] + for name, value in kwargs.items(): + arr, is_scalar = _coerce_1d(value, name=name) + arrays[name] = arr + scalar_flags.append(is_scalar) + try: + broadcast = np.broadcast_arrays(*arrays.values()) + except ValueError as err: + raise FerroTAInputError( + f"Inputs could not be broadcast together: {', '.join(arrays.keys())}" + ) from err + out = { + name: np.ascontiguousarray(arr, dtype=np.float64).reshape(-1) + for name, arr in zip(arrays.keys(), broadcast) + } + return out, all(scalar_flags) + + +def _result_or_scalar(result: np.ndarray, scalar_mode: bool) -> ScalarOrArray: + return float(result[0]) if scalar_mode else result + + +def iv_rank(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV rank in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_rank(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def iv_percentile(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV percentile in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_percentile(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def iv_zscore(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]: + """Compute rolling IV z-score in Rust while preserving the legacy API.""" + try: + arr = _to_f64(iv_series) + except ValueError as err: + raise FerroTAInputError(str(err)) from err + if len(arr) == 0: + raise FerroTAInputError("iv_series must not be empty.") + if window < 1: + raise FerroTAValueError(f"window must be >= 1, got {window}.") + try: + return np.asarray(_rust_iv_zscore(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def black_scholes_price( + spot: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + dividend_yield: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Price options under Black-Scholes-Merton.""" + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + spot=spot, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + dividend_yield=dividend_yield, + ) + try: + if scalar_mode: + return float( + _rust_bsm_price( + float(arrays["spot"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["dividend_yield"][0]), + ) + ) + out = _rust_bsm_price_batch( + arrays["spot"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + arrays["dividend_yield"], + option_type, + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def black_76_price( + forward: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", +) -> ScalarOrArray: + """Price options under Black-76.""" + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + forward=forward, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + ) + try: + if scalar_mode: + return float( + _rust_black76_price( + float(arrays["forward"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + ) + ) + out = _rust_black76_price_batch( + arrays["forward"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Model-dispatched option price helper.""" + model = _validate_model(model) + if model == "black76": + return black_76_price( + underlying, + strike, + rate, + time_to_expiry, + volatility, + option_type=option_type, + ) + return black_scholes_price( + underlying, + strike, + rate, + time_to_expiry, + volatility, + option_type=option_type, + dividend_yield=carry, + ) + + +def greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> OptionGreeks: + """Return delta, gamma, vega, theta, and rho.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + delta, gamma, vega, theta, rho = _rust_option_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + model, + float(arrays["carry"][0]), + ) + return OptionGreeks(delta, gamma, vega, theta, rho) + + delta, gamma, vega, theta, rho = _rust_option_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + model, + arrays["carry"], + ) + return OptionGreeks( + np.asarray(delta, dtype=np.float64), + np.asarray(gamma, dtype=np.float64), + np.asarray(vega, dtype=np.float64), + np.asarray(theta, dtype=np.float64), + np.asarray(rho, dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) + + +def implied_volatility( + price: ArrayLike | float, + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, + initial_guess: ArrayLike | float = 0.2, + tolerance: float = 1e-8, + max_iterations: int = 100, +) -> ScalarOrArray: + """Invert option prices to implied volatility.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + price=price, + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + carry=carry, + initial_guess=initial_guess, + ) + try: + if scalar_mode: + return float( + _rust_implied_volatility( + float(arrays["price"][0]), + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + option_type, + model, + float(arrays["carry"][0]), + float(arrays["initial_guess"][0]), + float(tolerance), + int(max_iterations), + ) + ) + out = _rust_implied_volatility_batch( + arrays["price"], + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + option_type, + model, + arrays["carry"], + arrays["initial_guess"], + float(tolerance), + int(max_iterations), + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def smile_metrics( + strikes: ArrayLike, + vols: ArrayLike, + reference_price: float, + time_to_expiry: float, + *, + model: str = "bsm", + rate: float = 0.0, + carry: float = 0.0, +) -> SmileMetrics: + """Compute ATM IV, 25-delta RR/BF, skew slope, and convexity.""" + model = _validate_model(model) + strikes_arr = _to_f64(strikes) + vols_arr = _to_f64(vols) + order = np.argsort(strikes_arr) + strikes_arr = strikes_arr[order] + vols_arr = vols_arr[order] + try: + atm_iv, rr25, bf25, slope, convexity = _rust_smile_metrics( + strikes_arr, + vols_arr, + float(reference_price), + float(time_to_expiry), + model, + float(rate), + float(carry), + ) + except ValueError as err: + _normalize_rust_error(err) + return SmileMetrics(atm_iv, rr25, bf25, slope, convexity) + + +def term_structure_slope(tenors: ArrayLike, atm_ivs: ArrayLike) -> float: + """Slope of ATM IV against tenor.""" + try: + return float(_rust_term_structure_slope(_to_f64(tenors), _to_f64(atm_ivs))) + except ValueError as err: + _normalize_rust_error(err) + + +def label_moneyness( + strikes: ArrayLike, + reference_price: float, + *, + option_type: str = "call", +) -> NDArray[np.object_]: + """Label strikes as ``ITM``, ``ATM``, or ``OTM``.""" + option_type = _validate_option_type(option_type) + try: + codes = np.asarray( + _rust_moneyness_labels( + _to_f64(strikes), float(reference_price), option_type + ), + dtype=np.int8, + ) + except ValueError as err: + _normalize_rust_error(err) + mapping = np.array(["OTM", "ATM", "ITM"], dtype=object) + return mapping[codes + 1] + + +def _parse_selector_steps(selector: str) -> int: + suffix = selector[3:] + if suffix == "": + return 1 + try: + return int(suffix) + except ValueError as err: + raise FerroTAValueError( + f"Could not parse strike selector '{selector}'. Expected forms like ATM, ITM1, OTM2." + ) from err + + +def select_strike( + strikes: ArrayLike, + reference_price: float, + *, + option_type: str = "call", + selector: str = "ATM", + delta_target: float | None = None, + volatilities: ArrayLike | None = None, + time_to_expiry: float | None = None, + model: str = "bsm", + rate: float = 0.0, + carry: float = 0.0, +) -> float | None: + """Select a strike by ATM/ITM/OTM offset or delta target.""" + option_type = _validate_option_type(option_type) + model = _validate_model(model) + strikes_arr = _to_f64(strikes) + + if len(strikes_arr) == 0: + raise FerroTAInputError("strikes must not be empty.") + + selector_norm = selector.strip().upper() + if delta_target is None and selector_norm.startswith("DELTA"): + try: + delta_target = float(selector_norm.replace("DELTA", "")) + except ValueError as err: + raise FerroTAValueError( + f"Could not parse delta selector '{selector}'. Example: selector='DELTA0.25'." + ) from err + + if delta_target is not None: + if volatilities is None or time_to_expiry is None: + raise FerroTAValueError( + "Delta-based strike selection requires volatilities and time_to_expiry." + ) + vols_arr = _to_f64(volatilities) + if len(vols_arr) != len(strikes_arr): + raise FerroTAInputError( + "strikes and volatilities must have the same length." + ) + order = np.argsort(strikes_arr) + strikes_arr = strikes_arr[order] + vols_arr = vols_arr[order] + try: + strike = _rust_select_strike_delta( + strikes_arr, + vols_arr, + float(reference_price), + float(time_to_expiry), + float(delta_target), + option_type, + model, + float(rate), + float(carry), + ) + except ValueError as err: + _normalize_rust_error(err) + return None if strike is None else float(strike) + + order = np.argsort(strikes_arr) + sorted_strikes = strikes_arr[order] + if selector_norm == "ATM": + offset = 0 + elif selector_norm.startswith("ITM"): + steps = _parse_selector_steps(selector_norm) + offset = -steps if option_type == "call" else steps + elif selector_norm.startswith("OTM"): + steps = _parse_selector_steps(selector_norm) + offset = steps if option_type == "call" else -steps + else: + raise FerroTAValueError( + f"Unsupported selector '{selector}'. Use ATM, ITM, OTM, or DELTA." + ) + + try: + strike = _rust_select_strike_offset( + sorted_strikes, float(reference_price), int(offset) + ) + except ValueError as err: + _normalize_rust_error(err) + return None if strike is None else float(strike) + + +def extended_greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + model: str = "bsm", + carry: ArrayLike | float = 0.0, +) -> ExtendedGreeks: + """Return vanna, volga, charm, speed, and color (second-order / cross Greeks). + + All Greeks are computed via closed-form BSM formulas. Black-76 is not + yet supported and returns NaN for all five values. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal — e.g. ``0.05`` for 5 %). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + model: + ``"bsm"`` (default). ``"black76"`` returns NaN for all fields. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + + Returns + ------- + ExtendedGreeks + Named tuple with fields: + + - **vanna** — ∂Δ/∂σ: sensitivity of delta to a change in vol. + - **volga** — ∂²V/∂σ² (vomma): sensitivity of vega to a change in vol. + - **charm** — ∂Δ/∂t: daily rate of change in delta (theta of delta). + - **speed** — ∂Γ/∂S: rate of change in gamma with respect to spot. + - **color** — ∂Γ/∂t: daily rate of change in gamma. + + Notes + ----- + Inputs may be scalars or broadcastable arrays. When arrays are supplied + each field of the returned :class:`ExtendedGreeks` is an ``NDArray``. + + Closed-form expressions (BSM, zero-carry):: + + vanna = -e^{-qT} · φ(d₁) · d₂ / σ + volga = S · e^{-qT} · φ(d₁) · √T · d₁ · d₂ / σ + charm = -e^{-qT} · φ(d₁) · [2(r-q)T - d₂·σ·√T] / (2T·σ·√T) + speed = -Γ/S · (d₁/(σ√T) + 1) + color = -Γ · [r-q + d₁·σ/(2√T) + (2(r-q)T - d₂·σ√T)·d₁/(2T·σ√T)] + """ + option_type = _validate_option_type(option_type) + model = _validate_model(model) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + vanna, volga, charm, speed, color = _rust_extended_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + model, + float(arrays["carry"][0]), + ) + return ExtendedGreeks(vanna, volga, charm, speed, color) + + vanna, volga, charm, speed, color = _rust_extended_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + model, + arrays["carry"], + ) + return ExtendedGreeks( + np.asarray(vanna, dtype=np.float64), + np.asarray(volga, dtype=np.float64), + np.asarray(charm, dtype=np.float64), + np.asarray(speed, dtype=np.float64), + np.asarray(color, dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) + + +def put_call_parity_deviation( + call_price: float, + put_price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + carry: float = 0.0, +) -> float: + """Put-call parity deviation: ``C − P − (S·e^{−q·T} − K·e^{−r·T})``. + + At no-arbitrage the deviation is exactly 0. A non-zero result indicates + mispricing, a data error, or a stale quote. + + Parameters + ---------- + call_price: + Market or model price of the call option. + put_price: + Market or model price of the put option. + spot: + Current underlying price. + strike: + Common strike price of the call and put. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + carry: + Continuous dividend yield / carry rate (annualised, decimal). + + Returns + ------- + float + Signed deviation. Positive → call is overpriced relative to put; + negative → put is overpriced relative to call. + + Examples + -------- + >>> from ferro_ta.analysis.options import option_price, put_call_parity_deviation + >>> call = option_price(100, 100, 0.05, 1.0, 0.2, option_type="call") + >>> put = option_price(100, 100, 0.05, 1.0, 0.2, option_type="put") + >>> put_call_parity_deviation(call, put, 100, 100, 0.05, 1.0) # ≈ 0.0 + """ + try: + return float( + _rust_put_call_parity_deviation( + float(call_price), + float(put_price), + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + float(carry), + ) + ) + except ValueError as err: + _normalize_rust_error(err) + + +def expected_move( + spot: float, + iv: float, + days_to_expiry: float, + trading_days_per_year: float = 252.0, +) -> tuple[float, float]: + """Expected ±1σ move over *days_to_expiry* calendar days. + + Uses the log-normal approximation:: + + upper_move = spot × e^{+σ√(days/trading_days)} − spot + lower_move = spot × e^{−σ√(days/trading_days)} − spot + + Parameters + ---------- + spot: + Current underlying price. + iv: + Implied volatility (annualised, decimal — e.g. ``0.20`` for 20 %). + days_to_expiry: + Number of calendar days until expiry. + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + tuple[float, float] + ``(lower_move, upper_move)`` — signed absolute price changes from + ``spot``. ``lower_move < 0``, ``upper_move > 0``. + + Notes + ----- + Because of log-normal skew, ``|upper_move| > |lower_move|``. + + Examples + -------- + >>> from ferro_ta.analysis.options import expected_move + >>> lower, upper = expected_move(100.0, 0.20, 30) + >>> round(upper, 2) + 7.14 + """ + try: + lower, upper = _rust_expected_move( + float(spot), float(iv), float(days_to_expiry), float(trading_days_per_year) + ) + return float(lower), float(upper) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# Digital options — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def digital_option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + digital_type: str = "cash_or_nothing", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Price a digital (binary) option under BSM. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + digital_type: + ``"cash_or_nothing"`` (default) — pays 1 unit of cash if ITM at + expiry; or ``"asset_or_nothing"`` — pays the underlying asset price. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + + Returns + ------- + float or NDArray[float64] + Option price. Returns a scalar when all inputs are scalars, or an + array when any input is an array. + + Notes + ----- + Closed-form BSM formulas:: + + Cash-or-nothing call: e^{−rT} · N(d₂) + Cash-or-nothing put: e^{−rT} · N(−d₂) + Asset-or-nothing call: S · e^{−qT} · N(d₁) + Asset-or-nothing put: S · e^{−qT} · N(−d₁) + + Put-call parity for cash-or-nothing: call + put = e^{−rT}. + Put-call parity for asset-or-nothing: call + put = S · e^{−qT}. + + Invalid inputs (non-positive spot/strike, negative time or vol) return NaN. + """ + from ferro_ta._ferro_ta import digital_price as _rust_digital_price + from ferro_ta._ferro_ta import digital_price_batch as _rust_digital_price_batch + + option_type = _validate_option_type(option_type) + digital_type = digital_type.lower().replace("-", "_") + if digital_type not in {"cash_or_nothing", "asset_or_nothing"}: + raise FerroTAValueError( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'." + ) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_digital_price( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + digital_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_digital_price_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + digital_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def digital_option_greeks( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + digital_type: str = "cash_or_nothing", + carry: ArrayLike | float = 0.0, +) -> OptionGreeks: + """Delta, gamma, and vega for a digital option via numerical bumping. + + Uses central finite differences (spot bump ε = spot × 10⁻³ for delta/gamma; + vol bump ε = 10⁻³ for vega). Theta and rho are set to NaN. + + Parameters + ---------- + underlying, strike, rate, time_to_expiry, volatility, option_type, carry: + Same as :func:`digital_option_price`. + digital_type: + ``"cash_or_nothing"`` (default) or ``"asset_or_nothing"``. + + Returns + ------- + OptionGreeks + Named tuple; only ``delta``, ``gamma``, ``vega`` are finite. + ``theta`` and ``rho`` are NaN. + """ + from ferro_ta._ferro_ta import digital_greeks as _rust_digital_greeks + from ferro_ta._ferro_ta import digital_greeks_batch as _rust_digital_greeks_batch + + option_type = _validate_option_type(option_type) + digital_type = digital_type.lower().replace("-", "_") + if digital_type not in {"cash_or_nothing", "asset_or_nothing"}: + raise FerroTAValueError( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'." + ) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + delta, gamma, vega = _rust_digital_greeks( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + digital_type, + float(arrays["carry"][0]), + ) + return OptionGreeks(delta, gamma, vega, float("nan"), float("nan")) + + delta, gamma, vega = _rust_digital_greeks_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + digital_type, + arrays["carry"], + ) + nan_arr = np.full_like(delta, float("nan")) + return OptionGreeks( + np.asarray(delta, dtype=np.float64), + np.asarray(gamma, dtype=np.float64), + np.asarray(vega, dtype=np.float64), + nan_arr, + nan_arr, + ) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# American options — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def american_option_price( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """American option price using the Barone-Adesi-Whaley (1987) approximation. + + Accurate to within a few basis points for standard equity/index parameters. + O(1) per evaluation — suitable for batch pricing or calibration. + + Parameters + ---------- + underlying: + Current underlying (spot) price. + strike: + Option strike price. + rate: + Risk-free rate (annualised, decimal). + time_to_expiry: + Time to expiry in years. + volatility: + Implied volatility (annualised, decimal). + option_type: + ``"call"`` (default) or ``"put"``. + carry: + Continuous carry / dividend yield (annualised, decimal). Default 0. + For calls with ``carry = 0`` (no dividends) early exercise is never + optimal and the result equals the European BSM price. + + Returns + ------- + float or NDArray[float64] + American option price ≥ European BSM price. + + Notes + ----- + The BAW approximation uses a quadratic equation to find the critical + exercise boundary S* via Newton-Raphson iteration, then adds the early + exercise premium on top of the European price. + + Reference: Barone-Adesi, G. & Whaley, R.E. (1987). "Efficient Analytic + Approximation of American Option Values." *Journal of Finance*, 42(2), + 301–320. + + See Also + -------- + early_exercise_premium : Difference between American and European prices. + """ + from ferro_ta._ferro_ta import american_price as _rust_american_price + from ferro_ta._ferro_ta import american_price_batch as _rust_american_price_batch + + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_american_price( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_american_price_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +def early_exercise_premium( + underlying: ArrayLike | float, + strike: ArrayLike | float, + rate: ArrayLike | float, + time_to_expiry: ArrayLike | float, + volatility: ArrayLike | float, + *, + option_type: str = "call", + carry: ArrayLike | float = 0.0, +) -> ScalarOrArray: + """Early exercise premium: American price − European BSM price. + + Represents the additional value an American option holder gains from the + right to exercise before expiry. Always ≥ 0. + + Parameters + ---------- + underlying, strike, rate, time_to_expiry, volatility, option_type, carry: + Same as :func:`american_option_price`. + + Returns + ------- + float or NDArray[float64] + Premium ≥ 0. Typically 0 for calls with no dividends. + + Notes + ----- + For equity calls with zero carry (no dividends), early exercise is never + optimal so the premium is ≈ 0. For puts (or calls on dividend-paying + underlyings), the premium increases with in-the-moneyness, rate, and + time to expiry. + """ + from ferro_ta._ferro_ta import ( + early_exercise_premium as _rust_early_exercise_premium, + ) + from ferro_ta._ferro_ta import ( + early_exercise_premium_batch as _rust_early_exercise_premium_batch, + ) + + option_type = _validate_option_type(option_type) + arrays, scalar_mode = _broadcast_inputs( + underlying=underlying, + strike=strike, + rate=rate, + time_to_expiry=time_to_expiry, + volatility=volatility, + carry=carry, + ) + try: + if scalar_mode: + return float( + _rust_early_exercise_premium( + float(arrays["underlying"][0]), + float(arrays["strike"][0]), + float(arrays["rate"][0]), + float(arrays["time_to_expiry"][0]), + float(arrays["volatility"][0]), + option_type, + float(arrays["carry"][0]), + ) + ) + out = _rust_early_exercise_premium_batch( + arrays["underlying"], + arrays["strike"], + arrays["rate"], + arrays["time_to_expiry"], + arrays["volatility"], + option_type, + arrays["carry"], + ) + return np.asarray(out, dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) + + +# --------------------------------------------------------------------------- +# Historical volatility estimators — populated once the Rust bridge is built +# --------------------------------------------------------------------------- + + +def close_to_close_vol( + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling close-to-close realized volatility (annualised). + + Baseline estimator — uses only closing prices. Less efficient than OHLC + estimators but requires only daily close data. + + Parameters + ---------- + close: + Array of closing prices (length ≥ window + 1). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window`` values are NaN. + + Notes + ----- + Formula:: + + σ = √( Σᵢ ln²(Cᵢ/Cᵢ₋₁) / window × trading_days_per_year ) + + No Bessel correction is applied (population variance, not sample variance). + """ + from ferro_ta._ferro_ta import close_to_close_vol as _rust_ctc + + try: + arr = _to_f64(close) + return np.asarray( + _rust_ctc(arr, int(window), float(trading_days_per_year)), dtype=np.float64 + ) + except ValueError as err: + _normalize_rust_error(err) + + +def parkinson_vol( + high: ArrayLike, + low: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Parkinson high-low realized volatility estimator (annualised). + + ~5× more efficient than close-to-close for diffusion processes. + Does **not** account for drift or overnight gaps. + + Parameters + ---------- + high, low: + Arrays of daily high and low prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *high*. First ``window - 1`` values are NaN. + + Notes + ----- + Formula per window:: + + σ² = (1 / (4·ln2·window)) · Σ ln²(Hᵢ/Lᵢ) × trading_days_per_year + + Reference: Parkinson, M. (1980). "The Extreme Value Method for + Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1). + """ + from ferro_ta._ferro_ta import parkinson_vol as _rust_parkinson + + try: + return np.asarray( + _rust_parkinson( + _to_f64(high), _to_f64(low), int(window), float(trading_days_per_year) + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def garman_klass_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Garman-Klass OHLC realized volatility estimator (annualised). + + Extends Parkinson by incorporating the open-close return. ~7.4× more + efficient than close-to-close. Does **not** handle overnight gaps. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window - 1`` values are NaN. + + Notes + ----- + Per-bar contribution:: + + GK = 0.5·ln²(H/L) − (2·ln2 − 1)·ln²(C/O) + + Reference: Garman, M.B. & Klass, M.J. (1980). "On the Estimation of + Security Price Volatilities from Historical Data." *Journal of Business*, 53(1). + """ + from ferro_ta._ferro_ta import garman_klass_vol as _rust_gk + + try: + return np.asarray( + _rust_gk( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def rogers_satchell_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Rogers-Satchell OHLC realized volatility estimator (annualised). + + Drift-invariant: unbiased for assets with non-zero expected return. + Does **not** handle overnight gaps. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window - 1`` values are NaN. + + Notes + ----- + Per-bar contribution (u = ln(H/O), d = ln(L/O), c = ln(C/O)):: + + RS = u·(u − c) + d·(d − c) + + Reference: Rogers, L.C.G. & Satchell, S.E. (1991). "Estimating Variance + from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4). + """ + from ferro_ta._ferro_ta import rogers_satchell_vol as _rust_rs + + try: + return np.asarray( + _rust_rs( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def yang_zhang_vol( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + window: int = 20, + trading_days_per_year: float = 252.0, +) -> NDArray[np.float64]: + """Rolling Yang-Zhang OHLC realized volatility estimator (annualised). + + The most efficient standard estimator (~14× vs close-to-close). Handles + overnight gaps by combining overnight, intraday open-close, and + Rogers-Satchell variance components with an optimal weight *k*. + + Parameters + ---------- + open, high, low, close: + Arrays of daily OHLC prices (same length, ≥ window + 1). + window: + Rolling look-back period in bars (default 20). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + NDArray[float64] + Same length as *close*. First ``window`` values are NaN. + + Notes + ----- + Mixed estimator:: + + σ²_YZ = σ²_overnight + k·σ²_open_close + (1−k)·σ²_RS + + where k = 0.34 / (1.34 + (window+1)/(window-1)). + + Reference: Yang, D. & Zhang, Q. (2000). "Drift-Independent Volatility + Estimation Based on High, Low, Open, and Close Prices." + *Journal of Business*, 73(3). + """ + from ferro_ta._ferro_ta import yang_zhang_vol as _rust_yz + + try: + return np.asarray( + _rust_yz( + _to_f64(open), + _to_f64(high), + _to_f64(low), + _to_f64(close), + int(window), + float(trading_days_per_year), + ), + dtype=np.float64, + ) + except ValueError as err: + _normalize_rust_error(err) + + +def vol_cone( + close: ArrayLike, + *, + windows: tuple[int, ...] = (21, 42, 63, 126, 252), + trading_days_per_year: float = 252.0, +) -> VolCone: + """Historical realised vol distribution across window lengths (volatility cone). + + For each window, computes the full history of rolling close-to-close + realised vol, then returns the min / p25 / median / p75 / max distribution. + Contextualises current implied vol: "Is 30 % IV cheap or expensive?" + + Parameters + ---------- + close: + Array of closing prices (length ≥ max(windows) + 1). + windows: + Tuple of rolling window sizes in bars. Default ``(21, 42, 63, 126, 252)`` + (approx. 1 month, 2 months, 3 months, 6 months, 1 year). + trading_days_per_year: + Annualisation factor (default 252). + + Returns + ------- + VolCone + Dataclass with arrays ``windows``, ``min``, ``p25``, ``median``, + ``p75``, ``max`` — one value per element of *windows*. + + Notes + ----- + Uses close-to-close vol internally. Overlay the current IV on the cone + to see whether it is historically cheap or expensive for each tenor. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.options import vol_cone + >>> rng = np.random.default_rng(0) + >>> close = 100 * np.cumprod(np.exp(rng.normal(0, 0.01, 500))) + >>> cone = vol_cone(close, windows=(21, 63, 252)) + >>> cone.median # annualised median realised vol per window + """ + from ferro_ta._ferro_ta import vol_cone as _rust_vol_cone + + try: + arr = _to_f64(close) + slices = _rust_vol_cone(arr, list(windows), float(trading_days_per_year)) + windows_arr = np.array([s[0] for s in slices], dtype=np.float64) + return VolCone( + windows=windows_arr, + min=np.array([s[1] for s in slices], dtype=np.float64), + p25=np.array([s[2] for s in slices], dtype=np.float64), + median=np.array([s[3] for s in slices], dtype=np.float64), + p75=np.array([s[4] for s in slices], dtype=np.float64), + max=np.array([s[5] for s in slices], dtype=np.float64), + ) + except ValueError as err: + _normalize_rust_error(err) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py new file mode 100644 index 0000000..ca0a7d5 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/options_strategy.py @@ -0,0 +1,326 @@ +""" +ferro_ta.analysis.options_strategy — Typed strategy parameter schemas. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import date +from enum import Enum +from typing import Any + +from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError + +__all__ = [ + "ExpirySelectorKind", + "StrikeSelectorKind", + "LegPreset", + "RiskMode", + "ExpirySelector", + "StrikeSelector", + "RiskControl", + "SimulationLimits", + "StrategyLeg", + "DerivativesStrategy", + "build_strategy_preset", +] + + +class ExpirySelectorKind(str, Enum): + CURRENT_WEEK = "current_week" + NEXT_WEEK = "next_week" + CURRENT_MONTH = "current_month" + NEXT_MONTH = "next_month" + EXPLICIT_DATE = "explicit_date" + + +class StrikeSelectorKind(str, Enum): + ATM = "atm" + ITM = "itm" + OTM = "otm" + DELTA = "delta" + EXPLICIT = "explicit" + + +class LegPreset(str, Enum): + STRADDLE = "straddle" + STRANGLE = "strangle" + IRON_CONDOR = "iron_condor" + BULL_CALL_SPREAD = "bull_call_spread" + BEAR_PUT_SPREAD = "bear_put_spread" + CUSTOM = "custom" + + +class RiskMode(str, Enum): + PER_LEG = "per_leg" + COMBINED_PNL = "combined_pnl" + + +@dataclass(frozen=True) +class ExpirySelector: + kind: ExpirySelectorKind | str + explicit_date: date | None = None + + def __post_init__(self) -> None: + kind = ExpirySelectorKind(self.kind) + object.__setattr__(self, "kind", kind) + if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None: + raise FerroTAValueError( + "ExpirySelector(kind='explicit_date') requires explicit_date." + ) + if ( + kind is not ExpirySelectorKind.EXPLICIT_DATE + and self.explicit_date is not None + ): + raise FerroTAValueError( + "explicit_date is only valid when kind='explicit_date'." + ) + + +@dataclass(frozen=True) +class StrikeSelector: + kind: StrikeSelectorKind | str + steps: int = 0 + delta: float | None = None + explicit_strike: float | None = None + + def __post_init__(self) -> None: + kind = StrikeSelectorKind(self.kind) + object.__setattr__(self, "kind", kind) + if self.steps < 0: + raise FerroTAValueError("steps must be >= 0.") + if kind is StrikeSelectorKind.DELTA and self.delta is None: + raise FerroTAValueError( + "StrikeSelector(kind='delta') requires a delta target." + ) + if self.delta is not None and not (0.0 < float(self.delta) < 1.0): + raise FerroTAValueError("delta must be in the open interval (0, 1).") + if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None: + raise FerroTAValueError( + "StrikeSelector(kind='explicit') requires explicit_strike." + ) + + +@dataclass(frozen=True) +class RiskControl: + stop_loss_type: str | None = None + stop_loss_value: float | None = None + target_type: str | None = None + target_value: float | None = None + trailstop_type: str | None = None + trailstop_value: float | None = None + breakeven_trigger: float | None = None + + def __post_init__(self) -> None: + for name in ( + "stop_loss_value", + "target_value", + "trailstop_value", + "breakeven_trigger", + ): + value = getattr(self, name) + if value is not None and float(value) < 0.0: + raise FerroTAValueError(f"{name} must be >= 0.") + + +@dataclass(frozen=True) +class SimulationLimits: + max_premium_outlay: float | None = None + max_loss_per_trade: float | None = None + daily_max_drawdown: float | None = None + cooldown_bars: int = 0 + reentry_allowed: bool = True + + def __post_init__(self) -> None: + for name in ( + "max_premium_outlay", + "max_loss_per_trade", + "daily_max_drawdown", + ): + value = getattr(self, name) + if value is not None and float(value) < 0.0: + raise FerroTAValueError(f"{name} must be >= 0.") + if self.cooldown_bars < 0: + raise FerroTAValueError("cooldown_bars must be >= 0.") + + +@dataclass(frozen=True) +class StrategyLeg: + underlying: str + expiry_selector: ExpirySelector | None + strike_selector: StrikeSelector | None + option_type: str | None + side: str = "long" + quantity: int = 1 + instrument: str = "option" + premium_limit: float | None = None + + def __post_init__(self) -> None: + if self.underlying.strip() == "": + raise FerroTAInputError("underlying must not be empty.") + if self.instrument not in {"option", "future", "stock"}: + raise FerroTAValueError( + "instrument must be 'option', 'future', or 'stock'." + ) + if self.instrument == "option": + if self.option_type not in {"call", "put"}: + raise FerroTAValueError( + "option legs require option_type='call' or 'put'." + ) + if self.expiry_selector is None: + raise FerroTAInputError("option legs require expiry_selector.") + if self.strike_selector is None: + raise FerroTAInputError("option legs require strike_selector.") + if self.side not in {"long", "short"}: + raise FerroTAValueError("side must be 'long' or 'short'.") + if self.quantity == 0: + raise FerroTAValueError("quantity must be non-zero.") + if self.premium_limit is not None and self.premium_limit < 0.0: + raise FerroTAValueError("premium_limit must be >= 0.") + + +@dataclass(frozen=True) +class DerivativesStrategy: + name: str + preset: LegPreset | str = LegPreset.CUSTOM + legs: tuple[StrategyLeg, ...] = field(default_factory=tuple) + risk_controls: RiskControl = field(default_factory=RiskControl) + risk_mode: RiskMode | str = RiskMode.COMBINED_PNL + commission: float = 0.0 + slippage: float = 0.0 + spread_assumption: float = 0.0 + limits: SimulationLimits = field(default_factory=SimulationLimits) + + def __post_init__(self) -> None: + preset = LegPreset(self.preset) + risk_mode = RiskMode(self.risk_mode) + object.__setattr__(self, "preset", preset) + object.__setattr__(self, "risk_mode", risk_mode) + if self.name.strip() == "": + raise FerroTAInputError("name must not be empty.") + if len(self.legs) == 0: + raise FerroTAInputError("legs must contain at least one strategy leg.") + for cost_name in ("commission", "slippage", "spread_assumption"): + if float(getattr(self, cost_name)) < 0.0: + raise FerroTAValueError(f"{cost_name} must be >= 0.") + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def build_strategy_preset( + preset: LegPreset | str, + *, + name: str, + underlying: str, + expiry_selector: ExpirySelector, + base_strike_selector: StrikeSelector | None = None, + risk_controls: RiskControl | None = None, + risk_mode: RiskMode | str = RiskMode.COMBINED_PNL, + commission: float = 0.0, + slippage: float = 0.0, + spread_assumption: float = 0.0, + limits: SimulationLimits | None = None, +) -> DerivativesStrategy: + """Build a common research preset using typed leg definitions.""" + preset = LegPreset(preset) + risk_controls = risk_controls or RiskControl() + limits = limits or SimulationLimits() + atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM) + + if preset is LegPreset.CUSTOM: + raise FerroTAValueError( + "build_strategy_preset does not construct CUSTOM presets." + ) + + legs: tuple[StrategyLeg, ...] + + if preset is LegPreset.STRADDLE: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "call", "long"), + StrategyLeg(underlying, expiry_selector, atm, "put", "long"), + ) + elif preset is LegPreset.STRANGLE: + legs = ( + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "long", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "long", + ), + ) + elif preset is LegPreset.BULL_CALL_SPREAD: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "call", "long"), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "short", + ), + ) + elif preset is LegPreset.BEAR_PUT_SPREAD: + legs = ( + StrategyLeg(underlying, expiry_selector, atm, "put", "long"), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "short", + ), + ) + elif preset is LegPreset.IRON_CONDOR: + legs = ( + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "put", + "short", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=2), + "put", + "long", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=1), + "call", + "short", + ), + StrategyLeg( + underlying, + expiry_selector, + StrikeSelector(StrikeSelectorKind.OTM, steps=2), + "call", + "long", + ), + ) + else: + raise FerroTAValueError(f"Unsupported preset '{preset.value}'.") + + return DerivativesStrategy( + name=name, + preset=preset, + legs=legs, + risk_controls=risk_controls, + risk_mode=risk_mode, + commission=commission, + slippage=slippage, + spread_assumption=spread_assumption, + limits=limits, + ) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/plot.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/plot.py new file mode 100644 index 0000000..43c2d36 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/plot.py @@ -0,0 +1,277 @@ +""" +Visualization utilities for backtest results. + +plot_backtest(result, *, title="Backtest", show=True, return_fig=False) + Generate an interactive Plotly chart with: + - Top panel: equity curve (normalized to 1.0) + - Middle panel: drawdown series (negative values, shaded red) + - Bottom panel: position/signal over time + Optional trade markers: entry (green triangle up) and exit (red triangle down) on equity curve. + +Requires plotly -- raises ImportError with install hint if not available. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + pass + +__all__ = ["plot_backtest"] + + +def plot_backtest( + result, # AdvancedBacktestResult + *, + title: str = "Backtest", + show: bool = True, + return_fig: bool = False, + benchmark: bool = True, +): + """Plot equity curve, drawdown, and positions. + + Parameters + ---------- + result : AdvancedBacktestResult + Backtest result object with equity, drawdown_series, positions, and trades. + title : str + Chart title. + show : bool + Call fig.show() if True. + return_fig : bool + Return the plotly Figure object. + benchmark : bool + Overlay benchmark equity curve if result has benchmark returns. + + Returns + ------- + plotly.graph_objects.Figure if return_fig=True, else None. + + Raises + ------ + ImportError + If plotly is not installed. + """ + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError: + raise ImportError( + "plotly is required for visualization. Install with: pip install plotly" + ) + + import numpy as np + + # ------------------------------------------------------------------ + # Extract result fields + # ------------------------------------------------------------------ + equity = np.asarray(result.equity, dtype=np.float64) + n = len(equity) + bars = np.arange(n) + + # Drawdown: prefer pre-computed drawdown_series, else compute from equity + if hasattr(result, "drawdown_series") and result.drawdown_series is not None: + drawdown = np.asarray(result.drawdown_series, dtype=np.float64) + else: + cum_max = np.maximum.accumulate(equity) + drawdown = np.where(cum_max > 0, equity / cum_max - 1.0, 0.0) + + positions = ( + np.asarray(result.positions, dtype=np.float64) + if hasattr(result, "positions") + else np.zeros(n) + ) + + # Trades (may be empty or None) + trades = getattr(result, "trades", None) + + # Benchmark equity (optional) + benchmark_equity = None + if ( + benchmark + and hasattr(result, "benchmark_equity") + and result.benchmark_equity is not None + ): + benchmark_equity = np.asarray(result.benchmark_equity, dtype=np.float64) + + # ------------------------------------------------------------------ + # Build 3-panel subplot + # ------------------------------------------------------------------ + fig = make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + row_heights=[0.5, 0.25, 0.25], + vertical_spacing=0.04, + subplot_titles=("Equity Curve", "Drawdown", "Positions"), + ) + + # ---- Panel 1: Equity curve ---------------------------------------- + fig.add_trace( + go.Scatter( + x=bars, + y=equity, + name="Strategy", + line=dict(color="#00d4ff", width=1.5), + hovertemplate="Bar %{x}
Equity: %{y:.4f}", + ), + row=1, + col=1, + ) + + # Benchmark overlay + if benchmark_equity is not None: + fig.add_trace( + go.Scatter( + x=bars[: len(benchmark_equity)], + y=benchmark_equity, + name="Benchmark", + line=dict(color="#f0a500", width=1.2, dash="dot"), + hovertemplate="Bar %{x}
Benchmark: %{y:.4f}", + ), + row=1, + col=1, + ) + + # Trade markers + if trades is not None and hasattr(trades, "__len__") and len(trades) > 0: + # trades may be a pd.DataFrame or a list of dicts + try: + # pandas DataFrame path + entry_bars = trades["entry_bar"].values + exit_bars = trades["exit_bar"].values + except (TypeError, KeyError, AttributeError): + # list-of-dicts path + try: + entry_bars = np.array([t["entry_bar"] for t in trades]) + exit_bars = np.array([t["exit_bar"] for t in trades]) + except (KeyError, TypeError): + entry_bars = np.array([]) + exit_bars = np.array([]) + + if len(entry_bars) > 0: + # Clip indices to equity length + entry_bars = np.clip(entry_bars.astype(int), 0, n - 1) + exit_bars = np.clip(exit_bars.astype(int), 0, n - 1) + + fig.add_trace( + go.Scatter( + x=entry_bars, + y=equity[entry_bars], + mode="markers", + name="Entry", + marker=dict( + symbol="triangle-up", + size=10, + color="lime", + line=dict(color="darkgreen", width=1), + ), + hovertemplate="Entry Bar %{x}
Equity: %{y:.4f}", + ), + row=1, + col=1, + ) + fig.add_trace( + go.Scatter( + x=exit_bars, + y=equity[exit_bars], + mode="markers", + name="Exit", + marker=dict( + symbol="triangle-down", + size=10, + color="red", + line=dict(color="darkred", width=1), + ), + hovertemplate="Exit Bar %{x}
Equity: %{y:.4f}", + ), + row=1, + col=1, + ) + + # ---- Panel 2: Drawdown ------------------------------------------- + fig.add_trace( + go.Scatter( + x=bars, + y=drawdown, + name="Drawdown", + fill="tozeroy", + fillcolor="rgba(220, 50, 50, 0.25)", + line=dict(color="rgba(220, 50, 50, 0.8)", width=1.0), + hovertemplate="Bar %{x}
Drawdown: %{y:.2%}", + ), + row=2, + col=1, + ) + + # ---- Panel 3: Positions ------------------------------------------ + fig.add_trace( + go.Scatter( + x=bars, + y=positions, + name="Position", + fill="tozeroy", + fillcolor="rgba(0, 150, 255, 0.2)", + line=dict(color="rgba(0, 150, 255, 0.7)", width=1.0), + hovertemplate="Bar %{x}
Position: %{y:.2f}", + ), + row=3, + col=1, + ) + + # ------------------------------------------------------------------ + # Styling: dark theme + ferro-ta branding + # ------------------------------------------------------------------ + metrics = getattr(result, "metrics", {}) + sharpe_str = f"Sharpe: {metrics.get('sharpe', float('nan')):.2f}" if metrics else "" + dd_str = ( + f"Max DD: {metrics.get('max_drawdown', float('nan')):.1%}" if metrics else "" + ) + subtitle = " | ".join(filter(None, [sharpe_str, dd_str])) + + fig.update_layout( + title=dict( + text=f"{title}" + (f"
{subtitle}" if subtitle else ""), + font=dict(size=18, color="#e0e0e0"), + ), + template="plotly_dark", + paper_bgcolor="#0e1117", + plot_bgcolor="#0e1117", + font=dict(color="#b0b8c1", size=11), + legend=dict( + orientation="h", + yanchor="bottom", + y=1.01, + xanchor="right", + x=1, + bgcolor="rgba(0,0,0,0)", + ), + hovermode="x unified", + height=700, + margin=dict(l=60, r=40, t=80, b=40), + ) + + # Axis styling + axis_style = dict( + gridcolor="rgba(255,255,255,0.07)", + zerolinecolor="rgba(255,255,255,0.15)", + tickfont=dict(size=10), + ) + fig.update_xaxes(**axis_style) + fig.update_yaxes(**axis_style) + + # Y-axis labels + fig.update_yaxes(title_text="Equity (norm.)", row=1, col=1) + fig.update_yaxes(title_text="Drawdown", tickformat=".1%", row=2, col=1) + fig.update_yaxes(title_text="Position", row=3, col=1) + fig.update_xaxes(title_text="Bar", row=3, col=1) + + # ------------------------------------------------------------------ + if show: + fig.show() + + if return_fig: + return fig + + return None diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/portfolio.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/portfolio.py new file mode 100644 index 0000000..5291014 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/portfolio.py @@ -0,0 +1,240 @@ +""" +ferro_ta.portfolio — Portfolio and multi-asset analytics. + +Compute-intensive portfolio metrics (correlation, volatility, beta, drawdown) +are implemented in Rust; this module provides the Python-facing API. + +Functions +--------- +correlation_matrix(returns_df_or_array) + Compute the pairwise Pearson correlation matrix for a returns table. + +portfolio_volatility(returns, weights) + Compute portfolio volatility sqrt(w' Σ w) from a returns table and + weights (or pass a covariance matrix directly). + +beta(asset_returns, benchmark_returns, *, window=None) + Compute beta of one asset vs a benchmark, full-sample or rolling. + +drawdown(equity, *, as_series=True) + Compute the drawdown series and max drawdown for an equity curve. + +Rust backend +------------ +All compute delegates to:: + + ferro_ta._ferro_ta.correlation_matrix + ferro_ta._ferro_ta.portfolio_volatility + ferro_ta._ferro_ta.beta_full + ferro_ta._ferro_ta.rolling_beta + ferro_ta._ferro_ta.drawdown_series +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import beta_full as _rust_beta_full +from ferro_ta._ferro_ta import correlation_matrix as _rust_corr +from ferro_ta._ferro_ta import drawdown_series as _rust_drawdown +from ferro_ta._ferro_ta import portfolio_volatility as _rust_port_vol +from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta +from ferro_ta._utils import _to_f64 + +__all__ = [ + "correlation_matrix", + "portfolio_volatility", + "beta", + "drawdown", +] + + +# --------------------------------------------------------------------------- +# correlation_matrix +# --------------------------------------------------------------------------- + + +def correlation_matrix(returns: Any) -> Any: + """Compute the pairwise Pearson correlation matrix. + + Parameters + ---------- + returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets) + Returns per bar and asset. Assets are columns. + + Returns + ------- + numpy.ndarray of shape (n_assets, n_assets), or pandas.DataFrame + with same column/index names if a DataFrame was passed. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import correlation_matrix + >>> rng = np.random.default_rng(0) + >>> r = rng.normal(0, 0.01, (100, 3)) + >>> corr = correlation_matrix(r) + >>> corr.shape + (3, 3) + >>> abs(corr[0, 0] - 1.0) < 1e-10 + True + """ + try: + import pandas as pd + + if isinstance(returns, pd.DataFrame): + cols = returns.columns.tolist() + arr = returns.values.astype(np.float64, copy=False) + arr = np.ascontiguousarray(arr) + result = _rust_corr(arr) + return pd.DataFrame(result, index=cols, columns=cols) # type: ignore[arg-type] + except ImportError: + pass + arr = np.ascontiguousarray(returns, dtype=np.float64) + return _rust_corr(arr) + + +# --------------------------------------------------------------------------- +# portfolio_volatility +# --------------------------------------------------------------------------- + + +def portfolio_volatility( + returns: Any, + weights: ArrayLike, + *, + annualise: Optional[float] = None, +) -> float: + """Compute portfolio volatility sqrt(w' Σ w). + + Parameters + ---------- + returns : pandas.DataFrame or 2-D array-like, shape (n_bars, n_assets) + Returns per bar/asset. The covariance matrix is computed from this. + weights : array-like of length n_assets + Portfolio weights (do not need to sum to 1). + annualise : float, optional + If given, the result is multiplied by ``sqrt(annualise)`` (e.g. + ``252`` for daily returns annualised to yearly). + + Returns + ------- + float + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import portfolio_volatility + >>> rng = np.random.default_rng(1) + >>> r = rng.normal(0, 0.01, (252, 3)) + >>> vol = portfolio_volatility(r, weights=[1/3, 1/3, 1/3]) + >>> vol > 0 + True + """ + try: + import pandas as pd + + if isinstance(returns, pd.DataFrame): + arr = returns.values.astype(np.float64, copy=False) + else: + arr = np.asarray(returns, dtype=np.float64) + except ImportError: + arr = np.asarray(returns, dtype=np.float64) + + arr = np.ascontiguousarray(arr) + cov = np.cov(arr.T) + if cov.ndim == 0: + cov = np.array([[float(cov)]]) + cov = np.ascontiguousarray(cov) + w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64)) + vol = _rust_port_vol(cov, w) + if annualise is not None: + vol *= float(annualise) ** 0.5 + return vol + + +# --------------------------------------------------------------------------- +# beta +# --------------------------------------------------------------------------- + + +def beta( + asset_returns: ArrayLike, + benchmark_returns: ArrayLike, + *, + window: Optional[int] = None, +) -> Union[float, NDArray[np.float64]]: + """Compute beta of an asset vs a benchmark. + + Parameters + ---------- + asset_returns, benchmark_returns : array-like + Fractional returns per bar (equal length, >= 2 elements). + window : int, optional + If given, compute rolling beta over a sliding window of this size. + Returns a 1-D array with ``NaN`` for the first ``window-1`` bars. + If ``None`` (default), return the full-sample scalar beta. + + Returns + ------- + float or numpy.ndarray + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import beta + >>> rng = np.random.default_rng(2) + >>> bench = rng.normal(0, 0.01, 100) + >>> asset = 1.2 * bench + rng.normal(0, 0.001, 100) + >>> abs(beta(asset, bench) - 1.2) < 0.05 + True + """ + a = _to_f64(asset_returns) + b = _to_f64(benchmark_returns) + if window is not None: + return _rust_rolling_beta(a, b, int(window)) + return _rust_beta_full(a, b) + + +# --------------------------------------------------------------------------- +# drawdown +# --------------------------------------------------------------------------- + + +def drawdown( + equity: ArrayLike, + *, + as_series: bool = True, +) -> Union[tuple[NDArray[np.float64], float], float]: + """Compute the drawdown series and maximum drawdown. + + Parameters + ---------- + equity : array-like + Equity or price series (e.g. portfolio equity curve). + as_series : bool + If ``True`` (default), return ``(drawdown_array, max_drawdown)``. + If ``False``, return only the scalar max_drawdown. + + Returns + ------- + (numpy.ndarray, float) when *as_series* is True; + float when *as_series* is False. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.portfolio import drawdown + >>> eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + >>> dd, max_dd = drawdown(eq) + >>> round(max_dd, 4) + -0.1818 + """ + eq = _to_f64(equity) + dd_arr, max_dd = _rust_drawdown(eq) + if as_series: + return dd_arr, max_dd + return max_dd diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/regime.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/regime.py new file mode 100644 index 0000000..b18b74e --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/regime.py @@ -0,0 +1,594 @@ +""" +ferro_ta.regime — Regime detection and structural breaks. +========================================================= + +Detect market regimes (trending vs ranging) and structural breaks in price or +indicator series using existing ferro-ta indicators plus rule-based methods. + +Functions +--------- +regime(ohlcv, method='adx', **kwargs) + Label each bar as trending (1), ranging (0), or warm-up (-1). + Supported methods: ``'adx'``, ``'combined'``. + +structural_breaks(series, method='cusum', **kwargs) + Detect structural breaks. Returns a binary mask (1 = break). + Supported methods: ``'cusum'``, ``'variance'``. + +regime_adx(adx, threshold=25.0) + Low-level: label bars using an ADX array directly. + +regime_combined(adx, atr, close, adx_threshold=25.0, atr_pct_threshold=0.005) + Low-level: ADX + ATR-ratio labelling. + +detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5) + Low-level: CUSUM-based structural break detection. + +rolling_variance_break(series, short_window=10, long_window=50, threshold=2.0) + Low-level: rolling variance ratio break detection. + +Rust backend +------------ + ferro_ta._ferro_ta.regime_adx + ferro_ta._ferro_ta.regime_combined + ferro_ta._ferro_ta.detect_breaks_cusum + ferro_ta._ferro_ta.rolling_variance_break +""" + +from __future__ import annotations + +from typing import Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import ( + detect_breaks_cusum as _rust_detect_breaks_cusum, +) +from ferro_ta._ferro_ta import ( + regime_adx as _rust_regime_adx, +) +from ferro_ta._ferro_ta import ( + regime_combined as _rust_regime_combined, +) +from ferro_ta._ferro_ta import ( + rolling_variance_break as _rust_rolling_variance_break, +) +from ferro_ta._utils import _to_f64 + +__all__ = [ + "regime", + "structural_breaks", + "regime_adx", + "regime_combined", + "detect_breaks_cusum", + "rolling_variance_break", +] + +# type alias for OHLCV tuple +OHLCVTuple = tuple[ArrayLike, ArrayLike, ArrayLike, ArrayLike, ArrayLike] + + +# --------------------------------------------------------------------------- +# Low-level wrappers +# --------------------------------------------------------------------------- + + +def regime_adx( + adx: ArrayLike, + threshold: float = 25.0, +) -> NDArray[np.int8]: + """Label each bar as trend (1), range (0), or warm-up (-1) using ADX. + + Parameters + ---------- + adx : array-like — ADX values (NaN during warm-up) + threshold : float — ADX level above which a bar is "trending" (default 25) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up (NaN) + """ + return np.asarray( + _rust_regime_adx(_to_f64(adx), float(threshold)), + dtype=np.int8, + ) + + +def regime_combined( + adx: ArrayLike, + atr: ArrayLike, + close: ArrayLike, + adx_threshold: float = 25.0, + atr_pct_threshold: float = 0.005, +) -> NDArray[np.int8]: + """Label bars using ADX + ATR-as-%-of-close rule. + + A bar is "trending" when both: + - ``adx[i] > adx_threshold`` + - ``atr[i] / close[i] > atr_pct_threshold`` + + Parameters + ---------- + adx : array-like — ADX values + atr : array-like — ATR values + close : array-like — close prices + adx_threshold : float — ADX threshold (default 25.0) + atr_pct_threshold : float — minimum ATR/close ratio (default 0.005) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` NaN + """ + return np.asarray( + _rust_regime_combined( + _to_f64(adx), + _to_f64(atr), + _to_f64(close), + float(adx_threshold), + float(atr_pct_threshold), + ), + dtype=np.int8, + ) + + +def detect_breaks_cusum( + series: ArrayLike, + window: int = 20, + threshold: float = 3.0, + slack: float = 0.5, +) -> NDArray[np.int8]: + """Detect structural breaks using CUSUM (cumulative sum) approach. + + Parameters + ---------- + series : array-like — price or indicator series to monitor + window : int — lookback window for mean/std estimation (>= 2, default 20) + threshold : float — CUSUM threshold in units of std (default 3.0) + slack : float — allowance term (default 0.5) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + """ + return np.asarray( + _rust_detect_breaks_cusum( + _to_f64(series), + int(window), + float(threshold), + float(slack), + ), + dtype=np.int8, + ) + + +def rolling_variance_break( + series: ArrayLike, + short_window: int = 10, + long_window: int = 50, + threshold: float = 2.0, +) -> NDArray[np.int8]: + """Detect volatility regime breaks using a rolling variance ratio test. + + Parameters + ---------- + series : array-like — returns or price series + short_window : int — recent variance lookback (>= 2, default 10) + long_window : int — baseline variance lookback (> short_window, default 50) + threshold : float — ratio short_var/long_var above which a break fires + (default 2.0) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + """ + return np.asarray( + _rust_rolling_variance_break( + _to_f64(series), + int(short_window), + int(long_window), + float(threshold), + ), + dtype=np.int8, + ) + + +# --------------------------------------------------------------------------- +# High-level API +# --------------------------------------------------------------------------- + + +def regime( + ohlcv: Union[OHLCVTuple, object], # also accepts pandas.DataFrame + method: str = "adx", + adx_threshold: float = 25.0, + atr_pct_threshold: float = 0.005, + adx_timeperiod: int = 14, + atr_timeperiod: int = 14, +) -> NDArray[np.int8]: + """Label each bar as trending (1) or ranging (0) using existing indicators. + + Parameters + ---------- + ohlcv : tuple ``(open, high, low, close, volume)`` or pandas DataFrame + method : str + - ``'adx'`` (default) — uses ADX > *adx_threshold* + - ``'combined'`` — uses ADX + ATR/close ratio + adx_threshold : float — ADX level threshold (default 25.0) + atr_pct_threshold : float — minimum ATR/close ratio for ``'combined'`` + (default 0.005 = 0.5%) + adx_timeperiod : int — ADX period (default 14) + atr_timeperiod : int — ATR period for combined method (default 14) + + Returns + ------- + numpy.ndarray of int8 — ``1`` trend, ``0`` range, ``-1`` warm-up + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.regime import regime + >>> rng = np.random.default_rng(1) + >>> n = 200 + >>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 + >>> open_ = close * rng.uniform(0.998, 1.002, n) + >>> high = np.maximum(close, open_) + rng.uniform(0, 0.5, n) + >>> low = np.minimum(close, open_) - rng.uniform(0, 0.5, n) + >>> vol = rng.uniform(1000, 5000, n) + >>> labels = regime((open_, high, low, close, vol)) + >>> # Count trending bars (excluding warm-up) + >>> valid = labels[labels >= 0] + >>> trend_pct = (valid == 1).sum() / len(valid) + """ + from ferro_ta import ADX, ATR # local import to avoid circular dependency + + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + cols = {c.lower(): c for c in ohlcv.columns} # type: ignore[union-attr] + high_arr = _to_f64(ohlcv[cols["high"]].values) # type: ignore[index] + low_arr = _to_f64(ohlcv[cols["low"]].values) # type: ignore[index] + close_arr = _to_f64(ohlcv[cols["close"]].values) # type: ignore[index] + else: + _, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + except ImportError: + _, high_arr, low_arr, close_arr, _ = [_to_f64(x) for x in ohlcv] # type: ignore[union-attr] + + adx_vals = np.asarray( + ADX(high_arr, low_arr, close_arr, timeperiod=adx_timeperiod), dtype=np.float64 + ) + + if method == "adx": + return regime_adx(adx_vals, threshold=adx_threshold) + elif method == "combined": + atr_vals = np.asarray( + ATR(high_arr, low_arr, close_arr, timeperiod=atr_timeperiod), + dtype=np.float64, + ) + return regime_combined( + adx_vals, + atr_vals, + close_arr, + adx_threshold=adx_threshold, + atr_pct_threshold=atr_pct_threshold, + ) + else: + raise ValueError(f"Unknown regime method '{method}'. Use 'adx' or 'combined'.") + + +# --------------------------------------------------------------------------- +# Phase 4: Volatility/Trend regime detection (pure NumPy) +# --------------------------------------------------------------------------- + + +try: + from ferro_ta._ferro_ta import sma as _rust_sma +except ImportError: + _rust_sma = None + + +def _rolling_sma_pure(arr: np.ndarray, window: int) -> np.ndarray: + """Rolling SMA — delegates to the Rust SMA when available.""" + if _rust_sma is not None: + return np.asarray(_rust_sma(arr, window), dtype=np.float64) + # Fallback: O(n) rolling SMA using cumsum + n = len(arr) + out = np.full(n, np.nan) + if window > n: + return out + cs = np.cumsum(arr) + out[window - 1] = cs[window - 1] / window + if window < n: + out[window:] = (cs[window:] - cs[: n - window]) / window + return out + + +def _rolling_std_pure(arr: np.ndarray, window: int) -> np.ndarray: + """O(n) rolling std using cumsum-of-squares on the valid (non-NaN) portion. + + Handles leading NaN values (e.g., log returns where arr[0] is NaN). + NaN is returned for warm-up bars. + """ + n = len(arr) + out = np.full(n, np.nan) + if window < 2 or window > n: + return out + + # Find the first non-NaN index + first_valid = 0 + while first_valid < n and np.isnan(arr[first_valid]): + first_valid += 1 + + if first_valid >= n: + return out # all NaN + + # Work on the valid slice + valid_slice = arr[first_valid:] + m = len(valid_slice) + if window > m: + return out + + cs = np.cumsum(valid_slice) + cs2 = np.cumsum(valid_slice**2) + + n_windows = m - window + 1 + s = np.empty(n_windows) + s2 = np.empty(n_windows) + s[0] = cs[window - 1] + s2[0] = cs2[window - 1] + if n_windows > 1: + s[1:] = cs[window:] - cs[: m - window] + s2[1:] = cs2[window:] - cs2[: m - window] + + mean = s / window + var = np.maximum(s2 / window - mean**2, 0.0) + stds = np.sqrt(var) + + # Place back into output (first result is at index first_valid + window - 1) + start_out = first_valid + window - 1 + out[start_out : start_out + n_windows] = stds + return out + + +def detect_volatility_regime( + close: ArrayLike, + window: int = 20, + n_regimes: int = 3, +) -> NDArray: + """Label bars by rolling volatility percentile bucket (0 = lowest vol regime). + + Uses rolling standard deviation of log returns. NaN for warm-up bars + (returned as -1 in the integer output). + + Parameters + ---------- + close : array-like + Close price series. + window : int + Rolling window for std computation (default 20). + n_regimes : int + Number of volatility regimes (default 3: low/mid/high = 0/1/2). + + Returns + ------- + NDArray[int64] + Integer array where each element is in {-1, 0, ..., n_regimes-1}. + -1 indicates NaN (warm-up) bars. + """ + c = np.asarray(close, dtype=np.float64) + n = len(c) + out = np.full(n, -1, dtype=np.int64) + + log_ret = np.full(n, np.nan) + with np.errstate(divide="ignore", invalid="ignore"): + log_ret[1:] = np.log(c[1:] / c[:-1]) + + rolling_vol = _rolling_std_pure(log_ret, window) + + valid = ~np.isnan(rolling_vol) + if not np.any(valid): + return out + + vol_vals = rolling_vol[valid] + pcts = [100.0 * k / n_regimes for k in range(1, n_regimes)] + boundaries = np.percentile(vol_vals, pcts) if pcts else np.array([]) + + labels = np.digitize(vol_vals, boundaries).astype(np.int64) + + out[valid] = labels + return out + + +def detect_trend_regime( + close: ArrayLike, + fast: int = 50, + slow: int = 200, +) -> NDArray: + """Label bars: 1=bull (fast SMA > slow SMA), -1=bear, 0=sideways/NaN warmup. + + Parameters + ---------- + close : array-like + Close price series. + fast : int + Fast SMA period (default 50). + slow : int + Slow SMA period (default 200). + + Returns + ------- + NDArray[int64] + Integer array with values in {-1, 0, 1}. + 0 for warm-up bars where either SMA is NaN. + """ + c = np.asarray(close, dtype=np.float64) + n = len(c) + out = np.zeros(n, dtype=np.int64) + + fast_sma = _rolling_sma_pure(c, fast) + slow_sma = _rolling_sma_pure(c, slow) + + valid = ~np.isnan(fast_sma) & ~np.isnan(slow_sma) + out[valid & (fast_sma > slow_sma)] = 1 + out[valid & (fast_sma < slow_sma)] = -1 + return out + + +def detect_combined_regime( + close: ArrayLike, + vol_window: int = 20, + fast: int = 50, + slow: int = 200, +) -> NDArray: + """Combine trend + vol into 6-state integer regime label. + + States: 0=bull+low-vol, 1=bull+mid-vol, 2=bull+high-vol, + 3=bear+low-vol, 4=bear+mid-vol, 5=bear+high-vol. + NaN bars (warm-up or sideways) → -1. + + Parameters + ---------- + close : array-like + Close price series. + vol_window : int + Rolling window for volatility regime detection. + fast, slow : int + SMA periods for trend regime detection. + + Returns + ------- + NDArray[int64] + Integer array with values in {-1, 0, 1, 2, 3, 4, 5}. + """ + c = np.asarray(close, dtype=np.float64) + n = len(c) + out = np.full(n, -1, dtype=np.int64) + + trend = detect_trend_regime(c, fast=fast, slow=slow) + vol = detect_volatility_regime(c, window=vol_window, n_regimes=3) + + bull_valid = (trend == 1) & (vol >= 0) + bear_valid = (trend == -1) & (vol >= 0) + + out[bull_valid] = vol[bull_valid] # 0, 1, or 2 + out[bear_valid] = 3 + vol[bear_valid] # 3, 4, or 5 + + return out + + +class RegimeFilter: + """Filter trading signals to only fire in allowed market regimes. + + Parameters + ---------- + allowed_regimes : list[int] + Which regime labels to trade in. Signals in other regimes are zeroed out. + vol_window : int + Rolling window for volatility regime detection. + fast, slow : int + SMA periods for trend regime detection. + """ + + def __init__( + self, + allowed_regimes: list[int], + vol_window: int = 20, + fast: int = 50, + slow: int = 200, + ) -> None: + self.allowed_regimes = list(allowed_regimes) + self._allowed_regimes_arr = np.array(allowed_regimes, dtype=np.int64) + self.vol_window = int(vol_window) + self.fast = int(fast) + self.slow = int(slow) + + def filter(self, signals: ArrayLike, close: ArrayLike) -> NDArray: + """Zero out signals where regime is not in allowed_regimes. + + Parameters + ---------- + signals : array-like + Signal array (+1, -1, 0, or NaN). + close : array-like + Close price series (same length as signals). + + Returns + ------- + NDArray[float64] + Filtered signal array — signals in disallowed regimes are set to 0. + """ + s = np.asarray(signals, dtype=np.float64).copy() + regimes = detect_combined_regime( + close, + vol_window=self.vol_window, + fast=self.fast, + slow=self.slow, + ) + in_allowed = np.isin(regimes, self._allowed_regimes_arr) + s[~in_allowed] = 0.0 + return s + + +# --------------------------------------------------------------------------- +# (original structural_breaks below) +# --------------------------------------------------------------------------- + + +def structural_breaks( + series: ArrayLike, + method: str = "cusum", + window: int = 20, + threshold: float = 3.0, + slack: float = 0.5, + short_window: int = 10, + long_window: int = 50, + variance_threshold: float = 2.0, +) -> NDArray[np.int8]: + """Detect structural breaks in a series. + + Parameters + ---------- + series : array-like — price or returns series to monitor + method : str + - ``'cusum'`` (default) — CUSUM-based break detection + - ``'variance'`` — rolling variance ratio break detection + window : int — CUSUM lookback window (default 20) + threshold: float — CUSUM threshold in std units (default 3.0) + slack : float — CUSUM slack term (default 0.5) + short_window : int — short variance window for ``'variance'`` (default 10) + long_window : int — long variance window for ``'variance'`` (default 50) + variance_threshold : float — variance ratio threshold (default 2.0) + + Returns + ------- + numpy.ndarray of int8 — ``1`` at break bars, ``0`` elsewhere + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.regime import structural_breaks + >>> rng = np.random.default_rng(42) + >>> # Create a series with a structural break in the middle + >>> s1 = rng.normal(0, 1, 100) + >>> s2 = rng.normal(5, 3, 100) # different mean/variance + >>> series = np.concatenate([s1, s2]) + >>> breaks = structural_breaks(series, method='cusum') + >>> int(breaks[100:115].any()) # break near index 100 + 1 + """ + if method == "cusum": + return detect_breaks_cusum( + series, window=window, threshold=threshold, slack=slack + ) + elif method == "variance": + return rolling_variance_break( + series, + short_window=short_window, + long_window=long_window, + threshold=variance_threshold, + ) + else: + raise ValueError( + f"Unknown structural_breaks method '{method}'. Use 'cusum' or 'variance'." + ) diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/resample.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/resample.py new file mode 100644 index 0000000..172c56f --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/resample.py @@ -0,0 +1,139 @@ +""" +OHLCV bar aggregation utilities. + +resample_ohlcv(open, high, low, close, volume, factor) + Aggregate every `factor` bars into one OHLCV bar. + open = first bar's open + high = max of highs + low = min of lows + close = last bar's close + volume = sum of volumes + +resample_ohlcv_labels(n_bars, factor) + Return an integer label array of length n_bars where label[i] = i // factor. + Useful for aligning fine-bar signals with coarse-bar indicators. + +align_to_coarse(coarse_values, factor, n_fine_bars) + Broadcast a coarse-bar array back to fine-bar length by repeating each value `factor` times. + Handles the case where n_fine_bars % factor != 0 (last group may be partial). +""" + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = ["resample_ohlcv", "resample_ohlcv_labels", "align_to_coarse"] + + +def resample_ohlcv( + open_: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + factor: int, +) -> tuple[NDArray, NDArray, NDArray, NDArray, NDArray]: + """Aggregate fine-bar OHLCV into coarser bars. + + Parameters + ---------- + open_ : array-like + Fine-bar open prices. + high : array-like + Fine-bar high prices. + low : array-like + Fine-bar low prices. + close : array-like + Fine-bar close prices. + volume : array-like + Fine-bar volume. + factor : int + Number of fine bars per coarse bar (e.g. 5 for 1-min -> 5-min). + + Returns + ------- + (open, high, low, close, volume) arrays of length ceil(n / factor). + Only complete groups are returned — if n % factor != 0, trailing bars are dropped. + """ + if factor < 1: + raise ValueError(f"factor must be >= 1, got {factor}") + + o = np.asarray(open_, dtype=np.float64) + h = np.asarray(high, dtype=np.float64) + low_arr = np.asarray(low, dtype=np.float64) + c = np.asarray(close, dtype=np.float64) + v = np.asarray(volume, dtype=np.float64) + + n = len(o) + n_complete = (n // factor) * factor # truncate to complete bars + + o = o[:n_complete].reshape(-1, factor) + h = h[:n_complete].reshape(-1, factor) + low_arr = low_arr[:n_complete].reshape(-1, factor) + c = c[:n_complete].reshape(-1, factor) + v = v[:n_complete].reshape(-1, factor) + + return ( + o[:, 0], # open = first bar's open + h.max(axis=1), # high = max of highs + low_arr.min(axis=1), # low = min of lows + c[:, -1], # close = last bar's close + v.sum(axis=1), # volume = sum of volumes + ) + + +def resample_ohlcv_labels(n_bars: int, factor: int) -> NDArray: + """Return coarse-bar index for each fine bar (i // factor). + + Parameters + ---------- + n_bars : int + Number of fine-resolution bars. + factor : int + Number of fine bars per coarse bar. + + Returns + ------- + NDArray of int64, shape (n_bars,), where label[i] = i // factor. + """ + if factor < 1: + raise ValueError(f"factor must be >= 1, got {factor}") + return np.arange(n_bars, dtype=np.int64) // factor + + +def align_to_coarse(coarse_values: ArrayLike, factor: int, n_fine_bars: int) -> NDArray: + """Broadcast coarse-bar array back to fine-bar resolution. + + Each coarse value is repeated `factor` times. If n_fine_bars % factor != 0, + the last coarse value covers the partial group at the end. + + Parameters + ---------- + coarse_values : array-like + Values at coarse resolution, shape (n_coarse,). + factor : int + Number of fine bars per coarse bar. + n_fine_bars : int + Total number of fine bars to produce. + + Returns + ------- + NDArray of shape (n_fine_bars,). + """ + if factor < 1: + raise ValueError(f"factor must be >= 1, got {factor}") + + coarse = np.asarray(coarse_values, dtype=np.float64) + n_coarse = len(coarse) + + # Build the full repeated array (may be longer than n_fine_bars if partial group exists) + repeated = np.repeat(coarse, factor) + + # If repeated is shorter than n_fine_bars (shouldn't happen with correct n_coarse, + # but handle defensively), pad with last value + if len(repeated) < n_fine_bars: + pad = np.full( + n_fine_bars - len(repeated), coarse[-1] if n_coarse > 0 else np.nan + ) + repeated = np.concatenate([repeated, pad]) + + return repeated[:n_fine_bars] diff --git a/vendor/ferro-ta-main/python/ferro_ta/analysis/signals.py b/vendor/ferro-ta-main/python/ferro_ta/analysis/signals.py new file mode 100644 index 0000000..5920e46 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/analysis/signals.py @@ -0,0 +1,222 @@ +""" +ferro_ta.signals — Signal composition and screening. + +Provides helpers to combine multiple indicator outputs into a composite score +and to screen/rank symbols by that score. + +Functions +--------- +compose(signals, weights=None, method='weighted') + Combine a DataFrame (or 2-D array) of signals into one composite score + per bar. Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based), + ``'mean'`` (equal-weight mean). + +screen(scores, top_n=None, bottom_n=None, above=None, below=None) + Filter/rank a dict or Series of per-symbol scores. + +rank_signals(x) + Compute the fractional rank of each element in *x* (wrapper around Rust). + +Rust backend +------------ + ferro_ta._ferro_ta.compose_weighted + ferro_ta._ferro_ta.rank_series + ferro_ta._ferro_ta.top_n_indices + ferro_ta._ferro_ta.bottom_n_indices +""" + +from __future__ import annotations + +from typing import Any, Optional, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n +from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank +from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted +from ferro_ta._ferro_ta import rank_series as _rust_rank_series +from ferro_ta._ferro_ta import top_n_indices as _rust_top_n +from ferro_ta._utils import _to_f64 + +__all__ = [ + "compose", + "screen", + "rank_signals", +] + + +# --------------------------------------------------------------------------- +# rank_signals +# --------------------------------------------------------------------------- + + +def rank_signals(x: ArrayLike) -> NDArray[np.float64]: + """Compute the fractional rank of each element (1-based, ascending). + + Ties receive the average of their rank positions. + + Parameters + ---------- + x : array-like — 1-D + + Returns + ------- + numpy.ndarray of ranks in [1, n] + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.signals import rank_signals + >>> rank_signals(np.array([3.0, 1.0, 2.0])) + array([3., 1., 2.]) + """ + return _rust_rank_series(_to_f64(x)) + + +# --------------------------------------------------------------------------- +# compose +# --------------------------------------------------------------------------- + + +def compose( + signals: Any, + weights: Optional[ArrayLike] = None, + method: str = "weighted", +) -> NDArray[np.float64]: + """Combine multiple signal columns into one composite score per bar. + + Parameters + ---------- + signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals) + Each column is one indicator/signal. + weights : array-like of length n_signals, optional + Weights for each signal column. Required for ``method='weighted'``. + If ``None`` and method is ``'weighted'``, equal weights are used. + method : str + Composition method: + - ``'weighted'`` (default) — weighted sum (Rust fast path) + - ``'mean'`` — equal-weight mean (equivalent to weighted with 1/n) + - ``'rank'`` — sum of per-signal ranks (rank-based scoring) + + Returns + ------- + numpy.ndarray of length n_bars + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.analysis.signals import compose + >>> rng = np.random.default_rng(0) + >>> sigs = rng.standard_normal((50, 3)) + >>> score = compose(sigs, weights=[0.5, 0.3, 0.2]) + >>> score.shape + (50,) + """ + try: + import pandas as pd + + if isinstance(signals, pd.DataFrame): + arr = signals.values.astype(np.float64, copy=False) + else: + arr = np.asarray(signals, dtype=np.float64) + except ImportError: + arr = np.asarray(signals, dtype=np.float64) + + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + n_bars, n_sigs = arr.shape + arr = np.ascontiguousarray(arr) + + if method == "mean": + w = np.full(n_sigs, 1.0 / n_sigs) + return _rust_compose_weighted(arr, w) + elif method == "rank": + return _rust_compose_rank(arr) + else: + # weighted (default) + if weights is None: + w = np.full(n_sigs, 1.0 / n_sigs) + else: + w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64)) + return _rust_compose_weighted(arr, w) + + +# --------------------------------------------------------------------------- +# screen +# --------------------------------------------------------------------------- + + +def screen( + scores: Union[dict[str, float], Any], + top_n: Optional[int] = None, + bottom_n: Optional[int] = None, + above: Optional[float] = None, + below: Optional[float] = None, +) -> Any: + """Filter and rank symbols by composite score. + + Parameters + ---------- + scores : dict {symbol: score} or pandas.Series or array-like + Per-symbol scores. + top_n : int, optional + Return the top-N symbols by score. + bottom_n : int, optional + Return the bottom-N symbols by score. + above : float, optional + Return all symbols with score > *above*. + below : float, optional + Return all symbols with score < *below*. + + Returns + ------- + dict {symbol: score} sorted by score (descending for top_n, ascending for + bottom_n), or a pandas.DataFrame if pandas is available and input is a + Series/DataFrame. + + Examples + -------- + >>> from ferro_ta.analysis.signals import screen + >>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3} + >>> result = screen(scores, top_n=2) + >>> list(result.keys()) + ['MSFT', 'AAPL'] + """ + # Normalise to dict + try: + import pandas as pd + + if isinstance(scores, pd.Series): + symbols = scores.index.tolist() # type: ignore[union-attr] + values = scores.values.astype(np.float64) # type: ignore[union-attr] + elif isinstance(scores, dict): + symbols = list(scores.keys()) + values = np.array(list(scores.values()), dtype=np.float64) + else: + symbols = list(range(len(scores))) + values = np.array(list(scores), dtype=np.float64) + except ImportError: + if isinstance(scores, dict): + symbols = list(scores.keys()) + values = np.array(list(scores.values()), dtype=np.float64) + else: + symbols = list(range(len(scores))) + values = np.array(list(scores), dtype=np.float64) + + if top_n is not None: + idxs = _rust_top_n(values, int(top_n)) + # Sort by score descending + idxs = sorted(idxs, key=lambda i: -values[i]) + return {symbols[i]: float(values[i]) for i in idxs} + if bottom_n is not None: + idxs = _rust_bottom_n(values, int(bottom_n)) + idxs = sorted(idxs, key=lambda i: values[i]) + return {symbols[i]: float(values[i]) for i in idxs} + if above is not None: + return {s: float(v) for s, v in zip(symbols, values) if v > above} + if below is not None: + return {s: float(v) for s, v in zip(symbols, values) if v < below} + # Default: return all sorted descending + order = sorted(range(len(values)), key=lambda i: -values[i]) + return {symbols[i]: float(values[i]) for i in order} diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/core/__init__.py new file mode 100644 index 0000000..25fd4a2 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/__init__.py @@ -0,0 +1,16 @@ +""" +ferro_ta.core — Core utilities: exceptions, configuration, logging, registry, raw bindings. + +Sub-modules +----------- +* :mod:`ferro_ta.core.exceptions` — Custom exception hierarchy and error helpers +* :mod:`ferro_ta.core.config` — Global configuration and defaults +* :mod:`ferro_ta.core.logging_utils` — Debug-logging helpers +* :mod:`ferro_ta.core.registry` — Indicator function registry +* :mod:`ferro_ta.core.raw` — Raw Rust-binding wrappers (zero-overhead pass-through) + +Import directly from sub-modules to avoid circular dependencies, e.g.:: + + from ferro_ta.core.exceptions import FerroTAError + from ferro_ta.core.registry import register, run +""" diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/config.py b/vendor/ferro-ta-main/python/ferro_ta/core/config.py new file mode 100644 index 0000000..1a0d412 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/config.py @@ -0,0 +1,257 @@ +""" +ferro_ta.config — Global configuration and indicator defaults. + +This module provides a simple configuration system that allows you to set +global default values for indicator parameters (e.g. default RSI period) +without having to pass them on every call. Defaults are overridden by +explicit keyword arguments to any indicator function. + +Usage +----- +>>> import ferro_ta.core.config as config +>>> config.set_default("timeperiod", 20) # global fallback for all indicators +>>> config.set_default("RSI.timeperiod", 14) # RSI-specific override + +>>> from ferro_ta import RSI +>>> import numpy as np +>>> close = np.arange(1.0, 25.0) +>>> RSI(close) # uses RSI.timeperiod=14 from config +>>> RSI(close, timeperiod=5) # explicit argument wins + +Context manager +--------------- +Use :class:`Config` as a context manager for temporary overrides: + +>>> with config.Config(timeperiod=5): +... result = RSI(close) # timeperiod=5 inside the block + +Resetting +--------- +>>> config.reset() # remove all custom defaults + +API +--- +set_default(key, value) — Set a global default. *key* can be a plain + parameter name (``"timeperiod"``) or an + indicator-qualified name (``"RSI.timeperiod"``). +get_default(key, fallback) — Get the current default for *key*. +reset(key=None) — Reset one or all defaults to their built-in values. +Config(**overrides) — Context manager: temporarily set defaults. +""" + +from __future__ import annotations + +import threading +from typing import Any, Optional + +# --------------------------------------------------------------------------- +# Thread-local storage — each thread can have independent config snapshots +# (rare in practice but safe for testing). +# --------------------------------------------------------------------------- +_local = threading.local() + + +def _store() -> dict[str, Any]: + """Return the thread-local defaults store, creating it if necessary.""" + if not hasattr(_local, "defaults"): + _local.defaults = {} + return _local.defaults + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def set_default(key: str, value: Any) -> None: + """Set a global default parameter value. + + Parameters + ---------- + key : str + Parameter name (e.g. ``"timeperiod"``) or indicator-qualified name + (e.g. ``"RSI.timeperiod"``). Indicator-qualified defaults take + precedence over plain defaults when both are set. + value : any + Default value to store. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.set_default("RSI.timeperiod", 14) + """ + _store()[key] = value + + +def get_default(key: str, fallback: Any = None) -> Any: + """Return the current default for *key*, or *fallback* if not set. + + Parameters + ---------- + key : str + Parameter name (e.g. ``"timeperiod"``). + fallback : any, optional + Value returned when no default is set. + + Returns + ------- + any + The stored default value, or *fallback*. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.get_default("timeperiod") + 20 + >>> config.get_default("nonexistent", -1) + -1 + """ + return _store().get(key, fallback) + + +def get_defaults_for(indicator_name: str) -> dict[str, Any]: + """Return all applicable defaults for the given indicator. + + Indicator-qualified keys (``"RSI.timeperiod"``) override plain keys + (``"timeperiod"``) in the returned dict. + + Parameters + ---------- + indicator_name : str + Name of the indicator (e.g. ``"RSI"``). + + Returns + ------- + dict + Merged defaults where indicator-specific values override global ones. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.set_default("RSI.timeperiod", 14) + >>> config.get_defaults_for("RSI") + {'timeperiod': 14} + >>> config.get_defaults_for("SMA") + {'timeperiod': 20} + """ + store = _store() + prefix = f"{indicator_name}." + + # Start with plain defaults + result: dict[str, Any] = {} + for k, v in store.items(): + if "." not in k: + result[k] = v + + # Override with indicator-qualified defaults + for k, v in store.items(): + if k.startswith(prefix): + result[k[len(prefix) :]] = v + + return result + + +def reset(key: Optional[str] = None) -> None: + """Reset defaults. + + Parameters + ---------- + key : str, optional + If given, remove only this key. If ``None``, remove all defaults. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 20) + >>> config.reset("timeperiod") + >>> config.get_default("timeperiod") is None + True + >>> config.reset() # clear everything + """ + store = _store() + if key is None: + store.clear() + else: + store.pop(key, None) + + +def list_defaults() -> dict[str, Any]: + """Return a copy of all currently set defaults. + + Returns + ------- + dict + Copy of the current defaults store. + + Examples + -------- + >>> import ferro_ta.core.config as config + >>> config.set_default("timeperiod", 10) + >>> config.list_defaults() + {'timeperiod': 10} + """ + return dict(_store()) + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + + +class Config: + """Context manager for temporary configuration overrides. + + On entry, applies the specified overrides on top of the current defaults. + On exit, restores the previous state exactly. + + Parameters + ---------- + **overrides + Key-value pairs to set temporarily. + + Examples + -------- + >>> import numpy as np + >>> import ferro_ta.core.config as config + >>> from ferro_ta import RSI + >>> close = np.arange(1.0, 25.0) + >>> with config.Config(timeperiod=5): + ... config.get_default("timeperiod") + 5 + >>> config.get_default("timeperiod") is None # restored after exit + True + """ + + def __init__(self, **overrides: Any) -> None: + self._overrides = overrides + self._saved: dict[str, Any] = {} + + def __enter__(self) -> Config: + store = _store() + # Save current values for all keys we're about to change + self._saved = {k: store.get(k) for k in self._overrides} + # Apply overrides + for k, v in self._overrides.items(): + store[k] = v + return self + + def __exit__(self, *_: Any) -> None: + store = _store() + for k, saved_v in self._saved.items(): + if saved_v is None: + store.pop(k, None) + else: + store[k] = saved_v + + +__all__ = [ + "set_default", + "get_default", + "get_defaults_for", + "reset", + "list_defaults", + "Config", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/exceptions.py b/vendor/ferro-ta-main/python/ferro_ta/core/exceptions.py new file mode 100644 index 0000000..aa7d3f3 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/exceptions.py @@ -0,0 +1,337 @@ +""" +Custom exception hierarchy for ferro_ta. + +Exception classes +----------------- +FerroTAError — Base class for all ferro_ta exceptions. +FerroTAValueError — Raised for invalid parameter values (e.g. timeperiod < 1). +FerroTAInputError — Raised for invalid input arrays (e.g. mismatched lengths, wrong dtype, unexpected NaN/Inf when strict mode is used). + +All custom exceptions inherit from both the ferro_ta base and the corresponding +built-in exception (ValueError) so that existing ``except ValueError`` clauses +continue to work after upgrading. + +Error codes +----------- +Every exception carries a ``code`` attribute (e.g. ``"FTERR001"``) for +programmatic handling: + + FTERR001 — Invalid parameter value (FerroTAValueError) + FTERR002 — Invalid input array (FerroTAInputError) + FTERR003 — Input array too short (FerroTAInputError) + FTERR004 — Input arrays have mismatched lengths (FerroTAInputError) + FTERR005 — Input array contains NaN or Inf (FerroTAInputError, strict mode) + FTERR006 — General Rust-bridge error (FerroTAValueError or FerroTAInputError) + +Examples +-------- +>>> from ferro_ta.core.exceptions import FerroTAError, FerroTAValueError, FerroTAInputError +>>> raise FerroTAValueError("timeperiod must be >= 1, got 0") +Traceback (most recent call last): + ... +ferro_ta.exceptions.FerroTAValueError: [FTERR001] timeperiod must be >= 1, got 0 +>>> try: +... raise FerroTAValueError("bad value") +... except FerroTAValueError as exc: +... print(exc.code) +FTERR001 + +NaN / Inf policy +---------------- +By default ferro_ta **propagates** NaN and Inf in input arrays — output values +that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is +raised for NaN or Inf values in the input data. If you need strict mode, call +:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them. +""" + +from __future__ import annotations + +from typing import NoReturn + +# --------------------------------------------------------------------------- +# Error code registry +# --------------------------------------------------------------------------- + +#: Maps each ``FerroTAError`` subclass to its default error code. +ERROR_CODES: dict[str, str] = { + "FerroTAError": "FTERR000", + "FerroTAValueError": "FTERR001", + "FerroTAInputError": "FTERR002", +} + +# Well-known codes for specific error kinds +_CODE_TOO_SHORT = "FTERR003" +_CODE_LENGTH_MISMATCH = "FTERR004" +_CODE_NOT_FINITE = "FTERR005" +_CODE_RUST_BRIDGE = "FTERR006" + +# Code descriptions (for reference and programmatic inspection) +ERROR_CODE_DESCRIPTIONS: dict[str, str] = { + "FTERR000": "General ferro_ta error (base class fallback)", + "FTERR001": "Invalid parameter value", + "FTERR002": "Invalid input array", + "FTERR003": "Input array too short", + "FTERR004": "Input arrays have mismatched lengths", + "FTERR005": "Input array contains NaN or Inf (strict mode)", + "FTERR006": "Rust-bridge error (re-raised from Rust ValueError)", +} + + +class FerroTAError(Exception): + """Base class for all ferro_ta exceptions. + + Attributes + ---------- + code : str + A short error code string (e.g. ``"FTERR001"``) for programmatic + handling. The code is included at the beginning of the exception + message. + suggestion : str | None + Optional human-readable suggestion for how to fix the error. + """ + + code: str = "FTERR000" + suggestion: str | None = None + + def __init__( + self, + message: str, + *, + code: str | None = None, + suggestion: str | None = None, + ) -> None: + self.code = code or type(self).code + self.suggestion = suggestion + full_msg = f"[{self.code}] {message}" + if suggestion: + full_msg = f"{full_msg}\n Suggestion: {suggestion}" + super().__init__(full_msg) + + +class FerroTAValueError(FerroTAError, ValueError): + """Raised when a parameter value is out of the accepted range. + + Examples: ``timeperiod < 1``, ``fastperiod >= slowperiod`` for MACD. + + Default error code: ``FTERR001``. + """ + + code = "FTERR001" + + +class FerroTAInputError(FerroTAError, ValueError): + """Raised when one or more input arrays are invalid. + + Examples: mismatched lengths for open/high/low/close, wrong dtype that + cannot be coerced to float64. + + Default error code: ``FTERR002``. + """ + + code = "FTERR002" + + +# --------------------------------------------------------------------------- +# Finer-grained exception subclasses (added in 1.2.0). +# +# These are drop-in compatible with the base classes: every subclass still +# inherits from ``FerroTAError`` and ``ValueError``, so existing user code +# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working. +# The subclasses exist so users can catch *specific* failure modes without +# string-matching on the error message. +# --------------------------------------------------------------------------- + + +class InvalidPeriodError(FerroTAValueError): + """Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range. + + Default error code: ``FTERR001``. + """ + + +class InsufficientDataError(FerroTAInputError): + """Input array is shorter than the minimum required for the indicator. + + Default error code: ``FTERR003``. + """ + + code = "FTERR003" + + +class LengthMismatchError(FerroTAInputError): + """Two or more input arrays (e.g. OHLC) have different lengths. + + Default error code: ``FTERR004``. + """ + + code = "FTERR004" + + +class NumericConvergenceError(FerroTAValueError): + """An iterative calculation failed to converge within tolerance. + + Raised by iterative pricing models (implied volatility root-finding, + Newton-Raphson, etc.) when the maximum iteration count is exhausted. + """ + + +class InvalidInputError(FerroTAInputError): + """Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape. + + Default error code: ``FTERR005``. + """ + + code = "FTERR005" + + +# Public aliases that match the names documented in the README and +# CHANGELOG [Unreleased] section. +FerroTaError = FerroTAError # type: ignore[misc] + +# --------------------------------------------------------------------------- +# Validation helpers (called by Python wrappers) +# --------------------------------------------------------------------------- + + +def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) -> None: + """Raise :class:`FerroTAValueError` if *value* < *minimum*. + + Parameters + ---------- + value: + The period parameter to validate. + name: + Human-readable parameter name for the error message. + minimum: + Minimum acceptable value (default 1). + + Raises + ------ + FerroTAValueError + If ``value < minimum``. + """ + if value < minimum: + raise InvalidPeriodError( + f"{name} must be >= {minimum}, got {value}", + suggestion=f"Set {name}={minimum} or higher.", + ) + + +def check_equal_length(**arrays: object) -> None: + """Raise :class:`FerroTAInputError` if the supplied arrays differ in length. + + Parameters + ---------- + **arrays: + Keyword arguments mapping name → array-like. At least two arrays + should be supplied for the check to be meaningful. + + Raises + ------ + FerroTAInputError + If any two arrays have different lengths. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.core.exceptions import check_equal_length + >>> check_equal_length(open=np.array([1.0]), close=np.array([1.0, 2.0])) + Traceback (most recent call last): + ... + ferro_ta.exceptions.FerroTAInputError: ... + """ + + lengths = {} + for name, arr in arrays.items(): + if hasattr(arr, "__len__"): + lengths[name] = len(arr) # type: ignore[arg-type] + elif hasattr(arr, "shape"): + lengths[name] = arr.shape[0] # type: ignore[union-attr] + + if len(set(lengths.values())) > 1: + detail = ", ".join(f"{k}={v}" for k, v in lengths.items()) + raise LengthMismatchError( + f"All input arrays must have the same length. Got: {detail}", + code=_CODE_LENGTH_MISMATCH, + suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.", + ) + + +def check_finite(arr: object, name: str = "input") -> None: + """Raise :class:`FerroTAInputError` if *arr* contains NaN or Inf. + + This is an *opt-in* strict-mode helper. ferro_ta does **not** call this + automatically — it is provided for users who want deterministic behaviour + when their data may contain missing values. + + Parameters + ---------- + arr: + Array-like to check. + name: + Human-readable name used in the error message. + + Raises + ------ + FerroTAInputError + If any element of *arr* is NaN or Inf. + """ + import numpy as np # local import + + a = np.asarray(arr, dtype=np.float64) + if not np.all(np.isfinite(a)): + raise InvalidInputError( + f"{name} contains NaN or Inf values. " + "ferro_ta propagates NaN by default; call check_finite() only " + "when you require all-finite inputs.", + code=_CODE_NOT_FINITE, + suggestion="Use numpy.nan_to_num() or dropna() to clean your data before passing it to ferro_ta.", + ) + + +def check_min_length(arr: object, min_len: int, name: str = "input") -> None: + """Raise :class:`FerroTAInputError` if *arr* has length less than *min_len*. + + Parameters + ---------- + arr: + Array-like to check. + min_len: + Minimum required length. + name: + Human-readable name used in the error message. + + Raises + ------ + FerroTAInputError + If ``len(arr) < min_len``. + """ + length = 0 + if hasattr(arr, "__len__"): + length = len(arr) # type: ignore[arg-type] + elif hasattr(arr, "shape"): + length = arr.shape[0] # type: ignore[union-attr] + if length < min_len: + raise InsufficientDataError( + f"{name} must have at least {min_len} elements, got {length}", + code=_CODE_TOO_SHORT, + suggestion=f"Provide at least {min_len} data points. Current length: {length}.", + ) + + +def _normalize_rust_error(err: ValueError) -> NoReturn: + """Re-raise a Rust-originated ValueError as FerroTAValueError or FerroTAInputError. + + Used by Python wrappers so users can catch FerroTA* exceptions consistently. + """ + msg = str(err).lower() + if ( + "length" in msg + or "same length" in msg + or "array" in msg + or "mismatch" in msg + or "dimension" in msg + or "1-d" in msg + ): + raise FerroTAInputError(str(err), code=_CODE_RUST_BRIDGE) from err + raise FerroTAValueError(str(err), code=_CODE_RUST_BRIDGE) from err diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/logging_utils.py b/vendor/ferro-ta-main/python/ferro_ta/core/logging_utils.py new file mode 100644 index 0000000..41c7019 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/logging_utils.py @@ -0,0 +1,328 @@ +""" +ferro_ta.logging_utils — Logging integration and debug utilities. + +Provides a structured logging interface for ferro_ta with configurable +verbosity, debug mode, and optional performance timing. + +Usage +----- +>>> import ferro_ta.logging_utils as ft_log +>>> ft_log.enable_debug() # turn on DEBUG-level output +>>> ft_log.disable_debug() # back to WARNING level + +>>> # Use as a context manager for a single call: +>>> with ft_log.debug_mode(): +... result = ferro_ta.SMA(close, timeperiod=20) + +>>> # Access the ferro_ta logger directly: +>>> import logging +>>> logger = logging.getLogger("ferro_ta") +>>> logger.setLevel(logging.DEBUG) + +API +--- +get_logger() — Return the ``ferro_ta`` :class:`logging.Logger`. +enable_debug() — Set the ferro_ta logger to DEBUG level. +disable_debug() — Reset the ferro_ta logger to WARNING level. +debug_mode() — Context manager: temporarily enable debug logging. +log_call(func, ...) — Log a function call with input shapes and timing. +benchmark(func, ...) — Run *func* n times and return timing statistics. +""" + +from __future__ import annotations + +import contextlib +import functools +import logging +import time +from collections.abc import Callable, Iterator +from typing import Any, TypeVar + +__all__ = [ + "get_logger", + "enable_debug", + "disable_debug", + "debug_mode", + "log_call", + "benchmark", +] + +# --------------------------------------------------------------------------- +# Logger setup — single ``ferro_ta`` logger, handlers added lazily. +# --------------------------------------------------------------------------- + +_LOGGER_NAME = "ferro_ta" +_DEFAULT_FORMAT = "%(levelname)s [%(name)s] %(message)s" + +F = TypeVar("F", bound=Callable[..., Any]) + + +def get_logger() -> logging.Logger: + """Return the ``ferro_ta`` package logger. + + The logger is created on first call. A :class:`logging.NullHandler` is + installed so that no output appears by default (following the best-practice + for library loggers). Call :func:`enable_debug` or configure the logger + explicitly to see output. + + Returns + ------- + logging.Logger + The ``ferro_ta`` package logger. + """ + logger = logging.getLogger(_LOGGER_NAME) + if not logger.handlers: + logger.addHandler(logging.NullHandler()) + return logger + + +def enable_debug(fmt: str = _DEFAULT_FORMAT) -> None: + """Enable DEBUG-level logging for ferro_ta. + + Adds a :class:`logging.StreamHandler` that writes to *stderr* using *fmt* + and sets the logger level to ``DEBUG``. Calling this multiple times is + safe — duplicate handlers are not added. + + Parameters + ---------- + fmt: + Log message format string passed to :class:`logging.Formatter`. + """ + logger = get_logger() + logger.setLevel(logging.DEBUG) + # Avoid duplicate stream handlers + has_stream = any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + if not has_stream: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(fmt)) + logger.addHandler(handler) + + +def disable_debug() -> None: + """Reset the ferro_ta logger to WARNING level and remove stream handlers.""" + logger = get_logger() + logger.setLevel(logging.WARNING) + logger.handlers = [h for h in logger.handlers if isinstance(h, logging.NullHandler)] + + +@contextlib.contextmanager +def debug_mode(fmt: str = _DEFAULT_FORMAT) -> Iterator[logging.Logger]: + """Context manager: enable debug logging for the duration of the block. + + Parameters + ---------- + fmt: + Log message format string. + + Yields + ------ + logging.Logger + The ``ferro_ta`` logger with DEBUG level active. + + Examples + -------- + >>> import numpy as np + >>> import ferro_ta.logging_utils as ft_log + >>> close = np.arange(1.0, 30.0) + >>> with ft_log.debug_mode(): + ... pass # ferro_ta calls inside here will log debug info + """ + prev_level = get_logger().level + enable_debug(fmt) + try: + yield get_logger() + finally: + disable_debug() + get_logger().setLevel(prev_level) + + +# --------------------------------------------------------------------------- +# Helper: shape summary for numpy / pandas / polars arrays +# --------------------------------------------------------------------------- + + +def _shape_str(obj: Any) -> str: + """Return a compact shape/type description for logging.""" + try: + import numpy as np # noqa: PLC0415 + + if isinstance(obj, np.ndarray): + return f"ndarray{obj.shape} dtype={obj.dtype}" + except ImportError: + pass + + if hasattr(obj, "shape"): + return f"{type(obj).__name__}{obj.shape}" + if hasattr(obj, "__len__"): + return f"{type(obj).__name__}[{len(obj)}]" # type: ignore[arg-type] + return repr(obj) + + +# --------------------------------------------------------------------------- +# log_call: decorator / manual call logger +# --------------------------------------------------------------------------- + + +def log_call( + func: Callable[..., Any], + *args: Any, + **kwargs: Any, +) -> Any: + """Call *func* with *args*/*kwargs*, logging input shapes and elapsed time. + + Parameters + ---------- + func: + The ferro_ta indicator function to call. + *args: + Positional arguments forwarded to *func*. + **kwargs: + Keyword arguments forwarded to *func*. + + Returns + ------- + Any + The return value of ``func(*args, **kwargs)``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> import ferro_ta.logging_utils as ft_log + >>> ft_log.enable_debug() + >>> close = np.arange(1.0, 30.0) + >>> result = ft_log.log_call(SMA, close, timeperiod=5) + """ + logger = get_logger() + name = getattr(func, "__name__", repr(func)) + + if logger.isEnabledFor(logging.DEBUG): + arg_shapes = ", ".join(_shape_str(a) for a in args) + kwarg_shapes = ", ".join(f"{k}={_shape_str(v)}" for k, v in kwargs.items()) + all_args = ", ".join(filter(None, [arg_shapes, kwarg_shapes])) + logger.debug("calling %s(%s)", name, all_args) + + t0 = time.perf_counter() + result = func(*args, **kwargs) + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + + if logger.isEnabledFor(logging.DEBUG): + out_shape = ( + _shape_str(result) + if not isinstance(result, tuple) + else str(tuple(_shape_str(r) for r in result)) + ) + logger.debug("%s → %s [%.3f ms]", name, out_shape, elapsed_ms) + + return result + + +# --------------------------------------------------------------------------- +# benchmark: run a function N times and report timing statistics +# --------------------------------------------------------------------------- + + +def benchmark( + func: Callable[..., Any], + *args: Any, + n: int = 100, + warmup: int = 5, + **kwargs: Any, +) -> dict[str, float]: + """Benchmark *func* by calling it *n* times and returning timing stats. + + Parameters + ---------- + func: + The ferro_ta indicator function to benchmark. + *args: + Positional arguments forwarded to *func* on each call. + n: + Number of timed iterations (default 100). + warmup: + Number of warm-up calls before timing starts (default 5). + **kwargs: + Keyword arguments forwarded to *func* on each call. + + Returns + ------- + dict[str, float] + Dictionary with keys ``"mean_ms"``, ``"min_ms"``, ``"max_ms"``, + ``"total_ms"``, ``"n"``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA + >>> import ferro_ta.logging_utils as ft_log + >>> close = np.random.randn(10_000) + >>> stats = ft_log.benchmark(SMA, close, timeperiod=20, n=50) + >>> print(f"mean={stats['mean_ms']:.3f} ms") + mean=... ms + """ + name = getattr(func, "__name__", repr(func)) + + for _ in range(warmup): + func(*args, **kwargs) + + times: list[float] = [] + for _ in range(n): + t0 = time.perf_counter() + func(*args, **kwargs) + times.append((time.perf_counter() - t0) * 1000.0) + + total = sum(times) + mean = total / n + stats: dict[str, float] = { + "mean_ms": mean, + "min_ms": min(times), + "max_ms": max(times), + "total_ms": total, + "n": float(n), + } + + logger = get_logger() + if logger.isEnabledFor(logging.INFO): + logger.info( + "benchmark %s n=%d mean=%.3f ms min=%.3f ms max=%.3f ms", + name, + n, + stats["mean_ms"], + stats["min_ms"], + stats["max_ms"], + ) + + return stats + + +# --------------------------------------------------------------------------- +# traced: decorator that wraps a function with log_call behaviour +# --------------------------------------------------------------------------- + + +def traced(func: F) -> F: + """Decorator: wrap *func* so every call is logged at DEBUG level. + + Parameters + ---------- + func: + Function to wrap. + + Returns + ------- + Callable + Wrapped function with identical signature. + + Examples + -------- + >>> import ferro_ta.logging_utils as ft_log + >>> @ft_log.traced + ... def my_indicator(close, timeperiod=14): + ... return close # placeholder + """ + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + return log_call(func, *args, **kwargs) + + return wrapper # type: ignore[return-value] diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/raw.py b/vendor/ferro-ta-main/python/ferro_ta/core/raw.py new file mode 100644 index 0000000..5048268 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/raw.py @@ -0,0 +1,391 @@ +""" +ferro_ta.raw — Zero-overhead access to the compiled Rust extension. + +Importing from this module gives you direct access to the PyO3-compiled +indicator functions **without** the pandas/polars wrapping, Python validation, +or ``_to_f64`` conversion overhead applied by the standard public API. + +When to use +----------- +Use ``ferro_ta.raw`` when: + +- You have benchmarked and confirmed that wrapper overhead is your bottleneck. +- Your inputs are already 1-D C-contiguous ``float64`` NumPy arrays. +- You do not need ``pandas.Series`` or ``polars.Series`` output. +- You understand the trade-off: no nice error messages, no index preservation. + +Stability +--------- +The raw API is **not guaranteed to be stable** across minor versions. +Function signatures follow the compiled Rust extension directly and may +change when the Rust layer changes. For a stable API use the public +``ferro_ta.*`` functions. + +Usage +----- +>>> import numpy as np +>>> from ferro_ta.core.raw import sma, ema, rsi +>>> +>>> close = np.random.rand(1000).astype(np.float64) +>>> result = sma(close, 20) # returns numpy.ndarray directly +>>> result2 = rsi(close, 14) +>>> result3 = ema(close, 20) + +Batch (Rust loop, 2-D input): +>>> data = np.random.rand(252, 100).astype(np.float64) +>>> sma_out = batch_sma(data, 20) # shape (252, 100) — Rust inner loop + +Available names +--------------- +All functions registered by the ``_ferro_ta`` extension module are accessible +from this namespace. In addition to the canonical imports below, you can +use the ``_ferro_ta`` module directly:: + + from ferro_ta._ferro_ta import sma # identical to ferro_ta.raw.sma +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Re-export everything from the compiled extension. +# The ``noqa: F401`` silences "imported but unused" warnings — these are +# intentional re-exports. +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( # noqa: F401 + # Streaming classes (PyO3 classes) + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, + ad, + adosc, + adx, + adxr, + apo, + aroon, + aroonosc, + atr, + avgprice, + batch_ema, + batch_rsi, + batch_sma, + bbands, + beta, + bop, + cci, + cdl2crows, + cdl3blackcrows, + cdl3inside, + cdl3linestrike, + cdl3outside, + cdl3starsinsouth, + cdl3whitesoldiers, + cdlabandonedbaby, + cdladvanceblock, + cdlbelthold, + cdlbreakaway, + cdlclosingmarubozu, + cdlconcealbabyswall, + cdlcounterattack, + cdldarkcloudcover, + cdldoji, + cdldojistar, + cdldragonflydoji, + cdlengulfing, + cdleveningdojistar, + cdleveningstar, + cdlgapsidesidewhite, + cdlgravestonedoji, + cdlhammer, + cdlhangingman, + cdlharami, + cdlharamicross, + cdlhighwave, + cdlhikkake, + cdlhikkakemod, + cdlhomingpigeon, + cdlidentical3crows, + cdlinneck, + cdlinvertedhammer, + cdlkicking, + cdlkickingbylength, + cdlladderbottom, + cdllongleggeddoji, + cdllongline, + cdlmarubozu, + cdlmatchinglow, + cdlmathold, + cdlmorningdojistar, + cdlmorningstar, + cdlonneck, + cdlpiercing, + cdlrickshawman, + cdlrisefall3methods, + cdlseparatinglines, + cdlshootingstar, + cdlshortline, + cdlspinningtop, + cdlstalledpattern, + cdlsticksandwich, + cdltakuri, + cdltasukigap, + cdlthrusting, + cdltristar, + cdlunique3river, + cdlupsidegap2crows, + cdlxsidegap3methods, + # Extended indicators + chandelier_exit, + choppiness_index, + cmo, + correl, + dema, + donchian, + dx, + ema, + ht_dcperiod, + ht_dcphase, + ht_phasor, + ht_sine, + ht_trendline, + ht_trendmode, + hull_ma, + ichimoku, + kama, + keltner_channels, + linearreg, + linearreg_angle, + linearreg_intercept, + linearreg_slope, + ma, + macd, + macdext, + macdfix, + mama, + mavp, + medprice, + mfi, + midpoint, + midprice, + minus_di, + minus_dm, + mom, + natr, + obv, + pivot_points, + plus_di, + plus_dm, + ppo, + roc, + rocp, + rocr, + rocr100, + # Rolling math operators + rolling_max, + rolling_maxindex, + rolling_min, + rolling_minindex, + rolling_sum, + rsi, + sar, + sarext, + sma, + stddev, + stoch, + stochf, + stochrsi, + supertrend, + t3, + tema, + trange, + trima, + trix, + tsf, + typprice, + ultosc, + var, + vwap, + vwma, + wclprice, + willr, + wma, +) + +__all__ = [ + # Overlap + "sma", + "ema", + "wma", + "dema", + "tema", + "trima", + "kama", + "t3", + "bbands", + "macd", + "macdfix", + "macdext", + "sar", + "sarext", + "ma", + "mavp", + "mama", + "midpoint", + "midprice", + # Momentum + "rsi", + "mom", + "roc", + "rocp", + "rocr", + "rocr100", + "mfi", + "willr", + "adx", + "adxr", + "apo", + "ppo", + "cci", + "cmo", + "aroon", + "aroonosc", + "bop", + "stoch", + "stochf", + "stochrsi", + "ultosc", + "dx", + "plus_di", + "minus_di", + "plus_dm", + "minus_dm", + "trix", + # Volume + "ad", + "adosc", + "obv", + # Volatility + "atr", + "natr", + "trange", + # Statistics + "stddev", + "var", + "beta", + "correl", + "linearreg", + "linearreg_slope", + "linearreg_intercept", + "linearreg_angle", + "tsf", + # Price transforms + "avgprice", + "medprice", + "typprice", + "wclprice", + # Cycle + "ht_trendline", + "ht_dcperiod", + "ht_dcphase", + "ht_phasor", + "ht_sine", + "ht_trendmode", + # Pattern recognition (all 61 CDL functions) + "cdl2crows", + "cdl3blackcrows", + "cdl3inside", + "cdl3linestrike", + "cdl3outside", + "cdl3starsinsouth", + "cdl3whitesoldiers", + "cdlabandonedbaby", + "cdladvanceblock", + "cdlbelthold", + "cdlbreakaway", + "cdlclosingmarubozu", + "cdlconcealbabyswall", + "cdlcounterattack", + "cdldarkcloudcover", + "cdldoji", + "cdldojistar", + "cdldragonflydoji", + "cdlengulfing", + "cdleveningdojistar", + "cdleveningstar", + "cdlgapsidesidewhite", + "cdlgravestonedoji", + "cdlhammer", + "cdlhangingman", + "cdlharami", + "cdlharamicross", + "cdlhighwave", + "cdlhikkake", + "cdlhikkakemod", + "cdlhomingpigeon", + "cdlidentical3crows", + "cdlinneck", + "cdlinvertedhammer", + "cdlkicking", + "cdlkickingbylength", + "cdlladderbottom", + "cdllongleggeddoji", + "cdllongline", + "cdlmarubozu", + "cdlmatchinglow", + "cdlmathold", + "cdlmorningdojistar", + "cdlmorningstar", + "cdlonneck", + "cdlpiercing", + "cdlrickshawman", + "cdlrisefall3methods", + "cdlseparatinglines", + "cdlshootingstar", + "cdlshortline", + "cdlspinningtop", + "cdlstalledpattern", + "cdlsticksandwich", + "cdltakuri", + "cdltasukigap", + "cdlthrusting", + "cdltristar", + "cdlunique3river", + "cdlupsidegap2crows", + "cdlxsidegap3methods", + # Batch (Rust-side 2-D loops — single GIL release) + "batch_sma", + "batch_ema", + "batch_rsi", + # Extended indicators (Rust) + "vwap", + "vwma", + "supertrend", + "donchian", + "choppiness_index", + "keltner_channels", + "hull_ma", + "chandelier_exit", + "ichimoku", + "pivot_points", + # Rolling math operators (Rust) + "rolling_sum", + "rolling_max", + "rolling_min", + "rolling_maxindex", + "rolling_minindex", + # Streaming classes (Rust PyO3) + "StreamingSMA", + "StreamingEMA", + "StreamingRSI", + "StreamingATR", + "StreamingBBands", + "StreamingMACD", + "StreamingStoch", + "StreamingVWAP", + "StreamingSupertrend", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/core/registry.py b/vendor/ferro-ta-main/python/ferro_ta/core/registry.py new file mode 100644 index 0000000..fe842f2 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/core/registry.py @@ -0,0 +1,199 @@ +""" +Plugin / Extension Registry +============================ + +A lightweight registry that allows users to register custom indicators and +call any indicator (built-in or custom) by name. + +Usage +----- +>>> import numpy as np +>>> import ferro_ta +>>> from ferro_ta.core.registry import register, run, get, list_indicators +>>> +>>> # Call a built-in indicator by name +>>> close = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) +>>> result = run("SMA", close, timeperiod=3) +>>> +>>> # Register a custom indicator +>>> def MY_IND(close, timeperiod=10): +... \"\"\"Custom indicator: simple sum / timeperiod.\"\"\" +... import numpy as np +... out = np.full_like(close, np.nan) +... for i in range(timeperiod - 1, len(close)): +... out[i] = close[i - timeperiod + 1 : i + 1].sum() / timeperiod +... return out +>>> register("MY_IND", MY_IND) +>>> result = run("MY_IND", close, timeperiod=3) + +Writing a plugin +---------------- +A plugin function must: + +1. Accept at least one positional array argument (``close``, ``high``, etc.). +2. Accept keyword arguments for parameters (e.g. ``timeperiod=14``). +3. Return a single ``numpy.ndarray`` *or* a tuple of ``numpy.ndarray`` for + multi-output indicators. + +Example:: + + def DOUBLE_RSI(close, timeperiod=14, smooth=3): + import ferro_ta + rsi = ferro_ta.RSI(close, timeperiod=timeperiod) + return ferro_ta.SMA(rsi, timeperiod=smooth) + + from ferro_ta.core.registry import register + register("DOUBLE_RSI", DOUBLE_RSI) + +API +--- +register(name, func) — Register *func* under *name*. +unregister(name) — Remove a registered indicator. +get(name) — Return the callable for *name*. +run(name, *args, **kw) — Look up *name* and call it with *args* / **kw*. +list_indicators() — Return a sorted list of all registered names. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from ferro_ta.core.exceptions import FerroTAError + + +class FerroTARegistryError(FerroTAError): + """Raised when a registry lookup fails (unknown indicator name).""" + + +# --------------------------------------------------------------------------- +# Internal registry (module-level singleton dict) +# --------------------------------------------------------------------------- + +_REGISTRY: dict[str, Callable[..., Any]] = {} + + +def register(name: str, func: Callable[..., Any]) -> None: + """Register a callable under *name*. + + Parameters + ---------- + name: + Indicator name (case-sensitive; convention is ALL_CAPS for + compatibility with TA-Lib naming). + func: + A callable that accepts at least one array-like positional argument + and optional keyword arguments, and returns a ``numpy.ndarray`` or a + tuple of ``numpy.ndarray``. + + Raises + ------ + TypeError + If *func* is not callable. + """ + if not callable(func): + raise TypeError(f"Expected a callable for '{name}', got {type(func).__name__}") + _REGISTRY[name] = func + + +def unregister(name: str) -> None: + """Remove the indicator registered under *name*. + + Parameters + ---------- + name: + Indicator name to remove. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + if name not in _REGISTRY: + raise FerroTARegistryError( + f"Indicator '{name}' is not registered. " + f"Available indicators: {sorted(_REGISTRY)[:10]}…" + ) + del _REGISTRY[name] + + +def get(name: str) -> Callable[..., Any]: + """Return the callable registered under *name*. + + Parameters + ---------- + name: + Indicator name (case-sensitive). + + Returns + ------- + Callable + The registered function. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + if name not in _REGISTRY: + raise FerroTARegistryError( + f"Unknown indicator '{name}'. " + f"Use list_indicators() to see all registered names." + ) + return _REGISTRY[name] + + +def run(name: str, *args: Any, **kwargs: Any) -> Any: + """Look up *name* in the registry and call it with *args* / *kwargs*. + + Parameters + ---------- + name: + Indicator name (case-sensitive). + *args: + Positional arguments forwarded to the indicator function. + **kwargs: + Keyword arguments forwarded to the indicator function. + + Returns + ------- + numpy.ndarray or tuple of numpy.ndarray + Whatever the indicator function returns. + + Raises + ------ + FerroTARegistryError + If *name* is not in the registry. + """ + func = get(name) + return func(*args, **kwargs) + + +def list_indicators() -> list[str]: + """Return a sorted list of all registered indicator names. + + Returns + ------- + list of str + Sorted list of indicator names. + """ + return sorted(_REGISTRY) + + +# --------------------------------------------------------------------------- +# Auto-register all built-in indicators from ferro_ta.__all__ +# --------------------------------------------------------------------------- + + +def _register_builtins() -> None: + """Register every built-in indicator from ``ferro_ta.__all__``.""" + # Lazy import to avoid circular imports at module load time + import ferro_ta # noqa: PLC0415 + + for _name in ferro_ta.__all__: # type: ignore[attr-defined] + _fn = getattr(ferro_ta, _name, None) + if callable(_fn): + _REGISTRY[_name] = _fn + + +_register_builtins() diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/__init__.py new file mode 100644 index 0000000..93e480b --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/__init__.py @@ -0,0 +1,25 @@ +""" +ferro_ta.indicators — Technical indicator functions. + +Sub-modules +----------- +* :mod:`ferro_ta.indicators.momentum` — Momentum Indicators (RSI, STOCH, ADX, CCI, …) +* :mod:`ferro_ta.indicators.overlap` — Overlap Studies (SMA, EMA, BBANDS, MACD, …) +* :mod:`ferro_ta.indicators.volatility` — Volatility Indicators (ATR, NATR, TRANGE) +* :mod:`ferro_ta.indicators.volume` — Volume Indicators (AD, ADOSC, OBV) +* :mod:`ferro_ta.indicators.statistic` — Statistic Functions (STDDEV, VAR, LINEARREG, …) +* :mod:`ferro_ta.indicators.price_transform` — Price Transforms (AVGPRICE, MEDPRICE, …) +* :mod:`ferro_ta.indicators.pattern` — Candlestick Pattern Recognition (CDL*) +* :mod:`ferro_ta.indicators.cycle` — Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, …) +* :mod:`ferro_ta.indicators.math_ops` — Math Operators/Transforms (ADD, SUB, SUM, …) +* :mod:`ferro_ta.indicators.extended` — Extended Indicators (VWAP, SUPERTREND, ICHIMOKU, …) + +All indicators are also importable directly from :mod:`ferro_ta`:: + + import ferro_ta + result = ferro_ta.RSI(close, timeperiod=14) + + # or directly from the sub-module: + from ferro_ta.indicators.momentum import RSI + result = RSI(close, timeperiod=14) +""" diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/cycle.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/cycle.py new file mode 100644 index 0000000..1f54ef6 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/cycle.py @@ -0,0 +1,187 @@ +""" +Cycle Indicators — Hilbert Transform-based cycle analysis. + +All functions use a 63-bar lookback period (first 63 values are NaN). + +Functions +--------- +HT_TRENDLINE — Hilbert Transform - Instantaneous Trendline +HT_DCPERIOD — Hilbert Transform - Dominant Cycle Period +HT_DCPHASE — Hilbert Transform - Dominant Cycle Phase +HT_PHASOR — Hilbert Transform - Phasor Components (returns inphase, quadrature) +HT_SINE — Hilbert Transform - SineWave (returns sine, leadsine) +HT_TRENDMODE — Hilbert Transform - Trend vs Cycle Mode (1=trend, 0=cycle) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + ht_dcperiod as _ht_dcperiod, +) +from ferro_ta._ferro_ta import ( + ht_dcphase as _ht_dcphase, +) +from ferro_ta._ferro_ta import ( + ht_phasor as _ht_phasor, +) +from ferro_ta._ferro_ta import ( + ht_sine as _ht_sine, +) +from ferro_ta._ferro_ta import ( + ht_trendline as _ht_trendline, +) +from ferro_ta._ferro_ta import ( + ht_trendmode as _ht_trendmode, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def HT_TRENDLINE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Instantaneous Trendline. + + Computes the underlying trend of the price series using the Hilbert + Transform. The trendline is the dominant-cycle-period average of the + smoothed price. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Trendline values; first 63 entries are ``NaN``. + """ + try: + return _ht_trendline(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_DCPERIOD(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Dominant Cycle Period. + + Estimates the current dominant cycle period in bars using the Hilbert + Transform. Values are smoothed and clamped to [6, 50]. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Dominant cycle period values; first 63 entries are ``NaN``. + """ + try: + return _ht_dcperiod(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_DCPHASE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Dominant Cycle Phase. + + Returns the instantaneous phase (in degrees) of the dominant cycle. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Phase values in degrees; first 63 entries are ``NaN``. + """ + try: + return _ht_dcphase(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_PHASOR( + close: ArrayLike, +) -> tuple[np.ndarray, np.ndarray]: + """Hilbert Transform - Phasor Components. + + Returns the In-Phase (I) and Quadrature (Q) components of the Hilbert + Transform. These represent the real and imaginary parts of the analytic + signal derived from the price series. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(inphase, quadrature)`` — two arrays; first 63 entries are ``NaN``. + """ + try: + return _ht_phasor(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_SINE( + close: ArrayLike, +) -> tuple[np.ndarray, np.ndarray]: + """Hilbert Transform - SineWave. + + Returns the sine and lead-sine (45-degree lead) of the dominant cycle + phase. Used to detect cycle turning points. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(sine, leadsine)`` — two arrays; first 63 entries are ``NaN``. + """ + try: + return _ht_sine(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def HT_TRENDMODE(close: ArrayLike) -> np.ndarray: + """Hilbert Transform - Trend vs Cycle Mode. + + Returns 1 when the market is in a trending mode (dominant cycle period + below 20 bars) and 0 when in a cycling mode. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray[int32] + Array of 1 (trending) or 0 (cycling). + """ + try: + return _ht_trendmode(_to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "HT_TRENDLINE", + "HT_DCPERIOD", + "HT_DCPHASE", + "HT_PHASOR", + "HT_SINE", + "HT_TRENDMODE", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/extended.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/extended.py new file mode 100644 index 0000000..1c4274e --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/extended.py @@ -0,0 +1,498 @@ +""" +Extended Indicators — Popular indicators not in the TA-Lib standard set. + +All indicator logic is implemented in Rust (PyO3) for maximum performance. +This module provides the public Python API with: +- Input validation +- ``_to_f64`` conversion +- pandas/polars-compatible return values (numpy arrays) + +Functions +--------- +VWAP — Volume Weighted Average Price (cumulative or rolling) +SUPERTREND — ATR-based trend-following signal +ICHIMOKU — Ichimoku Cloud +DONCHIAN — Donchian Channels +PIVOT_POINTS — Classic / Fibonacci / Camarilla pivot levels +KELTNER_CHANNELS — EMA ± ATR bands +HULL_MA — Hull Moving Average (WMA-based) +CHANDELIER_EXIT — ATR-based stop-loss / exit levels +VWMA — Volume Weighted Moving Average +CHOPPINESS_INDEX — Market choppiness / trending strength index + +Rust backend +------------ +All computations delegate to Rust functions in the ``_ferro_ta`` extension:: + + from ferro_ta._ferro_ta import supertrend, donchian, vwap, ... +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +# --------------------------------------------------------------------------- +# Import Rust implementations +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( + chandelier_exit as _rust_chandelier_exit, +) +from ferro_ta._ferro_ta import ( + choppiness_index as _rust_choppiness_index, +) +from ferro_ta._ferro_ta import ( + donchian as _rust_donchian, +) +from ferro_ta._ferro_ta import ( + hull_ma as _rust_hull_ma, +) +from ferro_ta._ferro_ta import ( + ichimoku as _rust_ichimoku, +) +from ferro_ta._ferro_ta import ( + keltner_channels as _rust_keltner_channels, +) +from ferro_ta._ferro_ta import ( + pivot_points as _rust_pivot_points, +) +from ferro_ta._ferro_ta import ( + supertrend as _rust_supertrend, +) +from ferro_ta._ferro_ta import ( + vwap as _rust_vwap, +) +from ferro_ta._ferro_ta import ( + vwma as _rust_vwma, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import FerroTAValueError, _normalize_rust_error + + +def VWAP( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 0, +) -> np.ndarray: + """Volume Weighted Average Price. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volumes. + timeperiod : int, optional + Rolling window length. ``0`` (default) computes a cumulative VWAP + from bar 0 (session VWAP). Any value ``>= 1`` uses a rolling window + of that length; the first ``timeperiod - 1`` values are ``NaN``. + + Returns + ------- + numpy.ndarray + Array of VWAP values. + + Notes + ----- + Typical price is used: ``(high + low + close) / 3``. + Implemented in Rust for maximum performance. + """ + if timeperiod < 0: + raise FerroTAValueError("timeperiod must be >= 0 for VWAP") + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + v = _to_f64(volume) + try: + return np.asarray(_rust_vwap(h, lo, c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUPERTREND( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 7, + multiplier: float = 3.0, +) -> tuple[np.ndarray, np.ndarray]: + """Supertrend indicator. + + An ATR-based trend-following indicator. Returns the Supertrend line and a + direction array. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + ATR period (default 7). + multiplier : float, optional + ATR multiplier for band width (default 3.0). + + Returns + ------- + supertrend : numpy.ndarray + The Supertrend line values. ``NaN`` during the warmup period. + direction : numpy.ndarray + ``1`` = uptrend (price above Supertrend), ``-1`` = downtrend. + ``0`` during warmup. + + Notes + ----- + Implemented in Rust — the sequential band-adjustment loop that was + previously a Python bottleneck now runs at native speed. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SUPERTREND + >>> h = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 9.0, 8.0, 9.0, 10.0, 11.0, + ... 12.0, 13.0, 14.0, 13.0, 12.0]) + >>> l = h - 1.0 + >>> c = (h + l) / 2.0 + >>> st, dir_ = SUPERTREND(h, l, c) + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + st, d = _rust_supertrend(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(st), np.asarray(d) + + +def ICHIMOKU( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + tenkan_period: int = 9, + kijun_period: int = 26, + senkou_b_period: int = 52, + displacement: int = 26, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Ichimoku Cloud (Ichimoku Kinko Hyo). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + tenkan_period : int, default 9 + Conversion line (Tenkan-sen) period. + kijun_period : int, default 26 + Base line (Kijun-sen) period. + senkou_b_period : int, default 52 + Leading Span B period. + displacement : int, default 26 + Displacement / cloud offset for Senkou A & B. + + Returns + ------- + tenkan, kijun, senkou_a, senkou_b, chikou : numpy.ndarray + Each is a 1-D float64 array of the same length as the inputs. + + Notes + ----- + Implemented in Rust with O(n) monotonic deque for all rolling windows. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + t, k, sa, sb, ch = _rust_ichimoku( + h, lo, c, tenkan_period, kijun_period, senkou_b_period, displacement + ) + except ValueError as e: + _normalize_rust_error(e) + return ( + np.asarray(t), + np.asarray(k), + np.asarray(sa), + np.asarray(sb), + np.asarray(ch), + ) + + +def DONCHIAN( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 20, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Donchian Channels — rolling highest high / lowest low. + + Parameters + ---------- + high : array-like + low : array-like + timeperiod : int, default 20 + + Returns + ------- + upper, middle, lower : numpy.ndarray + Rolling highest high, midpoint, and lowest low. + + Notes + ----- + Implemented in Rust with O(n) monotonic deque (no Python loop). + """ + h = _to_f64(high) + lo = _to_f64(low) + try: + upper, middle, lower = _rust_donchian(h, lo, timeperiod) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(upper), np.asarray(middle), np.asarray(lower) + + +def PIVOT_POINTS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + method: str = "classic", +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Pivot Points — support / resistance levels. + + Computes pivot points for each bar using the *previous bar's* H/L/C. + The first bar output is NaN. + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + method : {'classic', 'fibonacci', 'camarilla'}, default 'classic' + + Returns + ------- + pivot, r1, s1, r2, s2 : numpy.ndarray + + Notes + ----- + **Classic**: P=(H+L+C)/3; R1=2P−L; S1=2P−H; R2=P+(H−L); S2=P−(H−L) + + **Fibonacci**: P=(H+L+C)/3; R1=P+0.382*(H−L); S1=P−0.382*(H−L); + R2=P+0.618*(H−L); S2=P−0.618*(H−L) + + **Camarilla**: P=(H+L+C)/3; R1=C+1.1*(H−L)/12; S1=C−1.1*(H−L)/12; + R2=C+1.1*(H−L)/6; S2=C−1.1*(H−L)/6 + """ + valid_methods = {"classic", "fibonacci", "camarilla"} + if method.lower() not in valid_methods: + raise FerroTAValueError( + f"Unknown pivot method '{method}'. Use 'classic', 'fibonacci', or 'camarilla'." + ) + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + pivot, r1, s1, r2, s2 = _rust_pivot_points(h, lo, c, method) + except ValueError as e: + _normalize_rust_error(e) + return ( + np.asarray(pivot), + np.asarray(r1), + np.asarray(s1), + np.asarray(r2), + np.asarray(s2), + ) + + +def KELTNER_CHANNELS( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 20, + atr_period: int = 10, + multiplier: float = 2.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Keltner Channels — EMA ± (multiplier × ATR). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 20 + EMA period for the middle band. + atr_period : int, default 10 + ATR period for band width. + multiplier : float, default 2.0 + ATR multiplier. + + Returns + ------- + upper, middle, lower : numpy.ndarray + + Notes + ----- + Implemented in Rust — EMA and ATR computed inline without Python calls. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + upper, middle, lower = _rust_keltner_channels( + h, lo, c, timeperiod, atr_period, multiplier + ) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(upper), np.asarray(middle), np.asarray(lower) + + +def HULL_MA( + close: ArrayLike, + timeperiod: int = 16, +) -> np.ndarray: + """Hull Moving Average (HMA). + + A fast-responding moving average that reduces lag. + + Parameters + ---------- + close : array-like + timeperiod : int, default 16 + + Returns + ------- + numpy.ndarray + + Notes + ----- + Formula: ``HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`` + + Implemented in Rust — all WMA computations are in-process. + """ + c = _to_f64(close) + try: + return np.asarray(_rust_hull_ma(c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def CHANDELIER_EXIT( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 22, + multiplier: float = 3.0, +) -> tuple[np.ndarray, np.ndarray]: + """Chandelier Exit — ATR-based trailing stop levels. + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 22 + Lookback period for highest high / lowest low and ATR. + multiplier : float, default 3.0 + ATR multiplier. + + Returns + ------- + long_exit, short_exit : numpy.ndarray + + Notes + ----- + Implemented in Rust with O(n) monotonic deque for rolling max/min. + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + long_exit, short_exit = _rust_chandelier_exit(h, lo, c, timeperiod, multiplier) + except ValueError as e: + _normalize_rust_error(e) + return np.asarray(long_exit), np.asarray(short_exit) + + +def VWMA( + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 20, +) -> np.ndarray: + """Volume Weighted Moving Average. + + Parameters + ---------- + close : array-like + volume : array-like + timeperiod : int, default 20 + + Returns + ------- + numpy.ndarray + + Notes + ----- + ``VWMA = sum(close * volume, n) / sum(volume, n)`` + Implemented in Rust with O(n) prefix-sum approach. + """ + c = _to_f64(close) + v = _to_f64(volume) + try: + return np.asarray(_rust_vwma(c, v, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def CHOPPINESS_INDEX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Choppiness Index — measures market choppiness (range-bound vs trending). + + Parameters + ---------- + high : array-like + low : array-like + close : array-like + timeperiod : int, default 14 + + Returns + ------- + numpy.ndarray + Values in ``[0, 100]``. Values near 100 indicate choppy/range-bound + markets; values near 0 indicate strong trends. + + Notes + ----- + ``CI = 100 * log10(sum(ATR(1), n) / (highest_high − lowest_low)) / log10(n)`` + + Implemented in Rust with O(n) monotonic deques (no Python loop). + """ + h = _to_f64(high) + lo = _to_f64(low) + c = _to_f64(close) + try: + return np.asarray(_rust_choppiness_index(h, lo, c, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "VWAP", + "SUPERTREND", + "ICHIMOKU", + "DONCHIAN", + "PIVOT_POINTS", + "KELTNER_CHANNELS", + "HULL_MA", + "CHANDELIER_EXIT", + "VWMA", + "CHOPPINESS_INDEX", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/math_ops.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/math_ops.py new file mode 100644 index 0000000..a2be97b --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/math_ops.py @@ -0,0 +1,372 @@ +""" +Math Operators & Math Transforms — TA-Lib compatibility shims. + +Rolling functions (SUM, MAX, MIN, MAXINDEX, MININDEX) are implemented in Rust +using O(n) monotonic deque / prefix-sum algorithms. All other functions are +thin NumPy wrappers (element-wise operations). + +Functions +--------- +Math Operators: + ADD — Element-wise addition + SUB — Element-wise subtraction + MULT — Element-wise multiplication + DIV — Element-wise division + SUM — Rolling sum over *timeperiod* bars (Rust) + MAX — Rolling maximum over *timeperiod* bars (Rust) + MIN — Rolling minimum over *timeperiod* bars (Rust) + MAXINDEX — Index of rolling maximum over *timeperiod* bars (Rust) + MININDEX — Index of rolling minimum over *timeperiod* bars (Rust) + +Math Transforms (element-wise): + ACOS ASIN ATAN CEIL COS COSH EXP FLOOR LN LOG10 SIN SINH SQRT TAN TANH + +Rust backend +------------ +Rolling operators delegate to:: + + from ferro_ta._ferro_ta import rolling_sum, rolling_max, rolling_min, ... +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +# --------------------------------------------------------------------------- +# Import Rust rolling operators +# --------------------------------------------------------------------------- +from ferro_ta._ferro_ta import ( + rolling_max as _rust_rolling_max, +) +from ferro_ta._ferro_ta import ( + rolling_maxindex as _rust_rolling_maxindex, +) +from ferro_ta._ferro_ta import ( + rolling_min as _rust_rolling_min, +) +from ferro_ta._ferro_ta import ( + rolling_minindex as _rust_rolling_minindex, +) +from ferro_ta._ferro_ta import ( + rolling_sum as _rust_rolling_sum, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + +# --------------------------------------------------------------------------- +# Math Operators +# --------------------------------------------------------------------------- + + +def ADD(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise addition: real0 + real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.add(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUB(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise subtraction: real0 - real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.subtract(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def MULT(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise multiplication: real0 * real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + return np.multiply(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def DIV(real0: ArrayLike, real1: ArrayLike) -> np.ndarray: + """Element-wise division: real0 / real1. + + Parameters + ---------- + real0, real1 : array-like + Input arrays (same length). + + Returns + ------- + numpy.ndarray[float64] + """ + try: + # Suppress divide-by-zero warnings while preserving inf/NaN outputs. + with np.errstate(divide="ignore", invalid="ignore"): + return np.divide(_to_f64(real0), _to_f64(real1)) + except ValueError as e: + _normalize_rust_error(e) + + +def SUM(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling sum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) prefix-sum algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_sum(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MAX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling maximum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_max(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MIN(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Rolling minimum over *timeperiod* bars. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[float64] + NaN for the first ``timeperiod - 1`` bars. + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_min(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MAXINDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Index of the rolling maximum over *timeperiod* bars. + + The index is the absolute position in the input array. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[int64] + -1 for the first ``timeperiod - 1`` bars (warmup period). + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_maxindex(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +def MININDEX(real: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Index of the rolling minimum over *timeperiod* bars. + + The index is the absolute position in the input array. + + Parameters + ---------- + real : array-like + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray[int64] + -1 for the first ``timeperiod - 1`` bars (warmup period). + + Notes + ----- + Implemented in Rust using O(n) monotonic deque algorithm. + """ + try: + arr = _to_f64(real) + return np.asarray(_rust_rolling_minindex(arr, timeperiod)) + except ValueError as e: + _normalize_rust_error(e) + + +# --------------------------------------------------------------------------- +# Math Transforms (element-wise) +# --------------------------------------------------------------------------- + + +def ACOS(real: ArrayLike) -> np.ndarray: + """Arc cosine (element-wise). Returns NaN outside [-1, 1].""" + with np.errstate(invalid="ignore"): + return np.arccos(_to_f64(real)) + + +def ASIN(real: ArrayLike) -> np.ndarray: + """Arc sine (element-wise). Returns NaN outside [-1, 1].""" + with np.errstate(invalid="ignore"): + return np.arcsin(_to_f64(real)) + + +def ATAN(real: ArrayLike) -> np.ndarray: + """Arc tangent (element-wise).""" + return np.arctan(_to_f64(real)) + + +def CEIL(real: ArrayLike) -> np.ndarray: + """Ceiling (element-wise).""" + return np.ceil(_to_f64(real)) + + +def COS(real: ArrayLike) -> np.ndarray: + """Cosine (element-wise).""" + return np.cos(_to_f64(real)) + + +def COSH(real: ArrayLike) -> np.ndarray: + """Hyperbolic cosine (element-wise).""" + return np.cosh(_to_f64(real)) + + +def EXP(real: ArrayLike) -> np.ndarray: + """Exponential (element-wise).""" + return np.exp(_to_f64(real)) + + +def FLOOR(real: ArrayLike) -> np.ndarray: + """Floor (element-wise).""" + return np.floor(_to_f64(real)) + + +def LN(real: ArrayLike) -> np.ndarray: + """Natural logarithm (element-wise). Returns NaN for non-positive inputs.""" + with np.errstate(divide="ignore", invalid="ignore"): + return np.log(_to_f64(real)) + + +def LOG10(real: ArrayLike) -> np.ndarray: + """Base-10 logarithm (element-wise). Returns NaN for non-positive inputs.""" + with np.errstate(divide="ignore", invalid="ignore"): + return np.log10(_to_f64(real)) + + +def SIN(real: ArrayLike) -> np.ndarray: + """Sine (element-wise).""" + return np.sin(_to_f64(real)) + + +def SINH(real: ArrayLike) -> np.ndarray: + """Hyperbolic sine (element-wise).""" + return np.sinh(_to_f64(real)) + + +def SQRT(real: ArrayLike) -> np.ndarray: + """Square root (element-wise). Returns NaN for negative inputs.""" + with np.errstate(invalid="ignore"): + return np.sqrt(_to_f64(real)) + + +def TAN(real: ArrayLike) -> np.ndarray: + """Tangent (element-wise).""" + return np.tan(_to_f64(real)) + + +def TANH(real: ArrayLike) -> np.ndarray: + """Hyperbolic tangent (element-wise).""" + return np.tanh(_to_f64(real)) + + +__all__ = [ + # Math Operators + "ADD", + "SUB", + "MULT", + "DIV", + "SUM", + "MAX", + "MIN", + "MAXINDEX", + "MININDEX", + # Math Transforms + "ACOS", + "ASIN", + "ATAN", + "CEIL", + "COS", + "COSH", + "EXP", + "FLOOR", + "LN", + "LOG10", + "SIN", + "SINH", + "SQRT", + "TAN", + "TANH", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/momentum.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/momentum.py new file mode 100644 index 0000000..cd7b28b --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/momentum.py @@ -0,0 +1,908 @@ +""" +Momentum Indicators — Oscillators measuring speed and change of price movements. + +Functions +--------- +RSI — Relative Strength Index +MOM — Momentum +ROC — Rate of Change: ((price/prevPrice)-1)*100 +ROCP — Rate of Change Percentage: (price-prevPrice)/prevPrice +ROCR — Rate of Change Ratio: price/prevPrice +ROCR100 — Rate of Change Ratio 100 scale: (price/prevPrice)*100 +WILLR — Williams' %R +AROON — Aroon (returns aroon_down, aroon_up) +AROONOSC — Aroon Oscillator +CCI — Commodity Channel Index +MFI — Money Flow Index +BOP — Balance Of Power +STOCHF — Stochastic Fast +STOCH — Stochastic +STOCHRSI — Stochastic Relative Strength Index +APO — Absolute Price Oscillator +PPO — Percentage Price Oscillator +CMO — Chande Momentum Oscillator +PLUS_DM — Plus Directional Movement +MINUS_DM — Minus Directional Movement +PLUS_DI — Plus Directional Indicator +MINUS_DI — Minus Directional Indicator +DX — Directional Movement Index +ADX — Average Directional Movement Index +ADXR — Average Directional Movement Index Rating +TRIX — 1-day Rate-Of-Change of Triple Smooth EMA +ULTOSC — Ultimate Oscillator +TRANGE — True Range (also in volatility) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + adx as _adx, +) +from ferro_ta._ferro_ta import ( + adxr as _adxr, +) +from ferro_ta._ferro_ta import ( + apo as _apo, +) +from ferro_ta._ferro_ta import ( + aroon as _aroon, +) +from ferro_ta._ferro_ta import ( + aroonosc as _aroonosc, +) +from ferro_ta._ferro_ta import ( + bop as _bop, +) +from ferro_ta._ferro_ta import ( + cci as _cci, +) +from ferro_ta._ferro_ta import ( + cmo as _cmo, +) +from ferro_ta._ferro_ta import ( + dx as _dx, +) +from ferro_ta._ferro_ta import ( + mfi as _mfi, +) +from ferro_ta._ferro_ta import ( + minus_di as _minus_di, +) +from ferro_ta._ferro_ta import ( + minus_dm as _minus_dm, +) +from ferro_ta._ferro_ta import ( + mom as _mom, +) +from ferro_ta._ferro_ta import ( + plus_di as _plus_di, +) +from ferro_ta._ferro_ta import ( + plus_dm as _plus_dm, +) +from ferro_ta._ferro_ta import ( + ppo as _ppo, +) +from ferro_ta._ferro_ta import ( + roc as _roc, +) +from ferro_ta._ferro_ta import ( + rocp as _rocp, +) +from ferro_ta._ferro_ta import ( + rocr as _rocr, +) +from ferro_ta._ferro_ta import ( + rocr100 as _rocr100, +) +from ferro_ta._ferro_ta import ( + rsi as _rsi, +) +from ferro_ta._ferro_ta import ( + stoch as _stoch, +) +from ferro_ta._ferro_ta import ( + stochf as _stochf, +) +from ferro_ta._ferro_ta import ( + stochrsi as _stochrsi, +) +from ferro_ta._ferro_ta import ( + trix as _trix, +) +from ferro_ta._ferro_ta import ( + ultosc as _ultosc, +) +from ferro_ta._ferro_ta import ( + willr as _willr, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error +from ferro_ta.indicators.volatility import TRANGE + + +def RSI(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Relative Strength Index. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of RSI values (0–100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rsi(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MOM(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Momentum. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of MOM values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _mom(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROC(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change: ((price/prevPrice)-1)*100. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROC values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _roc(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCP(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Percentage: (price-prevPrice)/prevPrice. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCP values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocp(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCR(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Ratio: price/prevPrice. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCR values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocr(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ROCR100(close: ArrayLike, timeperiod: int = 10) -> np.ndarray: + """Rate of Change Ratio 100 scale: (price/prevPrice)*100. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 10). + + Returns + ------- + numpy.ndarray + Array of ROCR100 values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _rocr100(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def WILLR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Williams' %R. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of WILLR values (-100 to 0); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _willr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def AROON( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> tuple[np.ndarray, np.ndarray]: + """Aroon. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(aroondown, aroonup)`` — two arrays of equal length. + Leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _aroon(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def AROONOSC( + high: ArrayLike, + low: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Aroon Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of AROONOSC values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _aroonosc(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CCI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Commodity Channel Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of CCI values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _cci(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MFI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Money Flow Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MFI values (0–100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _mfi( + _to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume), timeperiod + ) + except ValueError as e: + _normalize_rust_error(e) + + +def BOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Balance Of Power. + + Parameters + ---------- + open : array-like + Sequence of open prices. + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of BOP values (-1 to 1). + """ + try: + return _bop(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCHF( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + fastd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic Fast. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + fastk_period : int, optional + %K period (default 5). + fastd_period : int, optional + %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(fastk, fastd)`` — two arrays of equal length. + """ + try: + return _stochf( + _to_f64(high), _to_f64(low), _to_f64(close), fastk_period, fastd_period + ) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCH( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + fastk_period: int = 5, + slowk_period: int = 3, + slowd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + fastk_period : int, optional + Fast %K period (default 5). + slowk_period : int, optional + Slow %K smoothing period (default 3). + slowd_period : int, optional + Slow %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(slowk, slowd)`` — two arrays of equal length. + """ + try: + return _stoch( + _to_f64(high), + _to_f64(low), + _to_f64(close), + fastk_period, + slowk_period, + slowd_period, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def STOCHRSI( + close: ArrayLike, + timeperiod: int = 14, + fastk_period: int = 5, + fastd_period: int = 3, +) -> tuple[np.ndarray, np.ndarray]: + """Stochastic Relative Strength Index. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + RSI period (default 14). + fastk_period : int, optional + Stochastic %K period (default 5). + fastd_period : int, optional + Stochastic %D smoothing period (default 3). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(fastk, fastd)`` — two arrays of equal length. + """ + try: + return _stochrsi(_to_f64(close), timeperiod, fastk_period, fastd_period) + except ValueError as e: + _normalize_rust_error(e) + + +def APO( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, +) -> np.ndarray: + """Absolute Price Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + + Returns + ------- + numpy.ndarray + Array of APO values; leading ``slowperiod - 1`` entries are ``NaN``. + """ + try: + return _apo(_to_f64(close), fastperiod, slowperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PPO( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Percentage Price Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(ppo, signal, histogram)`` — three arrays of equal length. + """ + try: + return _ppo(_to_f64(close), fastperiod, slowperiod, signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CMO(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Chande Momentum Oscillator. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of CMO values (-100 to 100); leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _cmo(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PLUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Plus Directional Movement. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of +DM values. + """ + try: + return _plus_dm(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MINUS_DM(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Minus Directional Movement. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of -DM values. + """ + try: + return _minus_dm(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def PLUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Plus Directional Indicator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of +DI values. + """ + try: + return _plus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MINUS_DI( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Minus Directional Indicator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of -DI values. + """ + try: + return _minus_di(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Directional Movement Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of DX values (0–100). + """ + try: + return _dx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ADX( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average Directional Movement Index. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ADX values (0–100). + """ + try: + return _adx(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ADXR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average Directional Movement Index Rating. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ADXR values (0–100). + """ + try: + return _adxr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRIX(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """1-day Rate-Of-Change of a Triple Smooth EMA. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + EMA period (default 30). + + Returns + ------- + numpy.ndarray + Array of TRIX values. + """ + try: + return _trix(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def ULTOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod1: int = 7, + timeperiod2: int = 14, + timeperiod3: int = 28, +) -> np.ndarray: + """Ultimate Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod1 : int, optional + First period (default 7). + timeperiod2 : int, optional + Second period (default 14). + timeperiod3 : int, optional + Third period (default 28). + + Returns + ------- + numpy.ndarray + Array of ULTOSC values (0–100). + """ + try: + return _ultosc( + _to_f64(high), + _to_f64(low), + _to_f64(close), + timeperiod1, + timeperiod2, + timeperiod3, + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "RSI", + "MOM", + "ROC", + "ROCP", + "ROCR", + "ROCR100", + "WILLR", + "AROON", + "AROONOSC", + "CCI", + "MFI", + "BOP", + "STOCHF", + "STOCH", + "STOCHRSI", + "APO", + "PPO", + "CMO", + "PLUS_DM", + "MINUS_DM", + "PLUS_DI", + "MINUS_DI", + "DX", + "ADX", + "ADXR", + "TRIX", + "ULTOSC", + "TRANGE", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/overlap.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/overlap.py new file mode 100644 index 0000000..1bebdb1 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/overlap.py @@ -0,0 +1,656 @@ +""" +Overlap Studies — Moving averages and bands that overlay directly on the price chart. + +Functions +--------- +SMA — Simple Moving Average +EMA — Exponential Moving Average +WMA — Weighted Moving Average +DEMA — Double Exponential Moving Average +TEMA — Triple Exponential Moving Average +TRIMA — Triangular Moving Average +KAMA — Kaufman Adaptive Moving Average +T3 — Triple Exponential Moving Average (Tillson T3) +BBANDS — Bollinger Bands +MACD — Moving Average Convergence/Divergence +MACDFIX — MACD with fixed 12/26 periods +MACDEXT — MACD with controllable MA types +SAR — Parabolic SAR +SAREXT — Parabolic SAR Extended +MA — Generic Moving Average (dispatches on matype) +MAVP — Moving Average with Variable Period +MAMA — MESA Adaptive Moving Average +MIDPOINT — MidPoint over period +MIDPRICE — MidPrice over period (High/Low) +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + bbands as _bbands, +) +from ferro_ta._ferro_ta import ( + dema as _dema, +) +from ferro_ta._ferro_ta import ( + ema as _ema, +) +from ferro_ta._ferro_ta import ( + kama as _kama, +) +from ferro_ta._ferro_ta import ( + ma as _ma, +) +from ferro_ta._ferro_ta import ( + macd as _macd, +) +from ferro_ta._ferro_ta import ( + macdext as _macdext, +) +from ferro_ta._ferro_ta import ( + macdfix as _macdfix, +) +from ferro_ta._ferro_ta import ( + mama as _mama, +) +from ferro_ta._ferro_ta import ( + mavp as _mavp, +) +from ferro_ta._ferro_ta import ( + midpoint as _midpoint, +) +from ferro_ta._ferro_ta import ( + midprice as _midprice, +) +from ferro_ta._ferro_ta import ( + sar as _sar, +) +from ferro_ta._ferro_ta import ( + sarext as _sarext, +) +from ferro_ta._ferro_ta import ( + sma as _sma, +) +from ferro_ta._ferro_ta import ( + t3 as _t3, +) +from ferro_ta._ferro_ta import ( + tema as _tema, +) +from ferro_ta._ferro_ta import ( + trima as _trima, +) +from ferro_ta._ferro_ta import ( + wma as _wma, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def SMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Simple Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of SMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _sma(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def EMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of EMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _ema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def WMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Weighted Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of WMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _wma(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Double Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of DEMA values; leading ``2 * (timeperiod - 1)`` entries are ``NaN``. + """ + try: + return _dema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TEMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Triple Exponential Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of TEMA values; leading ``3 * (timeperiod - 1)`` entries are ``NaN``. + """ + try: + return _tema(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRIMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Triangular Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + + Returns + ------- + numpy.ndarray + Array of TRIMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _trima(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def KAMA(close: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Kaufman Adaptive Moving Average. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Efficiency Ratio lookback period (default 30). + + Returns + ------- + numpy.ndarray + Array of KAMA values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _kama(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def T3(close: ArrayLike, timeperiod: int = 5, vfactor: float = 0.7) -> np.ndarray: + """Triple Exponential Moving Average (Tillson T3). + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 5). + vfactor : float, optional + Volume factor (default 0.7). + + Returns + ------- + numpy.ndarray + Array of T3 values. + """ + try: + return _t3(_to_f64(close), timeperiod, vfactor) + except ValueError as e: + _normalize_rust_error(e) + + +def BBANDS( + close: ArrayLike, + timeperiod: int = 5, + nbdevup: float = 2.0, + nbdevdn: float = 2.0, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Bollinger Bands. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Moving average window (default 5). + nbdevup : float, optional + Number of standard deviations above the middle band (default 2.0). + nbdevdn : float, optional + Number of standard deviations below the middle band (default 2.0). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(upperband, middleband, lowerband)`` — three arrays of equal length. + Leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _bbands(_to_f64(close), timeperiod, nbdevup, nbdevdn) + except ValueError as e: + _normalize_rust_error(e) + + +def MACD( + close: ArrayLike, + fastperiod: int = 12, + slowperiod: int = 26, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Moving Average Convergence/Divergence. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast EMA period (default 12). + slowperiod : int, optional + Slow EMA period (default 26). + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + Leading values that cannot be computed are ``NaN``. + """ + try: + return _macd(_to_f64(close), fastperiod, slowperiod, signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MACDFIX( + close: ArrayLike, + signalperiod: int = 9, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Moving Average Convergence/Divergence Fix 12/26. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + signalperiod : int, optional + Signal EMA period (default 9). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + """ + try: + return _macdfix(_to_f64(close), signalperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def SAR( + high: ArrayLike, + low: ArrayLike, + acceleration: float = 0.02, + maximum: float = 0.2, +) -> np.ndarray: + """Parabolic SAR. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + acceleration : float, optional + Acceleration factor step (default 0.02). + maximum : float, optional + Maximum acceleration factor (default 0.2). + + Returns + ------- + numpy.ndarray + Array of SAR values; first entry is ``NaN``. + """ + try: + return _sar(_to_f64(high), _to_f64(low), acceleration, maximum) + except ValueError as e: + _normalize_rust_error(e) + + +def MIDPOINT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """MidPoint over period — (max + min) / 2 of close. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MIDPOINT values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _midpoint(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MIDPRICE(high: ArrayLike, low: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """MidPrice over period — (highest high + lowest low) / 2. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + timeperiod : int, optional + Number of periods (default 14). + + Returns + ------- + numpy.ndarray + Array of MIDPRICE values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _midprice(_to_f64(high), _to_f64(low), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MA(close: ArrayLike, timeperiod: int = 30, matype: int = 0) -> np.ndarray: + """Generic Moving Average. + + Dispatches to the appropriate MA implementation based on *matype*. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Number of periods (default 30). + matype : int, optional + Moving average type (default 0): + + * 0 = SMA (Simple) + * 1 = EMA (Exponential) + * 2 = WMA (Weighted) + * 3 = DEMA (Double EMA) + * 4 = TEMA (Triple EMA) + * 5 = TRIMA (Triangular) + * 6 = KAMA (Kaufman Adaptive) + * 7 = T3 (Tillson) + + Returns + ------- + numpy.ndarray + Array of MA values. + """ + try: + return _ma(_to_f64(close), timeperiod, matype) + except ValueError as e: + _normalize_rust_error(e) + + +def MAVP( + close: ArrayLike, + periods: ArrayLike, + minperiod: int = 2, + maxperiod: int = 30, +) -> np.ndarray: + """Moving Average with Variable Period. + + Computes a simple moving average at each bar using the period given by the + corresponding element of *periods*. Periods are clamped to + ``[minperiod, maxperiod]``. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + periods : array-like + Sequence of period values (one per bar, same length as *close*). + minperiod : int, optional + Minimum allowed period (default 2). + maxperiod : int, optional + Maximum allowed period (default 30). + + Returns + ------- + numpy.ndarray + Array of variable-period MA values. + """ + try: + return _mavp(_to_f64(close), _to_f64(periods), minperiod, maxperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def MAMA( + close: ArrayLike, + fastlimit: float = 0.5, + slowlimit: float = 0.05, +) -> tuple[np.ndarray, np.ndarray]: + """MESA Adaptive Moving Average. + + Returns the MAMA and FAMA (Following Adaptive MA) lines. The adaptive + alpha is derived from the rate of phase change of the Hilbert Transform. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastlimit : float, optional + Upper bound on the adaptive smoothing factor (default 0.5). + slowlimit : float, optional + Lower bound on the adaptive smoothing factor (default 0.05). + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(mama, fama)`` — two arrays; first 32 entries are ``NaN``. + """ + try: + return _mama(_to_f64(close), fastlimit, slowlimit) + except ValueError as e: + _normalize_rust_error(e) + + +def SAREXT( + high: ArrayLike, + low: ArrayLike, + startvalue: float = 0.0, + offsetonreverse: float = 0.0, + accelerationinitlong: float = 0.02, + accelerationlong: float = 0.02, + accelerationmaxlong: float = 0.2, + accelerationinitshort: float = 0.02, + accelerationshort: float = 0.02, + accelerationmaxshort: float = 0.2, +) -> np.ndarray: + """Parabolic SAR Extended. + + An extended version of the Parabolic SAR that allows independent + acceleration parameters for long and short positions, plus an optional + fixed start value and a gap-on-reverse offset. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + startvalue : float, optional + Fixed initial SAR value (0 = auto-detect, default 0.0). + offsetonreverse : float, optional + Multiplier applied to the SAR on trend reversal (default 0.0). + accelerationinitlong : float, optional + Initial acceleration factor for long positions (default 0.02). + accelerationlong : float, optional + Acceleration step for long positions (default 0.02). + accelerationmaxlong : float, optional + Maximum acceleration for long positions (default 0.2). + accelerationinitshort : float, optional + Initial acceleration factor for short positions (default 0.02). + accelerationshort : float, optional + Acceleration step for short positions (default 0.02). + accelerationmaxshort : float, optional + Maximum acceleration for short positions (default 0.2). + + Returns + ------- + numpy.ndarray + Array of SAREXT values; first entry is ``NaN``. + """ + try: + return _sarext( + _to_f64(high), + _to_f64(low), + startvalue, + offsetonreverse, + accelerationinitlong, + accelerationlong, + accelerationmaxlong, + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def MACDEXT( + close: ArrayLike, + fastperiod: int = 12, + fastmatype: int = 1, + slowperiod: int = 26, + slowmatype: int = 1, + signalperiod: int = 9, + signalmatype: int = 1, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """MACD with Controllable MA Types. + + Like :func:`MACD` but allows specifying the moving average type for each + of the fast, slow, and signal lines independently. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + fastperiod : int, optional + Fast MA period (default 12). + fastmatype : int, optional + MA type for the fast line (default 1 = EMA). + slowperiod : int, optional + Slow MA period (default 26). + slowmatype : int, optional + MA type for the slow line (default 1 = EMA). + signalperiod : int, optional + Signal MA period (default 9). + signalmatype : int, optional + MA type for the signal line (default 1 = EMA). + + MA type codes: 0=SMA, 1=EMA, 2=WMA. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray] + ``(macd, signal, histogram)`` — three arrays of equal length. + """ + try: + return _macdext( + _to_f64(close), + fastperiod, + fastmatype, + slowperiod, + slowmatype, + signalperiod, + signalmatype, + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "SMA", + "EMA", + "WMA", + "DEMA", + "TEMA", + "TRIMA", + "KAMA", + "T3", + "BBANDS", + "MACD", + "MACDFIX", + "MACDEXT", + "SAR", + "SAREXT", + "MA", + "MAVP", + "MAMA", + "MIDPOINT", + "MIDPRICE", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/pattern.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/pattern.py new file mode 100644 index 0000000..a398b0c --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/pattern.py @@ -0,0 +1,1959 @@ +""" +Pattern Recognition — Candlestick pattern detection. + +All functions return an integer array where: + 100 = bullish signal + -100 = bearish signal + 0 = no pattern detected + +Functions +--------- +CDL2CROWS — Two Crows (bearish) +CDL3BLACKCROWS — Three Black Crows (bearish) +CDL3WHITESOLDIERS — Three White Soldiers (bullish) +CDL3INSIDE — Three Inside Up/Down +CDL3OUTSIDE — Three Outside Up/Down +CDLDOJI — Doji +CDLDOJISTAR — Doji Star +CDLENGULFING — Engulfing Pattern +CDLHAMMER — Hammer (bullish) +CDLHARAMI — Harami Pattern +CDLHARAMICROSS — Harami Cross Pattern +CDLMARUBOZU — Marubozu +CDLMORNINGSTAR — Morning Star (bullish, 3-candle) +CDLMORNINGDOJISTAR — Morning Doji Star (bullish, 3-candle) +CDLEVENINGSTAR — Evening Star (bearish, 3-candle) +CDLEVENINGDOJISTAR — Evening Doji Star (bearish, 3-candle) +CDLSHOOTINGSTAR — Shooting Star (bearish) +CDLSPINNINGTOP — Spinning Top +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + cdl2crows as _cdl2crows, +) +from ferro_ta._ferro_ta import ( + cdl3blackcrows as _cdl3blackcrows, +) +from ferro_ta._ferro_ta import ( + cdl3inside as _cdl3inside, +) +from ferro_ta._ferro_ta import ( + cdl3linestrike as _cdl3linestrike, +) +from ferro_ta._ferro_ta import ( + cdl3outside as _cdl3outside, +) +from ferro_ta._ferro_ta import ( + cdl3starsinsouth as _cdl3starsinsouth, +) +from ferro_ta._ferro_ta import ( + cdl3whitesoldiers as _cdl3whitesoldiers, +) +from ferro_ta._ferro_ta import ( + cdlabandonedbaby as _cdlabandonedbaby, +) +from ferro_ta._ferro_ta import ( + cdladvanceblock as _cdladvanceblock, +) +from ferro_ta._ferro_ta import ( + cdlbelthold as _cdlbelthold, +) +from ferro_ta._ferro_ta import ( + cdlbreakaway as _cdlbreakaway, +) +from ferro_ta._ferro_ta import ( + cdlclosingmarubozu as _cdlclosingmarubozu, +) +from ferro_ta._ferro_ta import ( + cdlconcealbabyswall as _cdlconcealbabyswall, +) +from ferro_ta._ferro_ta import ( + cdlcounterattack as _cdlcounterattack, +) +from ferro_ta._ferro_ta import ( + cdldarkcloudcover as _cdldarkcloudcover, +) +from ferro_ta._ferro_ta import ( + cdldoji as _cdldoji, +) +from ferro_ta._ferro_ta import ( + cdldojistar as _cdldojistar, +) +from ferro_ta._ferro_ta import ( + cdldragonflydoji as _cdldragonflydoji, +) +from ferro_ta._ferro_ta import ( + cdlengulfing as _cdlengulfing, +) +from ferro_ta._ferro_ta import ( + cdleveningdojistar as _cdleveningdojistar, +) +from ferro_ta._ferro_ta import ( + cdleveningstar as _cdleveningstar, +) +from ferro_ta._ferro_ta import ( + cdlgapsidesidewhite as _cdlgapsidesidewhite, +) +from ferro_ta._ferro_ta import ( + cdlgravestonedoji as _cdlgravestonedoji, +) +from ferro_ta._ferro_ta import ( + cdlhammer as _cdlhammer, +) +from ferro_ta._ferro_ta import ( + cdlhangingman as _cdlhangingman, +) +from ferro_ta._ferro_ta import ( + cdlharami as _cdlharami, +) +from ferro_ta._ferro_ta import ( + cdlharamicross as _cdlharamicross, +) +from ferro_ta._ferro_ta import ( + cdlhighwave as _cdlhighwave, +) +from ferro_ta._ferro_ta import ( + cdlhikkake as _cdlhikkake, +) +from ferro_ta._ferro_ta import ( + cdlhikkakemod as _cdlhikkakemod, +) +from ferro_ta._ferro_ta import ( + cdlhomingpigeon as _cdlhomingpigeon, +) +from ferro_ta._ferro_ta import ( + cdlidentical3crows as _cdlidentical3crows, +) +from ferro_ta._ferro_ta import ( + cdlinneck as _cdlinneck, +) +from ferro_ta._ferro_ta import ( + cdlinvertedhammer as _cdlinvertedhammer, +) +from ferro_ta._ferro_ta import ( + cdlkicking as _cdlkicking, +) +from ferro_ta._ferro_ta import ( + cdlkickingbylength as _cdlkickingbylength, +) +from ferro_ta._ferro_ta import ( + cdlladderbottom as _cdlladderbottom, +) +from ferro_ta._ferro_ta import ( + cdllongleggeddoji as _cdllongleggeddoji, +) +from ferro_ta._ferro_ta import ( + cdllongline as _cdllongline, +) +from ferro_ta._ferro_ta import ( + cdlmarubozu as _cdlmarubozu, +) +from ferro_ta._ferro_ta import ( + cdlmatchinglow as _cdlmatchinglow, +) +from ferro_ta._ferro_ta import ( + cdlmathold as _cdlmathold, +) +from ferro_ta._ferro_ta import ( + cdlmorningdojistar as _cdlmorningdojistar, +) +from ferro_ta._ferro_ta import ( + cdlmorningstar as _cdlmorningstar, +) +from ferro_ta._ferro_ta import ( + cdlonneck as _cdlonneck, +) +from ferro_ta._ferro_ta import ( + cdlpiercing as _cdlpiercing, +) +from ferro_ta._ferro_ta import ( + cdlrickshawman as _cdlrickshawman, +) +from ferro_ta._ferro_ta import ( + cdlrisefall3methods as _cdlrisefall3methods, +) +from ferro_ta._ferro_ta import ( + cdlseparatinglines as _cdlseparatinglines, +) +from ferro_ta._ferro_ta import ( + cdlshootingstar as _cdlshootingstar, +) +from ferro_ta._ferro_ta import ( + cdlshortline as _cdlshortline, +) +from ferro_ta._ferro_ta import ( + cdlspinningtop as _cdlspinningtop, +) +from ferro_ta._ferro_ta import ( + cdlstalledpattern as _cdlstalledpattern, +) +from ferro_ta._ferro_ta import ( + cdlsticksandwich as _cdlsticksandwich, +) +from ferro_ta._ferro_ta import ( + cdltakuri as _cdltakuri, +) +from ferro_ta._ferro_ta import ( + cdltasukigap as _cdltasukigap, +) +from ferro_ta._ferro_ta import ( + cdlthrusting as _cdlthrusting, +) +from ferro_ta._ferro_ta import ( + cdltristar as _cdltristar, +) +from ferro_ta._ferro_ta import ( + cdlunique3river as _cdlunique3river, +) +from ferro_ta._ferro_ta import ( + cdlupsidegap2crows as _cdlupsidegap2crows, +) +from ferro_ta._ferro_ta import ( + cdlxsidegap3methods as _cdlxsidegap3methods, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import FerroTAInputError, _normalize_rust_error + + +def _validate_ohlc_lengths(o, h, lo, c) -> None: + if not (len(o) == len(h) == len(lo) == len(c)): + raise FerroTAInputError( + f"All OHLC arrays must have the same length " + f"(open={len(o)}, high={len(h)}, low={len(lo)}, close={len(c)}).", + ) + + +def CDL2CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Two Crows — bearish 3-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl2crows(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Doji — open ≈ close, reflecting market indecision. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldoji(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLENGULFING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Engulfing Pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlengulfing(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHAMMER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hammer — small body at top, long lower shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhammer(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSHOOTINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Shooting Star — small body at bottom, long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlshootingstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMORNINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Morning Star — 3-candle bullish reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmorningstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLEVENINGSTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Evening Star — 3-candle bearish reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdleveningstar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMARUBOZU( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Marubozu — full body candle with no or minimal shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmarubozu(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSPINNINGTOP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Spinning Top — small body with shadows longer than the body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlspinningtop( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3BLACKCROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Black Crows — bearish 3-candle reversal. + + Three consecutive long bearish candles, each opening within the prior body + and closing near its low. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3blackcrows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3WHITESOLDIERS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three White Soldiers — bullish 3-candle reversal. + + Three consecutive long bullish candles, each opening within the prior body + and closing near its high. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3whitesoldiers( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3INSIDE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Inside Up/Down — harami followed by confirmation candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish Three Inside Up), -100 (bearish Three Inside Down), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3inside(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3OUTSIDE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Outside Up/Down — engulfing followed by confirmation candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish Three Outside Up), -100 (bearish Three Outside Down), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3outside(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Doji Star — doji that gaps away from the prior large candle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldojistar(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMORNINGDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Morning Doji Star — 3-candle bullish reversal with doji star. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmorningdojistar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLEVENINGDOJISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Evening Doji Star — 3-candle bearish reversal with doji star. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdleveningdojistar( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHARAMI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Harami Pattern — small candle inside the prior large candle's body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlharami(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHARAMICROSS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Harami Cross — doji inside the prior large candle's body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlharamicross( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3LINESTRIKE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three-Line Strike — 4-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3linestrike( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDL3STARSINSOUTH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Three Stars In The South — 3-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdl3starsinsouth( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLABANDONEDBABY( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Abandoned Baby — 3-candle reversal with gapping doji in the middle. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlabandonedbaby( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLADVANCEBLOCK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Advance Block — 3 bullish candles with weakening momentum, bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdladvanceblock( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLBELTHOLD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Belt-hold — single candle opening at extreme with long body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlbelthold(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLBREAKAWAY( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Breakaway — 5-candle reversal pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlbreakaway(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCLOSINGMARUBOZU( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Closing Marubozu — candle with no shadow on the closing side. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlclosingmarubozu( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCONCEALBABYSWALL( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Concealing Baby Swallow — 4-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlconcealbabyswall( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLCOUNTERATTACK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Counterattack Lines — 2-candle pattern with opposite candles closing at same price. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlcounterattack( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDARKCLOUDCOVER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Dark Cloud Cover — 2-candle bearish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldarkcloudcover( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLDRAGONFLYDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Dragonfly Doji — doji with long lower shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdldragonflydoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLGAPSIDESIDEWHITE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Up/Down-Gap Side-by-Side White Lines — 3-candle continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (upside gap), -100 (downside gap), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlgapsidesidewhite( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLGRAVESTONEDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Gravestone Doji — doji with long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlgravestonedoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHANGINGMAN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hanging Man — same shape as hammer but bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhangingman( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIGHWAVE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """High-Wave Candle — small body with very long upper and lower shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhighwave(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIKKAKE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Hikkake Pattern — inside bar followed by false breakout then reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhikkake(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHIKKAKEMOD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Modified Hikkake Pattern — hikkake with delayed confirmation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhikkakemod( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLHOMINGPIGEON( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Homing Pigeon — 2 bearish candles, second inside the first body. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlhomingpigeon( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLIDENTICAL3CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Identical Three Crows — 3 bearish candles each opening at prior close. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlidentical3crows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLINNECK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """In-Neck Pattern — bearish then bullish closing near prior close, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlinneck(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLINVERTEDHAMMER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Inverted Hammer — small body at bottom, long upper shadow. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlinvertedhammer( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLKICKING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Kicking — two opposite marubozu candles with a gap. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlkicking(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLKICKINGBYLENGTH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Kicking by the Longer Marubozu — direction determined by longer marubozu. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlkickingbylength( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLADDERBOTTOM( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Ladder Bottom — 5-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlladderbottom( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLONGLEGGEDDOJI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Long Legged Doji — doji with long upper and lower shadows. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdllongleggeddoji( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLLONGLINE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Long Line Candle — long body candle (body >= 70% of range). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdllongline(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMATCHINGLOW( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Matching Low — 2 bearish candles with equal closes, bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmatchinglow( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLMATHOLD( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Mat Hold — 5-candle bullish continuation pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlmathold(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLONNECK( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """On-Neck Pattern — bearish then bullish reaching only prior low, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlonneck(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLPIERCING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Piercing Pattern — bearish then bullish piercing past midpoint, bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlpiercing(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLRICKSHAWMAN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Rickshaw Man — doji with long shadows and body near center. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlrickshawman( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLRISEFALL3METHODS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Rising/Falling Three Methods — 5-candle continuation pattern. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlrisefall3methods( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSEPARATINGLINES( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Separating Lines — 2-candle continuation with same open, opposite direction. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlseparatinglines( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSHORTLINE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Short Line Candle — small body (body <= 30% of range). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlshortline(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSTALLEDPATTERN( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Stalled Pattern — 3 bullish candles with stalling on the third, bearish warning. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlstalledpattern( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLSTICKSANDWICH( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Stick Sandwich — 2 bearish candles surrounding a bullish, same close. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlsticksandwich( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTAKURI( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Takuri — Dragonfly Doji with very long lower shadow (>= 3x body). + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltakuri(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTASUKIGAP( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Tasuki Gap — 3-candle gap continuation with partial fill. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltasukigap(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTHRUSTING( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Thrusting Pattern — bearish then bullish reaching below midpoint, bearish continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlthrusting(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLTRISTAR( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Tristar Pattern — 3 dojis with reversal implication. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdltristar(o, h, lo, c) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLUNIQUE3RIVER( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Unique 3 River — 3-candle bullish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlunique3river( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLUPSIDEGAP2CROWS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Upside Gap Two Crows — 3-candle bearish reversal. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + -100 where pattern is detected, 0 otherwise. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlupsidegap2crows( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +def CDLXSIDEGAP3METHODS( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Upside/Downside Gap Three Methods — 3-candle gap fill continuation. + + Parameters + ---------- + open, high, low, close : array-like + OHLC price arrays. + + Returns + ------- + numpy.ndarray[int32] + 100 (bullish), -100 (bearish), or 0. + """ + o, h, lo, c = _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + _validate_ohlc_lengths(o, h, lo, c) + try: + return _cdlxsidegap3methods( + _to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close) + ) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/price_transform.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/price_transform.py new file mode 100644 index 0000000..6d2c8b9 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/price_transform.py @@ -0,0 +1,130 @@ +""" +Price Transformations — Helper functions to synthesize OHLC arrays into single arrays. + +Functions +--------- +AVGPRICE — Average Price: (Open + High + Low + Close) / 4 +MEDPRICE — Median Price: (High + Low) / 2 +TYPPRICE — Typical Price: (High + Low + Close) / 3 +WCLPRICE — Weighted Close Price: (High + Low + Close * 2) / 4 +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + avgprice as _avgprice, +) +from ferro_ta._ferro_ta import ( + medprice as _medprice, +) +from ferro_ta._ferro_ta import ( + typprice as _typprice, +) +from ferro_ta._ferro_ta import ( + wclprice as _wclprice, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def AVGPRICE( + open: ArrayLike, + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """Average Price: (Open + High + Low + Close) / 4. + + Parameters + ---------- + open : array-like + Sequence of open prices. + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of AVGPRICE values. + """ + try: + return _avgprice(_to_f64(open), _to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def MEDPRICE(high: ArrayLike, low: ArrayLike) -> np.ndarray: + """Median Price: (High + Low) / 2. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + + Returns + ------- + numpy.ndarray + Array of MEDPRICE values. + """ + try: + return _medprice(_to_f64(high), _to_f64(low)) + except ValueError as e: + _normalize_rust_error(e) + + +def TYPPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray: + """Typical Price: (High + Low + Close) / 3. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of TYPPRICE values. + """ + try: + return _typprice(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +def WCLPRICE(high: ArrayLike, low: ArrayLike, close: ArrayLike) -> np.ndarray: + """Weighted Close Price: (High + Low + Close * 2) / 4. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of WCLPRICE values. + """ + try: + return _wclprice(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["AVGPRICE", "MEDPRICE", "TYPPRICE", "WCLPRICE"] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/statistic.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/statistic.py new file mode 100644 index 0000000..a441d90 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/statistic.py @@ -0,0 +1,369 @@ +""" +Statistic Functions — Standard statistical math applied to rolling windows of price data. + +Functions +--------- +STDDEV — Standard Deviation +VAR — Variance +LINEARREG — Linear Regression +LINEARREG_SLOPE — Linear Regression Slope +LINEARREG_INTERCEPT — Linear Regression Intercept +LINEARREG_ANGLE — Linear Regression Angle (degrees) +TSF — Time Series Forecast +BETA — Beta +CORREL — Pearson's Correlation Coefficient (r) +DTW — Dynamic Time Warping (distance + warping path) +DTW_DISTANCE — Dynamic Time Warping distance only (faster) +BATCH_DTW — Batch DTW: N series vs 1 reference, in parallel +""" + +from __future__ import annotations + +from typing import Optional + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + batch_dtw as _batch_dtw, +) +from ferro_ta._ferro_ta import ( + beta as _beta, +) +from ferro_ta._ferro_ta import ( + correl as _correl, +) +from ferro_ta._ferro_ta import ( + dtw as _dtw, +) +from ferro_ta._ferro_ta import ( + dtw_distance as _dtw_distance, +) +from ferro_ta._ferro_ta import ( + linearreg as _linearreg, +) +from ferro_ta._ferro_ta import ( + linearreg_angle as _linearreg_angle, +) +from ferro_ta._ferro_ta import ( + linearreg_intercept as _linearreg_intercept, +) +from ferro_ta._ferro_ta import ( + linearreg_slope as _linearreg_slope, +) +from ferro_ta._ferro_ta import ( + stddev as _stddev, +) +from ferro_ta._ferro_ta import ( + tsf as _tsf, +) +from ferro_ta._ferro_ta import ( + var as _var, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def STDDEV(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray: + """Standard Deviation. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Rolling window size (default 5). + nbdev : float, optional + Number of standard deviations (default 1.0). + + Returns + ------- + numpy.ndarray + Array of STDDEV values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _stddev(_to_f64(close), timeperiod, nbdev) + except ValueError as e: + _normalize_rust_error(e) + + +def VAR(close: ArrayLike, timeperiod: int = 5, nbdev: float = 1.0) -> np.ndarray: + """Variance. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Rolling window size (default 5). + nbdev : float, optional + Number of deviations (default 1.0). + + Returns + ------- + numpy.ndarray + Array of VAR values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _var(_to_f64(close), timeperiod, nbdev) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of linear regression end-point values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_SLOPE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Slope. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of slope values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_slope(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_INTERCEPT(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Intercept. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of intercept values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_intercept(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def LINEARREG_ANGLE(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Linear Regression Angle (in degrees). + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of angle values in degrees; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _linearreg_angle(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TSF(close: ArrayLike, timeperiod: int = 14) -> np.ndarray: + """Time Series Forecast — linear regression extrapolated one period ahead. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Regression window (default 14). + + Returns + ------- + numpy.ndarray + Array of TSF values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _tsf(_to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def BETA(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 5) -> np.ndarray: + """Beta — regression slope of real0 relative to real1. + + Parameters + ---------- + real0 : array-like + Sequence of prices for asset 0 (dependent variable). + real1 : array-like + Sequence of prices for asset 1 (independent variable). + timeperiod : int, optional + Rolling window (default 5). + + Returns + ------- + numpy.ndarray + Array of BETA values; leading ``timeperiod`` entries are ``NaN``. + """ + try: + return _beta(_to_f64(real0), _to_f64(real1), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarray: + """Pearson's Correlation Coefficient (r). + + Parameters + ---------- + real0 : array-like + First data series. + real1 : array-like + Second data series. + timeperiod : int, optional + Rolling window (default 30). + + Returns + ------- + numpy.ndarray + Array of CORREL values (-1 to 1); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _correl(_to_f64(real0), _to_f64(real1), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def DTW( + series1: ArrayLike, + series2: ArrayLike, + window: Optional[int] = None, +) -> tuple[float, np.ndarray]: + """Dynamic Time Warping — distance and optimal warping path. + + Parameters + ---------- + series1 : array-like + First time series. + series2 : array-like + Second time series (may differ in length from series1). + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + distance : float + DTW distance (accumulated Euclidean cost along the optimal path). + path : numpy.ndarray, shape (N, 2) + Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to + ``(len(series1)-1, len(series2)-1)``. + """ + try: + return _dtw(_to_f64(series1), _to_f64(series2), window) + except ValueError as e: + _normalize_rust_error(e) + + +def DTW_DISTANCE( + series1: ArrayLike, + series2: ArrayLike, + window: Optional[int] = None, +) -> float: + """Dynamic Time Warping distance only (faster — no path reconstruction). + + Parameters + ---------- + series1 : array-like + First time series. + series2 : array-like + Second time series (may differ in length from series1). + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + float + DTW distance (accumulated Euclidean cost along the optimal path). + """ + try: + return _dtw_distance(_to_f64(series1), _to_f64(series2), window) + except ValueError as e: + _normalize_rust_error(e) + + +def BATCH_DTW( + matrix: ArrayLike, + reference: ArrayLike, + window: Optional[int] = None, +) -> np.ndarray: + """Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel. + + Parameters + ---------- + matrix : array-like, shape (N, L) + N time series of length L. Each row is compared against ``reference``. + reference : array-like, shape (L,) + The reference series. + window : int, optional + Sakoe-Chiba band width. ``None`` (default) = unconstrained. + + Returns + ------- + numpy.ndarray, shape (N,) + DTW distance from each row of ``matrix`` to ``reference``. + """ + try: + mat = np.ascontiguousarray(matrix, dtype=np.float64) + if mat.ndim != 2: + from ferro_ta.core.exceptions import FerroTAInputError + + raise FerroTAInputError( + f"matrix must be a 2-D array, got {mat.ndim}-D.", + suggestion="Pass a 2-D NumPy array of shape (N, L).", + ) + return _batch_dtw(mat, _to_f64(reference), window) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = [ + "STDDEV", + "VAR", + "LINEARREG", + "LINEARREG_SLOPE", + "LINEARREG_INTERCEPT", + "LINEARREG_ANGLE", + "TSF", + "BETA", + "CORREL", + "DTW", + "DTW_DISTANCE", + "BATCH_DTW", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/volatility.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/volatility.py new file mode 100644 index 0000000..d90815a --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/volatility.py @@ -0,0 +1,116 @@ +""" +Volatility Indicators — Measure the magnitude of price fluctuations. + +Functions +--------- +ATR — Average True Range +NATR — Normalized Average True Range +TRANGE — True Range +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + atr as _atr, +) +from ferro_ta._ferro_ta import ( + natr as _natr, +) +from ferro_ta._ferro_ta import ( + trange as _trange, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def ATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Average True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of ATR values; leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _atr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def NATR( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + timeperiod: int = 14, +) -> np.ndarray: + """Normalized Average True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + timeperiod : int, optional + Smoothing period (default 14). + + Returns + ------- + numpy.ndarray + Array of NATR values (percentage); leading ``timeperiod - 1`` entries are ``NaN``. + """ + try: + return _natr(_to_f64(high), _to_f64(low), _to_f64(close), timeperiod) + except ValueError as e: + _normalize_rust_error(e) + + +def TRANGE( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, +) -> np.ndarray: + """True Range. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + + Returns + ------- + numpy.ndarray + Array of True Range values. + """ + try: + return _trange(_to_f64(high), _to_f64(low), _to_f64(close)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["ATR", "NATR", "TRANGE"] diff --git a/vendor/ferro-ta-main/python/ferro_ta/indicators/volume.py b/vendor/ferro-ta-main/python/ferro_ta/indicators/volume.py new file mode 100644 index 0000000..1c7bab0 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/indicators/volume.py @@ -0,0 +1,123 @@ +""" +Volume Indicators — Require volume data to measure buying and selling pressure. + +Functions +--------- +AD — Chaikin A/D Line +ADOSC — Chaikin A/D Oscillator +OBV — On Balance Volume +""" + +from __future__ import annotations + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._ferro_ta import ( + ad as _ad, +) +from ferro_ta._ferro_ta import ( + adosc as _adosc, +) +from ferro_ta._ferro_ta import ( + obv as _obv, +) +from ferro_ta._utils import _to_f64 +from ferro_ta.core.exceptions import _normalize_rust_error + + +def AD( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, +) -> np.ndarray: + """Chaikin A/D Line. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + + Returns + ------- + numpy.ndarray + Cumulative A/D Line values. + """ + try: + return _ad(_to_f64(high), _to_f64(low), _to_f64(close), _to_f64(volume)) + except ValueError as e: + _normalize_rust_error(e) + + +def ADOSC( + high: ArrayLike, + low: ArrayLike, + close: ArrayLike, + volume: ArrayLike, + fastperiod: int = 3, + slowperiod: int = 10, +) -> np.ndarray: + """Chaikin A/D Oscillator. + + Parameters + ---------- + high : array-like + Sequence of high prices. + low : array-like + Sequence of low prices. + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + fastperiod : int, optional + Fast EMA period (default 3). + slowperiod : int, optional + Slow EMA period (default 10). + + Returns + ------- + numpy.ndarray + Array of ADOSC values; leading ``slowperiod - 1`` entries are ``NaN``. + """ + try: + return _adosc( + _to_f64(high), + _to_f64(low), + _to_f64(close), + _to_f64(volume), + fastperiod, + slowperiod, + ) + except ValueError as e: + _normalize_rust_error(e) + + +def OBV(close: ArrayLike, volume: ArrayLike) -> np.ndarray: + """On Balance Volume. + + Parameters + ---------- + close : array-like + Sequence of closing prices. + volume : array-like + Sequence of volume values. + + Returns + ------- + numpy.ndarray + Cumulative OBV values. + """ + try: + return _obv(_to_f64(close), _to_f64(volume)) + except ValueError as e: + _normalize_rust_error(e) + + +__all__ = ["AD", "ADOSC", "OBV"] diff --git a/vendor/ferro-ta-main/python/ferro_ta/logging_utils.py b/vendor/ferro-ta-main/python/ferro_ta/logging_utils.py new file mode 100644 index 0000000..cb42960 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/logging_utils.py @@ -0,0 +1,8 @@ +"""Backward-compat stub — moved to ``ferro_ta.core.logging_utils``.""" + +from ferro_ta.core.logging_utils import * # noqa: F401, F403 + +try: + from ferro_ta.core.logging_utils import __all__ # noqa: F401 +except ImportError: + pass diff --git a/vendor/ferro-ta-main/python/ferro_ta/mcp/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/mcp/__init__.py new file mode 100644 index 0000000..3aee4b3 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/mcp/__init__.py @@ -0,0 +1,1348 @@ +"""Optional MCP server exposing the public ferro-ta API.""" + +from __future__ import annotations + +import dataclasses +import enum +import importlib +import inspect +import json +import re +from collections.abc import Callable, Mapping +from datetime import date, datetime, time +from functools import lru_cache +from itertools import count +from typing import Any, cast, get_args, get_origin, get_type_hints + +import numpy as np + +import ferro_ta +from ferro_ta.tools import compute_indicator, run_backtest +from ferro_ta.tools.api_info import methods as api_methods + +__all__ = ["create_server", "run_server", "handle_list_tools", "handle_call_tool"] + +_MCP_INSTALL_HINT = ( + "The ferro-ta MCP server requires the optional 'mcp' dependency. " + 'Install it with `pip install "ferro-ta[mcp]"` or `uv sync --extra mcp`.' +) + +_REFERENCE_HELP = ( + "Use {'instance_id': ''} for stored objects or " + "{'callable': ''} for public callables." +) + +_JSON_ANY_TYPE: list[str] = [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string", +] + +_NO_DEFAULT = object() +_INSTANCE_STORE: dict[str, Any] = {} +_INSTANCE_META: dict[str, dict[str, Any]] = {} +_INSTANCE_COUNTER = count(1) + + +@dataclasses.dataclass(frozen=True) +class _ToolSpec: + """Resolved metadata and dispatcher for a single MCP tool.""" + + name: str + description: str + input_schema: dict[str, Any] + wrapper_signature: inspect.Signature + invoke: Callable[[dict[str, Any]], Any] + + +def _import_object(module_name: str, name: str) -> Any: + """Import *name* from *module_name*.""" + module = importlib.import_module(module_name) + return getattr(module, name) + + +@lru_cache(maxsize=1) +def _discover_public_callables() -> tuple[list[dict[str, str]], dict[str, Any]]: + """Return canonical public callable metadata and lookup targets.""" + entries = api_methods() + top_level = sorted( + [item for item in entries if item["category"] == "top_level"], + key=lambda item: item["name"], + ) + top_level_names = {item["name"] for item in top_level} + extras = sorted( + [ + item + for item in entries + if item["category"] != "top_level" and item["name"] not in top_level_names + ], + key=lambda item: item["name"], + ) + + public_entries: list[dict[str, str]] = [] + targets: dict[str, Any] = {} + for item in [*top_level, *extras]: + name = item["name"] + if name in targets: + continue + targets[name] = _import_object(item["module"], name) + public_entries.append(item) + + return public_entries, targets + + +def _slugify(value: str) -> str: + """Convert *value* into a short identifier fragment.""" + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "object" + + +def _instance_ref(value: Any, *, source_tool: str) -> dict[str, Any]: + """Store *value* and return a serialisable reference payload.""" + identifier = f"{_slugify(type(value).__name__)}-{next(_INSTANCE_COUNTER):04d}" + _INSTANCE_STORE[identifier] = value + payload = { + "instance_id": identifier, + "type": f"{type(value).__module__}.{type(value).__name__}", + "repr": repr(value), + "callable": callable(value), + "source_tool": source_tool, + } + snapshot = _object_snapshot(value) + if snapshot is not None: + payload["snapshot"] = snapshot + _INSTANCE_META[identifier] = payload + return payload + + +def _get_instance(identifier: str) -> Any: + """Return the stored instance for *identifier* or raise a clear error.""" + try: + return _INSTANCE_STORE[identifier] + except KeyError as exc: + raise KeyError(f"Unknown instance_id: {identifier!r}") from exc + + +def _is_instance_ref(value: Any) -> bool: + """Return whether *value* is a stored-object reference payload.""" + return isinstance(value, dict) and set(value) == {"instance_id"} + + +def _is_callable_ref(value: Any) -> bool: + """Return whether *value* is a public-callable reference payload.""" + return isinstance(value, dict) and set(value) == {"callable"} + + +def _public_method_summaries(value: Any) -> list[dict[str, str]]: + """Return public callable methods for *value*.""" + result: list[dict[str, str]] = [] + for method_name, member in inspect.getmembers(value): + if method_name.startswith("_") or not callable(member): + continue + try: + signature = str(inspect.signature(member)) + except (TypeError, ValueError): + signature = "()" + result.append({"name": method_name, "signature": signature}) + return result + + +def _object_snapshot(value: Any) -> Any: + """Return a serialisable snapshot for common non-primitive objects.""" + if isinstance(value, enum.Enum): + return value.value + + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _normalise_json(dataclasses.asdict(value), store_objects=False) + + dynamic_value = cast(Any, value) + + if hasattr(dynamic_value, "to_dict") and callable(dynamic_value.to_dict): + try: + return _normalise_json(dynamic_value.to_dict(), store_objects=False) + except TypeError: + if dynamic_value.__class__.__module__.startswith("pandas"): + return _normalise_json( + dynamic_value.to_dict(orient="list"), store_objects=False + ) + if dynamic_value.__class__.__module__.startswith("polars"): + return _normalise_json( + dynamic_value.to_dict(as_series=False), store_objects=False + ) + except Exception: + return None + + if hasattr(dynamic_value, "__dict__"): + fields = { + key: val + for key, val in vars(dynamic_value).items() + if not key.startswith("_") and not callable(val) + } + if fields: + return _normalise_json(fields, store_objects=False) + + slots = getattr(type(dynamic_value), "__slots__", ()) + if slots: + fields = {} + for slot in slots: + if slot.startswith("_") or not hasattr(dynamic_value, slot): + continue + slot_value = getattr(dynamic_value, slot) + if callable(slot_value): + continue + fields[slot] = slot_value + if fields: + return _normalise_json(fields, store_objects=False) + + return None + + +def _normalise_json(value: Any, *, store_objects: bool = True) -> Any: + """Convert Python and numpy-rich values into JSON-safe values.""" + if value is None or isinstance(value, (str, bool)): + return value + + if isinstance(value, (int, np.integer)): + return int(value) + + if isinstance(value, (float, np.floating)): + return None if np.isnan(value) else float(value) + + if isinstance(value, (date, datetime, time)): + return value.isoformat() + + if isinstance(value, enum.Enum): + return _normalise_json(value.value, store_objects=store_objects) + + if isinstance(value, np.ndarray): + return [ + _normalise_json(item, store_objects=store_objects) + for item in value.tolist() + ] + + if isinstance(value, np.generic): + return _normalise_json(value.item(), store_objects=store_objects) + + if isinstance(value, Mapping): + return { + str(key): _normalise_json(item, store_objects=store_objects) + for key, item in value.items() + } + + if isinstance(value, (list, tuple, set, frozenset)): + return [_normalise_json(item, store_objects=store_objects) for item in value] + + if hasattr(value, "tolist") and callable(value.tolist): + try: + return _normalise_json(value.tolist(), store_objects=store_objects) + except Exception: + pass + + snapshot = _object_snapshot(value) + if snapshot is not None: + return snapshot + + if store_objects: + return _instance_ref(value, source_tool="return_value") + + return repr(value) + + +def _json_result( + payload: Any, + *, + structured_key: str | None = None, +) -> dict[str, Any]: + """Wrap *payload* in the helper response shape.""" + structured: dict[str, Any] | None = None + if isinstance(payload, dict): + structured = payload + elif structured_key is not None: + structured = {structured_key: payload} + + result: dict[str, Any] = { + "content": [{"type": "text", "text": json.dumps(payload)}], + } + if structured is not None: + result["structuredContent"] = structured + return result + + +def _text_result( + text: str, + *, + structured: dict[str, Any] | None = None, + is_error: bool = False, +) -> dict[str, Any]: + """Wrap *text* in the helper response shape.""" + result: dict[str, Any] = { + "content": [{"type": "text", "text": text}], + } + if structured is not None: + result["structuredContent"] = structured + if is_error: + result["isError"] = True + return result + + +def _response_from_payload(payload: Any) -> dict[str, Any]: + """Create a helper response from a normalised payload.""" + if isinstance(payload, str): + return _text_result(payload, structured={"value": payload}) + return _json_result(payload) + + +def _load_mcp_sdk() -> tuple[type[Any], type[Any], type[Any]]: + """Import the optional MCP SDK lazily.""" + try: + fastmcp = importlib.import_module("mcp.server.fastmcp") + types = importlib.import_module("mcp.types") + except ImportError as exc: # pragma: no cover - exercised via tests + raise RuntimeError(_MCP_INSTALL_HINT) from exc + + return fastmcp.FastMCP, types.CallToolResult, types.TextContent + + +def _to_call_tool_result(result: dict[str, Any]) -> Any: + """Convert helper-style results into an MCP SDK CallToolResult.""" + _, call_tool_result_type, text_content_type = _load_mcp_sdk() + content = [ + text_content_type(type="text", text=item["text"]) + for item in result.get("content", []) + if item.get("type") == "text" + ] + return call_tool_result_type( + content=content, + structuredContent=result.get("structuredContent"), + isError=result.get("isError", False), + ) + + +def _safe_default(value: Any) -> Any: + """Return a JSON-safe schema default or a sentinel when unavailable.""" + if value is inspect._empty: + return _NO_DEFAULT + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, (str, bool, int, float)) or value is None: + return value + if isinstance(value, tuple) and all( + isinstance(item, (str, bool, int, float)) or item is None for item in value + ): + return list(value) + if isinstance(value, list) and all( + isinstance(item, (str, bool, int, float)) or item is None for item in value + ): + return value + return _NO_DEFAULT + + +def _wrapper_default(value: Any) -> Any: + """Return a lightweight default for generated wrapper signatures.""" + default = _safe_default(value) + if default is _NO_DEFAULT: + return None + return default + + +def _annotation_label(annotation: Any) -> str: + """Return a readable label for *annotation*.""" + if annotation is inspect._empty: + return "Any" + if isinstance(annotation, str): + return annotation + origin = get_origin(annotation) + if origin is not None: + return str(annotation).replace("typing.", "") + return getattr(annotation, "__name__", repr(annotation)) + + +def _annotation_options(annotation: Any) -> list[Any]: + """Flatten simple union annotations into a list of options.""" + if annotation is inspect._empty: + return [Any] + + origin = get_origin(annotation) + if origin in (None,): + return [annotation] + + if origin in (list, tuple, dict): + return [annotation] + + if origin in (Callable,): + return [annotation] + + args = [arg for arg in get_args(annotation) if arg is not type(None)] + return args or [annotation] + + +def _is_enum_annotation(annotation: Any) -> type[enum.Enum] | None: + """Return the enum class inside *annotation*, if any.""" + for option in _annotation_options(annotation): + if inspect.isclass(option) and issubclass(option, enum.Enum): + return option + return None + + +def _annotation_includes_callable(annotation: Any) -> bool: + """Return whether *annotation* includes a callable type.""" + label = _annotation_label(annotation) + if "Callable" in label: + return True + for option in _annotation_options(annotation): + origin = get_origin(option) + if origin in (Callable,): + return True + return False + + +def _annotation_includes_custom_class(annotation: Any) -> bool: + """Return whether *annotation* includes a non-builtin class.""" + for option in _annotation_options(annotation): + if not inspect.isclass(option): + continue + if issubclass(option, enum.Enum): + return True + if option.__module__ == "builtins": + continue + if option in (date, datetime, time): + continue + return True + return False + + +def _schema_and_py_type( + annotation: Any, *, param_name: str +) -> tuple[dict[str, Any], Any]: + """Map Python annotations to JSON Schema and wrapper annotations.""" + enum_type = _is_enum_annotation(annotation) + if enum_type is not None: + raw_values = [member.value for member in enum_type] + if all(isinstance(item, str) for item in raw_values): + schema = {"type": "string", "enum": list(raw_values)} + return schema, str + if all(isinstance(item, int) for item in raw_values): + schema = {"type": "integer", "enum": list(raw_values)} + return schema, int + + label = _annotation_label(annotation) + lower = label.lower() + + if "bool" in lower: + return {"type": "boolean"}, bool + if "int" in lower and "point" not in lower: + return {"type": "integer"}, int + if ( + "float" in lower + or "number" in lower + or "scalar" in lower + or "ndarray" in lower + or "arraylike" in lower + ): + if "scalarorarray" in lower: + return { + "type": _JSON_ANY_TYPE, + "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}", + }, Any + if "ndarray" in lower or "arraylike" in lower: + return {"type": "array", "items": {}}, list[Any] + return {"type": "number"}, float + if ( + "list" in lower + or "tuple" in lower + or "sequence" in lower + or "iterable" in lower + ): + return {"type": "array", "items": {}}, list[Any] + if "dict" in lower or "mapping" in lower: + return {"type": "object"}, dict[str, Any] + if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower: + return {"type": "string"}, str + if _annotation_includes_callable(annotation) or _annotation_includes_custom_class( + annotation + ): + return { + "type": _JSON_ANY_TYPE, + "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}", + }, Any + return {"type": _JSON_ANY_TYPE}, Any + + +def _build_signature_and_schema( + signature: inspect.Signature, + *, + type_hints: dict[str, Any], +) -> tuple[inspect.Signature, dict[str, Any]]: + """Build a wrapper signature and JSON schema for *signature*.""" + wrapper_parameters: list[inspect.Parameter] = [] + properties: dict[str, Any] = {} + required: list[str] = [] + + for parameter in signature.parameters.values(): + if parameter.name in {"self", "cls"}: + continue + + if parameter.kind == inspect.Parameter.VAR_POSITIONAL: + properties["args"] = { + "type": "array", + "items": {}, + "description": "Extra positional arguments.", + } + wrapper_parameters.append( + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ) + ) + continue + + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + properties["kwargs"] = { + "type": "object", + "description": "Extra keyword arguments.", + } + wrapper_parameters.append( + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ) + ) + continue + + annotation = type_hints.get(parameter.name, parameter.annotation) + schema, py_type = _schema_and_py_type(annotation, param_name=parameter.name) + default = _safe_default(parameter.default) + if default is not _NO_DEFAULT: + schema["default"] = default + else: + required.append(parameter.name) + + properties[parameter.name] = schema + wrapper_parameters.append( + inspect.Parameter( + parameter.name, + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=py_type, + default=( + inspect._empty + if parameter.default is inspect._empty + else _wrapper_default(parameter.default) + ), + ) + ) + + wrapper_signature = inspect.Signature(parameters=wrapper_parameters) + input_schema: dict[str, Any] = { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + return wrapper_signature, input_schema + + +def _type_hints_for_target(target: Any) -> dict[str, Any]: + """Return best-effort type hints for *target*.""" + hinted = target.__init__ if inspect.isclass(target) else target + try: + return get_type_hints(hinted, include_extras=True) + except Exception: + return {} + + +def _resolve_public_callable(name: str) -> Any: + """Resolve a public ferro-ta callable by its exposed MCP name.""" + _, targets = _discover_public_callables() + if name in targets: + return targets[name] + aliases = { + "sma": ferro_ta.SMA, + "ema": ferro_ta.EMA, + "rsi": ferro_ta.RSI, + "macd": ferro_ta.MACD, + "backtest": run_backtest, + } + try: + return aliases[name] + except KeyError as exc: + raise KeyError(f"Unknown callable reference: {name!r}") from exc + + +def _coerce_enum(value: Any, enum_type: type[enum.Enum]) -> enum.Enum: + """Convert *value* into *enum_type*.""" + if isinstance(value, enum_type): + return value + if isinstance(value, str): + if value in enum_type.__members__: + return enum_type[value] + for member in enum_type: + if member.value == value: + return member + return enum_type(value) + + +def _decode_value(value: Any, annotation: Any = Any) -> Any: + """Resolve stored-object and callable references inside *value*.""" + if _is_instance_ref(value): + return _get_instance(str(value["instance_id"])) + + if _is_callable_ref(value): + return _resolve_public_callable(str(value["callable"])) + + enum_type = _is_enum_annotation(annotation) + if enum_type is not None: + try: + return _coerce_enum(value, enum_type) + except Exception: + pass + + if _annotation_includes_callable(annotation) and isinstance(value, str): + try: + return _resolve_public_callable(value) + except KeyError: + pass + + if isinstance(value, list): + return [_decode_value(item, Any) for item in value] + + if isinstance(value, tuple): + return tuple(_decode_value(item, Any) for item in value) + + if isinstance(value, dict): + return {str(key): _decode_value(item, Any) for key, item in value.items()} + + return value + + +def _invoke_target( + target: Any, + *, + signature: inspect.Signature, + type_hints: dict[str, Any], + arguments: dict[str, Any], +) -> Any: + """Call *target* with decoded arguments.""" + positional_args: list[Any] = [] + keyword_args: dict[str, Any] = {} + has_varargs = any( + parameter.kind == inspect.Parameter.VAR_POSITIONAL + for parameter in signature.parameters.values() + ) + before_varargs = True + + for parameter in signature.parameters.values(): + if parameter.name in {"self", "cls"}: + continue + + annotation = type_hints.get(parameter.name, parameter.annotation) + + if parameter.kind == inspect.Parameter.VAR_POSITIONAL: + before_varargs = False + extra_args = arguments.get("args", []) or [] + positional_args.extend(_decode_value(item, Any) for item in extra_args) + continue + + if parameter.kind == inspect.Parameter.VAR_KEYWORD: + extra_kwargs = arguments.get("kwargs", {}) or {} + if not isinstance(extra_kwargs, dict): + raise TypeError("kwargs must be a JSON object") + keyword_args.update( + { + str(key): _decode_value(item, Any) + for key, item in extra_kwargs.items() + } + ) + continue + + expects_positional = ( + before_varargs + and has_varargs + and parameter.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ) + + if parameter.name in arguments: + decoded = _decode_value(arguments[parameter.name], annotation) + elif parameter.default is not inspect._empty: + if expects_positional: + decoded = parameter.default + else: + continue + else: + raise KeyError(f"Missing required argument: {parameter.name}") + + if expects_positional or parameter.kind == inspect.Parameter.POSITIONAL_ONLY: + positional_args.append(decoded) + else: + keyword_args[parameter.name] = decoded + + return target(*positional_args, **keyword_args) + + +def _describe_instance_payload(identifier: str) -> dict[str, Any]: + """Return a detailed description for a stored instance.""" + value = _get_instance(identifier) + payload = dict(_INSTANCE_META.get(identifier, {})) + payload.update( + { + "instance_id": identifier, + "module": type(value).__module__, + "class_name": type(value).__name__, + "methods": _public_method_summaries(value), + } + ) + return payload + + +def _list_instances_payload() -> list[dict[str, Any]]: + """Return current stored-object metadata.""" + return [ + _describe_instance_payload(identifier) for identifier in sorted(_INSTANCE_STORE) + ] + + +def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec: + """Create a generated tool spec for a public ferro-ta callable.""" + is_enum = inspect.isclass(target) and issubclass(target, enum.Enum) + type_hints = _type_hints_for_target(target) + + if is_enum: + signature = inspect.Signature( + [ + inspect.Parameter( + "value", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ) + ] + ) + wrapper_signature, input_schema = _build_signature_and_schema( + signature, + type_hints={"value": str}, + ) + description = ( + item["doc"] + or f"Construct a {item['name']} enum member. Returns a stored instance reference." + ) + + def invoke_enum( + arguments: dict[str, Any], *, enum_type: type[enum.Enum] = target + ) -> Any: + if "value" not in arguments: + raise KeyError("Missing required argument: value") + member = _coerce_enum(arguments["value"], enum_type) + return _instance_ref(member, source_tool=item["name"]) + + return _ToolSpec( + name=item["name"], + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke_enum, + ) + + signature = inspect.signature(target) + wrapper_signature, input_schema = _build_signature_and_schema( + signature, + type_hints=type_hints, + ) + is_class = inspect.isclass(target) + description = item["doc"] or f"Call {item['name']}." + if is_class: + description = ( + item["doc"] + or f"Construct a {item['name']} instance. Returns a stored instance reference." + ) + + def invoke_target( + arguments: dict[str, Any], + *, + raw_target: Any = target, + raw_signature: inspect.Signature = signature, + raw_type_hints: dict[str, Any] = type_hints, + returns_instance: bool = is_class, + source_tool: str = item["name"], + ) -> Any: + result = _invoke_target( + raw_target, + signature=raw_signature, + type_hints=raw_type_hints, + arguments=arguments, + ) + if returns_instance: + return _instance_ref(result, source_tool=source_tool) + return _normalise_json(result) + + return _ToolSpec( + name=item["name"], + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke_target, + ) + + +def _legacy_series_tool( + name: str, + *, + indicator_name: str, + description: str, +) -> _ToolSpec: + """Return a legacy lowercase indicator alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "timeperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=14, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "timeperiod": { + "type": "integer", + "description": "Look-back period.", + "default": 14, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + timeperiod = int(arguments.get("timeperiod", 14)) + return _normalise_json( + compute_indicator(indicator_name, close, timeperiod=timeperiod) + ) + + return _ToolSpec( + name=name, + description=description, + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _legacy_macd_tool() -> _ToolSpec: + """Return the legacy lowercase MACD alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "fastperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=12, + ), + inspect.Parameter( + "slowperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=26, + ), + inspect.Parameter( + "signalperiod", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=int, + default=9, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "fastperiod": { + "type": "integer", + "description": "Fast EMA period.", + "default": 12, + }, + "slowperiod": { + "type": "integer", + "description": "Slow EMA period.", + "default": 26, + }, + "signalperiod": { + "type": "integer", + "description": "Signal EMA period.", + "default": 9, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + result = compute_indicator( + "MACD", + close, + fastperiod=int(arguments.get("fastperiod", 12)), + slowperiod=int(arguments.get("slowperiod", 26)), + signalperiod=int(arguments.get("signalperiod", 9)), + ) + return _normalise_json(result) + + return _ToolSpec( + name="macd", + description=( + "Compute MACD (Moving Average Convergence/Divergence). " + "Returns the line, signal, and histogram." + ), + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _legacy_backtest_tool() -> _ToolSpec: + """Return the legacy lowercase backtest alias tool.""" + wrapper_signature = inspect.Signature( + [ + inspect.Parameter( + "close", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[float], + default=inspect._empty, + ), + inspect.Parameter( + "strategy", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default="rsi_30_70", + ), + inspect.Parameter( + "commission_per_trade", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=float, + default=0.0, + ), + inspect.Parameter( + "slippage_bps", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=float, + default=0.0, + ), + ] + ) + input_schema = { + "type": "object", + "properties": { + "close": { + "type": "array", + "items": {"type": "number"}, + "description": "Close price series.", + }, + "strategy": { + "type": "string", + "description": ( + "Strategy name: 'rsi_30_70', 'sma_crossover', or 'macd_crossover'." + ), + "default": "rsi_30_70", + }, + "commission_per_trade": { + "type": "number", + "description": "Fixed commission per trade.", + "default": 0.0, + }, + "slippage_bps": { + "type": "number", + "description": "Slippage in basis points.", + "default": 0.0, + }, + }, + "required": ["close"], + "additionalProperties": False, + } + + def invoke(arguments: dict[str, Any]) -> Any: + close = np.asarray(arguments["close"], dtype=np.float64) + result = run_backtest( + str(arguments.get("strategy", "rsi_30_70")), + close, + commission_per_trade=float(arguments.get("commission_per_trade", 0.0)), + slippage_bps=float(arguments.get("slippage_bps", 0.0)), + ) + return _normalise_json(result) + + return _ToolSpec( + name="backtest", + description=( + "Run a vectorized backtest on close prices using a named strategy. " + "Returns final equity, trade count, and the equity curve." + ), + input_schema=input_schema, + wrapper_signature=wrapper_signature, + invoke=invoke, + ) + + +def _instance_management_specs() -> list[_ToolSpec]: + """Return generic stored-object management tools.""" + list_signature = inspect.Signature([]) + simple_schema = { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": False, + } + + describe_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ) + ] + ) + describe_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored object identifier.", + } + }, + "required": ["instance_id"], + "additionalProperties": False, + } + + call_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "method", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ), + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ), + ] + ) + call_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored object identifier.", + }, + "method": { + "type": "string", + "description": "Public method name.", + }, + "args": { + "type": "array", + "items": {}, + "description": "Optional positional arguments.", + }, + "kwargs": { + "type": "object", + "description": f"Optional keyword arguments. {_REFERENCE_HELP}", + }, + }, + "required": ["instance_id", "method"], + "additionalProperties": False, + } + + callable_signature = inspect.Signature( + [ + inspect.Parameter( + "instance_id", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=str, + default=inspect._empty, + ), + inspect.Parameter( + "args", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=list[Any], + default=None, + ), + inspect.Parameter( + "kwargs", + kind=inspect.Parameter.KEYWORD_ONLY, + annotation=dict[str, Any], + default=None, + ), + ] + ) + callable_schema = { + "type": "object", + "properties": { + "instance_id": { + "type": "string", + "description": "Stored callable identifier.", + }, + "args": { + "type": "array", + "items": {}, + "description": "Optional positional arguments.", + }, + "kwargs": { + "type": "object", + "description": f"Optional keyword arguments. {_REFERENCE_HELP}", + }, + }, + "required": ["instance_id"], + "additionalProperties": False, + } + + def list_instances_tool(arguments: dict[str, Any]) -> Any: + del arguments + return _list_instances_payload() + + def describe_instance_tool(arguments: dict[str, Any]) -> Any: + return _describe_instance_payload(str(arguments["instance_id"])) + + def call_instance_method_tool(arguments: dict[str, Any]) -> Any: + value = _get_instance(str(arguments["instance_id"])) + method_name = str(arguments["method"]) + if method_name.startswith("_"): + raise ValueError("Only public methods can be called") + method = getattr(value, method_name) + if not callable(method): + raise TypeError( + f"{method_name!r} is not callable on {arguments['instance_id']!r}" + ) + args = [_decode_value(item, Any) for item in (arguments.get("args") or [])] + kwargs = { + str(key): _decode_value(item, Any) + for key, item in (arguments.get("kwargs") or {}).items() + } + return _normalise_json(method(*args, **kwargs)) + + def call_stored_callable_tool(arguments: dict[str, Any]) -> Any: + value = _get_instance(str(arguments["instance_id"])) + if not callable(value): + raise TypeError(f"{arguments['instance_id']!r} is not callable") + args = [_decode_value(item, Any) for item in (arguments.get("args") or [])] + kwargs = { + str(key): _decode_value(item, Any) + for key, item in (arguments.get("kwargs") or {}).items() + } + return _normalise_json(value(*args, **kwargs)) + + def delete_instance_tool(arguments: dict[str, Any]) -> Any: + identifier = str(arguments["instance_id"]) + payload = _describe_instance_payload(identifier) + _INSTANCE_STORE.pop(identifier) + _INSTANCE_META.pop(identifier, None) + return {"deleted": True, **payload} + + return [ + _ToolSpec( + name="list_instances", + description="List stored MCP object references created during this session.", + input_schema=simple_schema, + wrapper_signature=list_signature, + invoke=list_instances_tool, + ), + _ToolSpec( + name="describe_instance", + description="Describe a stored MCP object reference and list its public methods.", + input_schema=describe_schema, + wrapper_signature=describe_signature, + invoke=describe_instance_tool, + ), + _ToolSpec( + name="call_instance_method", + description="Call a public method on a stored MCP object reference.", + input_schema=call_schema, + wrapper_signature=call_signature, + invoke=call_instance_method_tool, + ), + _ToolSpec( + name="call_stored_callable", + description="Invoke a stored callable object reference.", + input_schema=callable_schema, + wrapper_signature=callable_signature, + invoke=call_stored_callable_tool, + ), + _ToolSpec( + name="delete_instance", + description="Delete a stored MCP object reference.", + input_schema=describe_schema, + wrapper_signature=describe_signature, + invoke=delete_instance_tool, + ), + ] + + +@lru_cache(maxsize=1) +def _tool_catalog() -> dict[str, _ToolSpec]: + """Return the full MCP tool catalog.""" + catalog: dict[str, _ToolSpec] = {} + + legacy_specs = [ + _legacy_series_tool( + "sma", + indicator_name="SMA", + description="Compute the Simple Moving Average (SMA) of a price series.", + ), + _legacy_series_tool( + "ema", + indicator_name="EMA", + description="Compute the Exponential Moving Average (EMA) of a price series.", + ), + _legacy_series_tool( + "rsi", + indicator_name="RSI", + description="Compute the Relative Strength Index (RSI) of a price series.", + ), + _legacy_macd_tool(), + _legacy_backtest_tool(), + ] + for spec in legacy_specs: + catalog[spec.name] = spec + + public_entries, targets = _discover_public_callables() + for item in public_entries: + spec = _build_public_tool_spec(item, targets[item["name"]]) + catalog[spec.name] = spec + + for spec in _instance_management_specs(): + catalog[spec.name] = spec + + return catalog + + +def _make_fastmcp_wrapper(spec: _ToolSpec) -> Callable[..., Any]: + """Create a FastMCP-friendly wrapper for *spec*.""" + + def wrapper(**kwargs: Any) -> Any: + return _to_call_tool_result(handle_call_tool(spec.name, dict(kwargs))) + + wrapper.__name__ = f"tool_{_slugify(spec.name).replace('-', '_')}" + wrapper.__doc__ = spec.description + setattr(wrapper, "__signature__", spec.wrapper_signature) + wrapper.__annotations__ = { + parameter.name: ( + Any if parameter.annotation is inspect._empty else parameter.annotation + ) + for parameter in spec.wrapper_signature.parameters.values() + } + wrapper.__annotations__["return"] = Any + return wrapper + + +def handle_list_tools() -> dict[str, Any]: + """Return the MCP ListTools response.""" + return { + "tools": [ + { + "name": spec.name, + "description": spec.description, + "inputSchema": spec.input_schema, + } + for spec in _tool_catalog().values() + ] + } + + +def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Dispatch an MCP CallTool request and return helper-style content.""" + try: + spec = _tool_catalog().get(name) + if spec is None: + return _text_result( + f"Unknown tool: {name!r}", + structured={"error": f"Unknown tool: {name!r}"}, + is_error=True, + ) + + payload = spec.invoke(arguments) + return _response_from_payload(payload) + + except Exception as exc: + return _text_result( + f"Error: {exc}", + structured={"error": str(exc)}, + is_error=True, + ) + + +@lru_cache(maxsize=1) +def create_server() -> Any: + """Create the FastMCP server lazily so MCP stays optional.""" + fast_mcp_type, _, _ = _load_mcp_sdk() + app = fast_mcp_type( + "ferro-ta", + instructions=( + "Expose ferro-ta's public API over MCP. " + "Use exact public ferro-ta names such as SMA, RSI, compute_indicator, " + "TickAggregator, or AlertManager, or the legacy aliases sma, ema, rsi, " + "macd, and backtest. Use list_instances, describe_instance, " + "call_instance_method, call_stored_callable, and delete_instance for " + "stateful objects and stored callables." + ), + ) + + for spec in _tool_catalog().values(): + app.add_tool( + _make_fastmcp_wrapper(spec), name=spec.name, description=spec.description + ) + + return app + + +def run_server() -> None: # pragma: no cover + """Run the stdio MCP server.""" + try: + create_server().run(transport="stdio") + except RuntimeError as exc: + raise SystemExit(str(exc)) from exc diff --git a/vendor/ferro-ta-main/python/ferro_ta/mcp/__main__.py b/vendor/ferro-ta-main/python/ferro_ta/mcp/__main__.py new file mode 100644 index 0000000..29bd240 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/mcp/__main__.py @@ -0,0 +1,6 @@ +"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``.""" + +from ferro_ta.mcp import run_server + +if __name__ == "__main__": + run_server() # pragma: no cover diff --git a/vendor/ferro-ta-main/python/ferro_ta/py.typed b/vendor/ferro-ta-main/python/ferro_ta/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/__init__.py b/vendor/ferro-ta-main/python/ferro_ta/tools/__init__.py new file mode 100644 index 0000000..af60100 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/__init__.py @@ -0,0 +1,29 @@ +""" +ferro_ta.tools — Developer tools, visualisation, alerting, and workflow utilities. + +Sub-modules +----------- +* :mod:`ferro_ta.tools.tools` — General-purpose utility helpers (compute_indicator, run_backtest, …) +* :mod:`ferro_ta.tools.viz` — Charting and visualisation API (matplotlib) +* :mod:`ferro_ta.tools.dashboard`— Interactive Streamlit/Dash dashboard helpers +* :mod:`ferro_ta.tools.alerts` — Alert manager and threshold checks +* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL +* :mod:`ferro_ta.tools.pipeline` — Indicator pipeline builder +* :mod:`ferro_ta.tools.workflow` — Workflow automation helpers +* :mod:`ferro_ta.tools.api_info` — API discovery helpers (:func:`indicators`, :func:`info`) +* :mod:`ferro_ta.tools.gpu` — GPU-accelerated indicator support (requires PyTorch) + +Example usage:: + + from ferro_ta.tools import compute_indicator, run_backtest, list_indicators + from ferro_ta.tools.alerts import check_cross +""" + +# Re-export the stable public API from tools.tools. +# tools/tools.py has no ferro_ta module-level imports, so this is safe. +from ferro_ta.tools.tools import ( # noqa: F401 + compute_indicator, + describe_indicator, + list_indicators, + run_backtest, +) diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/alerts.py b/vendor/ferro-ta-main/python/ferro_ta/tools/alerts.py new file mode 100644 index 0000000..6e54f23 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/alerts.py @@ -0,0 +1,432 @@ +""" +ferro_ta.alerts — Alerts and notification hooks. +================================================ + +Provides an ``AlertManager`` for registering conditions (threshold crossings, +series cross-overs) and dispatching events to callbacks and/or webhooks. +Supports both **backtest** mode (collect alerts in a list for analysis) and +**live** mode (invoke callbacks or POST to webhook URLs on each condition fire). + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools.alerts import AlertManager +>>> np.random.seed(0) +>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5) +>>> from ferro_ta import RSI +>>> rsi = RSI(close, timeperiod=14) +>>> am = AlertManager() +>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1) +>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1) +>>> fired = am.run_backtest() +>>> print(fired) + +API +--- +AlertManager + Registry for conditions and callbacks. Use ``add_threshold_condition`` + or ``add_cross_condition`` to register conditions, then call + ``run_backtest()`` to evaluate all conditions at once. + +check_threshold(series, level, direction) + Low-level: return int8 mask — 1 where *series* crosses *level*. + +check_cross(fast, slow) + Low-level: return int8 mask — 1 (cross up), -1 (cross down), 0 (no cross). + +collect_alert_bars(mask) + Low-level: return indices where *mask* is non-zero. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from ferro_ta._ferro_ta import check_cross as _rust_check_cross +from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold +from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars +from ferro_ta._utils import _to_f64 + +_log = logging.getLogger(__name__) + +__all__ = [ + "AlertEvent", + "AlertManager", + "check_threshold", + "check_cross", + "collect_alert_bars", +] + + +# --------------------------------------------------------------------------- +# Low-level wrappers +# --------------------------------------------------------------------------- + + +def check_threshold( + series: ArrayLike, + level: float, + direction: int, +) -> NDArray[np.int8]: + """Fire an alert when *series* crosses a threshold *level*. + + Parameters + ---------- + series : array-like — indicator values (e.g. RSI close prices) + level : float — threshold value + direction : int + ``1`` → fire when *series* crosses **above** *level*. + ``-1`` → fire when *series* crosses **below** *level*. + + Returns + ------- + numpy.ndarray of int8 — 1 at the bar where the crossing occurs, 0 elsewhere. + """ + return np.asarray( + _rust_check_threshold(_to_f64(series), float(level), int(direction)), + dtype=np.int8, + ) + + +def check_cross( + fast: ArrayLike, + slow: ArrayLike, +) -> NDArray[np.int8]: + """Detect cross-over / cross-under events between two series. + + Parameters + ---------- + fast : array-like — the "fast" series (e.g. short SMA) + slow : array-like — the "slow" series (e.g. long SMA) + + Returns + ------- + numpy.ndarray of int8: + ``1`` at bars where *fast* crosses **above** *slow* (bullish). + ``-1`` at bars where *fast* crosses **below** *slow* (bearish). + ``0`` elsewhere. + """ + return np.asarray( + _rust_check_cross(_to_f64(fast), _to_f64(slow)), + dtype=np.int8, + ) + + +def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]: + """Return bar indices where *mask* is non-zero (condition fired). + + Parameters + ---------- + mask : array-like of int8 — output of ``check_threshold`` or ``check_cross`` + + Returns + ------- + numpy.ndarray of int64 — indices of fired bars (ascending order) + """ + m = np.asarray(mask, dtype=np.int8) + return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64) + + +# --------------------------------------------------------------------------- +# AlertEvent +# --------------------------------------------------------------------------- + + +class AlertEvent: + """A single alert event. + + Attributes + ---------- + condition_id : str — user-supplied condition name + bar_index : int — bar index where the condition fired + value : float or None — optional series value at the fired bar + payload : dict — extra metadata (e.g. symbol, direction) + """ + + __slots__ = ("condition_id", "bar_index", "value", "payload") + + def __init__( + self, + condition_id: str, + bar_index: int, + value: Optional[float] = None, + payload: Optional[dict[str, Any]] = None, + ) -> None: + self.condition_id = condition_id + self.bar_index = bar_index + self.value = value + self.payload = payload or {} + + def __repr__(self) -> str: + return ( + f"AlertEvent(condition_id={self.condition_id!r}, " + f"bar_index={self.bar_index}, value={self.value})" + ) + + def to_dict(self) -> dict[str, Any]: + """Return event as a plain dict (suitable for JSON serialisation).""" + return { + "condition_id": self.condition_id, + "bar_index": self.bar_index, + "value": self.value, + **self.payload, + } + + +# --------------------------------------------------------------------------- +# Internal dataclass for condition storage +# --------------------------------------------------------------------------- + + +@dataclass +class _AlertCondition: + """Internal representation of a registered alert condition.""" + + kind: str # "threshold" or "cross" + condition_id: str + series_a: np.ndarray # primary series (or fast series for cross) + series_b: Optional[np.ndarray] # slow series for cross, else None + level: Optional[float] # threshold level (threshold only) + direction: Optional[int] # +1 / -1 (threshold) or None (cross) + callback: Optional[Callable[..., Any]] + webhook_url: Optional[str] + extra_payload: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# AlertManager +# --------------------------------------------------------------------------- + + +class AlertManager: + """Registry for alert conditions. + + Supports both **backtest** mode (collect events in a list) and + **live** mode (dispatch via callback and/or webhook). + + Parameters + ---------- + symbol : str, optional + Symbol name included in every event payload. + live : bool + If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked + immediately. In backtest mode (``live=False``, default) no external + calls are made unless ``force_live=True`` in ``run_backtest()``. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.alerts import AlertManager + >>> from ferro_ta import RSI, SMA + >>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100 + >>> rsi = RSI(close) + >>> sma20 = SMA(close, 20) + >>> sma50 = SMA(close, 50) + >>> am = AlertManager(symbol="BTC") + >>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1) + >>> am.add_cross_condition("sma_x", sma20, sma50) + >>> events = am.run_backtest() + >>> for ev in events: + ... print(ev) + """ + + def __init__( + self, + symbol: str = "", + live: bool = False, + ) -> None: + self._symbol = symbol + self._live = live + self._conditions: list[_AlertCondition] = [] + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def add_threshold_condition( + self, + condition_id: str, + series: ArrayLike, + level: float, + direction: int, + callback: Optional[Callable[[AlertEvent], None]] = None, + webhook_url: Optional[str] = None, + **extra_payload: Any, + ) -> None: + """Register a threshold crossing condition. + + Parameters + ---------- + condition_id : str — unique name for this condition + series : array-like — the indicator / price series to watch + level : float — threshold level + direction : int — ``1`` (cross above) or ``-1`` (cross below) + callback : callable, optional — ``callback(event)`` invoked on fire + webhook_url : str, optional — HTTP POST target (live mode only) + **extra_payload : extra keys merged into ``AlertEvent.payload`` + """ + self._conditions.append( + _AlertCondition( + kind="threshold", + condition_id=condition_id, + series_a=np.asarray(series, dtype=np.float64), + series_b=None, + level=float(level), + direction=int(direction), + callback=callback, + webhook_url=webhook_url, + extra_payload=dict(extra_payload), + ) + ) + + def add_cross_condition( + self, + condition_id: str, + fast: ArrayLike, + slow: ArrayLike, + callback: Optional[Callable[[AlertEvent], None]] = None, + webhook_url: Optional[str] = None, + **extra_payload: Any, + ) -> None: + """Register a series cross-over / cross-under condition. + + Parameters + ---------- + condition_id : str — unique name for this condition + fast : array-like — the "fast" series + slow : array-like — the "slow" series + callback : callable, optional — ``callback(event)`` invoked on fire + webhook_url : str, optional — HTTP POST target (live mode only) + **extra_payload : extra keys merged into ``AlertEvent.payload`` + """ + self._conditions.append( + _AlertCondition( + kind="cross", + condition_id=condition_id, + series_a=np.asarray(fast, dtype=np.float64), + series_b=np.asarray(slow, dtype=np.float64), + level=None, + direction=None, + callback=callback, + webhook_url=webhook_url, + extra_payload=dict(extra_payload), + ) + ) + + # ------------------------------------------------------------------ + # Evaluation + # ------------------------------------------------------------------ + + def run_backtest( + self, + force_live: bool = False, + ) -> list[AlertEvent]: + """Evaluate all registered conditions in batch (backtest mode). + + No callbacks or webhooks are invoked unless ``force_live=True``. + + Parameters + ---------- + force_live : bool + If ``True``, invoke callbacks and webhooks even in backtest mode. + + Returns + ------- + list of :class:`AlertEvent` — all events that fired, sorted by bar + index (then condition_id for ties). + """ + events: list[AlertEvent] = [] + do_live = self._live or force_live + + for cond in self._conditions: + if cond.kind == "threshold": + mask = _rust_check_threshold( + np.ascontiguousarray(cond.series_a, dtype=np.float64), + float(cond.level), # type: ignore[arg-type] + int(cond.direction), # type: ignore[arg-type] + ) + bars = _rust_collect_alert_bars(mask) + for bar_idx in bars: + ev = AlertEvent( + condition_id=cond.condition_id, + bar_index=int(bar_idx), + value=float(cond.series_a[int(bar_idx)]), + payload={ + "symbol": self._symbol, + "direction": int(cond.direction), # type: ignore[arg-type] + **cond.extra_payload, + }, + ) + events.append(ev) + if do_live: + self._dispatch(ev, cond.callback, cond.webhook_url) + elif cond.kind == "cross": + mask = _rust_check_cross( + np.ascontiguousarray(cond.series_a, dtype=np.float64), + np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type] + ) + bars = _rust_collect_alert_bars(mask) + for bar_idx in bars: + cross_dir = int(mask[int(bar_idx)]) + ev = AlertEvent( + condition_id=cond.condition_id, + bar_index=int(bar_idx), + value=float(cond.series_a[int(bar_idx)]), + payload={ + "symbol": self._symbol, + "direction": cross_dir, + **cond.extra_payload, + }, + ) + events.append(ev) + if do_live: + self._dispatch(ev, cond.callback, cond.webhook_url) + + events.sort(key=lambda e: (e.bar_index, e.condition_id)) + return events + + # ------------------------------------------------------------------ + # Dispatch helpers + # ------------------------------------------------------------------ + + @staticmethod + def _dispatch( + event: AlertEvent, + callback: Optional[Callable[[AlertEvent], None]], + webhook_url: Optional[str], + ) -> None: + """Invoke callback and/or HTTP POST to webhook.""" + if callback is not None: + try: + callback(event) + except Exception as exc: # noqa: BLE001 + _log.warning("Alert callback raised an exception: %s", exc) + + if webhook_url: + AlertManager._post_webhook(webhook_url, event.to_dict()) + + @staticmethod + def _post_webhook(url: str, payload: dict[str, Any]) -> None: + """HTTP POST *payload* as JSON to *url* (best-effort, no retry).""" + import urllib.error + import urllib.request + + try: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5): + pass + except (urllib.error.URLError, OSError, ValueError) as exc: + _log.warning("Webhook POST to %s failed: %s", url, exc) diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/api_info.py b/vendor/ferro-ta-main/python/ferro_ta/tools/api_info.py new file mode 100644 index 0000000..5196252 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/api_info.py @@ -0,0 +1,300 @@ +""" +ferro_ta.api_info — API discovery helpers. + +Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info` +for exploring the ferro_ta public API without reading source code. + +Usage +----- +>>> import ferro_ta +>>> ferro_ta.indicators() # all indicators, sorted +>>> ferro_ta.indicators(category="momentum") # filter by category +>>> ferro_ta.methods() # public callables across modules +>>> ferro_ta.about()["version"] # package metadata summary +>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA + +API +--- +indicators(category=None) — Return list of dicts describing every indicator. +methods(category=None) — Return list of public callables across modules. +about() — Return package/version/module summary metadata. +info(func_or_name) — Return a dict with full signature/docstring info. +""" + +from __future__ import annotations + +import importlib +import inspect +from typing import Any + +__all__ = ["indicators", "methods", "about", "info"] + +# --------------------------------------------------------------------------- +# Category → module mapping used by indicators() +# --------------------------------------------------------------------------- + +_CATEGORY_MODULES: dict[str, str] = { + "overlap": "ferro_ta.indicators.overlap", + "momentum": "ferro_ta.indicators.momentum", + "volume": "ferro_ta.indicators.volume", + "volatility": "ferro_ta.indicators.volatility", + "statistic": "ferro_ta.indicators.statistic", + "price_transform": "ferro_ta.indicators.price_transform", + "pattern": "ferro_ta.indicators.pattern", + "cycle": "ferro_ta.indicators.cycle", + "math_ops": "ferro_ta.indicators.math_ops", + "extended": "ferro_ta.indicators.extended", + "batch": "ferro_ta.data.batch", + "streaming": "ferro_ta.data.streaming", + "resampling": "ferro_ta.data.resampling", + "aggregation": "ferro_ta.data.aggregation", + "signals": "ferro_ta.analysis.signals", + "portfolio": "ferro_ta.analysis.portfolio", + "features": "ferro_ta.analysis.features", + "alerts": "ferro_ta.tools.alerts", + "crypto": "ferro_ta.analysis.crypto", + "regime": "ferro_ta.analysis.regime", +} + +_METHOD_MODULES: dict[str, str] = { + "top_level": "ferro_ta", + **_CATEGORY_MODULES, + "options": "ferro_ta.analysis.options", + "futures": "ferro_ta.analysis.futures", + "backtest": "ferro_ta.analysis.backtest", + "options_strategy": "ferro_ta.analysis.options_strategy", + "derivatives_payoff": "ferro_ta.analysis.derivatives_payoff", + "attribution": "ferro_ta.analysis.attribution", + "cross_asset": "ferro_ta.analysis.cross_asset", + "tools": "ferro_ta.tools.tools", + "viz": "ferro_ta.tools.viz", +} + + +def _iter_module_callables( + module_name: str, +) -> list[tuple[str, Any]]: + """Import *module_name* and return its ``__all__`` callables.""" + try: + mod = importlib.import_module(module_name) + except Exception: + return [] + + names = getattr(mod, "__all__", []) + result = [] + for name in names: + obj = getattr(mod, name, None) + if callable(obj): + result.append((name, obj)) + return result + + +def indicators(category: str | None = None) -> list[dict[str, Any]]: + """Return a list of all ferro_ta indicators with metadata. + + Each entry is a dict with the following keys: + + - ``"name"`` (str): The indicator name, e.g. ``"SMA"``. + - ``"category"`` (str): The category / sub-module, e.g. ``"overlap"``. + - ``"module"`` (str): The fully qualified module name. + - ``"doc"`` (str): First line of the docstring, or ``""`` if absent. + - ``"params"`` (list[str]): Names of the function's parameters. + + Parameters + ---------- + category : str | None + If given, only return indicators from that category. Must be one of + the keys in :data:`ferro_ta.api_info._CATEGORY_MODULES`. + + Returns + ------- + list[dict[str, Any]] + Sorted alphabetically by ``"name"``. + + Examples + -------- + >>> import ferro_ta + >>> all_inds = ferro_ta.indicators() + >>> len(all_inds) > 50 + True + >>> overlap_inds = ferro_ta.indicators(category="overlap") + >>> any(d["name"] == "SMA" for d in overlap_inds) + True + """ + cats: dict[str, str] = ( + {category: _CATEGORY_MODULES[category]} + if category is not None + else _CATEGORY_MODULES + ) + result: list[dict[str, Any]] = [] + seen: set[str] = set() + + for cat, mod_name in cats.items(): + for name, func in _iter_module_callables(mod_name): + if name in seen: + continue + seen.add(name) + doc = inspect.getdoc(func) or "" + first_line = doc.splitlines()[0] if doc else "" + try: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + except (ValueError, TypeError): + params = [] + result.append( + { + "name": name, + "category": cat, + "module": mod_name, + "doc": first_line, + "params": params, + } + ) + + result.sort(key=lambda d: d["name"]) + return result + + +def methods(category: str | None = None) -> list[dict[str, Any]]: + """Return public callables across ferro_ta modules. + + Parameters + ---------- + category : str | None + Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``, + ``"options"``, ``"futures"``, or ``"batch"``. + """ + cats: dict[str, str] = ( + {category: _METHOD_MODULES[category]} + if category is not None + else _METHOD_MODULES + ) + + result: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for cat, mod_name in cats.items(): + for name, func in _iter_module_callables(mod_name): + key = (mod_name, name) + if key in seen: + continue + seen.add(key) + doc = inspect.getdoc(func) or "" + first_line = doc.splitlines()[0] if doc else "" + try: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + except (ValueError, TypeError): + params = [] + result.append( + { + "name": name, + "category": cat, + "module": mod_name, + "doc": first_line, + "params": params, + } + ) + + result.sort(key=lambda d: (d["category"], d["name"])) + return result + + +def about() -> dict[str, Any]: + """Return a small metadata summary for the installed ferro_ta package.""" + import ferro_ta # noqa: PLC0415 + + top_level_exports = sorted(getattr(ferro_ta, "__all__", [])) + return { + "name": "ferro-ta", + "version": getattr(ferro_ta, "__version__", "0+unknown"), + "top_level_export_count": len(top_level_exports), + "indicator_count": len(indicators()), + "method_count": len(methods()), + "categories": sorted(_METHOD_MODULES.keys()), + "top_level_exports": top_level_exports, + } + + +def info(func_or_name: Any) -> dict[str, Any]: + """Return detailed information about an indicator function. + + Parameters + ---------- + func_or_name : callable | str + The indicator function (e.g. ``ferro_ta.SMA``) or its name as a + string (e.g. ``"SMA"``). + + Returns + ------- + dict[str, Any] + Dictionary with the following keys: + + - ``"name"`` (str) + - ``"module"`` (str) + - ``"signature"`` (str): Full ``inspect.signature`` string. + - ``"doc"`` (str): Full docstring. + - ``"params"`` (dict[str, dict]): Mapping of parameter name → + ``{"default": ..., "kind": str}`` for each parameter. + + Raises + ------ + ValueError + If *func_or_name* is a string that does not match any indicator. + + Examples + -------- + >>> import ferro_ta + >>> d = ferro_ta.info(ferro_ta.SMA) + >>> d["name"] + 'SMA' + >>> "close" in d["params"] + True + """ + if isinstance(func_or_name, str): + import ferro_ta # noqa: PLC0415 + + func = getattr(ferro_ta, func_or_name, None) + if func is None: + raise ValueError( + f"No indicator named {func_or_name!r} found in ferro_ta. " + "Use ferro_ta.indicators() to list all available indicators." + ) + else: + func = func_or_name + + name = getattr(func, "__name__", repr(func)) + module = getattr(func, "__module__", "") + doc = inspect.getdoc(func) or "" + + try: + sig = inspect.signature(func) + sig_str = str(sig) + params = {} + for pname, param in sig.parameters.items(): + kind_map = { + inspect.Parameter.POSITIONAL_ONLY: "positional_only", + inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword", + inspect.Parameter.VAR_POSITIONAL: "var_positional", + inspect.Parameter.KEYWORD_ONLY: "keyword_only", + inspect.Parameter.VAR_KEYWORD: "var_keyword", + } + params[pname] = { + "default": ( + param.default + if param.default is not inspect.Parameter.empty + else None + ), + "has_default": param.default is not inspect.Parameter.empty, + "kind": kind_map.get(param.kind, "unknown"), + } + except (ValueError, TypeError): + sig_str = "()" + params = {} + + return { + "name": name, + "module": module, + "signature": sig_str, + "doc": doc, + "params": params, + } diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/dashboard.py b/vendor/ferro-ta-main/python/ferro_ta/tools/dashboard.py new file mode 100644 index 0000000..3b8c09c --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/dashboard.py @@ -0,0 +1,345 @@ +""" +ferro_ta.dashboard — Interactive dashboards and exploration helpers. +=================================================================== + +Optional helpers for interactive exploration in Jupyter notebooks (via +ipywidgets) and a Streamlit template. All widgets are optional: if ipywidgets +or streamlit are not installed, a clear ``ImportError`` is raised with install +instructions. + +Functions +--------- +indicator_widget(close, indicator_fn, param_name, param_range) + Create an ipywidgets slider that updates an indicator plot in real time. + +backtest_widget(close, strategy_fn, param_name, param_range) + Create an ipywidgets slider that re-runs a backtest and shows equity curve. + +streamlit_app() + Launch a minimal Streamlit dashboard (call from a ``streamlit run`` script). + +Notes +----- +To install optional dependencies:: + + pip install ferro-ta[dashboard] # installs ipywidgets + pip install streamlit # for Streamlit app + +Only the Python layer is in this module — all heavy computation delegated to +existing ferro-ta indicator and backtest functions. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "indicator_widget", + "backtest_widget", + "streamlit_app", +] + + +# --------------------------------------------------------------------------- +# Jupyter / ipywidgets helpers +# --------------------------------------------------------------------------- + + +def indicator_widget( + close: ArrayLike, + indicator_fn: Callable[..., Any], + param_name: str, + param_range: Sequence[int], + title: str = "Indicator", +) -> Any: + """Create an interactive Jupyter widget with a parameter slider. + + Renders a ``matplotlib`` chart with the close price overlaid by the + indicator output. Dragging the slider updates the chart in real time. + + Parameters + ---------- + close : array-like — close price series + indicator_fn : callable — indicator function, e.g. ``ferro_ta.SMA``. + Signature: ``fn(close, **{param_name: value}) -> ndarray``. + param_name : str — name of the integer parameter to vary (e.g. ``'timeperiod'``). + param_range : sequence of int — values to iterate over (e.g. ``range(5, 51)``). + title : str — chart title. + + Returns + ------- + ipywidgets ``Output`` widget — display it in a Jupyter cell. + + Requires + -------- + ``ipywidgets``, ``matplotlib`` + + Examples + -------- + >>> from ferro_ta import SMA + >>> from ferro_ta.tools.dashboard import indicator_widget + >>> w = indicator_widget(close, SMA, 'timeperiod', range(5, 51)) + >>> display(w) # in a Jupyter cell + """ + try: + import ipywidgets as widgets + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "indicator_widget requires ipywidgets and matplotlib.\n" + "Install with: pip install ipywidgets matplotlib" + ) from exc + + c = np.asarray(close, dtype=np.float64) + param_values = list(param_range) + + out = widgets.Output() + + def update(change: Any) -> None: + value = change["new"] + with out: + out.clear_output(wait=True) + fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot(c, label="Close", alpha=0.5) + ind_out = indicator_fn(c, **{param_name: value}) + if isinstance(ind_out, tuple): + for arr in ind_out: + ax.plot(np.asarray(arr, dtype=np.float64), alpha=0.8) + else: + ax.plot( + np.asarray(ind_out, dtype=np.float64), + label=f"{indicator_fn.__name__}({param_name}={value})", + ) + ax.set_title(f"{title} — {param_name}={value}") + ax.legend() + plt.tight_layout() + plt.show() + + slider = widgets.IntSlider( + value=param_values[len(param_values) // 2], + min=min(param_values), + max=max(param_values), + step=1, + description=param_name, + continuous_update=False, + ) + slider.observe(update, names="value") + update({"new": slider.value}) + + return widgets.VBox([slider, out]) + + +def backtest_widget( + close: ArrayLike, + strategy: Union[str, Callable[..., Any]] = "rsi_30_70", + param_name: str = "timeperiod", + param_range: Sequence[int] = range(5, 30), + title: str = "Backtest", +) -> Any: + """Create an interactive Jupyter widget that re-runs a backtest on slider change. + + Parameters + ---------- + close : array-like — close prices + strategy : str or callable — backtest strategy (see ``ferro_ta.backtest.backtest``). + param_name : str — strategy parameter name to vary. + param_range: sequence of int — parameter values to iterate. + title : str — chart title. + + Returns + ------- + ipywidgets ``VBox`` widget. + + Requires + -------- + ``ipywidgets``, ``matplotlib`` + """ + try: + import ipywidgets as widgets + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "backtest_widget requires ipywidgets and matplotlib.\n" + "Install with: pip install ipywidgets matplotlib" + ) from exc + + from ferro_ta.analysis.backtest import backtest + + c = np.asarray(close, dtype=np.float64) + param_values = list(param_range) + out = widgets.Output() + + def update(change: Any) -> None: + value = change["new"] + with out: + out.clear_output(wait=True) + result = backtest(c, strategy=strategy, **{param_name: value}) + fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True) + axes[0].plot(c, label="Close", alpha=0.7) + axes[0].set_title(f"{title} — {param_name}={value}") + axes[0].legend() + axes[1].plot(result.equity, label="Equity", color="green") + axes[1].axhline(1.0, color="gray", linestyle="--", alpha=0.5) + axes[1].set_title( + f"Equity (trades={result.n_trades}, final={result.final_equity:.3f})" + ) + axes[1].legend() + plt.tight_layout() + plt.show() + + slider = widgets.IntSlider( + value=param_values[len(param_values) // 2], + min=min(param_values), + max=max(param_values), + step=1, + description=param_name, + continuous_update=False, + ) + slider.observe(update, names="value") + update({"new": slider.value}) + return widgets.VBox([slider, out]) + + +# --------------------------------------------------------------------------- +# Streamlit app template +# --------------------------------------------------------------------------- + + +def streamlit_app() -> None: + """Run a minimal Streamlit TA dashboard. + + Call this function from a Python script and run with:: + + streamlit run your_script.py + + The dashboard provides: + - A file uploader for OHLCV CSV data (or uses synthetic data as fallback). + - An indicator selector (SMA, EMA, RSI, MACD, Bollinger Bands). + - A parameter slider. + - A price + indicator chart. + - A backtest panel (RSI strategy) with equity curve. + + Requires + -------- + ``streamlit``, ``matplotlib`` or ``plotly`` (optional) + + Examples + -------- + Create a file ``ta_dashboard.py``:: + + from ferro_ta.tools.dashboard import streamlit_app + streamlit_app() + + Then run:: + + streamlit run ta_dashboard.py + """ + try: + import streamlit as st + except ImportError as exc: + raise ImportError( + "streamlit_app requires streamlit.\nInstall with: pip install streamlit" + ) from exc + + import ferro_ta as ft + from ferro_ta.analysis.backtest import backtest + + st.title("ferro-ta Interactive Dashboard") + + # ---- Data ---- + st.sidebar.header("Data") + uploaded = st.sidebar.file_uploader("Upload OHLCV CSV", type=["csv"]) + + if uploaded is not None: + try: + import pandas as pd + + df = pd.read_csv(uploaded) + cols = {c.lower(): c for c in df.columns} + close = df[cols["close"]].values.astype(np.float64) + except (ImportError, KeyError, ValueError) as e: + st.error(f"Could not read CSV: {e}") + close = _synthetic_close() + else: + st.info( + "Using synthetic data. Upload a CSV with a 'close' column to use real data." + ) + close = _synthetic_close() + + n = len(close) + st.sidebar.write(f"Bars loaded: {n}") + + # ---- Indicator ---- + st.sidebar.header("Indicator") + indicator_name = st.sidebar.selectbox( + "Indicator", ["SMA", "EMA", "RSI", "MACD", "BBANDS"] + ) + timeperiod = st.sidebar.slider("Period", min_value=2, max_value=200, value=20) + + st.subheader(f"Price + {indicator_name}({timeperiod})") + + try: + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(figsize=(12, 4)) + ax.plot(close, label="Close", alpha=0.5) + + if indicator_name == "SMA": + ax.plot(np.asarray(ft.SMA(close, timeperiod=timeperiod)), label="SMA") + elif indicator_name == "EMA": + ax.plot(np.asarray(ft.EMA(close, timeperiod=timeperiod)), label="EMA") + elif indicator_name == "RSI": + fig2, ax2 = plt.subplots(figsize=(12, 2)) + ax2.plot( + np.asarray(ft.RSI(close, timeperiod=timeperiod)), + label="RSI", + color="orange", + ) + ax2.axhline(30, color="green", linestyle="--", alpha=0.5) + ax2.axhline(70, color="red", linestyle="--", alpha=0.5) + ax2.set_title("RSI") + st.pyplot(fig2) + elif indicator_name == "MACD": + macd, signal, hist = ft.MACD(close) + ax.plot(np.asarray(macd), label="MACD") + ax.plot(np.asarray(signal), label="Signal") + elif indicator_name == "BBANDS": + upper, middle, lower = ft.BBANDS(close, timeperiod=timeperiod) + ax.plot(np.asarray(upper), label="Upper", linestyle="--") + ax.plot(np.asarray(middle), label="Middle") + ax.plot(np.asarray(lower), label="Lower", linestyle="--") + + ax.legend() + st.pyplot(fig) + except (ImportError, ValueError, RuntimeError) as e: + st.error(f"Error computing indicator: {e}") + + # ---- Backtest panel ---- + st.subheader("Backtest (RSI 30/70 strategy)") + if st.button("Run Backtest"): + result = backtest(close, strategy="rsi_30_70", timeperiod=timeperiod) + try: + import matplotlib.pyplot as plt + + fig3, ax3 = plt.subplots(figsize=(12, 3)) + ax3.plot(result.equity, color="green", label="Equity") + ax3.axhline(1.0, color="gray", linestyle="--") + ax3.set_title( + f"Equity trades={result.n_trades} final={result.final_equity:.4f}" + ) + ax3.legend() + st.pyplot(fig3) + except ImportError: + st.write( + f"Final equity: {result.final_equity:.4f} trades: {result.n_trades}" + ) + + +def _synthetic_close(n: int = 500) -> NDArray: + """Generate a synthetic close price series for the dashboard demo.""" + rng = np.random.default_rng(42) + return np.cumprod(1 + rng.normal(0, 0.01, n)) * 100.0 diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/dsl.py b/vendor/ferro-ta-main/python/ferro_ta/tools/dsl.py new file mode 100644 index 0000000..877bec1 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/dsl.py @@ -0,0 +1,525 @@ +""" +ferro_ta.dsl — Strategy expression DSL. + +A small domain-specific language that lets users define rule-based trading +strategies as strings (e.g. ``"RSI(14) < 30 and close > SMA(20)"``) and +evaluate them to produce a boolean or integer signal series. + +This module provides: +- :func:`parse_expression` — validate and compile an expression string. +- :func:`evaluate` — evaluate a compiled expression against OHLCV data. +- :class:`Strategy` — convenience wrapper around parse + evaluate. + +The expression grammar supports: +- Indicator calls: ``RSI(14)``, ``SMA(20)``, ``BBANDS(20, 2)`` +- Price series references: ``close``, ``open``, ``high``, ``low``, ``volume`` +- Comparison operators: ``<``, ``>``, ``<=``, ``>=``, ``==``, ``!=`` +- Logical connectives: ``and``, ``or``, ``not`` +- Cross-above/below helpers: ``cross_above(a, b)``, ``cross_below(a, b)`` +- Parentheses for grouping + +Evaluating an expression returns a 1-D integer array of 1 (signal on) and 0 +(signal off), with leading ``0`` values during indicator warm-up. + +Examples +-------- +>>> import numpy as np +>>> from ferro_ta.tools.dsl import Strategy +>>> rng = np.random.default_rng(0) +>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100 +>>> ohlcv = {"close": close} +>>> strat = Strategy("RSI(14) < 30") +>>> signal = strat.evaluate(ohlcv) +>>> signal.shape +(100,) +>>> set(signal.tolist()).issubset({0, 1}) +True +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any, Optional + +import numpy as np +from numpy.typing import NDArray + +from ferro_ta._utils import _to_f64 +from ferro_ta.core.registry import run as _registry_run + +__all__ = [ + "parse_expression", + "evaluate", + "Strategy", +] + +# --------------------------------------------------------------------------- +# Supported indicator / function names (resolved via registry) +# --------------------------------------------------------------------------- + +_PRICE_KEYS = {"close", "open", "high", "low", "volume"} + +# --------------------------------------------------------------------------- +# Expression AST (minimal) +# --------------------------------------------------------------------------- + + +class _Expr: + """Abstract expression node.""" + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + raise NotImplementedError + + +class _PriceRef(_Expr): + def __init__(self, name: str) -> None: + self.name = name + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + if self.name not in ctx: + raise ValueError(f"Price series '{self.name}' not found in OHLCV data.") + return ctx[self.name] + + +class _IndicatorCall(_Expr): + def __init__( + self, + name: str, + args: list[float], + output_index: int = 0, + ) -> None: + self.name = name + self.args = args + self.output_index = output_index + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + close = ctx.get("close") + high = ctx.get("high") + low = ctx.get("low") + volume = ctx.get("volume") + if close is None: + raise ValueError("'close' series is required to evaluate indicator calls.") + kwargs: dict[str, Any] = {} + if self.args: + # Heuristic: first numeric arg → timeperiod + kwargs["timeperiod"] = int(self.args[0]) + # Additional args passed as extra kwargs are not supported in this + # simple DSL; only the first param is used as timeperiod. + + # Try different signatures + result = None + for positional in [ + [close], + [high, low, close] if high is not None and low is not None else None, + [high, low, close, volume] + if volume is not None and high is not None + else None, + ]: + if positional is None: + continue + try: + result = _registry_run(self.name, *positional, **kwargs) + break + except Exception: + continue + if result is None: + raise ValueError( + f"Cannot evaluate indicator '{self.name}' with available data." + ) + + if isinstance(result, tuple): + arr = result[self.output_index] + else: + arr = result + return np.asarray(arr, dtype=np.float64) + + +class _Comparison(_Expr): + _OPS: dict[str, Callable[[Any, Any], Any]] = { + "<": lambda a, b: a < b, + ">": lambda a, b: a > b, + "<=": lambda a, b: a <= b, + ">=": lambda a, b: a >= b, + "==": lambda a, b: a == b, + "!=": lambda a, b: a != b, + } + + def __init__(self, left: _Expr, op: str, right: _Expr) -> None: + self.left = left + self.op = op + self.right = right + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + lv = self.left.eval(ctx) + rv = self.right.eval(ctx) + fn = self._OPS[self.op] + result = fn(lv, rv) + return result.astype(np.int32) + + +class _Logic(_Expr): + def __init__(self, op: str, operands: list[_Expr]) -> None: + self.op = op # 'and' | 'or' + self.operands = operands + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + result = self.operands[0].eval(ctx).astype(bool) + for operand in self.operands[1:]: + v = operand.eval(ctx).astype(bool) + if self.op == "and": + result = result & v + else: + result = result | v + return result.astype(np.int32) + + +class _Not(_Expr): + def __init__(self, operand: _Expr) -> None: + self.operand = operand + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + return (~self.operand.eval(ctx).astype(bool)).astype(np.int32) + + +class _CrossFunc(_Expr): + def __init__(self, direction: str, a: _Expr, b: _Expr) -> None: + self.direction = direction # 'above' | 'below' + self.a = a + self.b = b + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + av = self.a.eval(ctx).astype(np.float64) + bv = self.b.eval(ctx).astype(np.float64) + n = len(av) + result = np.zeros(n, dtype=np.int32) + if self.direction == "above": + for i in range(1, n): + if av[i] > bv[i] and av[i - 1] <= bv[i - 1]: + result[i] = 1 + else: + for i in range(1, n): + if av[i] < bv[i] and av[i - 1] >= bv[i - 1]: + result[i] = 1 + return result + + +class _Scalar(_Expr): + def __init__(self, value: float) -> None: + self.value = value + + def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray: + return np.array([self.value]) + + +# --------------------------------------------------------------------------- +# Tokeniser +# --------------------------------------------------------------------------- + +_TOKEN_SPEC = [ + ("NUMBER", r"-?\d+\.?\d*"), + ("AND", r"\band\b"), + ("OR", r"\bor\b"), + ("NOT", r"\bnot\b"), + ("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"), + ("OP", r"<=|>=|==|!=|<|>"), + ("LPAREN", r"\("), + ("RPAREN", r"\)"), + ("COMMA", r","), + ("SKIP", r"\s+"), +] + +_TOKEN_RE = re.compile( + "|".join(f"(?P<{name}>{pattern})" for name, pattern in _TOKEN_SPEC) +) + + +def _tokenise(expr: str) -> list[tuple[str, str]]: + tokens: list[tuple[str, str]] = [] + for m in _TOKEN_RE.finditer(expr): + kind = m.lastgroup + value = m.group() + if kind == "SKIP" or kind is None: + continue + tokens.append((kind, value)) + # Check for unmatched characters + matched_len = sum(len(m.group()) for m in _TOKEN_RE.finditer(expr)) + if matched_len != len(expr.replace(" ", "").replace("\t", "").replace("\n", "")): + # rough check; just skip + pass + return tokens + + +# --------------------------------------------------------------------------- +# Recursive-descent parser +# --------------------------------------------------------------------------- + + +class _Parser: + def __init__(self, tokens: list[tuple[str, str]]) -> None: + self.tokens = tokens + self.pos = 0 + + def peek(self) -> Optional[tuple[str, str]]: + if self.pos < len(self.tokens): + return self.tokens[self.pos] + return None + + def consume(self, kind: Optional[str] = None) -> tuple[str, str]: + tok = self.peek() + if tok is None: + raise ValueError("Unexpected end of expression.") + if kind and tok[0] != kind: + raise ValueError(f"Expected {kind}, got {tok[0]!r} ({tok[1]!r}).") + self.pos += 1 + return tok + + def parse(self) -> _Expr: + expr = self.parse_or() + if self.peek() is not None: + raise ValueError( + f"Unexpected token at position {self.pos}: {self.peek()!r}" + ) + return expr + + def parse_or(self) -> _Expr: + left = self.parse_and() + operands = [left] + while self.peek() and self.peek()[0] == "OR": # type: ignore[index] + self.consume("OR") + operands.append(self.parse_and()) + return operands[0] if len(operands) == 1 else _Logic("or", operands) + + def parse_and(self) -> _Expr: + left = self.parse_not() + operands = [left] + while self.peek() and self.peek()[0] == "AND": # type: ignore[index] + self.consume("AND") + operands.append(self.parse_not()) + return operands[0] if len(operands) == 1 else _Logic("and", operands) + + def parse_not(self) -> _Expr: + if self.peek() and self.peek()[0] == "NOT": # type: ignore[index] + self.consume("NOT") + return _Not(self.parse_not()) + return self.parse_comparison() + + def parse_comparison(self) -> _Expr: + left = self.parse_atom() + tok = self.peek() + if tok and tok[0] == "OP": + op = tok[1] + self.consume("OP") + right = self.parse_atom() + return _Comparison(left, op, right) + return left + + def parse_atom(self) -> _Expr: + tok = self.peek() + if tok is None: + raise ValueError("Unexpected end of expression in atom.") + + if tok[0] == "NUMBER": + self.consume("NUMBER") + return _Scalar(float(tok[1])) + + if tok[0] == "LPAREN": + self.consume("LPAREN") + expr = self.parse_or() + self.consume("RPAREN") + return expr + + if tok[0] == "NOT": + self.consume("NOT") + return _Not(self.parse_comparison()) + + if tok[0] == "IDENT": + name = tok[1] + self.consume("IDENT") + + # Check if followed by '(' + if self.peek() and self.peek()[0] == "LPAREN": # type: ignore[index] + self.consume("LPAREN") + # Parse comma-separated args + args: list[float] = [] + sub_exprs: list[_Expr] = [] + while self.peek() and self.peek()[0] != "RPAREN": # type: ignore[index] + t = self.peek() + if t and t[0] == "NUMBER": + self.consume("NUMBER") + args.append(float(t[1])) + elif t and t[0] == "IDENT": + # nested indicator or price ref used as sub-expression + sub_exprs.append(self.parse_atom()) + if self.peek() and self.peek()[0] == "COMMA": # type: ignore[index] + self.consume("COMMA") + self.consume("RPAREN") + + name_upper = name.upper() + if name_upper == "CROSS_ABOVE": + if len(sub_exprs) < 2: + raise ValueError("cross_above requires two arguments.") + return _CrossFunc("above", sub_exprs[0], sub_exprs[1]) + if name_upper == "CROSS_BELOW": + if len(sub_exprs) < 2: + raise ValueError("cross_below requires two arguments.") + return _CrossFunc("below", sub_exprs[0], sub_exprs[1]) + return _IndicatorCall(name_upper, args) + else: + # Price reference or bare indicator name + name_lower = name.lower() + if name_lower in _PRICE_KEYS: + return _PriceRef(name_lower) + # Treat as indicator with no args + return _IndicatorCall(name.upper(), []) + + raise ValueError(f"Unexpected token: {tok!r}") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_expression(expr: str) -> _Expr: + """Parse and compile an expression string into an AST. + + Parameters + ---------- + expr : str + Strategy expression, e.g. ``"RSI(14) < 30 and close > SMA(20)"``. + + Returns + ------- + Compiled expression object (internal type). + + Raises + ------ + ValueError + If the expression cannot be parsed. + + Examples + -------- + >>> from ferro_ta.tools.dsl import parse_expression + >>> ast = parse_expression("RSI(14) < 30") + >>> ast is not None + True + """ + if not isinstance(expr, str) or not expr.strip(): + raise ValueError("expr must be a non-empty string.") + tokens = _tokenise(expr.strip()) + parser = _Parser(tokens) + return parser.parse() + + +def evaluate( + expr: Any, + ohlcv: Any, + *, + close_col: str = "close", + high_col: str = "high", + low_col: str = "low", + open_col: str = "open", + volume_col: str = "volume", +) -> NDArray[np.int32]: + """Evaluate a strategy expression against OHLCV data. + + Parameters + ---------- + expr : str or compiled expression + Either a strategy expression string or the result of + :func:`parse_expression`. + ohlcv : dict of arrays, pandas.DataFrame, or array-like + OHLCV data. At minimum ``close`` is required for indicator-only + expressions. + + Returns + ------- + numpy.ndarray of dtype int32 (values 0 or 1), same length as input. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.dsl import evaluate + >>> rng = np.random.default_rng(1) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 60)) * 100 + >>> signal = evaluate("RSI(14) < 40", {"close": close}) + >>> set(signal.tolist()).issubset({0, 1}) + True + """ + if isinstance(expr, str): + ast = parse_expression(expr) + else: + ast = expr + + # Build context dict + def _extract(col: str, key: str) -> Optional[NDArray]: + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame) and col in ohlcv.columns: + return _to_f64(ohlcv[col].to_numpy()) + except ImportError: + pass + if isinstance(ohlcv, dict) and key in ohlcv: + return _to_f64(ohlcv[key]) + return None + + ctx: dict[str, NDArray[np.float64]] = {} + for col, key in [ + (close_col, "close"), + (high_col, "high"), + (low_col, "low"), + (open_col, "open"), + (volume_col, "volume"), + ]: + val = _extract(col, key) + if val is not None: + ctx[key] = val + + if "close" not in ctx and isinstance(ohlcv, np.ndarray): + ctx["close"] = _to_f64(ohlcv) + + result = ast.eval(ctx) + # Broadcast scalar to full length + n = len(ctx.get("close", np.array([]))) + if result.shape == (1,) and n > 0: + result = np.broadcast_to(result, (n,)).copy() + + # Convert to int32 signal while avoiding warnings when casting NaN/inf. + # For numeric indicator outputs, treat non-finite values as "no signal" (0). + if np.issubdtype(result.dtype, np.floating): + result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0) + return result.astype(np.int32) + + +class Strategy: + """Convenience class for defining and evaluating a strategy expression. + + Parameters + ---------- + expr : str + Strategy expression string. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.dsl import Strategy + >>> rng = np.random.default_rng(42) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100 + >>> strat = Strategy("RSI(14) < 30") + >>> signal = strat.evaluate({"close": close}) + >>> signal.shape + (100,) + """ + + def __init__(self, expr: str) -> None: + self.expr_str = expr + self._ast = parse_expression(expr) + + def evaluate(self, ohlcv: Any, **kwargs: Any) -> NDArray[np.int32]: + """Evaluate this strategy on *ohlcv* data.""" + return evaluate(self._ast, ohlcv, **kwargs) + + def __repr__(self) -> str: + return f"Strategy({self.expr_str!r})" diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/gpu.py b/vendor/ferro-ta-main/python/ferro_ta/tools/gpu.py new file mode 100644 index 0000000..edd38ef --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/gpu.py @@ -0,0 +1,224 @@ +""" +ferro_ta.gpu — Optional GPU-accelerated indicator backend via PyTorch. + +When the caller passes a PyTorch Tensor as input, the GPU path is used and the +result is returned as a PyTorch Tensor. When a NumPy array (or plain Python +sequence) is passed, the standard CPU path is used — there is **no behaviour +change** for existing CPU-only code. + +Install the optional GPU extra to enable this feature: + + pip install "ferro-ta[gpu]" + +Or install PyTorch manually: + + pip install torch + +Usage +----- +>>> import torch +>>> from ferro_ta.tools.gpu import sma, ema, rsi +>>> +>>> close_gpu = torch.tensor([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10], device='cuda') # or 'mps' +>>> result = sma(close_gpu, timeperiod=3) +>>> type(result) # torch.Tensor +>>> result_cpu = result.cpu().numpy() + +See ``docs/gpu-backend.md`` for design notes, limitations, and benchmark data. +""" + +from __future__ import annotations + +from typing import Any, cast + +import numpy as np + +# --------------------------------------------------------------------------- +# PyTorch detection +# --------------------------------------------------------------------------- + +try: + import torch as _torch + + _TORCH_AVAILABLE = True +except ImportError: + _torch = None # type: ignore[assignment] + _TORCH_AVAILABLE = False + + +def _is_torch(arr: object) -> bool: + """Return True when *arr* is a PyTorch Tensor.""" + return ( + _TORCH_AVAILABLE is True + and _torch is not None + and isinstance(arr, _torch.Tensor) + ) + + +def _to_cpu(arr: object) -> np.ndarray: + """Convert a PyTorch Tensor to a NumPy array; pass NumPy arrays through.""" + if _is_torch(arr): + return cast(Any, arr).cpu().numpy() + return np.asarray(arr, dtype=np.float64) + + +def _to_gpu(arr: np.ndarray, device: Any = None) -> Any: + """Move a NumPy array to the GPU (returns torch.Tensor).""" + assert _torch is not None + return _torch.tensor(arr, device=device) + + +# --------------------------------------------------------------------------- +# GPU implementations +# --------------------------------------------------------------------------- + + +def _sma_gpu(close, timeperiod: int): + """SMA on a PyTorch Tensor using cumsum-based rolling mean.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n < timeperiod: + return result + # cumsum-based O(n) rolling sum + cs = torch.cumsum(close, dim=0) + # window sum for index i: cs[i] - cs[i - timeperiod] (i >= timeperiod-1) + win = cs[timeperiod - 1 :] + win = win.clone() + win[1:] -= cs[: len(win) - 1] + result[timeperiod - 1 :] = win / timeperiod + return result + + +def _ema_gpu(close, timeperiod: int): + """EMA on a PyTorch Tensor — SMA-seeded, element-wise loop in Python/PyTorch.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n < timeperiod: + return result + k = 2.0 / (timeperiod + 1.0) + # Seed with SMA of first window (already on GPU) + seed = float(torch.mean(close[:timeperiod]).item()) + result[timeperiod - 1] = seed + # Recurrence on CPU for numerical correctness then move back + close_cpu = close.cpu().numpy() + res_cpu = np.full(n, np.nan) + res_cpu[timeperiod - 1] = seed + prev = seed + for i in range(timeperiod, n): + val = float(close_cpu[i]) * k + prev * (1.0 - k) + res_cpu[i] = val + prev = val + return torch.tensor(res_cpu, dtype=close.dtype, device=close.device) + + +def _rsi_gpu(close, timeperiod: int): + """RSI on a PyTorch Tensor — compute diffs on GPU, finish on CPU.""" + if _torch is None: + raise RuntimeError("PyTorch is not installed") + torch = _torch + n = close.shape[0] + result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device) + if timeperiod < 1 or n <= timeperiod: + return result + # Compute price diffs on GPU + diffs = torch.diff(close).cpu().numpy() # (n-1,) numpy array + # CPU recurrence (Wilder smoothing) + res_cpu = np.full(n, np.nan) + avg_gain = np.mean(np.maximum(diffs[:timeperiod], 0.0)) + avg_loss = np.mean(np.maximum(-diffs[:timeperiod], 0.0)) + rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf + res_cpu[timeperiod] = 100.0 - 100.0 / (1.0 + rs) + for i in range(timeperiod + 1, n): + d = diffs[i - 1] + gain = d if d > 0.0 else 0.0 + loss = -d if d < 0.0 else 0.0 + avg_gain = (avg_gain * (timeperiod - 1) + gain) / timeperiod + avg_loss = (avg_loss * (timeperiod - 1) + loss) / timeperiod + rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf + res_cpu[i] = 100.0 - 100.0 / (1.0 + rs) + return torch.tensor(res_cpu, dtype=close.dtype, device=close.device) + + +# --------------------------------------------------------------------------- +# Public API — PyTorch in → PyTorch out; NumPy in → NumPy out +# --------------------------------------------------------------------------- + + +def sma(close, timeperiod: int = 30): + """Simple Moving Average — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + Close price array. + timeperiod : int, default 30 + Look-back window. + + Returns + ------- + numpy.ndarray or torch.Tensor + Same type as *close*. First ``timeperiod - 1`` values are NaN. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _sma_gpu(close, timeperiod) + # CPU fallback + from ferro_ta import SMA # noqa: PLC0415 + + return SMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +def ema(close, timeperiod: int = 30): + """Exponential Moving Average — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + timeperiod : int, default 30 + + Returns + ------- + numpy.ndarray or torch.Tensor — same type as *close*. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _ema_gpu(close, timeperiod) + from ferro_ta import EMA # noqa: PLC0415 + + return EMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +def rsi(close, timeperiod: int = 14): + """Relative Strength Index — GPU-accelerated when *close* is a PyTorch Tensor. + + Parameters + ---------- + close : numpy.ndarray or torch.Tensor + timeperiod : int, default 14 + + Returns + ------- + numpy.ndarray or torch.Tensor — same type as *close*. Values in [0, 100]. + """ + if _is_torch(close): + if not close.is_floating_point(): + close = close.float() + return _rsi_gpu(close, timeperiod) + from ferro_ta import RSI # noqa: PLC0415 + + return RSI(np.asarray(close, dtype=np.float64), timeperiod=timeperiod) + + +__all__ = [ + "sma", + "ema", + "rsi", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/pipeline.py b/vendor/ferro-ta-main/python/ferro_ta/tools/pipeline.py new file mode 100644 index 0000000..1b9c839 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/pipeline.py @@ -0,0 +1,343 @@ +""" +ferro_ta.pipeline — Indicator Pipeline and Composition API. + +Build reusable pipelines that apply one or more indicators to price arrays +in a single call. A :class:`Pipeline` collects named steps, runs them in +order, and returns the results as a dictionary. + +This module is designed for: + +- Backtesting workflows that need multiple indicators computed on the same data. +- Feature engineering for machine-learning pipelines. +- Batch scenarios where you want all indicator values in one dictionary. + +Usage +----- +>>> import numpy as np +>>> from ferro_ta.tools.pipeline import Pipeline +>>> from ferro_ta import SMA, EMA, RSI +>>> +>>> 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]) +>>> +>>> pipe = ( +... Pipeline() +... .add("sma_10", SMA, timeperiod=10) +... .add("ema_10", EMA, timeperiod=10) +... .add("rsi_14", RSI, timeperiod=14) +... ) +>>> results = pipe.run(close) +>>> print(list(results.keys())) +['sma_10', 'ema_10', 'rsi_14'] +>>> results["sma_10"].shape +(15,) + +Chaining convenience +-------------------- +:meth:`Pipeline.add` returns ``self`` so calls can be chained. + +The :func:`make_pipeline` function is a convenience wrapper: + +>>> from ferro_ta.tools.pipeline import make_pipeline +>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}), +... rsi_14=(RSI, {"timeperiod": 14})) +>>> results = pipe.run(close) + +Multi-output indicators +----------------------- +For indicators that return tuples (e.g. BBANDS, MACD) you can pass an +optional ``output_keys`` argument to unpack the tuple into named keys: + +>>> from ferro_ta import BBANDS, MACD +>>> pipe = ( +... Pipeline() +... .add("bb", BBANDS, output_keys=["bb_upper", "bb_mid", "bb_lower"], +... timeperiod=5, nbdevup=2.0, nbdevdn=2.0) +... .add("macd", MACD, output_keys=["macd", "signal", "hist"], +... fastperiod=3, slowperiod=5, signalperiod=2) +... ) +>>> results = pipe.run(close) +>>> list(results.keys()) +['bb_upper', 'bb_mid', 'bb_lower', 'macd', 'signal', 'hist'] +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +from ferro_ta._utils import _to_f64 + +# --------------------------------------------------------------------------- +# Internal step type +# --------------------------------------------------------------------------- + + +class _Step: + """A single pipeline step (one indicator call).""" + + __slots__ = ("name", "func", "kwargs", "output_keys") + + def __init__( + self, + name: str, + func: Callable[..., Any], + kwargs: dict[str, Any], + output_keys: Optional[list[str]], + ) -> None: + self.name = name + self.func = func + self.kwargs = kwargs + self.output_keys = output_keys + + +# --------------------------------------------------------------------------- +# Pipeline +# --------------------------------------------------------------------------- + + +class Pipeline: + """A reusable indicator pipeline. + + A Pipeline stores a sequence of named indicator steps and can be applied + to one or more data arrays. Calling :meth:`run` returns a dictionary + mapping step names to result arrays. + + Parameters + ---------- + steps : list of (name, func, kwargs, output_keys), optional + Pre-built steps (rarely needed; prefer :meth:`add`). + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, RSI + >>> from ferro_ta.tools.pipeline import Pipeline + >>> close = np.arange(1.0, 20.0) + >>> results = Pipeline().add("sma5", SMA, timeperiod=5).run(close) + >>> results["sma5"].shape + (19,) + """ + + def __init__(self, steps: Optional[list[_Step]] = None) -> None: + self._steps: list[_Step] = list(steps) if steps else [] + + # ------------------------------------------------------------------ + # Step management + # ------------------------------------------------------------------ + + def add( + self, + name: str, + func: Callable[..., Any], + output_keys: Optional[list[str]] = None, + **kwargs: Any, + ) -> Pipeline: + """Add an indicator step to the pipeline. + + Parameters + ---------- + name : str + Key under which the result is stored in the output dict. + For multi-output indicators with *output_keys*, this argument + is ignored (the output_keys are used instead). + func : callable + Indicator function (e.g. ``SMA``, ``RSI``, ``BBANDS``). + output_keys : list of str, optional + For multi-output indicators that return a tuple (e.g. BBANDS, + MACD), supply the names for each output. If not provided and + the indicator returns a tuple, the results are stored as + ``name_0``, ``name_1``, … . + **kwargs + Keyword arguments forwarded to *func* (e.g. ``timeperiod=14``). + + Returns + ------- + Pipeline + Returns ``self`` for chaining. + + Raises + ------ + ValueError + If *name* is already used by an existing step (and no + *output_keys* are supplied). + TypeError + If *func* is not callable. + """ + if not callable(func): + raise TypeError(f"func must be callable, got {type(func).__name__}") + + # Check for duplicate names (only when output_keys is not given) + existing = self._output_names() + if output_keys: + for key in output_keys: + if key in existing: + raise ValueError(f"Duplicate output key '{key}' in pipeline") + else: + if name in existing: + raise ValueError( + f"A step named '{name}' already exists. " + "Use a different name or remove the existing step first." + ) + + self._steps.append(_Step(name, func, kwargs, output_keys)) + return self + + def remove(self, name: str) -> Pipeline: + """Remove the step identified by *name* (or *output_keys* containing *name*). + + Parameters + ---------- + name : str + Step name or one of the output keys. + + Returns + ------- + Pipeline + Returns ``self`` for chaining. + + Raises + ------ + KeyError + If no step with the given name is found. + """ + for i, step in enumerate(self._steps): + if step.name == name or (step.output_keys and name in step.output_keys): + del self._steps[i] + return self + raise KeyError(f"No step named '{name}' in pipeline") + + def steps(self) -> list[str]: + """Return a list of step names (or output keys for multi-output steps).""" + return self._output_names() + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run(self, close: ArrayLike, **extra: Any) -> dict[str, np.ndarray]: + """Apply all pipeline steps to *close* and return results. + + Parameters + ---------- + close : array-like + Primary input array (close prices). For indicators that need + additional arrays (e.g. high/low/volume), pass them as keyword + arguments (see *extra*). + **extra + Additional arrays (e.g. ``high=…``, ``low=…``, ``volume=…``). + Each step's kwargs are merged with *extra* on a per-call basis; + step-level kwargs take precedence. + + Returns + ------- + dict of str → numpy.ndarray + Mapping from output name to result array. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, ATR + >>> from ferro_ta.tools.pipeline import Pipeline + >>> n = 20 + >>> close = np.random.rand(n) + 10 + >>> high = close + 0.5 + >>> low = close - 0.5 + >>> pipe = ( + ... Pipeline() + ... .add("sma", SMA, timeperiod=5) + ... ) + >>> out = pipe.run(close) + >>> out["sma"].shape + (20,) + """ + close_arr = _to_f64(close) + output: dict[str, np.ndarray] = {} + + for step in self._steps: + # Build merged kwargs: extra is the base; step-level kwargs override + merged = dict(extra) + merged.update(step.kwargs) + + result = step.func(close_arr, **merged) + + if isinstance(result, tuple): + if step.output_keys: + if len(step.output_keys) != len(result): + raise ValueError( + f"Step '{step.name}': output_keys has {len(step.output_keys)} " + f"entries but the function returned {len(result)} values." + ) + for key, arr in zip(step.output_keys, result): + output[key] = np.asarray(arr, dtype=np.float64) + else: + for i, arr in enumerate(result): + output[f"{step.name}_{i}"] = np.asarray(arr, dtype=np.float64) + else: + output[step.name] = np.asarray(result, dtype=np.float64) + + return output + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _output_names(self) -> list[str]: + names: list[str] = [] + for step in self._steps: + if step.output_keys: + names.extend(step.output_keys) + else: + names.append(step.name) + return names + + def __len__(self) -> int: + return len(self._steps) + + def __repr__(self) -> str: + step_str = ", ".join(self._output_names()) + return f"Pipeline([{step_str}])" + + +# --------------------------------------------------------------------------- +# Convenience factory +# --------------------------------------------------------------------------- + + +def make_pipeline(**named_steps: tuple[Callable[..., Any], dict[str, Any]]) -> Pipeline: + """Build a :class:`Pipeline` from keyword arguments. + + Parameters + ---------- + **named_steps + Each keyword argument is a step: ``name=(func, kwargs_dict)``. + + Returns + ------- + Pipeline + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta import SMA, RSI + >>> from ferro_ta.tools.pipeline import make_pipeline + >>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}), + ... rsi_14=(RSI, {"timeperiod": 14})) + >>> results = pipe.run(np.arange(1.0, 25.0)) + >>> sorted(results.keys()) + ['rsi_14', 'sma_5'] + """ + pipe = Pipeline() + for name, step in named_steps.items(): + func, kwargs = step + pipe.add(name, func, **kwargs) + return pipe + + +__all__ = [ + "Pipeline", + "make_pipeline", +] diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/tools.py b/vendor/ferro-ta-main/python/ferro_ta/tools/tools.py new file mode 100644 index 0000000..4a5e61f --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/tools.py @@ -0,0 +1,284 @@ +""" +ferro_ta.tools — Stable Tool Wrappers for Agent / LLM Integration +================================================================= + +Provides stable, well-documented functions that are easy to wrap as +LangChain/LlamaIndex/OpenAI Function tools or to call from automated agents. + +All functions have clear signatures, descriptive docstrings, and return +JSON-serializable types so that agent frameworks can inspect and call them +without special handling. + +See ``docs/agentic.md`` for the full agentic workflow guide, LangChain +integration examples, and scheduling instructions. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools import compute_indicator, run_backtest, list_indicators +>>> +>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 +>>> +>>> # Compute a single indicator by name +>>> result = compute_indicator("SMA", close, timeperiod=14) +>>> +>>> # Run a backtest +>>> summary = run_backtest("rsi_30_70", close) +>>> print(summary["final_equity"]) + +API +--- +compute_indicator(name, *args, **kwargs) → array or dict + Compute a built-in or registered indicator by name. + +run_backtest(strategy, close, **kwargs) → dict + Run a backtest and return a summary dict. + +list_indicators() → list[str] + list all registered indicator names. + +describe_indicator(name) → str + Return the docstring of a registered indicator (or a summary). +""" + +from __future__ import annotations + +from typing import Any, Union + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "compute_indicator", + "run_backtest", + "list_indicators", + "describe_indicator", +] + + +def compute_indicator( + name: str, + *args: ArrayLike, + **kwargs: Any, +) -> Union[NDArray[np.float64], dict[str, NDArray[np.float64]]]: + """Compute a named indicator and return the result. + + Delegates to the ferro_ta registry so that both built-in and custom + indicators can be called by name. + + Parameters + ---------- + name : str + Indicator name (e.g. ``"SMA"``, ``"RSI"``, ``"BBANDS"``). + Case-sensitive; use :func:`list_indicators` to see all names. + *args : array-like + Positional data arrays forwarded to the indicator (e.g. close, high). + **kwargs + Parameter keyword arguments forwarded to the indicator + (e.g. ``timeperiod=14``). + + Returns + ------- + ndarray or dict of str → ndarray + For single-output indicators, returns a 1-D ``numpy.ndarray``. + For multi-output indicators (e.g. BBANDS, MACD), returns a dict + mapping output names to arrays. The dict keys follow TA-Lib + conventions where known (``"upper"``/``"middle"``/``"lower"`` for + BBANDS; ``"macd"``/``"signal"``/``"hist"`` for MACD; etc.). + + Raises + ------ + ferro_ta.registry.FerroTARegistryError + If *name* is not a known indicator. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools import compute_indicator + >>> close = np.linspace(100, 110, 20) + >>> result = compute_indicator("SMA", close, timeperiod=5) + >>> result.shape + (20,) + >>> bb = compute_indicator("BBANDS", close, timeperiod=5) + >>> sorted(bb.keys()) + ['lower', 'middle', 'upper'] + """ + from ferro_ta.core.registry import run as _registry_run + + raw = _registry_run(name, *args, **kwargs) + + if isinstance(raw, tuple): + # Multi-output: try to map to named keys for well-known indicators + _multi_keys: dict[str, list[str]] = { + "BBANDS": ["upper", "middle", "lower"], + "MACD": ["macd", "signal", "hist"], + "MACDEXT": ["macd", "signal", "hist"], + "MACDFIX": ["macd", "signal", "hist"], + "STOCH": ["slowk", "slowd"], + "STOCHF": ["fastk", "fastd"], + "STOCHRSI": ["fastk", "fastd"], + "AROON": ["aroondown", "aroonup"], + "HT_PHASOR": ["inphase", "quadrature"], + "HT_SINE": ["sine", "leadsine"], + "MAMA": ["mama", "fama"], + } + keys = _multi_keys.get(name.upper()) + if keys and len(keys) == len(raw): + return {k: np.asarray(v, dtype=np.float64) for k, v in zip(keys, raw)} + # Fallback: use integer keys + return {str(i): np.asarray(v, dtype=np.float64) for i, v in enumerate(raw)} + + return np.asarray(raw, dtype=np.float64) + + +def run_backtest( + strategy: str, + close: ArrayLike, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + **strategy_kwargs: Any, +) -> dict[str, Any]: + """Run a named backtest strategy and return a summary dictionary. + + This is a convenience wrapper around :func:`ferro_ta.backtest.backtest` + that returns a JSON-serializable summary dict rather than a + ``BacktestResult`` object, making it easy to use from agent tools. + + Parameters + ---------- + strategy : str + Name of the built-in strategy: ``"rsi_30_70"``, ``"sma_crossover"``, + or ``"macd_crossover"``. + close : array-like + Close prices (1-D, at least 2 bars). + commission_per_trade : float + Fixed commission deducted from equity on each position change. + slippage_bps : float + Slippage in basis points applied on position-change bars. + **strategy_kwargs + Extra kwargs forwarded to the strategy function + (e.g. ``timeperiod=14``, ``oversold=25``). + + Returns + ------- + dict + Summary with the following keys: + + * ``"strategy"`` — the strategy name used. + * ``"n_bars"`` — number of price bars. + * ``"n_trades"`` — number of position changes. + * ``"final_equity"`` — terminal equity value (start = 1.0). + * ``"max_drawdown"`` — maximum drawdown fraction (0–1, positive value + represents the magnitude of loss). + * ``"equity"`` — equity curve as a Python list of floats. + * ``"signals"`` — signal array as a Python list. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools import run_backtest + >>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 + >>> summary = run_backtest("rsi_30_70", close) + >>> isinstance(summary["final_equity"], float) + True + """ + from ferro_ta.analysis.backtest import backtest as _backtest + + result = _backtest( + close, + strategy=strategy, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + **strategy_kwargs, + ) + + equity = np.asarray(result.equity, dtype=np.float64) + # Compute max drawdown + running_max = np.maximum.accumulate(equity) + drawdowns = (running_max - equity) / np.where(running_max > 0, running_max, 1.0) + max_dd = float(np.nanmax(drawdowns)) if len(drawdowns) > 0 else 0.0 + + return { + "strategy": strategy, + "n_bars": len(result.signals), + "n_trades": result.n_trades, + "final_equity": result.final_equity, + "max_drawdown": max_dd, + "equity": equity.tolist(), + "signals": np.asarray(result.signals, dtype=np.float64).tolist(), + } + + +def list_indicators() -> list[str]: + """Return a sorted list of all registered indicator names. + + Includes both built-in ferro_ta indicators and any custom indicators + registered via :func:`ferro_ta.registry.register`. + + Returns + ------- + list of str + Sorted list of indicator names (e.g. ``["AD", "ADOSC", "ADX", …]``). + + Examples + -------- + >>> from ferro_ta.tools import list_indicators + >>> names = list_indicators() + >>> "SMA" in names + True + >>> "RSI" in names + True + """ + from ferro_ta.core.registry import list_indicators as _list + + return _list() + + +def describe_indicator(name: str) -> str: + """Return a human-readable description of a registered indicator. + + Looks up the indicator's docstring and returns the first paragraph (up to + the first blank line) so it can be used in agent prompts or tool + descriptions. + + Parameters + ---------- + name : str + Indicator name (case-sensitive). Use :func:`list_indicators` to get + valid names. + + Returns + ------- + str + The first paragraph of the indicator's docstring, or a fallback + message if no docstring is available. + + Raises + ------ + ferro_ta.registry.FerroTARegistryError + If *name* is not a known indicator. + + Examples + -------- + >>> from ferro_ta.tools import describe_indicator + >>> desc = describe_indicator("SMA") + >>> isinstance(desc, str) and len(desc) > 0 + True + """ + from ferro_ta.core.registry import get as _get + + func = _get(name) + doc = getattr(func, "__doc__", None) or "" + if not doc.strip(): + return f"{name}: no description available." + + # Return only the first paragraph (before the first blank line) + lines = doc.strip().splitlines() + para: list[str] = [] + for line in lines: + stripped = line.strip() + if stripped == "" and para: + break + para.append(stripped) + + return " ".join(para).strip() or f"{name}: no description available." diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/viz.py b/vendor/ferro-ta-main/python/ferro_ta/tools/viz.py new file mode 100644 index 0000000..20e40ba --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/viz.py @@ -0,0 +1,351 @@ +""" +ferro_ta.viz — Charting and visualisation API. + +Generates charts (matplotlib and/or Plotly) with indicators overlaid on price. + +API +--- +plot(ohlcv, indicators=None, *, backend='matplotlib', title=None, + figsize=None, savefig=None, show=False) + Generate a chart from OHLCV data and optional indicator series. + Returns a figure object for further customisation. + +Backends +-------- +- ``'matplotlib'`` — requires ``matplotlib`` (recommended for static charts) +- ``'plotly'`` — requires ``plotly`` (recommended for interactive charts) + +Install optional backends:: + + pip install ferro-ta[plot] # adds matplotlib + plotly + pip install matplotlib # matplotlib only + pip install plotly # plotly only + +Examples +-------- +>>> import numpy as np +>>> from ferro_ta import RSI, SMA +>>> from ferro_ta.tools.viz import plot +>>> rng = np.random.default_rng(0) +>>> n = 60 +>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 +>>> ohlcv = {"close": close, "open": close, "high": close * 1.01, +... "low": close * 0.99, "volume": np.ones(n) * 1000} +>>> fig = plot(ohlcv, indicators={"RSI(14)": RSI(close, timeperiod=14), +... "SMA(20)": SMA(close, timeperiod=20)}, +... backend='matplotlib', show=False) +>>> fig is not None +True +""" + +from __future__ import annotations + +import warnings +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +__all__ = [ + "plot", +] + +# --------------------------------------------------------------------------- +# plot +# --------------------------------------------------------------------------- + + +def plot( + ohlcv: Any, + indicators: Optional[dict[str, ArrayLike]] = None, + *, + backend: str = "matplotlib", + title: Optional[str] = None, + figsize: Optional[tuple[float, float]] = None, + savefig: Optional[str] = None, + show: bool = True, + volume: bool = True, + close_col: str = "close", + volume_col: str = "volume", +) -> Any: + """Generate a chart from OHLCV data and optional indicator series. + + Parameters + ---------- + ohlcv : dict, pandas.DataFrame, or array-like + OHLCV data. At minimum a ``close`` key/column is required. + indicators : dict {label: array}, optional + Additional indicator series to plot below the price panel. + Each entry is plotted in its own subplot. + backend : str + ``'matplotlib'`` (default) or ``'plotly'``. + title : str, optional + Chart title. + figsize : (width, height), optional + Figure size in inches (matplotlib) or pixels (plotly). + savefig : str, optional + Save figure to this file path (e.g. ``'chart.png'``, ``'chart.html'``). + show : bool + If ``True``, call ``plt.show()`` or ``fig.show()`` interactively. + volume : bool + If ``True`` and a volume series is present, add a volume subplot. + close_col, volume_col : str + Column names when *ohlcv* is a DataFrame. + + Returns + ------- + matplotlib.figure.Figure or plotly.graph_objects.Figure + + Raises + ------ + ImportError + If the requested backend is not installed. + """ + close_arr, volume_arr = _extract_close_volume(ohlcv, close_col, volume_col) + + if backend == "matplotlib": + return _plot_matplotlib( + close_arr, + volume_arr if volume else None, + indicators, + title=title, + figsize=figsize, + savefig=savefig, + show=show, + ) + elif backend == "plotly": + return _plot_plotly( + close_arr, + volume_arr if volume else None, + indicators, + title=title, + figsize=figsize, + savefig=savefig, + show=show, + ) + else: + raise ValueError( + f"Unknown backend {backend!r}. Supported: 'matplotlib', 'plotly'." + ) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _extract_close_volume( + ohlcv: Any, + close_col: str, + volume_col: str, +) -> tuple[NDArray[np.float64], Optional[NDArray[np.float64]]]: + """Extract close and (optional) volume from various input formats.""" + try: + import pandas as pd + + if isinstance(ohlcv, pd.DataFrame): + close = ohlcv[close_col].values.astype(np.float64) + volume = ( + ohlcv[volume_col].values.astype(np.float64) + if volume_col in ohlcv.columns + else None + ) + return close, volume + except ImportError: + pass + + if isinstance(ohlcv, dict): + close = np.asarray( + ohlcv.get(close_col, ohlcv.get("close", [])), dtype=np.float64 + ) + vol_key = volume_col if volume_col in ohlcv else "volume" + volume = ( + np.asarray(ohlcv[vol_key], dtype=np.float64) if vol_key in ohlcv else None + ) + return close, volume + + # Plain array + return np.asarray(ohlcv, dtype=np.float64), None + + +def _n_subplots(indicators: Optional[dict], volume_arr: Optional[NDArray]) -> int: + n = 1 # price + if volume_arr is not None: + n += 1 + if indicators: + n += len(indicators) + return n + + +# --------------------------------------------------------------------------- +# Matplotlib backend +# --------------------------------------------------------------------------- + + +def _plot_matplotlib( + close: NDArray, + volume: Optional[NDArray], + indicators: Optional[dict[str, ArrayLike]], + *, + title: Optional[str], + figsize: Optional[tuple], + savefig: Optional[str], + show: bool, +) -> Any: + try: + import matplotlib.gridspec as gridspec + import matplotlib.pyplot as plt + except ImportError as exc: + raise ImportError( + "matplotlib is required for the 'matplotlib' backend. " + "Install with: pip install matplotlib" + ) from exc + + n_subplots = _n_subplots(indicators, volume) + height_ratios = [3] + [1] * (n_subplots - 1) + fig_h = figsize[1] if figsize else 2.5 * n_subplots + 1 + fig_w = figsize[0] if figsize else 12.0 + fig = plt.figure(figsize=(fig_w, fig_h)) + gs = gridspec.GridSpec(n_subplots, 1, height_ratios=height_ratios, hspace=0.35) + + ax_price = fig.add_subplot(gs[0]) + ax_price.plot(close, color="#1f77b4", linewidth=1.2, label="close") + ax_price.set_ylabel("Price") + ax_price.legend(loc="upper left", fontsize=8) + ax_price.grid(alpha=0.3) + if title: + ax_price.set_title(title) + + row = 1 + if volume is not None: + ax_vol = fig.add_subplot(gs[row], sharex=ax_price) + ax_vol.bar(range(len(volume)), volume, color="#aec7e8", alpha=0.7, width=0.8) + ax_vol.set_ylabel("Volume") + ax_vol.grid(alpha=0.3) + row += 1 + + if indicators: + colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"] + for idx, (label, arr) in enumerate(indicators.items()): + ax_ind = fig.add_subplot(gs[row], sharex=ax_price) + color = colors[idx % len(colors)] + arr_np = np.asarray(arr, dtype=np.float64) + ax_ind.plot(arr_np, color=color, linewidth=1.0, label=label) + ax_ind.set_ylabel(label, fontsize=8) + ax_ind.legend(loc="upper left", fontsize=8) + ax_ind.grid(alpha=0.3) + row += 1 + + # Use tight_layout when possible but suppress known benign UserWarning + # about incompatible Axes configurations. + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message="This figure includes Axes that are not compatible with tight_layout.*", + category=UserWarning, + ) + plt.tight_layout() + + if savefig: + fig.savefig(savefig, dpi=100, bbox_inches="tight") + if show: + plt.show() + return fig + + +# --------------------------------------------------------------------------- +# Plotly backend +# --------------------------------------------------------------------------- + + +def _plot_plotly( + close: NDArray, + volume: Optional[NDArray], + indicators: Optional[dict[str, ArrayLike]], + *, + title: Optional[str], + figsize: Optional[tuple], + savefig: Optional[str], + show: bool, +) -> Any: + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError as exc: + raise ImportError( + "plotly is required for the 'plotly' backend. " + "Install with: pip install plotly" + ) from exc + + n_subplots = _n_subplots(indicators, volume) + row_heights = [0.5] + [0.1] * (n_subplots - 1) + total = sum(row_heights) + row_heights = [r / total for r in row_heights] + shared_xaxes = True + subplot_titles = ["Price"] + if volume is not None: + subplot_titles.append("Volume") + if indicators: + subplot_titles.extend(list(indicators.keys())) + + fig = make_subplots( + rows=n_subplots, + cols=1, + shared_xaxes=shared_xaxes, + row_heights=row_heights, + subplot_titles=subplot_titles, + vertical_spacing=0.05, + ) + x = list(range(len(close))) + fig.add_trace( + go.Scatter( + x=x, y=close.tolist(), mode="lines", name="close", line={"color": "#1f77b4"} + ), + row=1, + col=1, + ) + + row = 2 + if volume is not None: + fig.add_trace( + go.Bar(x=x, y=volume.tolist(), name="volume", marker_color="#aec7e8"), + row=row, + col=1, + ) + row += 1 + + if indicators: + colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"] + for idx, (label, arr) in enumerate(indicators.items()): + arr_np = np.asarray(arr, dtype=np.float64) + color = colors[idx % len(colors)] + fig.add_trace( + go.Scatter( + x=x, + y=arr_np.tolist(), + mode="lines", + name=label, + line={"color": color}, + ), + row=row, + col=1, + ) + row += 1 + + fig_w = figsize[0] if figsize else 900 + fig_h = figsize[1] if figsize else 500 + fig.update_layout( + title=title or "ferro_ta Chart", + width=fig_w, + height=fig_h, + showlegend=True, + ) + + if savefig: + if savefig.endswith(".html"): + fig.write_html(savefig) + else: + fig.write_image(savefig) + if show: + fig.show() + return fig diff --git a/vendor/ferro-ta-main/python/ferro_ta/tools/workflow.py b/vendor/ferro-ta-main/python/ferro_ta/tools/workflow.py new file mode 100644 index 0000000..a3d314f --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/tools/workflow.py @@ -0,0 +1,333 @@ +""" +ferro_ta.workflow — End-to-End Workflow Orchestration +===================================================== + +Provides a lightweight DAG/linear workflow that chains data acquisition, +resampling, indicator computation, strategy signal generation, and alerting +in a single call. All heavy computation is delegated to existing ferro_ta +modules; this module is **pure orchestration** with no new algorithmic logic. + +See ``docs/agentic.md`` for a full end-to-end example including LangChain +integration and scheduling. + +Quick start +----------- +>>> import numpy as np +>>> from ferro_ta.tools.workflow import Workflow +>>> +>>> # Build a workflow +>>> wf = ( +... Workflow() +... .add_indicator("sma_20", "SMA", timeperiod=20) +... .add_indicator("rsi_14", "RSI", timeperiod=14) +... .add_strategy("rsi_30_70") +... ) +>>> +>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 +>>> result = wf.run(close) +>>> print(result.keys()) + +API +--- +Workflow + Fluent builder that chains: indicators → strategy → backtest → alerts. + +run_pipeline(close, indicators, strategy, alert_level) + Functional interface: single call that returns all outputs. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import numpy as np +from numpy.typing import ArrayLike + +__all__ = [ + "Workflow", + "run_pipeline", +] + + +class Workflow: + """Fluent builder for an end-to-end ferro_ta workflow. + + A :class:`Workflow` chains these optional steps in order: + + 1. **Indicators** — compute one or more named indicators on close prices. + 2. **Strategy** — optionally run a backtest strategy and capture the result. + 3. **Alerts** — optionally define threshold or cross alerts on any indicator + output and collect firing bars. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.workflow import Workflow + >>> rng = np.random.default_rng(42) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + >>> result = ( + ... Workflow() + ... .add_indicator("sma_20", "SMA", timeperiod=20) + ... .add_indicator("rsi_14", "RSI", timeperiod=14) + ... .run(close) + ... ) + >>> "sma_20" in result + True + >>> "rsi_14" in result + True + """ + + def __init__(self) -> None: + self._indicator_steps: list[tuple[str, str, dict[str, Any]]] = [] + self._strategy: Optional[str] = None + self._strategy_kwargs: dict[str, Any] = {} + self._alert_steps: list[tuple[str, str, float, int]] = [] + + # ------------------------------------------------------------------ + # Fluent builders + # ------------------------------------------------------------------ + + def add_indicator( + self, + output_key: str, + indicator_name: str, + **kwargs: Any, + ) -> Workflow: + """Add an indicator step. + + Parameters + ---------- + output_key : str + Key under which the result will be stored in the output dict. + indicator_name : str + Name of the indicator (e.g. ``"SMA"``, ``"RSI"``). + **kwargs + Parameters forwarded to the indicator (e.g. ``timeperiod=14``). + + Returns + ------- + Workflow + Self, for chaining. + """ + self._indicator_steps.append((output_key, indicator_name, kwargs)) + return self + + def add_strategy( + self, + strategy: str, + **strategy_kwargs: Any, + ) -> Workflow: + """Set the backtest strategy to run. + + Only one strategy can be active at a time; calling this method again + replaces the previous strategy. + + Parameters + ---------- + strategy : str + Strategy name (``"rsi_30_70"``, ``"sma_crossover"``, or + ``"macd_crossover"``). + **strategy_kwargs + Extra parameters forwarded to the strategy function. + + Returns + ------- + Workflow + Self, for chaining. + """ + self._strategy = strategy + self._strategy_kwargs = dict(strategy_kwargs) + return self + + def add_alert( + self, + indicator_key: str, + level: float, + direction: int = 1, + ) -> Workflow: + """Add a threshold crossing alert on an indicator output. + + The alert fires on bars where the specified indicator crosses *level* + in *direction*. + + Parameters + ---------- + indicator_key : str + Key of an indicator already added via :meth:`add_indicator`. + level : float + Alert level (e.g. 30 for RSI oversold). + direction : int + ``+1`` → alert when series crosses *above* level. + ``-1`` → alert when series crosses *below* level. + + Returns + ------- + Workflow + Self, for chaining. + """ + alert_key = f"alert_{indicator_key}_{level:.4g}_{direction:+d}" + self._alert_steps.append((alert_key, indicator_key, level, direction)) + return self + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run( + self, + close: ArrayLike, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, + ) -> dict[str, Any]: + """Execute the workflow and return all outputs. + + Parameters + ---------- + close : array-like + Close price series (1-D). + commission_per_trade : float + Commission forwarded to backtest (if strategy is set). + slippage_bps : float + Slippage in bps forwarded to backtest (if strategy is set). + + Returns + ------- + dict + Dictionary containing: + + * Each indicator key → ``numpy.ndarray`` result (or dict for + multi-output indicators such as BBANDS/MACD). + * ``"backtest"`` → summary dict (only if a strategy was added). + * Each alert key → list of bar indices where alert fired + (only if alerts were added). + """ + from ferro_ta.tools import compute_indicator, run_backtest + + close_arr = np.asarray(close, dtype=np.float64) + output: dict[str, Any] = {} + + # Step 1: compute indicators + for output_key, indicator_name, kwargs in self._indicator_steps: + output[output_key] = compute_indicator(indicator_name, close_arr, **kwargs) + + # Step 2: run backtest strategy (if set) + if self._strategy is not None: + output["backtest"] = run_backtest( + self._strategy, + close_arr, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + **self._strategy_kwargs, + ) + + # Step 3: compute alerts + if self._alert_steps: + from ferro_ta.tools.alerts import check_threshold, collect_alert_bars + + for alert_key, ind_key, level, direction in self._alert_steps: + series = output.get(ind_key) + if series is None: + continue + # For multi-output indicators, skip alert silently + if isinstance(series, dict): + continue + arr = np.asarray(series, dtype=np.float64) + mask = check_threshold(arr, level=level, direction=direction) + output[alert_key] = collect_alert_bars(mask).tolist() + + return output + + +# --------------------------------------------------------------------------- +# Functional interface +# --------------------------------------------------------------------------- + + +def run_pipeline( + close: ArrayLike, + indicators: Optional[dict[str, dict[str, Any]]] = None, + strategy: Optional[str] = None, + strategy_kwargs: Optional[dict[str, Any]] = None, + alert_level: Optional[float] = None, + alert_indicator: Optional[str] = None, + alert_direction: int = -1, + commission_per_trade: float = 0.0, + slippage_bps: float = 0.0, +) -> dict[str, Any]: + """Run a full ferro_ta pipeline in one call. + + Functional wrapper around :class:`Workflow` for scripting and agent use. + + Parameters + ---------- + close : array-like + Close price series. + indicators : dict of {str: dict}, optional + Mapping of ``output_key → kwargs_dict`` for indicators to compute. + The indicator name must be embedded as ``"name"`` in the kwargs dict. + + Example:: + + indicators = { + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + } + + strategy : str, optional + Built-in strategy name (``"rsi_30_70"`` etc.). + strategy_kwargs : dict, optional + Extra kwargs for the strategy. + alert_level : float, optional + If set, add a threshold alert on *alert_indicator* at this level. + alert_indicator : str, optional + Key of the indicator to alert on (must be in *indicators*). + alert_direction : int + Direction of the alert: ``+1`` cross-above, ``-1`` cross-below. + commission_per_trade : float + Backtest commission. + slippage_bps : float + Backtest slippage in bps. + + Returns + ------- + dict + Same structure as :meth:`Workflow.run`. + + Examples + -------- + >>> import numpy as np + >>> from ferro_ta.tools.workflow import run_pipeline + >>> rng = np.random.default_rng(0) + >>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100 + >>> result = run_pipeline( + ... close, + ... indicators={ + ... "sma_20": {"name": "SMA", "timeperiod": 20}, + ... "rsi_14": {"name": "RSI", "timeperiod": 14}, + ... }, + ... strategy="rsi_30_70", + ... ) + >>> "sma_20" in result + True + >>> "backtest" in result + True + """ + wf = Workflow() + + if indicators: + for key, params in indicators.items(): + params = dict(params) + ind_name = params.pop("name") + wf.add_indicator(key, ind_name, **params) + + if strategy: + wf.add_strategy(strategy, **(strategy_kwargs or {})) + + if alert_level is not None and alert_indicator is not None: + wf.add_alert(alert_indicator, level=alert_level, direction=alert_direction) + + return wf.run( + close, + commission_per_trade=commission_per_trade, + slippage_bps=slippage_bps, + ) diff --git a/vendor/ferro-ta-main/python/ferro_ta/utils.py b/vendor/ferro-ta-main/python/ferro_ta/utils.py new file mode 100644 index 0000000..e94f869 --- /dev/null +++ b/vendor/ferro-ta-main/python/ferro_ta/utils.py @@ -0,0 +1,9 @@ +""" +Public utilities for ferro_ta (Pandas DataFrame OHLCV contract, etc.). +""" + +from __future__ import annotations + +from ferro_ta._utils import get_ohlcv + +__all__ = ["get_ohlcv"] diff --git a/vendor/ferro-ta-main/scripts/build_api_manifest.py b/vendor/ferro-ta-main/scripts/build_api_manifest.py new file mode 100644 index 0000000..9f92a3e --- /dev/null +++ b/vendor/ferro-ta-main/scripts/build_api_manifest.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Build a cross-surface API manifest for ferro-ta. + +The generated manifest summarizes: +- Python indicator/method exposure (from ferro_ta.tools.api_info) +- Core Rust crate public functions (ferro_ta_core) +- WASM/Node exported functions (from wasm pkg d.ts) + +Output is written to `docs/api_manifest.json`. +""" + +from __future__ import annotations + +import argparse +import ast +import datetime as _dt +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def _load_api_info_module(root: Path, module_path: Path): + python_root = str(root / "python") + if python_root not in sys.path: + sys.path.insert(0, python_root) + spec = importlib.util.spec_from_file_location( + "ferro_ta_tools_api_info", module_path + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load module spec from {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # type: ignore[assignment] + return module + + +def _module_file(root: Path, module_name: str) -> Path | None: + module_rel = module_name.replace(".", "/") + file_path = root / "python" / f"{module_rel}.py" + if file_path.exists(): + return file_path + init_path = root / "python" / module_rel / "__init__.py" + if init_path.exists(): + return init_path + return None + + +def _extract_dunder_all(file_path: Path) -> list[str]: + try: + source = file_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(file_path)) + except Exception: + return [] + + exports: list[str] = [] + for node in tree.body: + value_node = None + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "__all__": + value_node = node.value + break + elif isinstance(node, ast.AnnAssign): + target = node.target + if isinstance(target, ast.Name) and target.id == "__all__": + value_node = node.value + if value_node is None: + continue + try: + value = ast.literal_eval(value_node) + except Exception: + continue + if isinstance(value, str): + exports = [value] + elif isinstance(value, (list, tuple)): + exports = [item for item in value if isinstance(item, str)] + return exports + + +def _module_exports(root: Path, module_name: str) -> list[str]: + file_path = _module_file(root, module_name) + if file_path is None: + return [] + return _extract_dunder_all(file_path) + + +def _extract_python_api(root: Path) -> dict[str, Any]: + module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py" + api_info_module = _load_api_info_module(root, module_path) + + category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {})) + method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {})) + + indicators: list[dict[str, Any]] = [] + seen_indicators: set[str] = set() + for category, module_name in category_modules.items(): + for name in _module_exports(root, module_name): + if name in seen_indicators: + continue + seen_indicators.add(name) + indicators.append( + { + "name": name, + "category": category, + "module": module_name, + "doc": "", + "params": [], + } + ) + + methods: list[dict[str, Any]] = [] + seen_methods: set[tuple[str, str]] = set() + for category, module_name in method_modules.items(): + for name in _module_exports(root, module_name): + key = (module_name, name) + if key in seen_methods: + continue + seen_methods.add(key) + methods.append( + { + "name": name, + "category": category, + "module": module_name, + "doc": "", + "params": [], + } + ) + + indicators.sort(key=lambda entry: entry["name"]) + methods.sort(key=lambda entry: (entry["category"], entry["name"])) + + categories = sorted({entry["category"] for entry in indicators}) + + if not indicators: + raise RuntimeError( + "No Python indicators discovered from source exports. " + "Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations." + ) + + return { + "indicator_count": len(indicators), + "method_count": len(methods), + "categories": categories, + "indicators": indicators, + "methods": methods, + } + + +def _extract_core_exports(root: Path) -> list[dict[str, str]]: + core_src = root / "crates" / "ferro_ta_core" / "src" + entries: list[dict[str, str]] = [] + + for rs_file in sorted(core_src.rglob("*.rs")): + rel = rs_file.relative_to(core_src).as_posix() + module = rel[:-3].replace("/", ".") + text = rs_file.read_text(encoding="utf-8") + for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text): + entries.append( + { + "module": module, + "function": match.group(1), + "file": rel, + } + ) + + entries.sort(key=lambda item: (item["module"], item["function"])) + return entries + + +def _extract_wasm_exports(root: Path) -> list[str]: + exports: set[str] = set() + + # Source exports are the canonical declaration of the WASM/Node API and + # avoid drift when a stale wasm/pkg folder is present locally. + wasm_lib = root / "wasm" / "src" / "lib.rs" + if wasm_lib.exists(): + text = wasm_lib.read_text(encoding="utf-8") + for match in re.finditer( + r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", + text, + ): + exports.add(match.group(1)) + if exports: + return sorted(exports) + + # Fallback to generated declarations if source parsing did not find exports. + dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts" + if dts_path.exists(): + for line in dts_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("export function "): + name = line[len("export function ") :].split("(")[0].strip() + if name: + exports.add(name) + + return sorted(exports) + + +def _safe_git_head(root: Path) -> str | None: + try: + completed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=root, + capture_output=True, + text=True, + check=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + value = completed.stdout.strip() + return value or None + + +def build_manifest( + root: Path, include_runtime_metadata: bool = False +) -> dict[str, Any]: + python_api = _extract_python_api(root) + rust_core = _extract_core_exports(root) + wasm_exports = _extract_wasm_exports(root) + + python_indicator_names = {entry["name"] for entry in python_api["indicators"]} + python_indicator_names_lc = {name.lower() for name in python_indicator_names} + wasm_set = set(wasm_exports) + wasm_set_lc = {name.lower() for name in wasm_set} + common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc)) + + manifest: dict[str, Any] = { + "surfaces": { + "python": python_api, + "rust_core": { + "public_function_count": len(rust_core), + "functions": rust_core, + }, + "wasm_node": { + "export_count": len(wasm_exports), + "exports": wasm_exports, + }, + }, + "parity_summary": { + "python_indicator_count": len(python_indicator_names_lc), + "wasm_export_count": len(wasm_set), + "common_python_wasm_count": len(common_with_wasm), + "common_python_wasm": common_with_wasm, + "python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc), + "wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc), + }, + } + + if include_runtime_metadata: + manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat() + manifest["git_head"] = _safe_git_head(root) + + return manifest + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build cross-surface API manifest") + parser.add_argument( + "--output", + type=Path, + default=Path("docs/api_manifest.json"), + help="Output JSON path relative to repo root (default: docs/api_manifest.json)", + ) + parser.add_argument( + "--include-runtime-metadata", + action="store_true", + help=( + "Include non-deterministic metadata fields (timestamp, git head). " + "Disabled by default to keep manifest reproducible for CI checks." + ), + ) + args = parser.parse_args() + + root = _repo_root() + output_path = (root / args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + manifest = build_manifest( + root, include_runtime_metadata=args.include_runtime_metadata + ) + output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(f"Wrote API manifest to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/vendor/ferro-ta-main/scripts/bump_version.py b/vendor/ferro-ta-main/scripts/bump_version.py new file mode 100644 index 0000000..5505c23 --- /dev/null +++ b/vendor/ferro-ta-main/scripts/bump_version.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Update or verify ferro-ta version strings across release files. + +Usage +----- +python3 scripts/bump_version.py 1.0.3 +python3 scripts/bump_version.py --check +python3 scripts/bump_version.py --show +""" + +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +@dataclass(frozen=True) +class VersionCarrier: + label: str + path: Path + pattern: str + replacement: str + + def read(self) -> str: + text = self.path.read_text(encoding="utf-8") + match = re.search(self.pattern, text, flags=re.MULTILINE) + if not match: + raise ValueError(f"Could not find version for {self.label} in {self.path}") + return match.group(2) + + def write(self, version: str) -> bool: + text = self.path.read_text(encoding="utf-8") + updated, count = re.subn( + self.pattern, + rf"\g<1>{version}\g<3>", + text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError(f"Could not update {self.label} in {self.path}") + changed = updated != text + if changed: + self.path.write_text(updated, encoding="utf-8") + return changed + + +CARRIERS = [ + VersionCarrier( + "cargo_root", + ROOT / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_dep", + ROOT / "Cargo.toml", + r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)("[^}]*\})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_crate", + ROOT / "crates" / "ferro_ta_core" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_readme", + ROOT / "crates" / "ferro_ta_core" / "README.md", + r'(ferro_ta_core = ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "pyproject", + ROOT / "pyproject.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_cargo", + ROOT / "wasm" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_package", + ROOT / "wasm" / "package.json", + r'("version": ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "conda", + ROOT / "conda" / "meta.yaml", + r'({% set version = ")([^"]+)(" %})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_changelog", + ROOT / "docs" / "changelog.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_support_matrix", + ROOT / "docs" / "support_matrix.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), +] + + +def _read_versions() -> dict[str, str]: + return {carrier.label: carrier.read() for carrier in CARRIERS} + + +def _print_versions(versions: dict[str, str]) -> None: + for label, version in versions.items(): + print(f"{label:20} {version}") + + +def _check_versions() -> int: + versions = _read_versions() + unique = sorted(set(versions.values())) + _print_versions(versions) + if len(unique) != 1: + print() + print(f"ERROR: version mismatch detected: {', '.join(unique)}") + return 1 + print() + print(f"OK: all tracked versions match {unique[0]}") + return 0 + + +def _set_version(version: str) -> int: + if not SEMVER_RE.match(version): + print(f"ERROR: expected MAJOR.MINOR.PATCH, got {version!r}") + return 1 + + changed_paths: list[Path] = [] + for carrier in CARRIERS: + if carrier.write(version): + changed_paths.append(carrier.path) + + if changed_paths: + print(f"Updated version to {version}:") + for path in sorted(set(changed_paths)): + print(f" - {path.relative_to(ROOT)}") + else: + print(f"No changes needed. All tracked files already use {version}.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", nargs="?", help="New version to write") + parser.add_argument( + "--check", + action="store_true", + help="Fail if tracked version strings do not match", + ) + parser.add_argument( + "--show", + action="store_true", + help="Print tracked version strings without modifying files", + ) + args = parser.parse_args() + + if args.check: + return _check_versions() + if args.show: + _print_versions(_read_versions()) + return 0 + if args.version: + return _set_version(args.version) + + parser.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/scripts/check_api_manifest.py b/vendor/ferro-ta-main/scripts/check_api_manifest.py new file mode 100644 index 0000000..dd162be --- /dev/null +++ b/vendor/ferro-ta-main/scripts/check_api_manifest.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Check that docs/api_manifest.json is up-to-date. + +This script regenerates the deterministic manifest in-memory and compares it to +the committed file. It exits non-zero if drift is detected. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + python_root = str(root / "python") + if python_root not in sys.path: + sys.path.insert(0, python_root) + scripts_root = str(root / "scripts") + if scripts_root not in sys.path: + sys.path.insert(0, scripts_root) + + from build_api_manifest import build_manifest + + manifest_path = root / "docs" / "api_manifest.json" + + if not manifest_path.exists(): + print( + "docs/api_manifest.json is missing. Run:\n" + " python scripts/build_api_manifest.py --output docs/api_manifest.json" + ) + return 1 + + expected = build_manifest(root, include_runtime_metadata=False) + actual = json.loads(manifest_path.read_text(encoding="utf-8")) + + if actual != expected: + print( + "docs/api_manifest.json is out of date.\n" + "Run:\n" + " python scripts/build_api_manifest.py --output docs/api_manifest.json\n" + "and commit the updated file." + ) + return 1 + + print("docs/api_manifest.json is up to date.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/scripts/check_changelog.py b/vendor/ferro-ta-main/scripts/check_changelog.py new file mode 100644 index 0000000..e879198 --- /dev/null +++ b/vendor/ferro-ta-main/scripts/check_changelog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Validate that CHANGELOG.md keeps a single top-level [Unreleased] section.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +def main() -> int: + changelog = Path("CHANGELOG.md") + if not changelog.exists(): + print("ERROR: CHANGELOG.md not found.") + return 1 + + text = changelog.read_text(encoding="utf-8") + headings = list(re.finditer(r"^## \[(.+?)\]\s*$", text, flags=re.MULTILINE)) + unreleased = [m for m in headings if m.group(1) == "Unreleased"] + + if not unreleased: + print("ERROR: CHANGELOG.md is missing a '## [Unreleased]' heading.") + return 1 + if len(unreleased) > 1: + print("ERROR: CHANGELOG.md contains multiple '## [Unreleased]' headings.") + return 1 + + if headings and headings[0].group(1) != "Unreleased": + print("ERROR: '## [Unreleased]' must be the first top-level changelog section.") + return 1 + + print("OK: CHANGELOG.md contains a single top-level [Unreleased] section.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/ferro-ta-main/scripts/pre_push_checks.sh b/vendor/ferro-ta-main/scripts/pre_push_checks.sh new file mode 100644 index 0000000..0417d4c --- /dev/null +++ b/vendor/ferro-ta-main/scripts/pre_push_checks.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# Pre-push CI gate — runs checks in parallel to minimise wall-clock time. +# +# Usage: +# scripts/pre_push_checks.sh # all checks +# scripts/pre_push_checks.sh rust_clippy wasm # selected checks +# scripts/pre_push_checks.sh --list +# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +AVAILABLE_CHECKS=( + version changelog manifest + rust_fmt rust_clippy rust_core rust_bench + python_lint python_typecheck python_test + docs wasm +) +DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +need_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2; exit 1 + fi +} + +run_cmd() { + printf ' +' + printf ' %q' "$@" + printf '\n' + "$@" +} + +usage() { + cat <<'EOF' +Usage: + scripts/pre_push_checks.sh + scripts/pre_push_checks.sh [ ...] + scripts/pre_push_checks.sh --list + +Environment: + FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop) +EOF +} + +# --------------------------------------------------------------------------- +# Individual check functions +# --------------------------------------------------------------------------- + +run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; } +run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; } +run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; } +run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; } +run_python_lint() { + need_cmd uv + run_cmd uv run --with ruff ruff check python/ tests/ + run_cmd uv run --with ruff ruff format --check python/ tests/ +} + +run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; } +run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; } +run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; } + +run_python_typecheck() { + need_cmd uv + run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \ + --ignore-missing-imports --no-error-summary + run_cmd uv run --with pyright python -m pyright python/ferro_ta +} + +# python_test and docs both need a compiled extension. +# Use a flag file so only the first concurrent caller runs maturin develop; +# subsequent callers (in parallel background jobs) wait and reuse it. +_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock" +_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done" + +ensure_python_env() { + [[ -f "$_MATURIN_FLAG" ]] && return + ( + flock 9 + if [[ ! -f "$_MATURIN_FLAG" ]]; then + need_cmd uv + run_cmd uv sync --extra dev --extra docs --extra mcp + run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release + touch "$_MATURIN_FLAG" + fi + ) 9>"$_MATURIN_LOCK" +} + +run_python_test() { + ensure_python_env + run_cmd uv run --extra dev --extra mcp --with pytest-cov \ + pytest tests/unit/ tests/integration/ \ + -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 +} + +run_docs() { + ensure_python_env + run_cmd uv run --extra docs python -m sphinx -b html docs docs/_build -W --keep-going +} + +run_wasm() { + need_cmd node; need_cmd wasm-pack + ( + cd wasm + run_cmd wasm-pack test --node + run_cmd npm run build + if [[ "${FERRO_FAST:-0}" != "1" ]]; then + local bj="../.wasm_benchmark.prepush.json" + run_cmd node bench.js --json "$bj" + rm -f "$bj" + fi + ) +} + +run_check() { + case "$1" in + version) run_version ;; + changelog) run_changelog ;; + manifest) run_manifest ;; + rust_fmt) run_rust_fmt ;; + rust_clippy) run_rust_clippy ;; + rust_core) run_rust_core ;; + rust_bench) run_rust_bench ;; + python_lint) run_python_lint ;; + python_typecheck) run_python_typecheck ;; + python_test) run_python_test ;; + docs) run_docs ;; + wasm) run_wasm ;; + *) echo "Unknown check: $1 — use --list" >&2; exit 1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Parallel runner — starts all checks concurrently, collects results +# --------------------------------------------------------------------------- + +run_parallel() { + local -a checks=("$@") + [[ "${#checks[@]}" -eq 0 ]] && return 0 + + local -a pids logs names + local start + start=$(date +%s) + + printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}" + + for check in "${checks[@]}"; do + local log + log=$(mktemp /tmp/ferro_prepush_XXXXXX) + logs+=("$log") + names+=("$check") + run_check "$check" >"$log" 2>&1 & + pids+=($!) + done + + local failed=0 + local -a failed_names + printf '\n' + for i in "${!pids[@]}"; do + if wait "${pids[$i]}" 2>/dev/null; then + printf ' ✓ %s\n' "${names[$i]}" + else + printf ' ✗ %s\n' "${names[$i]}" + failed_names+=("${names[$i]}") + failed=1 + fi + done + + # Print logs for failed checks only + if [[ "$failed" -eq 1 ]]; then + for i in "${!names[@]}"; do + local name="${names[$i]}" + if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then + printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n' + cat "${logs[$i]}" + fi + done + fi + + for log in "${logs[@]}"; do rm -f "$log"; done + rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG" + + printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))" + return "$failed" +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; } +[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; } + +selected_checks=() +if [[ "$#" -gt 0 ]]; then + selected_checks=("$@") +else + selected_checks=("${DEFAULT_CHECKS[@]}") + if [[ "${FERRO_FAST:-0}" == "1" ]]; then + selected_checks=() + for c in "${DEFAULT_CHECKS[@]}"; do + [[ "$c" == "docs" || "$c" == "wasm" ]] && continue + selected_checks+=("$c") + done + printf 'FERRO_FAST=1: skipping docs + wasm\n' + fi +fi + +# --------------------------------------------------------------------------- +# Execution strategy: +# Phase 1 — instant gate (sequential, fail-fast): +# version, changelog, manifest, python_lint, rust_fmt +# These are trivial to run and catch the most common mistakes early. +# If any fail here we abort immediately without waiting for slow checks. +# +# Phase 2 — everything else in parallel: +# rust_clippy, rust_core, rust_bench, python_typecheck, +# python_test, docs, wasm +# --------------------------------------------------------------------------- + +FAST_CHECKS=(version changelog manifest python_lint rust_fmt) + +phase1=() +phase2=() +for c in "${selected_checks[@]}"; do + is_fast=0 + for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done + if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi +done + +# Phase 1: fast gate +if [[ "${#phase1[@]}" -gt 0 ]]; then + printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}" + start1=$(date +%s) + for c in "${phase1[@]}"; do + printf ' [%s] ... ' "$c" + log=$(mktemp /tmp/ferro_prepush_XXXXXX) + if run_check "$c" >"$log" 2>&1; then + printf 'ok\n' + else + printf 'FAILED\n' + cat "$log" + rm -f "$log" + echo "" >&2 + echo "Fast gate failed on '$c' — aborting before slow checks." >&2 + exit 1 + fi + rm -f "$log" + done + printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))" +fi + +# Phase 2: parallel slow checks +if [[ "${#phase2[@]}" -gt 0 ]]; then + printf '\nPhase 2 — parallel slow checks\n' + run_parallel "${phase2[@]}" || exit 1 +fi + +printf '\nAll pre-push checks passed.\n' diff --git a/vendor/ferro-ta-main/src/aggregation/mod.rs b/vendor/ferro-ta-main/src/aggregation/mod.rs new file mode 100644 index 0000000..f1e4c58 --- /dev/null +++ b/vendor/ferro-ta-main/src/aggregation/mod.rs @@ -0,0 +1,119 @@ +//! Tick/trade aggregation (thin PyO3 wrapper over ferro_ta_core::aggregation). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +type Ohlcv5<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +type Ohlcv5AndLabels<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// Aggregate tick/trade data into tick bars (every N ticks become one bar). +#[pyfunction] +#[pyo3(signature = (price, size, ticks_per_bar))] +pub fn aggregate_tick_bars<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + ticks_per_bar: usize, +) -> PyResult> { + if ticks_per_bar == 0 { + return Err(PyValueError::new_err("ticks_per_bar must be >= 1")); + } + let p = price.as_slice()?; + let s = size.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n { + return Err(PyValueError::new_err( + "price and size must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv) = ferro_ta_core::aggregation::aggregate_tick_bars(p, s, ticks_per_bar); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate tick data into volume bars (fixed volume threshold). +#[pyfunction] +#[pyo3(signature = (price, size, volume_threshold))] +pub fn aggregate_volume_bars_ticks<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + volume_threshold: f64, +) -> PyResult> { + if volume_threshold <= 0.0 { + return Err(PyValueError::new_err("volume_threshold must be > 0")); + } + let p = price.as_slice()?; + let s = size.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n { + return Err(PyValueError::new_err( + "price and size must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv) = + ferro_ta_core::aggregation::aggregate_volume_bars_ticks(p, s, volume_threshold); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate tick data into time bars using pre-computed integer bucket labels. +#[pyfunction] +#[pyo3(signature = (price, size, labels))] +pub fn aggregate_time_bars<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + size: PyReadonlyArray1<'py, f64>, + labels: PyReadonlyArray1<'py, i64>, +) -> PyResult> { + let p = price.as_slice()?; + let s = size.as_slice()?; + let lbl = labels.as_slice()?; + let n = p.len(); + if n == 0 || s.len() != n || lbl.len() != n { + return Err(PyValueError::new_err( + "price, size, and labels must be non-empty and equal length", + )); + } + let (ro, rh, rl, rc, rv, rlbl) = ferro_ta_core::aggregation::aggregate_time_bars(p, s, lbl); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + rlbl.into_pyarray(py), + )) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(aggregate_tick_bars, m)?)?; + m.add_function(wrap_pyfunction!(aggregate_volume_bars_ticks, m)?)?; + m.add_function(wrap_pyfunction!(aggregate_time_bars, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/alerts/mod.rs b/vendor/ferro-ta-main/src/alerts/mod.rs new file mode 100644 index 0000000..cd40b52 --- /dev/null +++ b/vendor/ferro-ta-main/src/alerts/mod.rs @@ -0,0 +1,73 @@ +//! Alerts — condition evaluation helpers (thin PyO3 wrapper over ferro_ta_core::alerts). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Fire an alert when *series* crosses a threshold level. +/// +/// Parameters +/// ---------- +/// series : 1-D float64 array +/// level : float — threshold value +/// direction : int — ``1`` (cross above) or ``-1`` (cross below) +/// +/// Returns +/// ------- +/// 1-D int8 array — 1 at crossing bars, 0 elsewhere. +#[pyfunction] +pub fn check_threshold<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + level: f64, + direction: i32, +) -> PyResult>> { + if direction != 1 && direction != -1 { + return Err(PyValueError::new_err( + "direction must be 1 (cross above) or -1 (cross below)", + )); + } + let s = series.as_slice()?; + let result = ferro_ta_core::alerts::check_threshold(s, level, direction); + Ok(result.into_pyarray(py)) +} + +/// Detect cross-over / cross-under events between two series. +/// +/// Returns +/// ------- +/// 1-D int8 array: ``1`` = bullish, ``-1`` = bearish, ``0`` = none. +#[pyfunction] +pub fn check_cross<'py>( + py: Python<'py>, + fast: PyReadonlyArray1<'py, f64>, + slow: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let f = fast.as_slice()?; + let s = slow.as_slice()?; + if f.len() != s.len() { + return Err(PyValueError::new_err( + "fast and slow must have the same length", + )); + } + let result = ferro_ta_core::alerts::check_cross(f, s); + Ok(result.into_pyarray(py)) +} + +/// Collect bar indices where *mask* is non-zero. +#[pyfunction] +pub fn collect_alert_bars<'py>( + py: Python<'py>, + mask: PyReadonlyArray1<'py, i8>, +) -> PyResult>> { + let m = mask.as_slice()?; + let result = ferro_ta_core::alerts::collect_alert_bars(m); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(check_threshold, m)?)?; + m.add_function(wrap_pyfunction!(check_cross, m)?)?; + m.add_function(wrap_pyfunction!(collect_alert_bars, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/attribution/mod.rs b/vendor/ferro-ta-main/src/attribution/mod.rs new file mode 100644 index 0000000..c38167b --- /dev/null +++ b/vendor/ferro-ta-main/src/attribution/mod.rs @@ -0,0 +1,79 @@ +//! Performance attribution (thin PyO3 wrapper over ferro_ta_core::attribution). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::validation; + +/// Compute trade-level statistics from trade PnL and hold durations. +#[pyfunction] +pub fn trade_stats( + pnl: PyReadonlyArray1<'_, f64>, + hold_bars: PyReadonlyArray1<'_, f64>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let p = pnl.as_slice()?; + let h = hold_bars.as_slice()?; + let n = p.len(); + if n == 0 { + return Err(PyValueError::new_err("pnl must be non-empty")); + } + validation::validate_equal_length(&[(n, "pnl"), (h.len(), "hold_bars")])?; + Ok(ferro_ta_core::attribution::trade_stats(p, h)) +} + +/// Group per-bar returns by month index and sum each month's contribution. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn monthly_contribution<'py>( + py: Python<'py>, + bar_returns: PyReadonlyArray1<'py, f64>, + month_index: PyReadonlyArray1<'py, i64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let ret = bar_returns.as_slice()?; + let mi = month_index.as_slice()?; + let n = ret.len(); + validation::validate_equal_length(&[(n, "bar_returns"), (mi.len(), "month_index")])?; + let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(ret, mi); + Ok((months.into_pyarray(py), contributions.into_pyarray(py))) +} + +/// Attribute per-bar returns to each signal label. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn signal_attribution<'py>( + py: Python<'py>, + bar_returns: PyReadonlyArray1<'py, f64>, + signal_labels: PyReadonlyArray1<'py, i64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let ret = bar_returns.as_slice()?; + let lbl = signal_labels.as_slice()?; + let n = ret.len(); + validation::validate_equal_length(&[(n, "bar_returns"), (lbl.len(), "signal_labels")])?; + let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(ret, lbl); + Ok((labels.into_pyarray(py), contributions.into_pyarray(py))) +} + +/// Extract trade-level pnl and hold durations from positions and strategy returns. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn extract_trades<'py>( + py: Python<'py>, + positions: PyReadonlyArray1<'py, f64>, + strategy_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let pos = positions.as_slice()?; + let ret = strategy_returns.as_slice()?; + let n = pos.len(); + validation::validate_equal_length(&[(n, "positions"), (ret.len(), "strategy_returns")])?; + let (pnl, hold) = ferro_ta_core::attribution::extract_trades(pos, ret); + Ok((pnl.into_pyarray(py), hold.into_pyarray(py))) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(trade_stats, m)?)?; + m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?; + m.add_function(wrap_pyfunction!(signal_attribution, m)?)?; + m.add_function(wrap_pyfunction!(extract_trades, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/backtest/commission.rs b/vendor/ferro-ta-main/src/backtest/commission.rs new file mode 100644 index 0000000..0338a85 --- /dev/null +++ b/vendor/ferro-ta-main/src/backtest/commission.rs @@ -0,0 +1,294 @@ +//! PyO3 wrapper around `ferro_ta_core::commission::CommissionModel`. +//! +//! Exposes all fields as Python properties, provides static preset constructors, +//! and supports JSON persistence (save/load). + +use ferro_ta_core::commission::CommissionModel as CoreModel; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use std::fs; + +/// Advanced commission and tax model for Indian and global markets. +/// +/// All `_rate` fields are fractions (e.g. 0.001 = 0.1%). +/// Per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g. INR). +/// +/// ## Example +/// ```python +/// from ferro_ta._ferro_ta import CommissionModel +/// +/// # Use a built-in preset +/// m = CommissionModel.equity_delivery_india() +/// cost = m.total_cost(100_000.0, 1.0, True) +/// print(f"Buy cost: ₹{cost:.2f}") +/// +/// # Save and reload +/// m.save("/tmp/my_commission.json") +/// m2 = CommissionModel.load("/tmp/my_commission.json") +/// ``` +#[pyclass(module = "ferro_ta._ferro_ta", name = "CommissionModel")] +#[derive(Clone, Default)] +pub struct PyCommissionModel { + pub(crate) inner: CoreModel, +} + +#[pymethods] +impl PyCommissionModel { + /// Create a zero-commission model (all fields = 0, lot_size = 1). + #[new] + pub fn new() -> Self { + Self::default() + } + + // ---- Brokerage fields ----------------------------------------------- + + #[getter] + pub fn flat_per_order(&self) -> f64 { + self.inner.flat_per_order + } + #[setter] + pub fn set_flat_per_order(&mut self, v: f64) { + self.inner.flat_per_order = v; + } + + #[getter] + pub fn rate_of_value(&self) -> f64 { + self.inner.rate_of_value + } + #[setter] + pub fn set_rate_of_value(&mut self, v: f64) { + self.inner.rate_of_value = v; + } + + #[getter] + pub fn per_lot(&self) -> f64 { + self.inner.per_lot + } + #[setter] + pub fn set_per_lot(&mut self, v: f64) { + self.inner.per_lot = v; + } + + #[getter] + pub fn max_brokerage(&self) -> f64 { + self.inner.max_brokerage + } + #[setter] + pub fn set_max_brokerage(&mut self, v: f64) { + self.inner.max_brokerage = v; + } + + #[getter] + pub fn spread_bps(&self) -> f64 { + self.inner.spread_bps + } + #[setter] + pub fn set_spread_bps(&mut self, v: f64) { + self.inner.spread_bps = v; + } + + // ---- STT fields ----------------------------------------------------- + + #[getter] + pub fn stt_rate(&self) -> f64 { + self.inner.stt_rate + } + #[setter] + pub fn set_stt_rate(&mut self, v: f64) { + self.inner.stt_rate = v; + } + + #[getter] + pub fn stt_on_buy(&self) -> bool { + self.inner.stt_on_buy + } + #[setter] + pub fn set_stt_on_buy(&mut self, v: bool) { + self.inner.stt_on_buy = v; + } + + #[getter] + pub fn stt_on_sell(&self) -> bool { + self.inner.stt_on_sell + } + #[setter] + pub fn set_stt_on_sell(&mut self, v: bool) { + self.inner.stt_on_sell = v; + } + + // ---- Exchange / regulatory fields ----------------------------------- + + #[getter] + pub fn exchange_charges_rate(&self) -> f64 { + self.inner.exchange_charges_rate + } + #[setter] + pub fn set_exchange_charges_rate(&mut self, v: f64) { + self.inner.exchange_charges_rate = v; + } + + #[getter] + pub fn regulatory_charges_rate(&self) -> f64 { + self.inner.regulatory_charges_rate + } + #[setter] + pub fn set_regulatory_charges_rate(&mut self, v: f64) { + self.inner.regulatory_charges_rate = v; + } + + #[getter] + pub fn gst_rate(&self) -> f64 { + self.inner.gst_rate + } + #[setter] + pub fn set_gst_rate(&mut self, v: f64) { + self.inner.gst_rate = v; + } + + #[getter] + pub fn stamp_duty_rate(&self) -> f64 { + self.inner.stamp_duty_rate + } + #[setter] + pub fn set_stamp_duty_rate(&mut self, v: f64) { + self.inner.stamp_duty_rate = v; + } + + #[getter] + pub fn lot_size(&self) -> f64 { + self.inner.lot_size + } + #[setter] + pub fn set_lot_size(&mut self, v: f64) { + self.inner.lot_size = v; + } + + #[getter] + pub fn short_borrow_rate_annual(&self) -> f64 { + self.inner.short_borrow_rate_annual + } + #[setter] + pub fn set_short_borrow_rate_annual(&mut self, v: f64) { + self.inner.short_borrow_rate_annual = v; + } + + // ---- Compute -------------------------------------------------------- + + /// Total transaction cost in absolute currency units. + /// + /// Args: + /// trade_value: price × quantity in base currency + /// num_lots: number of lots transacted + /// is_buy: True for buy (entry) leg, False for sell (exit) leg + pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 { + self.inner.total_cost(trade_value, num_lots, is_buy) + } + + /// Cost as fraction of `initial_capital` (for normalised equity loops). + /// + /// Returns 0.0 if `initial_capital` ≤ 0. + pub fn cost_fraction( + &self, + trade_value: f64, + num_lots: f64, + is_buy: bool, + initial_capital: f64, + ) -> f64 { + self.inner + .cost_fraction(trade_value, num_lots, is_buy, initial_capital) + } + + // ---- Presets (static constructors) ---------------------------------- + + /// Zero-commission model (all fields = 0). + #[staticmethod] + pub fn zero() -> Self { + Self { + inner: CoreModel::zero(), + } + } + + /// Indian equity delivery preset (0.1% brokerage capped ₹20, STT both sides, full levies). + #[staticmethod] + pub fn equity_delivery_india() -> Self { + Self { + inner: CoreModel::equity_delivery_india(), + } + } + + /// Indian equity intraday preset (0.03% brokerage capped ₹20, STT sell only, full levies). + #[staticmethod] + pub fn equity_intraday_india() -> Self { + Self { + inner: CoreModel::equity_intraday_india(), + } + } + + /// Indian index futures preset (₹20 flat, STT sell only, lot_size=25). + #[staticmethod] + pub fn futures_india() -> Self { + Self { + inner: CoreModel::futures_india(), + } + } + + /// Indian index options preset (₹20 flat, STT on premium sell side, lot_size=25). + #[staticmethod] + pub fn options_india() -> Self { + Self { + inner: CoreModel::options_india(), + } + } + + /// Simple proportional model — `rate` fraction applied both ways, no taxes. + #[staticmethod] + pub fn proportional(rate: f64) -> Self { + Self { + inner: CoreModel::proportional(rate), + } + } + + // ---- JSON persistence ----------------------------------------------- + + /// Serialize this model to a JSON string. + pub fn to_json(&self) -> PyResult { + self.inner + .to_json() + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Deserialize a `CommissionModel` from a JSON string. + #[staticmethod] + pub fn from_json(s: &str) -> PyResult { + CoreModel::from_json(s) + .map(|inner| Self { inner }) + .map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Save this model to a JSON file at `path`. + pub fn save(&self, path: &str) -> PyResult<()> { + let json = self.to_json()?; + fs::write(path, json).map_err(|e| PyValueError::new_err(e.to_string())) + } + + /// Load a `CommissionModel` from a JSON file at `path`. + #[staticmethod] + pub fn load(path: &str) -> PyResult { + let s = fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?; + Self::from_json(&s) + } + + fn __repr__(&self) -> String { + format!( + "CommissionModel(flat={}, rate_pct={:.4}%, stt={:.4}%, lot_size={})", + self.inner.flat_per_order, + self.inner.rate_of_value * 100.0, + self.inner.stt_rate * 100.0, + self.inner.lot_size, + ) + } + + fn __eq__(&self, other: &Self) -> bool { + self.inner == other.inner + } +} diff --git a/vendor/ferro-ta-main/src/backtest/currency.rs b/vendor/ferro-ta-main/src/backtest/currency.rs new file mode 100644 index 0000000..f0a6b39 --- /dev/null +++ b/vendor/ferro-ta-main/src/backtest/currency.rs @@ -0,0 +1,133 @@ +//! PyO3 wrapper around `ferro_ta_core::currency::Currency`. + +use ferro_ta_core::currency::Currency as CoreCurrency; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Immutable currency descriptor with formatting support. +/// +/// ## Example +/// ```python +/// from ferro_ta._ferro_ta import Currency +/// +/// inr = Currency.INR() +/// print(inr.format(123456.78)) # ₹1,23,456.78 +/// +/// usd = Currency.from_code("USD") +/// print(usd.format(1234567.89)) # $1,234,567.89 +/// ``` +#[pyclass(name = "Currency", module = "ferro_ta._ferro_ta", frozen)] +#[derive(Clone)] +pub struct PyCurrency { + pub(crate) inner: &'static CoreCurrency, +} + +#[pymethods] +impl PyCurrency { + /// Format *amount* according to this currency's style. + pub fn format(&self, amount: f64) -> String { + self.inner.format(amount) + } + + #[getter] + pub fn code(&self) -> &str { + self.inner.code + } + + #[getter] + pub fn symbol(&self) -> &str { + self.inner.symbol + } + + #[getter] + pub fn decimal_places(&self) -> u8 { + self.inner.decimal_places + } + + #[getter] + pub fn lakh_grouping(&self) -> bool { + self.inner.lakh_grouping + } + + // ---- Static constructors (presets) ---- + + #[staticmethod] + pub fn from_code(code: &str) -> PyResult { + CoreCurrency::from_code(code) + .map(|c| PyCurrency { inner: c }) + .ok_or_else(|| { + PyValueError::new_err(format!( + "Unknown currency code '{code}'. Supported: INR, USD, EUR, GBP, JPY, USDT" + )) + }) + } + + /// Indian Rupee. + #[staticmethod] + #[allow(non_snake_case)] + pub fn INR() -> Self { + PyCurrency { + inner: &CoreCurrency::INR, + } + } + + /// US Dollar. + #[staticmethod] + #[allow(non_snake_case)] + pub fn USD() -> Self { + PyCurrency { + inner: &CoreCurrency::USD, + } + } + + /// Euro. + #[staticmethod] + #[allow(non_snake_case)] + pub fn EUR() -> Self { + PyCurrency { + inner: &CoreCurrency::EUR, + } + } + + /// British Pound. + #[staticmethod] + #[allow(non_snake_case)] + pub fn GBP() -> Self { + PyCurrency { + inner: &CoreCurrency::GBP, + } + } + + /// Japanese Yen. + #[staticmethod] + #[allow(non_snake_case)] + pub fn JPY() -> Self { + PyCurrency { + inner: &CoreCurrency::JPY, + } + } + + /// Tether USD. + #[staticmethod] + #[allow(non_snake_case)] + pub fn USDT() -> Self { + PyCurrency { + inner: &CoreCurrency::USDT, + } + } + + fn __repr__(&self) -> String { + format!("Currency({:?})", self.inner.code) + } + + fn __eq__(&self, other: &Self) -> bool { + self.inner.code == other.inner.code + } + + fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.inner.code.hash(&mut hasher); + hasher.finish() + } +} diff --git a/vendor/ferro-ta-main/src/backtest/mod.rs b/vendor/ferro-ta-main/src/backtest/mod.rs new file mode 100644 index 0000000..5d4997a --- /dev/null +++ b/vendor/ferro-ta-main/src/backtest/mod.rs @@ -0,0 +1,821 @@ +//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`. + +pub mod commission; +pub mod currency; + +use commission::PyCommissionModel; +use currency::PyCurrency; +use ferro_ta_core::backtest as core_bt; +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use rayon::prelude::*; + +use crate::validation; + +// --------------------------------------------------------------------------- +// BacktestConfig pyclass wrapping core struct +// --------------------------------------------------------------------------- + +#[pyclass(name = "BacktestConfig")] +#[derive(Clone)] +pub struct BacktestConfig { + #[pyo3(get, set)] + pub fill_mode: String, + #[pyo3(get, set)] + pub stop_loss_pct: f64, + #[pyo3(get, set)] + pub take_profit_pct: f64, + #[pyo3(get, set)] + pub trailing_stop_pct: f64, + #[pyo3(get, set)] + pub slippage_bps: f64, + #[pyo3(get, set)] + pub initial_capital: f64, + #[pyo3(get, set)] + pub commission_per_trade: f64, + #[pyo3(get, set)] + pub max_hold_bars: usize, + #[pyo3(get, set)] + pub slippage_pct_range: f64, + #[pyo3(get, set)] + pub breakeven_pct: f64, + #[pyo3(get, set)] + pub periods_per_year: f64, + #[pyo3(get, set)] + pub margin_ratio: f64, + #[pyo3(get, set)] + pub margin_call_pct: f64, + #[pyo3(get, set)] + pub daily_loss_limit: f64, + #[pyo3(get, set)] + pub total_loss_limit: f64, + #[pyo3(get, set)] + pub commission: Option, +} + +#[pymethods] +impl BacktestConfig { + #[new] + #[pyo3(signature = ( + fill_mode = "market_open", + stop_loss_pct = 0.0, + take_profit_pct = 0.0, + trailing_stop_pct = 0.0, + slippage_bps = 0.0, + initial_capital = 100_000.0, + commission_per_trade = 0.0, + max_hold_bars = 0, + slippage_pct_range = 0.0, + breakeven_pct = 0.0, + periods_per_year = 252.0, + margin_ratio = 0.0, + margin_call_pct = 0.5, + daily_loss_limit = 0.0, + total_loss_limit = 0.0, + commission = None, + ))] + #[allow(clippy::too_many_arguments)] + pub fn new( + fill_mode: &str, + stop_loss_pct: f64, + take_profit_pct: f64, + trailing_stop_pct: f64, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, + max_hold_bars: usize, + slippage_pct_range: f64, + breakeven_pct: f64, + periods_per_year: f64, + margin_ratio: f64, + margin_call_pct: f64, + daily_loss_limit: f64, + total_loss_limit: f64, + commission: Option, + ) -> Self { + BacktestConfig { + fill_mode: fill_mode.to_string(), + stop_loss_pct, + take_profit_pct, + trailing_stop_pct, + slippage_bps, + initial_capital, + commission_per_trade, + max_hold_bars, + slippage_pct_range, + breakeven_pct, + periods_per_year, + margin_ratio, + margin_call_pct, + daily_loss_limit, + total_loss_limit, + commission, + } + } +} + +// --------------------------------------------------------------------------- +// Signal generators +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))] +pub fn rsi_threshold_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + oversold: f64, + overbought: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (close, fast = 10, slow = 30))] +pub fn sma_crossover_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fast: usize, + slow: usize, +) -> PyResult>> { + validation::validate_timeperiod(fast, "fast", 1)?; + validation::validate_timeperiod(slow, "slow", 1)?; + let prices = close.as_slice()?; + let out = core_bt::sma_crossover_signals(prices, fast, slow).map_err(PyValueError::new_err)?; + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +pub fn macd_crossover_signals<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + validation::validate_timeperiod(signalperiod, "signalperiod", 1)?; + let prices = close.as_slice()?; + let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod) + .map_err(PyValueError::new_err)?; + Ok(out.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// Backtest core (close-only) +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = ( + close, signals, + commission = None, + slippage_bps = 0.0, + initial_capital = 100_000.0, + commission_per_trade = 0.0, +))] +#[allow(clippy::type_complexity)] +pub fn backtest_core<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + signals: PyReadonlyArray1<'py, f64>, + commission: Option>, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let c = close.as_slice()?; + let s = signals.as_slice()?; + validation::validate_equal_length(&[(c.len(), "close"), (s.len(), "signals")])?; + + let cm = commission.as_ref().map(|c| &c.inner); + let result = core_bt::backtest_core( + c, + s, + cm, + slippage_bps, + initial_capital, + commission_per_trade, + ) + .map_err(PyValueError::new_err)?; + + Ok(( + result.positions.into_pyarray(py), + result.bar_returns.into_pyarray(py), + result.strategy_returns.into_pyarray(py), + result.equity.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// OHLCV backtest +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = ( + open, high, low, close, signals, + fill_mode = "market_open", + stop_loss_pct = 0.0, + take_profit_pct = 0.0, + trailing_stop_pct = 0.0, + commission = None, + slippage_bps = 0.0, + initial_capital = 100_000.0, + commission_per_trade = 0.0, + limit_prices = None, + max_hold_bars = 0, + slippage_pct_range = 0.0, + breakeven_pct = 0.0, + periods_per_year = 252.0, + margin_ratio = 0.0, + margin_call_pct = 0.5, + daily_loss_limit = 0.0, + total_loss_limit = 0.0, +))] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub fn backtest_ohlcv_core<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + signals: PyReadonlyArray1<'py, f64>, + fill_mode: &str, + stop_loss_pct: f64, + take_profit_pct: f64, + trailing_stop_pct: f64, + commission: Option>, + slippage_bps: f64, + initial_capital: f64, + commission_per_trade: f64, + limit_prices: Option>, + max_hold_bars: usize, + slippage_pct_range: f64, + breakeven_pct: f64, + periods_per_year: f64, + margin_ratio: f64, + margin_call_pct: f64, + daily_loss_limit: f64, + total_loss_limit: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let s = signals.as_slice()?; + let n = c.len(); + + validation::validate_equal_length(&[ + (n, "close"), + (o.len(), "open"), + (h.len(), "high"), + (l.len(), "low"), + (s.len(), "signals"), + ])?; + + let config = core_bt::BacktestConfig { + fill_mode: fill_mode.to_string(), + stop_loss_pct, + take_profit_pct, + trailing_stop_pct, + slippage_bps, + initial_capital, + commission_per_trade, + max_hold_bars, + slippage_pct_range, + breakeven_pct, + periods_per_year, + margin_ratio, + margin_call_pct, + daily_loss_limit, + total_loss_limit, + commission: commission.as_ref().map(|c| c.inner.clone()), + }; + + let lp_opt: Option<&[f64]> = limit_prices.as_ref().and_then(|lp| lp.as_slice().ok()); + + let result = core_bt::backtest_ohlcv_core(o, h, l, c, s, &config, lp_opt) + .map_err(PyValueError::new_err)?; + + Ok(( + result.positions.into_pyarray(py), + result.fill_prices.into_pyarray(py), + result.bar_returns.into_pyarray(py), + result.strategy_returns.into_pyarray(py), + result.equity.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// Performance metrics +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (strategy_returns, equity, periods_per_year = 252.0, risk_free_rate = 0.0, benchmark_returns = None))] +pub fn compute_performance_metrics<'py>( + py: Python<'py>, + strategy_returns: PyReadonlyArray1<'py, f64>, + equity: PyReadonlyArray1<'py, f64>, + periods_per_year: f64, + risk_free_rate: f64, + benchmark_returns: Option>, +) -> PyResult> { + let r = strategy_returns.as_slice()?; + let eq = equity.as_slice()?; + let br = benchmark_returns.as_ref().and_then(|b| b.as_slice().ok()); + + let metrics = core_bt::compute_performance_metrics(r, eq, periods_per_year, risk_free_rate, br) + .map_err(PyValueError::new_err)?; + + let dict = PyDict::new(py); + dict.set_item("total_return", metrics.total_return)?; + dict.set_item("cagr", metrics.cagr)?; + dict.set_item("annualized_vol", metrics.annualized_vol)?; + dict.set_item("sharpe", metrics.sharpe)?; + dict.set_item("sortino", metrics.sortino)?; + dict.set_item("calmar", metrics.calmar)?; + dict.set_item("max_drawdown", metrics.max_drawdown)?; + dict.set_item("avg_drawdown", metrics.avg_drawdown)?; + dict.set_item( + "max_drawdown_duration_bars", + metrics.max_drawdown_duration_bars as i64, + )?; + dict.set_item( + "avg_drawdown_duration_bars", + metrics.avg_drawdown_duration_bars, + )?; + dict.set_item("ulcer_index", metrics.ulcer_index)?; + dict.set_item("omega_ratio", metrics.omega_ratio)?; + dict.set_item("win_rate", metrics.win_rate)?; + dict.set_item("profit_factor", metrics.profit_factor)?; + dict.set_item("r_expectancy", metrics.r_expectancy)?; + dict.set_item("avg_win", metrics.avg_win)?; + dict.set_item("avg_loss", metrics.avg_loss)?; + dict.set_item("tail_ratio", metrics.tail_ratio)?; + dict.set_item("skewness", metrics.skewness)?; + dict.set_item("kurtosis", metrics.kurtosis)?; + dict.set_item("best_bar", metrics.best_bar)?; + dict.set_item("worst_bar", metrics.worst_bar)?; + dict.set_item("n_trades", metrics.n_trades as i64)?; + dict.set_item("n_position_changes", metrics.n_position_changes as i64)?; + + if let Some(v) = metrics.benchmark_total_return { + dict.set_item("benchmark_total_return", v)?; + } + if let Some(v) = metrics.benchmark_cagr { + dict.set_item("benchmark_cagr", v)?; + } + if let Some(v) = metrics.benchmark_annualized_vol { + dict.set_item("benchmark_annualized_vol", v)?; + } + if let Some(v) = metrics.benchmark_sharpe { + dict.set_item("benchmark_sharpe", v)?; + } + if let Some(v) = metrics.alpha { + dict.set_item("alpha", v)?; + } + if let Some(v) = metrics.beta { + dict.set_item("beta", v)?; + } + if let Some(v) = metrics.tracking_error { + dict.set_item("tracking_error", v)?; + } + if let Some(v) = metrics.information_ratio { + dict.set_item("information_ratio", v)?; + } + + Ok(dict) +} + +// --------------------------------------------------------------------------- +// Trade extraction +// --------------------------------------------------------------------------- + +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn extract_trades_ohlcv<'py>( + py: Python<'py>, + positions: PyReadonlyArray1<'py, f64>, + fill_prices: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let pos = positions.as_slice()?; + let fp = fill_prices.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + + validation::validate_equal_length(&[ + (pos.len(), "positions"), + (fp.len(), "fill_prices"), + (h.len(), "high"), + (l.len(), "low"), + ])?; + + let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l).map_err(PyValueError::new_err)?; + + let mut entry_bars: Vec = Vec::with_capacity(trades.len()); + let mut exit_bars: Vec = Vec::with_capacity(trades.len()); + let mut directions: Vec = Vec::with_capacity(trades.len()); + let mut entry_prices: Vec = Vec::with_capacity(trades.len()); + let mut exit_prices: Vec = Vec::with_capacity(trades.len()); + let mut pnl_pcts: Vec = Vec::with_capacity(trades.len()); + let mut duration_bars_vec: Vec = Vec::with_capacity(trades.len()); + let mut maes: Vec = Vec::with_capacity(trades.len()); + let mut mfes: Vec = Vec::with_capacity(trades.len()); + + for t in &trades { + entry_bars.push(t.entry_bar); + exit_bars.push(t.exit_bar); + directions.push(t.direction); + entry_prices.push(t.entry_price); + exit_prices.push(t.exit_price); + pnl_pcts.push(t.pnl_pct); + duration_bars_vec.push(t.duration_bars); + maes.push(t.mae); + mfes.push(t.mfe); + } + + Ok(( + entry_bars.into_pyarray(py), + exit_bars.into_pyarray(py), + directions.into_pyarray(py), + entry_prices.into_pyarray(py), + exit_prices.into_pyarray(py), + pnl_pcts.into_pyarray(py), + duration_bars_vec.into_pyarray(py), + maes.into_pyarray(py), + mfes.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// Multi-asset backtest +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = ( + close_2d, weights_2d, + commission_per_trade = 0.0, + slippage_bps = 0.0, + parallel = true, + max_asset_weight = 1.0, + max_gross_exposure = 0.0, + max_net_exposure = 0.0, +))] +#[allow(clippy::too_many_arguments, clippy::type_complexity)] +pub fn backtest_multi_asset_core<'py>( + py: Python<'py>, + close_2d: PyReadonlyArray2<'py, f64>, + weights_2d: PyReadonlyArray2<'py, f64>, + commission_per_trade: f64, + slippage_bps: f64, + parallel: bool, + max_asset_weight: f64, + max_gross_exposure: f64, + max_net_exposure: f64, +) -> PyResult<( + Bound<'py, PyArray2>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let c_arr = close_2d.as_array(); + let w_arr = weights_2d.as_array(); + let (n_bars, n_assets) = c_arr.dim(); + + if w_arr.dim() != (n_bars, n_assets) { + return Err(PyValueError::new_err(format!( + "weights_2d shape {:?} must match close_2d shape {:?}", + w_arr.dim(), + c_arr.dim() + ))); + } + + // Transpose to (n_assets, n_bars) for the core function + let mut close_cm: Vec> = vec![vec![0.0; n_bars]; n_assets]; + let mut weights_cm: Vec> = vec![vec![0.0; n_bars]; n_assets]; + for j in 0..n_assets { + for i in 0..n_bars { + close_cm[j][i] = c_arr[[i, j]]; + weights_cm[j][i] = w_arr[[i, j]]; + } + } + + // For parallel execution, use rayon directly on the core's single_asset_backtest. + // Apply portfolio constraints first via the core function's logic. + + // Apply constraints + #[allow(clippy::needless_range_loop)] + if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 { + for i in 0..n_bars { + if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 { + for j in 0..n_assets { + let w = weights_cm[j][i]; + if w.abs() > max_asset_weight { + weights_cm[j][i] = w.signum() * max_asset_weight; + } + } + } + if max_gross_exposure > 0.0 { + let gross: f64 = (0..n_assets).map(|j| weights_cm[j][i].abs()).sum(); + if gross > max_gross_exposure { + let scale = max_gross_exposure / gross; + for j in 0..n_assets { + weights_cm[j][i] *= scale; + } + } + } + if max_net_exposure > 0.0 { + let net: f64 = (0..n_assets).map(|j| weights_cm[j][i]).sum(); + if net.abs() > max_net_exposure { + let excess = net - net.signum() * max_net_exposure; + let adj_per_asset = excess / n_assets as f64; + for j in 0..n_assets { + weights_cm[j][i] -= adj_per_asset; + } + } + } + } + } + + // Run per-asset backtests (parallel or serial) + let asset_strategy_returns: Vec> = py.allow_threads(|| { + let run_asset = |j: usize| -> Vec { + let (_, strat_rets, _) = core_bt::single_asset_backtest( + &close_cm[j], + &weights_cm[j], + commission_per_trade, + slippage_bps, + ); + strat_rets + }; + + if parallel { + (0..n_assets).into_par_iter().map(run_asset).collect() + } else { + (0..n_assets).map(run_asset).collect() + } + }); + + // Assemble asset_returns 2D array (n_bars, n_assets) + let mut asset_ret_arr = Array2::::zeros((n_bars, n_assets)); + for j in 0..n_assets { + for i in 0..n_bars { + asset_ret_arr[[i, j]] = asset_strategy_returns[j][i]; + } + } + + // Portfolio returns + let mut portfolio_returns = vec![0.0_f64; n_bars]; + for i in 0..n_bars { + let mut s = 0.0_f64; + for j in 0..n_assets { + s += asset_ret_arr[[i, j]]; + } + portfolio_returns[i] = s; + } + + // Portfolio equity + let mut portfolio_equity = vec![1.0_f64; n_bars]; + let mut cum = 1.0_f64; + for i in 0..n_bars { + cum *= 1.0 + portfolio_returns[i]; + portfolio_equity[i] = cum; + } + + Ok(( + asset_ret_arr.into_pyarray(py), + portfolio_returns.into_pyarray(py), + portfolio_equity.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// Monte Carlo bootstrap +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (strategy_returns, n_sims = 1000, seed = 42, block_size = 1))] +pub fn monte_carlo_bootstrap<'py>( + py: Python<'py>, + strategy_returns: PyReadonlyArray1<'py, f64>, + n_sims: usize, + seed: u64, + block_size: usize, +) -> PyResult>> { + let r = strategy_returns.as_slice()?; + let n = r.len(); + + // Use rayon for parallel Monte Carlo (preserving the original parallel behavior) + if n < 2 { + return Err(PyValueError::new_err( + "strategy_returns must have at least 2 elements", + )); + } + if n_sims == 0 { + return Err(PyValueError::new_err("n_sims must be >= 1")); + } + let bsize = block_size.max(1).min(n); + + let mut result = Array2::::zeros((n_sims, n)); + + py.allow_threads(|| { + result + .as_slice_mut() + .unwrap() + .par_chunks_mut(n) + .enumerate() + .for_each(|(sim_idx, row)| { + let mut state = seed + .wrapping_mul(6_364_136_223_846_793_005_u64) + .wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64)); + core_bt::lcg_next(&mut state); + core_bt::lcg_next(&mut state); + + if bsize == 1 { + for dst in row.iter_mut() { + *dst = r[core_bt::lcg_index(&mut state, n)]; + } + } else { + let mut filled = 0_usize; + while filled < n { + let start = core_bt::lcg_index(&mut state, n); + let take = bsize.min(n - filled); + for k in 0..take { + row[filled + k] = r[(start + k) % n]; + } + filled += take; + } + } + + let mut cum = 1.0_f64; + for elem in row.iter_mut().take(n) { + cum *= 1.0 + *elem; + *elem = cum; + } + }); + }); + + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// Walk-forward indices +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (n_bars, train_bars, test_bars, anchored = false, step_bars = 0))] +pub fn walk_forward_indices<'py>( + py: Python<'py>, + n_bars: usize, + train_bars: usize, + test_bars: usize, + anchored: bool, + step_bars: usize, +) -> PyResult>> { + let folds = core_bt::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars) + .map_err(PyValueError::new_err)?; + + let n_folds = folds.len(); + let mut arr = Array2::::zeros((n_folds, 4)); + for (i, fold) in folds.iter().enumerate() { + for j in 0..4 { + arr[[i, j]] = fold[j]; + } + } + + Ok(arr.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// Kelly criterion +// --------------------------------------------------------------------------- + +#[pyfunction] +pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult { + core_bt::kelly_fraction(win_rate, avg_win, avg_loss).map_err(PyValueError::new_err) +} + +#[pyfunction] +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult { + core_bt::half_kelly_fraction(win_rate, avg_win, avg_loss).map_err(PyValueError::new_err) +} + +// --------------------------------------------------------------------------- +// StreamingBacktest +// --------------------------------------------------------------------------- + +#[pyclass(name = "StreamingBacktest")] +pub struct StreamingBacktest { + inner: core_bt::StreamingBacktest, +} + +#[pymethods] +impl StreamingBacktest { + #[new] + #[pyo3(signature = (commission_per_trade=0.0, slippage_bps=0.0))] + pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self { + StreamingBacktest { + inner: core_bt::StreamingBacktest::new(commission_per_trade, slippage_bps), + } + } + + pub fn on_bar<'py>( + &mut self, + py: Python<'py>, + close: f64, + signal: f64, + ) -> PyResult> { + let result = self.inner.on_bar(close, signal); + let d = PyDict::new(py); + d.set_item("position", result.position)?; + d.set_item("bar_return", result.bar_return)?; + d.set_item("equity", result.equity)?; + d.set_item("n_trades", result.n_trades)?; + Ok(d) + } + + #[getter] + pub fn equity(&self) -> f64 { + self.inner.equity + } + + #[getter] + pub fn position(&self) -> f64 { + self.inner.position + } + + #[getter] + pub fn n_trades(&self) -> usize { + self.inner.n_trades + } + + pub fn summary<'py>(&self, py: Python<'py>) -> PyResult> { + let s = self.inner.summary(); + let d = PyDict::new(py); + d.set_item("equity", s.equity)?; + d.set_item("n_trades", s.n_trades)?; + d.set_item("total_commission", s.total_commission)?; + d.set_item("win_rate", s.win_rate)?; + d.set_item("avg_win", s.avg_win)?; + d.set_item("avg_loss", s.avg_loss)?; + d.set_item("kelly_fraction", s.kelly_fraction)?; + Ok(d) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } +} + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?; + m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?; + m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?; + m.add_function(wrap_pyfunction!(backtest_core, m)?)?; + m.add_function(wrap_pyfunction!(backtest_ohlcv_core, m)?)?; + m.add_function(wrap_pyfunction!(compute_performance_metrics, m)?)?; + m.add_function(wrap_pyfunction!(extract_trades_ohlcv, m)?)?; + m.add_function(wrap_pyfunction!(backtest_multi_asset_core, m)?)?; + m.add_function(wrap_pyfunction!(monte_carlo_bootstrap, m)?)?; + m.add_function(wrap_pyfunction!(walk_forward_indices, m)?)?; + m.add_function(wrap_pyfunction!(kelly_fraction, m)?)?; + m.add_function(wrap_pyfunction!(half_kelly_fraction, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/batch/mod.rs b/vendor/ferro-ta-main/src/batch/mod.rs new file mode 100644 index 0000000..0a6b2bf --- /dev/null +++ b/vendor/ferro-ta-main/src/batch/mod.rs @@ -0,0 +1,450 @@ +//! Rust-side batch execution — run SMA/EMA/RSI over all columns of a 2-D +//! array in a **single GIL release**, avoiding per-column Python round-trips. +//! +//! Python shapes: `(n_samples, n_series)` — C-contiguous row-major. +//! Rust iterates over columns (series) and rows (time) inside native code. +//! +//! When `parallel = true` (default), columns are processed in parallel via +//! [Rayon](https://docs.rs/rayon) after releasing the GIL. For small inputs +//! the sequential path (`parallel = false`) may be faster due to thread-pool +//! overhead. +//! +//! All indicator logic lives in `ferro_ta_core::batch`. This module is a thin +//! PyO3 wrapper that converts numpy ↔ Rust types and optionally adds Rayon +//! parallelism. + +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; + +// --------------------------------------------------------------------------- +// numpy ↔ Vec> helpers +// --------------------------------------------------------------------------- + +/// Convert a numpy (n_samples, n_series) array into `Vec>` where +/// `result[j]` is column j (one time-series of length n_samples). +fn numpy2d_to_columns(arr: &ndarray::ArrayView2<'_, f64>) -> Vec> { + let (_n_samples, n_series) = arr.dim(); + (0..n_series).map(|j| arr.column(j).to_vec()).collect() +} + +/// Convert `Vec>` (columns) back into a numpy (n_samples, n_series) array. +fn columns_to_numpy2d<'py>( + py: Python<'py>, + n_samples: usize, + columns: Vec>, +) -> Bound<'py, PyArray2> { + let n_series = columns.len(); + let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); + for (j, col) in columns.into_iter().enumerate() { + for (i, val) in col.into_iter().enumerate() { + result[[i, j]] = val; + } + } + result.into_pyarray(py) +} + +/// Convert a pair of column-vectors into a pair of numpy 2-D arrays. +fn column_pair_to_numpy2d<'py>( + py: Python<'py>, + n_samples: usize, + cols_a: Vec>, + cols_b: Vec>, +) -> (Bound<'py, PyArray2>, Bound<'py, PyArray2>) { + ( + columns_to_numpy2d(py, n_samples, cols_a), + columns_to_numpy2d(py, n_samples, cols_b), + ) +} + +fn validate_same_shape( + expected: (usize, usize), + actual: (usize, usize), + name: &str, +) -> PyResult<()> { + if actual == expected { + Ok(()) + } else { + Err(PyValueError::new_err(format!( + "{name} must have shape {:?}, got {:?}", + expected, actual + ))) + } +} + +fn map_core_err(err: String) -> PyErr { + PyValueError::new_err(err) +} + +// --------------------------------------------------------------------------- +// Parallel-aware unary batch helper +// --------------------------------------------------------------------------- + +/// Run a unary batch function. When `parallel` is true, split column extraction +/// across Rayon threads and process in parallel; otherwise delegate sequentially +/// to `ferro_ta_core::batch`. +fn run_unary_batch_par<'py, F>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + parallel: bool, + per_col: F, +) -> PyResult>> +where + F: Fn(&[f64]) -> Vec + Sync, +{ + let arr = data.as_array(); + let (n_samples, _n_series) = arr.dim(); + let columns = numpy2d_to_columns(&arr); + + let col_results: Vec> = py.allow_threads(|| { + if parallel { + columns.par_iter().map(|col| per_col(col)).collect() + } else { + columns.iter().map(|col| per_col(col)).collect() + } + }); + + Ok(columns_to_numpy2d(py, n_samples, col_results)) +} + +// --------------------------------------------------------------------------- +// batch_sma +// --------------------------------------------------------------------------- + +/// Batch Simple Moving Average — applies SMA to every column of a 2-D array. +/// +/// Parameters +/// ---------- +/// data : numpy array, shape (n_samples, n_series), dtype float64 +/// timeperiod : int +/// parallel : bool, default True +/// When True, columns are processed in parallel via Rayon (GIL released). +/// +/// Returns +/// ------- +/// numpy array, shape (n_samples, n_series), dtype float64 +/// Same shape as input; first ``timeperiod-1`` rows are NaN. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 30, parallel = true))] +pub fn batch_sma<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::overlap::sma(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_ema +// --------------------------------------------------------------------------- + +/// Batch Exponential Moving Average — applies EMA to every column. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 30, parallel = true))] +pub fn batch_ema<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::overlap::ema(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_rsi +// --------------------------------------------------------------------------- + +/// Batch RSI — applies RSI (Wilder seeding) to every column. +#[pyfunction] +#[pyo3(signature = (data, timeperiod = 14, parallel = true))] +pub fn batch_rsi<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let (n_samples, n_series) = data.as_array().dim(); + log::debug!( + "batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" + ); + run_unary_batch_par(py, data, parallel, |col| { + ferro_ta_core::momentum::rsi(col, timeperiod) + }) +} + +// --------------------------------------------------------------------------- +// batch_atr +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14, parallel = true))] +pub fn batch_atr<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec> = py.allow_threads(|| { + let process = |i: usize| { + ferro_ta_core::volatility::atr(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod) + }; + if parallel { + (0..n_series).into_par_iter().map(process).collect() + } else { + (0..n_series).map(process).collect() + } + }); + Ok(columns_to_numpy2d(py, n_samples, col_results)) +} + +// --------------------------------------------------------------------------- +// batch_stoch +// --------------------------------------------------------------------------- + +type StochBatchResult<'py> = (Bound<'py, PyArray2>, Bound<'py, PyArray2>); + +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, slowk_period = 3, slowd_period = 3, parallel = true))] +#[allow(clippy::too_many_arguments)] +pub fn batch_stoch<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, + parallel: bool, +) -> PyResult> { + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec<(Vec, Vec)> = py.allow_threads(|| { + let process = |i: usize| { + ferro_ta_core::momentum::stoch( + &h_cols[i], + &l_cols[i], + &c_cols[i], + fastk_period, + slowk_period, + slowd_period, + ) + }; + if parallel { + (0..n_series).into_par_iter().map(process).collect() + } else { + (0..n_series).map(process).collect() + } + }); + + let (all_k, all_d): (Vec>, Vec>) = col_results.into_iter().unzip(); + Ok(column_pair_to_numpy2d(py, n_samples, all_k, all_d)) +} + +// --------------------------------------------------------------------------- +// batch_adx +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14, parallel = true))] +pub fn batch_adx<'py>( + py: Python<'py>, + high: PyReadonlyArray2<'py, f64>, + low: PyReadonlyArray2<'py, f64>, + close: PyReadonlyArray2<'py, f64>, + timeperiod: usize, + parallel: bool, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + let arr_h = high.as_array(); + let arr_l = low.as_array(); + let arr_c = close.as_array(); + let (n_samples, n_series) = arr_h.dim(); + validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?; + validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?; + + let h_cols = numpy2d_to_columns(&arr_h); + let l_cols = numpy2d_to_columns(&arr_l); + let c_cols = numpy2d_to_columns(&arr_c); + + let col_results: Vec> = py.allow_threads(|| { + let process = + |i: usize| ferro_ta_core::momentum::adx(&h_cols[i], &l_cols[i], &c_cols[i], timeperiod); + if parallel { + (0..n_series).into_par_iter().map(process).collect() + } else { + (0..n_series).map(process).collect() + } + }); + Ok(columns_to_numpy2d(py, n_samples, col_results)) +} + +// --------------------------------------------------------------------------- +// grouped 1-D execution +// --------------------------------------------------------------------------- + +type IndicatorArrayList = Vec>>; + +#[pyfunction] +#[pyo3(signature = (close, names, timeperiods, parallel = true))] +pub fn run_close_indicators<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + names: Vec, + timeperiods: Vec, + parallel: bool, +) -> PyResult { + let close_values = close.as_slice()?; + + if parallel { + // Parallel path: call core per-indicator in parallel via Rayon + let results: Vec, String>> = py.allow_threads(|| { + (0..names.len()) + .into_par_iter() + .map(|idx| { + ferro_ta_core::batch::run_close_indicators( + close_values, + &[names[idx].clone()], + &[timeperiods[idx]], + ) + .map(|mut v| v.remove(0)) + }) + .collect() + }); + results + .into_iter() + .map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err)) + .collect() + } else { + let results = + ferro_ta_core::batch::run_close_indicators(close_values, &names, &timeperiods) + .map_err(map_core_err)?; + Ok(results + .into_iter() + .map(|v| v.into_pyarray(py).unbind()) + .collect()) + } +} + +#[pyfunction] +#[pyo3(signature = (high, low, close, names, timeperiods, parallel = true))] +pub fn run_hlc_indicators<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + names: Vec, + timeperiods: Vec, + parallel: bool, +) -> PyResult { + let high_values = high.as_slice()?; + let low_values = low.as_slice()?; + let close_values = close.as_slice()?; + + if high_values.len() != low_values.len() || high_values.len() != close_values.len() { + return Err(PyValueError::new_err( + "high, low, and close must have equal length", + )); + } + + if parallel { + let results: Vec, String>> = py.allow_threads(|| { + (0..names.len()) + .into_par_iter() + .map(|idx| { + ferro_ta_core::batch::run_hlc_indicators( + high_values, + low_values, + close_values, + &[names[idx].clone()], + &[timeperiods[idx]], + ) + .map(|mut v| v.remove(0)) + }) + .collect() + }); + results + .into_iter() + .map(|r| r.map(|v| v.into_pyarray(py).unbind()).map_err(map_core_err)) + .collect() + } else { + let results = ferro_ta_core::batch::run_hlc_indicators( + high_values, + low_values, + close_values, + &names, + &timeperiods, + ) + .map_err(map_core_err)?; + Ok(results + .into_iter() + .map(|v| v.into_pyarray(py).unbind()) + .collect()) + } +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(batch_sma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_ema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_rsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_atr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_stoch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(batch_adx, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(run_close_indicators, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(run_hlc_indicators, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/chunked/mod.rs b/vendor/ferro-ta-main/src/chunked/mod.rs new file mode 100644 index 0000000..05e306e --- /dev/null +++ b/vendor/ferro-ta-main/src/chunked/mod.rs @@ -0,0 +1,152 @@ +//! Chunked / out-of-core execution helpers (thin PyO3 wrapper over ferro_ta_core::chunked). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Remove the first *overlap* elements from an array. +#[pyfunction] +pub fn trim_overlap<'py>( + py: Python<'py>, + chunk_out: PyReadonlyArray1<'py, f64>, + overlap: usize, +) -> PyResult>> { + let s = chunk_out.as_slice()?; + if overlap > s.len() { + return Err(PyValueError::new_err(format!( + "overlap ({overlap}) must be <= chunk length ({})", + s.len() + ))); + } + let result = ferro_ta_core::chunked::trim_overlap(s, overlap); + Ok(result.into_pyarray(py)) +} + +/// Concatenate a list of trimmed chunk results into a single output array. +#[pyfunction] +pub fn stitch_chunks<'py>( + py: Python<'py>, + chunks: Vec>, +) -> PyResult>> { + let vecs: Vec> = chunks + .iter() + .map(|c| c.as_slice().map(|s| s.to_vec())) + .collect::>()?; + let refs: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect(); + let result = ferro_ta_core::chunked::stitch_chunks(&refs); + Ok(result.into_pyarray(py)) +} + +/// Compute (start, end) index pairs for chunked processing. +#[pyfunction] +pub fn make_chunk_ranges<'py>( + py: Python<'py>, + n: usize, + chunk_size: usize, + overlap: usize, +) -> PyResult>> { + if chunk_size == 0 { + return Err(PyValueError::new_err("chunk_size must be >= 1")); + } + let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// chunk_apply_close_indicator — stays in PyO3 (dispatches to ferro_ta_core indicators) +// --------------------------------------------------------------------------- + +fn compute_close_indicator( + indicator: &str, + series: &[f64], + timeperiod: usize, +) -> PyResult> { + match indicator { + "SMA" => Ok(ferro_ta_core::overlap::sma(series, timeperiod)), + "EMA" => Ok(ferro_ta_core::overlap::ema(series, timeperiod)), + "RSI" => Ok(ferro_ta_core::momentum::rsi(series, timeperiod)), + _ => Err(PyValueError::new_err(format!( + "chunk_apply_close_indicator does not support indicator '{indicator}'" + ))), + } +} + +/// Run chunked execution for close-only indicators in Rust. +#[pyfunction] +#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))] +pub fn chunk_apply_close_indicator<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + indicator: &str, + timeperiod: usize, + chunk_size: usize, + overlap: usize, +) -> PyResult>> { + if timeperiod == 0 { + return Err(PyValueError::new_err("timeperiod must be >= 1")); + } + if chunk_size == 0 { + return Err(PyValueError::new_err("chunk_size must be >= 1")); + } + + let values = series.as_slice()?; + if values.is_empty() { + return Ok(Vec::::new().into_pyarray(py)); + } + + let name = indicator.to_ascii_uppercase(); + let n = values.len(); + let mut stitched: Vec = Vec::with_capacity(n); + let mut start = 0usize; + let mut chunk_index = 0usize; + + loop { + let end = (start + chunk_size + overlap).min(n); + let chunk = &values[start..end]; + let out = compute_close_indicator(name.as_str(), chunk, timeperiod)?; + + let discard = if chunk_index == 0 { 0 } else { overlap }; + if discard > out.len() { + return Err(PyValueError::new_err(format!( + "overlap ({discard}) must be <= chunk output length ({})", + out.len() + ))); + } + stitched.extend_from_slice(&out[discard..]); + + if end >= n { + break; + } + start = end.saturating_sub(overlap); + chunk_index += 1; + } + + if stitched.len() != n { + return Err(PyValueError::new_err(format!( + "internal chunk stitching error: expected output length {n}, got {}", + stitched.len() + ))); + } + + Ok(stitched.into_pyarray(py)) +} + +/// Forward-fill NaN values in a 1-D array. +#[pyfunction] +pub fn forward_fill_nan<'py>( + py: Python<'py>, + values: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let input = values.as_slice()?; + let result = ferro_ta_core::chunked::forward_fill_nan(input); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(trim_overlap, m)?)?; + m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?; + m.add_function(wrap_pyfunction!(make_chunk_ranges, m)?)?; + m.add_function(wrap_pyfunction!(chunk_apply_close_indicator, m)?)?; + m.add_function(wrap_pyfunction!(forward_fill_nan, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/crypto/mod.rs b/vendor/ferro-ta-main/src/crypto/mod.rs new file mode 100644 index 0000000..0adde51 --- /dev/null +++ b/vendor/ferro-ta-main/src/crypto/mod.rs @@ -0,0 +1,55 @@ +//! Crypto and 24/7 market helpers (thin PyO3 wrapper over ferro_ta_core::crypto). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Compute the cumulative PnL from funding rate payments. +#[pyfunction] +pub fn funding_cumulative_pnl<'py>( + py: Python<'py>, + position_size: PyReadonlyArray1<'py, f64>, + funding_rate: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let pos = position_size.as_slice()?; + let rate = funding_rate.as_slice()?; + if pos.len() != rate.len() { + return Err(PyValueError::new_err( + "position_size and funding_rate must have the same length", + )); + } + let result = ferro_ta_core::crypto::funding_cumulative_pnl(pos, rate); + Ok(result.into_pyarray(py)) +} + +/// Assign a sequential integer label per bar based on a fixed-size period. +#[pyfunction] +pub fn continuous_bar_labels<'py>( + py: Python<'py>, + n_bars: usize, + period_bars: usize, +) -> PyResult>> { + if period_bars == 0 { + return Err(PyValueError::new_err("period_bars must be >= 1")); + } + let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars); + Ok(result.into_pyarray(py)) +} + +/// Return bar indices where a new UTC day begins. +#[pyfunction] +pub fn mark_session_boundaries<'py>( + py: Python<'py>, + timestamps_ns: PyReadonlyArray1<'py, i64>, +) -> PyResult>> { + let ts = timestamps_ns.as_slice()?; + let result = ferro_ta_core::crypto::mark_session_boundaries(ts); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(funding_cumulative_pnl, m)?)?; + m.add_function(wrap_pyfunction!(continuous_bar_labels, m)?)?; + m.add_function(wrap_pyfunction!(mark_session_boundaries, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_dcperiod.rs b/vendor/ferro-ta-main/src/cycle/ht_dcperiod.rs new file mode 100644 index 0000000..377685d --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_dcperiod.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Dominant Cycle Period in bars. +#[pyfunction] +pub fn ht_dcperiod<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_dcperiod(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_dcphase.rs b/vendor/ferro-ta-main/src/cycle/ht_dcphase.rs new file mode 100644 index 0000000..fa11246 --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_dcphase.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Dominant Cycle Phase in degrees. +#[pyfunction] +pub fn ht_dcphase<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_dcphase(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_phasor.rs b/vendor/ferro-ta-main/src/cycle/ht_phasor.rs new file mode 100644 index 0000000..fea25e9 --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_phasor.rs @@ -0,0 +1,13 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Phasor components. Returns (inphase, quadrature) tuple. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn ht_phasor<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let (inphase, quadrature) = ferro_ta_core::cycle::ht_phasor(close.as_slice()?); + Ok((inphase.into_pyarray(py), quadrature.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_sine.rs b/vendor/ferro-ta-main/src/cycle/ht_sine.rs new file mode 100644 index 0000000..fa2756a --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_sine.rs @@ -0,0 +1,13 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform SineWave. Returns (sine, leadsine) where leadsine leads sine by 45°. +#[pyfunction] +#[allow(clippy::type_complexity)] +pub fn ht_sine<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let (sine, lead_sine) = ferro_ta_core::cycle::ht_sine(close.as_slice()?); + Ok((sine.into_pyarray(py), lead_sine.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_trendline.rs b/vendor/ferro-ta-main/src/cycle/ht_trendline.rs new file mode 100644 index 0000000..a05d832 --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_trendline.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Instantaneous Trendline (Ehlers). Smooths price over the dominant cycle period. +#[pyfunction] +pub fn ht_trendline<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_trendline(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/cycle/ht_trendmode.rs b/vendor/ferro-ta-main/src/cycle/ht_trendmode.rs new file mode 100644 index 0000000..8163103 --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/ht_trendmode.rs @@ -0,0 +1,12 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling. +#[pyfunction] +pub fn ht_trendmode<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let result = ferro_ta_core::cycle::ht_trendmode(close.as_slice()?); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/cycle/mod.rs b/vendor/ferro-ta-main/src/cycle/mod.rs new file mode 100644 index 0000000..4ba4845 --- /dev/null +++ b/vendor/ferro-ta-main/src/cycle/mod.rs @@ -0,0 +1,23 @@ +//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers). +//! The shared HT core computation lives in `common.rs`; each indicator has its own file. +//! +//! All functions use a 63-bar lookback period (first 63 values are NaN). + +mod ht_dcperiod; +mod ht_dcphase; +mod ht_phasor; +mod ht_sine; +mod ht_trendline; +mod ht_trendmode; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::ht_trendline::ht_trendline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_dcperiod::ht_dcperiod, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_dcphase::ht_dcphase, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_phasor::ht_phasor, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_sine::ht_sine, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ht_trendmode::ht_trendmode, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/extended/mod.rs b/vendor/ferro-ta-main/src/extended/mod.rs new file mode 100644 index 0000000..376695d --- /dev/null +++ b/vendor/ferro-ta-main/src/extended/mod.rs @@ -0,0 +1,315 @@ +//! Extended Indicators — thin PyO3 wrappers delegating to `ferro_ta_core::extended`. +//! +//! All compute-heavy work lives in the core crate. These functions convert +//! numpy arrays to slices, call the core, and convert the results back. + +#![allow(clippy::type_complexity)] +#![allow(clippy::too_many_arguments)] + +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// --------------------------------------------------------------------------- +// VWAP +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, timeperiod = 0))] +pub fn vwap<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + validation::validate_equal_length(&[ + (h.len(), "high"), + (lo.len(), "low"), + (c.len(), "close"), + (v.len(), "volume"), + ])?; + let result = ferro_ta_core::extended::vwap(h, lo, c, v, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// VWMA +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, volume, timeperiod = 20))] +pub fn vwma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + validation::validate_equal_length(&[(c.len(), "close"), (v.len(), "volume")])?; + let result = ferro_ta_core::extended::vwma(c, v, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// SUPERTREND +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 7, multiplier = 3.0))] +pub fn supertrend<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + multiplier: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (st, dir) = ferro_ta_core::extended::supertrend(h, lo, c, timeperiod, multiplier); + Ok((st.into_pyarray(py), dir.into_pyarray(py))) +} + +// --------------------------------------------------------------------------- +// DONCHIAN +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 20))] +pub fn donchian<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low")])?; + let (upper, middle, lower) = ferro_ta_core::extended::donchian(h, lo, timeperiod); + Ok(( + upper.into_pyarray(py), + middle.into_pyarray(py), + lower.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// CHOPPINESS_INDEX +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn choppiness_index<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let result = ferro_ta_core::extended::choppiness_index(h, lo, c, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// KELTNER_CHANNELS +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 20, atr_period = 10, multiplier = 2.0))] +pub fn keltner_channels<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + atr_period: usize, + multiplier: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + validation::validate_timeperiod(atr_period, "atr_period", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (upper, middle, lower) = + ferro_ta_core::extended::keltner_channels(h, lo, c, timeperiod, atr_period, multiplier); + Ok(( + upper.into_pyarray(py), + middle.into_pyarray(py), + lower.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// HULL_MA +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 16))] +pub fn hull_ma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let c = close.as_slice()?; + let result = ferro_ta_core::extended::hull_ma(c, timeperiod); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// CHANDELIER_EXIT +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 22, multiplier = 3.0))] +pub fn chandelier_exit<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + multiplier: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (long_exit, short_exit) = + ferro_ta_core::extended::chandelier_exit(h, lo, c, timeperiod, multiplier); + Ok((long_exit.into_pyarray(py), short_exit.into_pyarray(py))) +} + +// --------------------------------------------------------------------------- +// ICHIMOKU +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, tenkan_period = 9, kijun_period = 26, senkou_b_period = 52, displacement = 26))] +pub fn ichimoku<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + tenkan_period: usize, + kijun_period: usize, + senkou_b_period: usize, + displacement: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(tenkan_period, "tenkan_period", 1)?; + validation::validate_timeperiod(kijun_period, "kijun_period", 1)?; + validation::validate_timeperiod(senkou_b_period, "senkou_b_period", 1)?; + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + let (tenkan, kijun, senkou_a, senkou_b, chikou) = ferro_ta_core::extended::ichimoku( + h, + lo, + c, + tenkan_period, + kijun_period, + senkou_b_period, + displacement, + ); + Ok(( + tenkan.into_pyarray(py), + kijun.into_pyarray(py), + senkou_a.into_pyarray(py), + senkou_b.into_pyarray(py), + chikou.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// PIVOT_POINTS +// --------------------------------------------------------------------------- + +#[pyfunction] +#[pyo3(signature = (high, low, close, method = "classic"))] +pub fn pivot_points<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + method: &str, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + let h = high.as_slice()?; + let lo = low.as_slice()?; + let c = close.as_slice()?; + validation::validate_equal_length(&[(h.len(), "high"), (lo.len(), "low"), (c.len(), "close")])?; + + let method_lower = method.to_lowercase(); + if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") { + return Err(PyValueError::new_err(format!( + "Unknown pivot method '{}'. Use 'classic', 'fibonacci', or 'camarilla'.", + method + ))); + } + + let (pivot, r1, s1, r2, s2) = ferro_ta_core::extended::pivot_points(h, lo, c, method); + Ok(( + pivot.into_pyarray(py), + r1.into_pyarray(py), + s1.into_pyarray(py), + r2.into_pyarray(py), + s2.into_pyarray(py), + )) +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(vwap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(vwma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(supertrend, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(donchian, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(choppiness_index, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(keltner_channels, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(hull_ma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(chandelier_exit, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(ichimoku, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(pivot_points, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/futures/basis.rs b/vendor/ferro-ta-main/src/futures/basis.rs new file mode 100644 index 0000000..2175d5b --- /dev/null +++ b/vendor/ferro-ta-main/src/futures/basis.rs @@ -0,0 +1,34 @@ +use pyo3::prelude::*; + +#[pyfunction] +pub fn futures_basis(spot: f64, future: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::basis(spot, future)) +} + +#[pyfunction] +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::annualized_basis( + spot, + future, + time_to_expiry, + )) +} + +#[pyfunction] +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::implied_carry_rate( + spot, + future, + time_to_expiry, + )) +} + +#[pyfunction] +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::basis::carry_spread( + spot, + future, + rate, + time_to_expiry, + )) +} diff --git a/vendor/ferro-ta-main/src/futures/curve.rs b/vendor/ferro-ta-main/src/futures/curve.rs new file mode 100644 index 0000000..725a9b3 --- /dev/null +++ b/vendor/ferro-ta-main/src/futures/curve.rs @@ -0,0 +1,52 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn calendar_spreads<'py>( + py: Python<'py>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + Ok( + ferro_ta_core::futures::curve::calendar_spreads(futures_prices.as_slice()?) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn curve_slope<'py>( + tenors: PyReadonlyArray1<'py, f64>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let tenors = tenors.as_slice()?; + let futures_prices = futures_prices.as_slice()?; + validation::validate_equal_length(&[ + (tenors.len(), "tenors"), + (futures_prices.len(), "futures_prices"), + ])?; + Ok(ferro_ta_core::futures::curve::curve_slope( + tenors, + futures_prices, + )) +} + +#[pyfunction] +pub fn curve_summary<'py>( + spot: f64, + tenors: PyReadonlyArray1<'py, f64>, + futures_prices: PyReadonlyArray1<'py, f64>, +) -> PyResult<(f64, f64, f64, bool)> { + let tenors = tenors.as_slice()?; + let futures_prices = futures_prices.as_slice()?; + validation::validate_equal_length(&[ + (tenors.len(), "tenors"), + (futures_prices.len(), "futures_prices"), + ])?; + let summary = ferro_ta_core::futures::curve::curve_summary(spot, tenors, futures_prices); + Ok(( + summary.front_basis, + summary.average_basis, + summary.slope, + summary.is_contango, + )) +} diff --git a/vendor/ferro-ta-main/src/futures/mod.rs b/vendor/ferro-ta-main/src/futures/mod.rs new file mode 100644 index 0000000..b05b813 --- /dev/null +++ b/vendor/ferro-ta-main/src/futures/mod.rs @@ -0,0 +1,38 @@ +//! PyO3 wrappers for futures analytics. + +mod basis; +mod curve; +mod roll; +mod synthetic; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!( + self::synthetic::synthetic_forward, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::synthetic::synthetic_spot, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::synthetic::parity_gap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::futures_basis, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::annualized_basis, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::implied_carry_rate, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::basis::carry_spread, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::weighted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::back_adjusted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::roll::ratio_adjusted_continuous_contract, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roll::roll_yield, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::calendar_spreads, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_slope, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_summary, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/futures/roll.rs b/vendor/ferro-ta-main/src/futures/roll.rs new file mode 100644 index 0000000..03a9819 --- /dev/null +++ b/vendor/ferro-ta-main/src/futures/roll.rs @@ -0,0 +1,75 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn weighted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::weighted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn back_adjusted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::back_adjusted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn ratio_adjusted_continuous_contract<'py>( + py: Python<'py>, + front: PyReadonlyArray1<'py, f64>, + next: PyReadonlyArray1<'py, f64>, + next_weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let front = front.as_slice()?; + let next = next.as_slice()?; + let next_weights = next_weights.as_slice()?; + validation::validate_equal_length(&[ + (front.len(), "front"), + (next.len(), "next"), + (next_weights.len(), "next_weights"), + ])?; + Ok( + ferro_ta_core::futures::roll::ratio_adjusted_continuous(front, next, next_weights) + .into_pyarray(py), + ) +} + +#[pyfunction] +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> PyResult { + Ok(ferro_ta_core::futures::roll::roll_yield( + front_price, + next_price, + time_to_expiry, + )) +} diff --git a/vendor/ferro-ta-main/src/futures/synthetic.rs b/vendor/ferro-ta-main/src/futures/synthetic.rs new file mode 100644 index 0000000..67a7c35 --- /dev/null +++ b/vendor/ferro-ta-main/src/futures/synthetic.rs @@ -0,0 +1,60 @@ +use pyo3::prelude::*; + +#[pyfunction] +pub fn synthetic_forward( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::synthetic_forward( + call_price, + put_price, + strike, + rate, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, strike, rate, time_to_expiry, carry = 0.0))] +pub fn synthetic_spot( + call_price: f64, + put_price: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::synthetic_spot( + call_price, + put_price, + strike, + rate, + carry, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))] +pub fn parity_gap( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::futures::synthetic::parity_gap( + call_price, + put_price, + spot, + strike, + rate, + carry, + time_to_expiry, + )) +} diff --git a/vendor/ferro-ta-main/src/lib.rs b/vendor/ferro-ta-main/src/lib.rs new file mode 100644 index 0000000..f8e5eee --- /dev/null +++ b/vendor/ferro-ta-main/src/lib.rs @@ -0,0 +1,76 @@ +pub mod aggregation; +pub mod alerts; +pub mod attribution; +pub mod backtest; +pub mod batch; +pub mod chunked; +pub mod crypto; +pub mod cycle; +pub mod extended; +pub mod futures; +pub mod math_ops; +pub mod momentum; +pub mod options; +pub mod overlap; +pub mod pattern; +pub mod portfolio; +pub mod price_transform; +pub mod regime; +pub mod resampling; +pub mod signals; +pub mod statistic; +pub mod streaming; +pub mod validation; +pub mod volatility; +pub mod volume; + +use pyo3::prelude::*; + +/// ferro_ta — A fast Technical Analysis library powered by Rust. +/// +/// Indicators are organized into modules matching the TA-Lib category structure: +/// - **overlap** : Overlap Studies (SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, MACD, BBANDS, SAR, MA, MAVP, MAMA, SAREXT, MACDEXT, …) +/// - **momentum** : Momentum Indicators (RSI, STOCH, ADX, CCI, WILLR, AROON, MFI, …) +/// - **volume** : Volume Indicators (AD, ADOSC, OBV) +/// - **volatility** : Volatility Indicators (ATR, NATR, TRANGE) +/// - **statistic** : Statistic Functions (STDDEV, VAR, LINEARREG, BETA, CORREL, …) +/// - **price_transform**: Price Transformations (AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE) +/// - **pattern** : Pattern Recognition (CDLDOJI, CDLENGULFING, CDLHAMMER, …) +/// - **cycle** : Cycle Indicators (HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, HT_PHASOR, HT_SINE, HT_TRENDMODE) +/// - **batch** : Batch Execution (batch_sma, batch_ema, batch_rsi — 2-D array input) +/// - **streaming** : Streaming Indicators (StreamingSMA, StreamingEMA, … — bar-by-bar PyO3 classes) +/// - **extended** : Extended Indicators (VWAP, SUPERTREND, DONCHIAN, ICHIMOKU, …) +/// - **math_ops** : Rolling Math Operators (rolling_sum, rolling_max, rolling_min, …) +/// - **resampling** : OHLCV resampling helpers (volume_bars, ohlcv_agg) +/// - **aggregation** : Tick/trade aggregation pipeline (aggregate_tick_bars, aggregate_volume_bars_ticks, aggregate_time_bars) +/// - **portfolio** : Portfolio analytics (portfolio_volatility, beta_full, rolling_beta, drawdown_series, correlation_matrix, relative_strength, spread, zscore_series, compose_weighted) +/// - **signals** : Signal helpers (rank_series, top_n_indices, bottom_n_indices) +#[pymodule] +fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> { + pyo3_log::init(); + overlap::register(m)?; + momentum::register(m)?; + volume::register(m)?; + volatility::register(m)?; + statistic::register(m)?; + price_transform::register(m)?; + pattern::register(m)?; + cycle::register(m)?; + batch::register(m)?; + streaming::register(m)?; + extended::register(m)?; + math_ops::register(m)?; + options::register(m)?; + futures::register(m)?; + resampling::register(m)?; + aggregation::register(m)?; + portfolio::register(m)?; + signals::register(m)?; + alerts::register(m)?; + crypto::register(m)?; + chunked::register(m)?; + regime::register(m)?; + attribution::register(m)?; + backtest::register(m)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/math_ops/mod.rs b/vendor/ferro-ta-main/src/math_ops/mod.rs new file mode 100644 index 0000000..6c70f7c --- /dev/null +++ b/vendor/ferro-ta-main/src/math_ops/mod.rs @@ -0,0 +1,84 @@ +//! Rolling math operators (thin PyO3 wrapper over ferro_ta_core::math_ops). + +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Rolling sum over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_sum<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_sum(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque). +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_max<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_max(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque). +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_min<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_min(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Index of rolling maximum over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_maxindex<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_maxindex(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Index of rolling minimum over `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (real, timeperiod = 30))] +pub fn rolling_minindex<'py>( + py: Python<'py>, + real: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = real.as_slice()?; + let result = ferro_ta_core::math_ops::rolling_minindex(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(rolling_sum, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_max, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_min, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_maxindex, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(rolling_minindex, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/momentum/adx.rs b/vendor/ferro-ta-main/src/momentum/adx.rs new file mode 100644 index 0000000..0921699 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/adx.rs @@ -0,0 +1,205 @@ +//! ADX family: PLUS_DM, MINUS_DM, +DI, -DI, DX, ADX, ADXR. +//! Thin wrappers that delegate to ferro_ta_core::momentum. + +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Six-tuple of bound PyArray1 vectors (PLUS_DM, MINUS_DM, +DI, -DI, DX, ADX). +type AdxAllResult<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// Plus Directional Movement (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn plus_dm<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + validation::validate_equal_length(&[(highs.len(), "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::momentum::plus_dm(highs, lows, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Minus Directional Movement (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn minus_dm<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + validation::validate_equal_length(&[(highs.len(), "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::momentum::minus_dm(highs, lows, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Plus Directional Indicator (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn plus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::plus_di(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Minus Directional Indicator (Wilder smoothing). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn minus_di<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::minus_di(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Directional Movement Index: 100 * |+DI - -DI| / (+DI + -DI). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn dx<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::dx(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Average Directional Movement Index (Wilder smoothing of DX). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adx<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::adx(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// ADX Rating: (ADX[i] + ADX[i - timeperiod]) / 2. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adxr<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::momentum::adxr(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Compute all six ADX-family outputs in a single TR/PDM/MDM pass. +/// +/// Returns (plus_dm, minus_dm, plus_di, minus_di, dx, adx) — six arrays of +/// the same length as the inputs. Use this when you need more than one ADX +/// family output to avoid redundant computation. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn adx_all<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + validation::validate_equal_length(&[ + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let (pdm, mdm, pdi, mdi, dx, adx) = + ferro_ta_core::momentum::adx_all(highs, lows, closes, timeperiod); + Ok(( + pdm.into_pyarray(py), + mdm.into_pyarray(py), + pdi.into_pyarray(py), + mdi.into_pyarray(py), + dx.into_pyarray(py), + adx.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/momentum/apo.rs b/vendor/ferro-ta-main/src/momentum/apo.rs new file mode 100644 index 0000000..a7f357c --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/apo.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Absolute Price Oscillator: fast EMA - slow EMA. +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26))] +pub fn apo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + let mut fast_ema = ExponentialMovingAverage::new(fastperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut slow_ema = ExponentialMovingAverage::new(slowperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let warmup = slowperiod - 1; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let fast = fast_ema.next(price); + let slow = slow_ema.next(price); + if i >= warmup { + result[i] = fast - slow; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/aroon.rs b/vendor/ferro-ta-main/src/momentum/aroon.rs new file mode 100644 index 0000000..7ac451a --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/aroon.rs @@ -0,0 +1,87 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Aroon. Returns (aroon_down, aroon_up) tuple. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +#[allow(clippy::type_complexity)] +pub fn aroon<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut aroon_down = vec![f64::NAN; n]; + let mut aroon_up = vec![f64::NAN; n]; + let period_f = timeperiod as f64; + + for i in timeperiod..n { + let window_size = timeperiod + 1; + let start = i + 1 - window_size; + let mut max_val = highs[start]; + let mut min_val = lows[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if highs[start + j] >= max_val { + max_val = highs[start + j]; + max_idx = j; + } + if lows[start + j] <= min_val { + min_val = lows[start + j]; + min_idx = j; + } + } + aroon_up[i] = 100.0 * (max_idx as f64) / period_f; + aroon_down[i] = 100.0 * (min_idx as f64) / period_f; + } + Ok((aroon_down.into_pyarray(py), aroon_up.into_pyarray(py))) +} + +/// Aroon Oscillator: aroon_up - aroon_down. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn aroonosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut result = vec![f64::NAN; n]; + let period_f = timeperiod as f64; + + #[allow(clippy::needless_range_loop)] + for i in timeperiod..n { + let window_size = timeperiod + 1; + let start = i + 1 - window_size; + let mut max_val = highs[start]; + let mut min_val = lows[start]; + let mut max_idx = 0usize; + let mut min_idx = 0usize; + for j in 0..window_size { + if highs[start + j] >= max_val { + max_val = highs[start + j]; + max_idx = j; + } + if lows[start + j] <= min_val { + min_val = lows[start + j]; + min_idx = j; + } + } + let up = 100.0 * (max_idx as f64) / period_f; + let down = 100.0 * (min_idx as f64) / period_f; + result[i] = up - down; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/bop.rs b/vendor/ferro-ta-main/src/momentum/bop.rs new file mode 100644 index 0000000..f678f90 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/bop.rs @@ -0,0 +1,35 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Balance Of Power: (close - open) / (high - low). Zero when range is zero. +#[pyfunction] +pub fn bop<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let opens = open.as_slice()?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = opens.len(); + validation::validate_equal_length(&[ + (n, "open"), + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut result = vec![f64::NAN; n]; + for i in 0..n { + let range = highs[i] - lows[i]; + if range != 0.0 { + result[i] = (closes[i] - opens[i]) / range; + } else { + result[i] = 0.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/cci.rs b/vendor/ferro-ta-main/src/momentum/cci.rs new file mode 100644 index 0000000..b579536 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/cci.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Commodity Channel Index (TA-Lib–compatible): (typical_price - SMA) / (0.015 * MAD). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn cci<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let tp: Vec = highs + .iter() + .zip(lows.iter()) + .zip(closes.iter()) + .map(|((&h, &l), &c)| (h + l + c) / 3.0) + .collect(); + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let window = &tp[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::() / timeperiod as f64; + result[i] = if mad != 0.0 { + (tp[i] - mean) / (0.015 * mad) + } else { + 0.0 + }; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/cmo.rs b/vendor/ferro-ta-main/src/momentum/cmo.rs new file mode 100644 index 0000000..2ae4bf1 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/cmo.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Chande Momentum Oscillator: 100 * (sum of gains - sum of losses) / (sum of gains + sum of losses) over window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn cmo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + + if n < timeperiod + 1 { + return Ok(result.into_pyarray(py)); + } + + let changes: Vec = prices.windows(2).map(|w| w[1] - w[0]).collect(); + + #[allow(clippy::needless_range_loop)] + for i in timeperiod..n { + let mut ups = 0.0_f64; + let mut downs = 0.0_f64; + for ch in &changes[(i - timeperiod)..i] { + if *ch > 0.0 { + ups += ch; + } else { + downs -= ch; + } + } + let denom = ups + downs; + result[i] = if denom != 0.0 { + 100.0 * (ups - downs) / denom + } else { + 0.0 + }; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/mfi.rs b/vendor/ferro-ta-main/src/momentum/mfi.rs new file mode 100644 index 0000000..690c2a4 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/mfi.rs @@ -0,0 +1,31 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Money Flow Index: volume-weighted RSI (typical price * volume). Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, timeperiod = 14))] +pub fn mfi<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + log::debug!("MFI: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::volume::mfi(highs, lows, closes, vols, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/mod.rs b/vendor/ferro-ta-main/src/momentum/mod.rs new file mode 100644 index 0000000..7babb82 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/mod.rs @@ -0,0 +1,54 @@ +//! Momentum indicators — RSI, stochastics, ADX, CCI, etc. +//! Each indicator (or small group) lives in its own file for maintainability. + +mod adx; +mod apo; +mod aroon; +mod bop; +mod cci; +mod cmo; +mod mfi; +mod mom; +mod ppo; +mod roc; +mod rsi; +mod stoch; +mod stochf; +mod stochrsi; +mod trix; +mod ultosc; +mod willr; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::rsi::rsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mom::mom, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::roc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocp, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::roc::rocr100, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::willr::willr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::aroon::aroon, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::aroon::aroonosc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cci::cci, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mfi::mfi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::bop::bop, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stochf::stochf, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stoch::stoch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::stochrsi::stochrsi, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::apo::apo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ppo::ppo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cmo::cmo, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::plus_dm, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::minus_dm, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::plus_di, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::minus_di, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::dx, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::adx, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::adxr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adx::adx_all, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::trix::trix, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ultosc::ultosc, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/momentum/mom.rs b/vendor/ferro-ta-main/src/momentum/mom.rs new file mode 100644 index 0000000..55ee9f4 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/mom.rs @@ -0,0 +1,21 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Momentum: close[i] - close[i - timeperiod]. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn mom<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + result[i] = prices[i] - prices[i - timeperiod]; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/ppo.rs b/vendor/ferro-ta-main/src/momentum/ppo.rs new file mode 100644 index 0000000..7f1bde2 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/ppo.rs @@ -0,0 +1,52 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::PercentagePriceOscillator; +use ta::Next; + +/// Percentage Price Oscillator. Returns (ppo_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn ppo<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + validation::validate_timeperiod(signalperiod, "signalperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = PercentagePriceOscillator::new(fastperiod, slowperiod, signalperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let warmup = slowperiod + signalperiod - 2; + let mut ppo_line = vec![f64::NAN; n]; + let mut signal_line = vec![f64::NAN; n]; + let mut hist = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let out = indicator.next(price); + if i >= warmup { + ppo_line[i] = out.ppo; + signal_line[i] = out.signal; + hist[i] = out.histogram; + } + } + Ok(( + ppo_line.into_pyarray(py), + signal_line.into_pyarray(py), + hist.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/momentum/roc.rs b/vendor/ferro-ta-main/src/momentum/roc.rs new file mode 100644 index 0000000..289439b --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/roc.rs @@ -0,0 +1,92 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::RateOfChange; +use ta::Next; + +/// Rate of Change: (price - prev) / prev * 100. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn roc<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = + RateOfChange::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let val = indicator.next(price); + if i >= timeperiod { + result[i] = val; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Percentage: (price - prev) / prev. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocp<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = (prices[i] - prev) / prev; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Ratio: price / prev. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocr<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = prices[i] / prev; + } + } + Ok(result.into_pyarray(py)) +} + +/// Rate of Change Ratio × 100: (price / prev) * 100. Leading timeperiod values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 10))] +pub fn rocr100<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + let prev = prices[i - timeperiod]; + if prev != 0.0 { + result[i] = (prices[i] / prev) * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/rsi.rs b/vendor/ferro-ta-main/src/momentum/rsi.rs new file mode 100644 index 0000000..8582282 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/rsi.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Relative Strength Index. Uses TA-Lib–compatible Wilder smoothing seed: +/// seed = SMA of first `timeperiod` gains (or losses), then Wilder EMA. +/// Returns NaN for the first `timeperiod` bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn rsi<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = ferro_ta_core::momentum::rsi(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/stoch.rs b/vendor/ferro-ta-main/src/momentum/stoch.rs new file mode 100644 index 0000000..d3ca4c7 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/stoch.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Slow Stochastic. Returns (slowk, slowd). Matches TA-Lib: Fast %K raw, Slow %K = SMA(fast %K, slowk_period), Slow %D = SMA(slow %K, slowd_period). +/// Uses O(n) sliding max/min via monotonic deques. +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, slowk_period = 3, slowd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stoch<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(slowk_period, "slowk_period", 1)?; + validation::validate_timeperiod(slowd_period, "slowd_period", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let (slowk, slowd) = ferro_ta_core::momentum::stoch( + highs, + lows, + closes, + fastk_period, + slowk_period, + slowd_period, + ); + Ok((slowk.into_pyarray(py), slowd.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/momentum/stochf.rs b/vendor/ferro-ta-main/src/momentum/stochf.rs new file mode 100644 index 0000000..24728fe --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/stochf.rs @@ -0,0 +1,62 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{ExponentialMovingAverage, FastStochastic}; +use ta::{DataItem, Next}; + +/// Fast Stochastic. Returns (fastk, fastd). %K from high-low range; %D is EMA of %K. +#[pyfunction] +#[pyo3(signature = (high, low, close, fastk_period = 5, fastd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stochf<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(fastd_period, "fastd_period", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + + let mut fast_stoch = + FastStochastic::new(fastk_period).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut d_ema = ExponentialMovingAverage::new(fastd_period) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup_k = fastk_period - 1; + let warmup_d = warmup_k + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for (i, ((&h, &l), &c)) in highs.iter().zip(lows.iter()).zip(closes.iter()).enumerate() { + let bar = DataItem::builder() + .high(h) + .low(l) + .close(c) + .open(c) + .volume(0.0) + .build() + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let k = fast_stoch.next(&bar); + if i >= warmup_k { + fastk[i] = k; + let d = d_ema.next(k); + if i >= warmup_d { + fastd[i] = d; + } + } + } + Ok((fastk.into_pyarray(py), fastd.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/momentum/stochrsi.rs b/vendor/ferro-ta-main/src/momentum/stochrsi.rs new file mode 100644 index 0000000..3e974ce --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/stochrsi.rs @@ -0,0 +1,106 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn compute_rsi_talib(prices: &[f64], period: usize) -> Vec { + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + if n <= period || period == 0 { + return result; + } + let mut avg_gain = 0.0_f64; + let mut avg_loss = 0.0_f64; + for i in 1..=period { + let delta = prices[i] - prices[i - 1]; + if delta > 0.0 { + avg_gain += delta; + } else { + avg_loss += -delta; + } + } + avg_gain /= period as f64; + avg_loss /= period as f64; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[period] = 100.0 - 100.0 / (1.0 + rs); + let period_f = period as f64; + for i in (period + 1)..n { + let delta = prices[i] - prices[i - 1]; + let (gain, loss) = if delta > 0.0 { + (delta, 0.0) + } else { + (0.0, -delta) + }; + avg_gain = (avg_gain * (period_f - 1.0) + gain) / period_f; + avg_loss = (avg_loss * (period_f - 1.0) + loss) / period_f; + let rs = if avg_loss == 0.0 { + f64::MAX + } else { + avg_gain / avg_loss + }; + result[i] = 100.0 - 100.0 / (1.0 + rs); + } + result +} + +/// Stochastic RSI (TA-Lib–compatible): stochastic applied to RSI. Returns (fastk, fastd). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14, fastk_period = 5, fastd_period = 3))] +#[allow(clippy::type_complexity)] +pub fn stochrsi<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + fastk_period: usize, + fastd_period: usize, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + validation::validate_timeperiod(fastk_period, "fastk_period", 1)?; + validation::validate_timeperiod(fastd_period, "fastd_period", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let rsi_vals = compute_rsi_talib(prices, timeperiod); + + let rsi_warmup = timeperiod; + let k_warmup = rsi_warmup + fastk_period - 1; + let d_warmup = k_warmup + fastd_period - 1; + + let mut fastk = vec![f64::NAN; n]; + let mut fastd = vec![f64::NAN; n]; + + for i in k_warmup..n { + if rsi_vals[i].is_nan() { + continue; + } + let start = i + 1 - fastk_period; + if (start..=i).any(|j| rsi_vals[j].is_nan()) { + continue; + } + let mx = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::NEG_INFINITY, f64::max); + let mn = rsi_vals[start..=i] + .iter() + .cloned() + .fold(f64::INFINITY, f64::min); + fastk[i] = if mx != mn { + 100.0 * (rsi_vals[i] - mn) / (mx - mn) + } else { + 50.0 + }; + } + + for i in d_warmup..n { + let start = i + 1 - fastd_period; + let window = &fastk[start..=i]; + if window.iter().all(|v| !v.is_nan()) { + fastd[i] = window.iter().sum::() / fastd_period as f64; + } + } + Ok((fastk.into_pyarray(py), fastd.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/momentum/trix.rs b/vendor/ferro-ta-main/src/momentum/trix.rs new file mode 100644 index 0000000..2cbcb08 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/trix.rs @@ -0,0 +1,51 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn trix<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema3 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup = 3 * (timeperiod - 1); + let mut ema3_vals = vec![f64::NAN; n]; + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i >= timeperiod - 1 { + let v2 = ema2.next(v1); + if i >= 2 * (timeperiod - 1) { + let v3 = ema3.next(v2); + if i >= warmup { + ema3_vals[i] = v3; + } + } + } + } + + for i in (warmup + 1)..n { + let prev = ema3_vals[i - 1]; + if !ema3_vals[i].is_nan() && !prev.is_nan() && prev != 0.0 { + result[i] = (ema3_vals[i] - prev) / prev * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/ultosc.rs b/vendor/ferro-ta-main/src/momentum/ultosc.rs new file mode 100644 index 0000000..b3cef76 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/ultosc.rs @@ -0,0 +1,73 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Ultimate Oscillator: weighted sum of buying pressure over three periods (7, 14, 28). +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod1 = 7, timeperiod2 = 14, timeperiod3 = 28))] +pub fn ultosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod1: usize, + timeperiod2: usize, + timeperiod3: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod1, "timeperiod1", 1)?; + validation::validate_timeperiod(timeperiod2, "timeperiod2", 1)?; + validation::validate_timeperiod(timeperiod3, "timeperiod3", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + + let max_period = timeperiod1.max(timeperiod2).max(timeperiod3); + let mut result = vec![f64::NAN; n]; + + let mut bp = vec![0.0_f64; n]; + let mut tr = vec![0.0_f64; n]; + for i in 1..n { + let true_low = lows[i].min(closes[i - 1]); + let true_high = highs[i].max(closes[i - 1]); + bp[i] = closes[i] - true_low; + tr[i] = true_high - true_low; + } + + for i in max_period..n { + let raw1 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod1)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod1)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + let raw2 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod2)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod2)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + let raw3 = { + let sum_bp: f64 = bp[(i + 1 - timeperiod3)..=i].iter().sum(); + let sum_tr: f64 = tr[(i + 1 - timeperiod3)..=i].iter().sum(); + if sum_tr != 0.0 { + sum_bp / sum_tr + } else { + 0.0 + } + }; + result[i] = 100.0 * (4.0 * raw1 + 2.0 * raw2 + raw3) / 7.0; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/momentum/willr.rs b/vendor/ferro-ta-main/src/momentum/willr.rs new file mode 100644 index 0000000..cce8948 --- /dev/null +++ b/vendor/ferro-ta-main/src/momentum/willr.rs @@ -0,0 +1,44 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// Williams' %R: -100 * (highest high - close) / (highest high - lowest low) over the window. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn willr<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut max_ind = Maximum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, ((&h, &l), &c)) in highs.iter().zip(lows.iter()).zip(closes.iter()).enumerate() { + let highest = max_ind.next(h); + let lowest = min_ind.next(l); + if i + 1 >= timeperiod { + let range = highest - lowest; + if range != 0.0 { + result[i] = -100.0 * (highest - c) / range; + } else { + result[i] = -50.0; + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/options/american.rs b/vendor/ferro-ta-main/src/options/american.rs new file mode 100644 index 0000000..34e5f68 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/american.rs @@ -0,0 +1,127 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use ferro_ta_core::options::american::{ + american_price_baw as core_american_price, + early_exercise_premium as core_early_exercise_premium, +}; + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn american_price( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(core_american_price( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn american_price_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_american_price(u, k, r, c, t, v, kind)) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn early_exercise_premium( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(core_early_exercise_premium( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn early_exercise_premium_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_early_exercise_premium(u, k, r, c, t, v, kind)) + .collect(); + Ok(out.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/options/chain.rs b/vendor/ferro-ta-main/src/options/chain.rs new file mode 100644 index 0000000..c2d055b --- /dev/null +++ b/vendor/ferro-ta-main/src/options/chain.rs @@ -0,0 +1,64 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (strikes, reference_price, option_type = "call"))] +pub fn moneyness_labels<'py>( + py: Python<'py>, + strikes: PyReadonlyArray1<'py, f64>, + reference_price: f64, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let strikes = strikes.as_slice()?; + let labels = ferro_ta_core::options::chain::label_moneyness(strikes, reference_price, kind); + Ok(labels.into_pyarray(py)) +} + +#[pyfunction] +pub fn select_strike_offset<'py>( + strikes: PyReadonlyArray1<'py, f64>, + reference_price: f64, + offset: isize, +) -> PyResult> { + Ok(ferro_ta_core::options::chain::select_strike_by_offset( + strikes.as_slice()?, + reference_price, + offset, + )) +} + +#[pyfunction] +#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, target_delta, option_type = "call", model = "bsm", rate = 0.0, carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn select_strike_delta<'py>( + strikes: PyReadonlyArray1<'py, f64>, + vols: PyReadonlyArray1<'py, f64>, + reference_price: f64, + time_to_expiry: f64, + target_delta: f64, + option_type: &str, + model: &str, + rate: f64, + carry: f64, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let strikes = strikes.as_slice()?; + let vols = vols.as_slice()?; + validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?; + Ok(ferro_ta_core::options::chain::select_strike_by_delta( + strikes, + vols, + ferro_ta_core::options::ChainGreeksContext { + model, + reference_price, + rate, + carry, + time_to_expiry, + kind, + }, + target_delta, + )) +} diff --git a/vendor/ferro-ta-main/src/options/digital.rs b/vendor/ferro-ta-main/src/options/digital.rs new file mode 100644 index 0000000..75c84c7 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/digital.rs @@ -0,0 +1,164 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use ferro_ta_core::options::digital::{ + digital_greeks as core_digital_greeks, digital_price as core_digital_price, DigitalKind, +}; + +fn parse_digital_kind(s: &str) -> PyResult { + match s.to_ascii_lowercase().replace('-', "_").as_str() { + "cash_or_nothing" | "cash" => Ok(DigitalKind::CashOrNothing), + "asset_or_nothing" | "asset" => Ok(DigitalKind::AssetOrNothing), + _ => Err(PyValueError::new_err( + "digital_type must be 'cash_or_nothing' or 'asset_or_nothing'", + )), + } +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn digital_price( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + digital_type: &str, + carry: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + Ok(core_digital_price( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + dkind, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn digital_price_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + digital_type: &str, + carry: Option>, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let out: Vec = underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + .map(|(((((&u, &k), &r), &t), &v), &c)| core_digital_price(u, k, r, c, t, v, kind, dkind)) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + digital_type: &str, + carry: f64, +) -> PyResult<(f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + Ok(core_digital_greeks( + underlying, + strike, + rate, + carry, + time_to_expiry, + volatility, + kind, + dkind, + )) +} + +type GreekTriple<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn digital_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + digital_type: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let dkind = parse_digital_kind(digital_type)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let tte = time_to_expiry.as_slice()?; + let vol = volatility.as_slice()?; + let n = underlying.len(); + let carry_vec = match carry { + Some(arr) => arr.as_slice()?.to_vec(), + None => vec![0.0; n], + }; + let mut delta = Vec::with_capacity(n); + let mut gamma = Vec::with_capacity(n); + let mut vega = Vec::with_capacity(n); + for (((((&u, &k), &r), &t), &v), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(tte.iter()) + .zip(vol.iter()) + .zip(carry_vec.iter()) + { + let (d, g, ve) = core_digital_greeks(u, k, r, c, t, v, kind, dkind); + delta.push(d); + gamma.push(g); + vega.push(ve); + } + Ok(( + delta.into_pyarray(py), + gamma.into_pyarray(py), + vega.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/options/greeks.rs b/vendor/ferro-ta-main/src/options/greeks.rs new file mode 100644 index 0000000..7b7f06a --- /dev/null +++ b/vendor/ferro-ta-main/src/options/greeks.rs @@ -0,0 +1,242 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +type ExtendedGreekArrays<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +type GreekArrays<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn option_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + model: &str, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let greeks = + ferro_ta_core::options::greeks::model_greeks(ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }); + Ok(( + greeks.delta, + greeks.gamma, + greeks.vega, + greeks.theta, + greeks.rho, + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn option_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; underlying.len()], + }; + validation::validate_equal_length(&[ + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (carry_vec.len(), "carry"), + ])?; + + let mut delta = Vec::with_capacity(underlying.len()); + let mut gamma = Vec::with_capacity(underlying.len()); + let mut vega = Vec::with_capacity(underlying.len()); + let mut theta = Vec::with_capacity(underlying.len()); + let mut rho = Vec::with_capacity(underlying.len()); + for (((((&u, &k), &r), &t), &vol), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(carry_vec.iter()) + { + let g = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + volatility: vol, + }, + ); + delta.push(g.delta); + gamma.push(g.gamma); + vega.push(g.vega); + theta.push(g.theta); + rho.push(g.rho); + } + + Ok(( + delta.into_pyarray(py), + gamma.into_pyarray(py), + vega.into_pyarray(py), + theta.into_pyarray(py), + rho.into_pyarray(py), + )) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn extended_greeks( + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + model: &str, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let eg = ferro_ta_core::options::greeks::model_extended_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }, + ); + Ok((eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)) +} + +#[pyfunction] +#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))] +#[allow(clippy::too_many_arguments)] +pub fn extended_greeks_batch<'py>( + py: Python<'py>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, +) -> PyResult> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; underlying.len()], + }; + validation::validate_equal_length(&[ + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (carry_vec.len(), "carry"), + ])?; + + let mut vanna = Vec::with_capacity(underlying.len()); + let mut volga = Vec::with_capacity(underlying.len()); + let mut charm = Vec::with_capacity(underlying.len()); + let mut speed = Vec::with_capacity(underlying.len()); + let mut color = Vec::with_capacity(underlying.len()); + for (((((&u, &k), &r), &t), &vol), &c) in underlying + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(carry_vec.iter()) + { + let eg = ferro_ta_core::options::greeks::model_extended_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + volatility: vol, + }, + ); + vanna.push(eg.vanna); + volga.push(eg.volga); + charm.push(eg.charm); + speed.push(eg.speed); + color.push(eg.color); + } + + Ok(( + vanna.into_pyarray(py), + volga.into_pyarray(py), + charm.into_pyarray(py), + speed.into_pyarray(py), + color.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/options/iv.rs b/vendor/ferro-ta-main/src/options/iv.rs new file mode 100644 index 0000000..d5bba3d --- /dev/null +++ b/vendor/ferro-ta-main/src/options/iv.rs @@ -0,0 +1,149 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = 0.0, initial_guess = 0.2, tolerance = 1e-8, max_iterations = 100))] +#[allow(clippy::too_many_arguments)] +pub fn implied_volatility( + price: f64, + underlying: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + option_type: &str, + model: &str, + carry: f64, + initial_guess: f64, + tolerance: f64, + max_iterations: usize, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + Ok(ferro_ta_core::options::iv::implied_volatility( + ferro_ta_core::options::OptionContract { + model, + underlying, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + price, + ferro_ta_core::options::IvSolverConfig { + initial_guess, + tolerance, + max_iterations, + }, + )) +} + +#[pyfunction] +#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = None, initial_guess = None, tolerance = 1e-8, max_iterations = 100))] +#[allow(clippy::too_many_arguments)] +pub fn implied_volatility_batch<'py>( + py: Python<'py>, + price: PyReadonlyArray1<'py, f64>, + underlying: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + option_type: &str, + model: &str, + carry: Option>, + initial_guess: Option>, + tolerance: f64, + max_iterations: usize, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let model = super::parse_pricing_model(model)?; + let price = price.as_slice()?; + let underlying = underlying.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let carry_vec = match carry { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.0; price.len()], + }; + let guess_vec = match initial_guess { + Some(array) => array.as_slice()?.to_vec(), + None => vec![0.2; price.len()], + }; + validation::validate_equal_length(&[ + (price.len(), "price"), + (underlying.len(), "underlying"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (carry_vec.len(), "carry"), + (guess_vec.len(), "initial_guess"), + ])?; + + let out: Vec = price + .iter() + .zip(underlying.iter()) + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(carry_vec.iter()) + .zip(guess_vec.iter()) + .map(|((((((&p, &u), &k), &r), &t), &c), &guess)| { + ferro_ta_core::options::iv::implied_volatility( + ferro_ta_core::options::OptionContract { + model, + underlying: u, + strike: k, + rate: r, + carry: c, + time_to_expiry: t, + kind, + }, + p, + ferro_ta_core::options::IvSolverConfig { + initial_guess: guess, + tolerance, + max_iterations, + }, + ) + }) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_rank<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_rank(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_percentile<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_percentile(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (iv_series, window = 252))] +pub fn iv_zscore<'py>( + py: Python<'py>, + iv_series: PyReadonlyArray1<'py, f64>, + window: i64, +) -> PyResult>> { + let window = validation::parse_timeperiod(window, "window", 1)?; + let out = ferro_ta_core::options::iv::iv_zscore(iv_series.as_slice()?, window); + Ok(out.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/options/mod.rs b/vendor/ferro-ta-main/src/options/mod.rs new file mode 100644 index 0000000..625b1d0 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/mod.rs @@ -0,0 +1,148 @@ +//! PyO3 wrappers for options analytics. + +mod american; +mod chain; +mod digital; +mod greeks; +mod iv; +mod payoff; +mod pricing; +mod realized_vol; +mod surface; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +pub(crate) fn parse_option_kind(option_type: &str) -> PyResult { + match option_type.to_ascii_lowercase().as_str() { + "call" | "c" => Ok(ferro_ta_core::options::OptionKind::Call), + "put" | "p" => Ok(ferro_ta_core::options::OptionKind::Put), + _ => Err(PyValueError::new_err(format!( + "option_type must be 'call' or 'put', got {option_type}" + ))), + } +} + +pub(crate) fn parse_pricing_model(model: &str) -> PyResult { + match model.to_ascii_lowercase().as_str() { + "bsm" | "black_scholes" | "black-scholes" | "blackscholes" => { + Ok(ferro_ta_core::options::PricingModel::BlackScholes) + } + "black76" | "black_76" | "black-76" => Ok(ferro_ta_core::options::PricingModel::Black76), + _ => Err(PyValueError::new_err(format!( + "model must be one of 'bsm'/'black_scholes' or 'black76', got {model}" + ))), + } +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::pricing::black76_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price_batch, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::pricing::black76_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::pricing::put_call_parity_deviation, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::greeks::option_greeks_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::greeks::extended_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::greeks::extended_greeks_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::iv::implied_volatility_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_rank, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_percentile, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_zscore, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::surface::smile_metrics, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::surface::term_structure_slope, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::surface::expected_move, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::chain::select_strike_offset, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_payoff_dense, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_payoff_legs, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::aggregate_greeks_dense, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::aggregate_greeks_legs, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::payoff::strategy_value_dense, + m + )?)?; + // Digital options + m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::digital::digital_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::digital::digital_greeks_batch, + m + )?)?; + // American options + m.add_function(pyo3::wrap_pyfunction!(self::american::american_price, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::american_price_batch, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::early_exercise_premium, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::american::early_exercise_premium_batch, + m + )?)?; + // Historical volatility estimators + vol cone + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::close_to_close_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::parkinson_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::garman_klass_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::rogers_satchell_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::realized_vol::yang_zhang_vol, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::realized_vol::vol_cone, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/options/payoff.rs b/vendor/ferro-ta-main/src/options/payoff.rs new file mode 100644 index 0000000..5183027 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/payoff.rs @@ -0,0 +1,495 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyTuple}; + +#[derive(Clone, Copy)] +enum Instrument { + Option, + Future, + Stock, +} + +#[derive(Clone, Copy)] +enum Side { + Long, + Short, +} + +#[derive(Clone, Copy)] +enum OptionType { + Call, + Put, +} + +impl Side { + fn sign(self) -> f64 { + match self { + Side::Long => 1.0, + Side::Short => -1.0, + } + } +} + +fn parse_instrument(v: i64) -> PyResult { + match v { + 0 => Ok(Instrument::Option), + 1 => Ok(Instrument::Future), + 2 => Ok(Instrument::Stock), + _ => Err(PyValueError::new_err( + "instrument must be 0 (option), 1 (future), or 2 (stock)", + )), + } +} + +fn parse_side(v: i64) -> PyResult { + match v { + 1 => Ok(Side::Long), + -1 => Ok(Side::Short), + _ => Err(PyValueError::new_err("side must be 1 (long) or -1 (short)")), + } +} + +fn parse_option_type(v: i64) -> PyResult { + match v { + 1 => Ok(OptionType::Call), + -1 => Ok(OptionType::Put), + _ => Err(PyValueError::new_err( + "option_type must be 1 (call) or -1 (put)", + )), + } +} + +fn parse_instrument_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "option" => Ok(Instrument::Option), + "future" => Ok(Instrument::Future), + "stock" => Ok(Instrument::Stock), + _ => Err(PyValueError::new_err( + "instrument must be 'option', 'future', or 'stock'", + )), + } +} + +fn parse_side_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "long" => Ok(Side::Long), + "short" => Ok(Side::Short), + _ => Err(PyValueError::new_err("side must be 'long' or 'short'")), + } +} + +fn parse_option_type_label(v: &str) -> PyResult { + match v.to_ascii_lowercase().as_str() { + "call" => Ok(OptionType::Call), + "put" => Ok(OptionType::Put), + _ => Err(PyValueError::new_err("option_type must be 'call' or 'put'")), + } +} + +fn leg_attr_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + value.extract::().map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected string" + )) + }) +} + +fn leg_attr_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + value.extract::().map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected float" + )) + }) +} + +fn leg_attr_optional_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult> { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + if value.is_none() { + return Ok(None); + } + value.extract::().map(Some).map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected string or None" + )) + }) +} + +fn leg_attr_optional_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult> { + let value = leg + .getattr(name) + .map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?; + if value.is_none() { + return Ok(None); + } + value.extract::().map(Some).map_err(|_| { + PyValueError::new_err(format!( + "leg field '{name}' has invalid type; expected float or None" + )) + }) +} + +/// Compute aggregate strategy payoff over a spot grid. +/// +/// Encoded arrays (same length = n_legs): +/// - `instruments`: 0=option, 1=future +/// - `sides`: 1=long, -1=short +/// - `option_types`: 1=call, -1=put (ignored for futures) +/// - `strikes`: strike for options, ignored for futures +/// - `premiums`: premium for options, ignored for futures +/// - `entry_prices`: entry price for futures, ignored for options +/// - `quantities`, `multipliers`: applied to both instruments +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn strategy_payoff_dense<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + instruments: PyReadonlyArray1<'py, i64>, + sides: PyReadonlyArray1<'py, i64>, + option_types: PyReadonlyArray1<'py, i64>, + strikes: PyReadonlyArray1<'py, f64>, + premiums: PyReadonlyArray1<'py, f64>, + entry_prices: PyReadonlyArray1<'py, f64>, + quantities: PyReadonlyArray1<'py, f64>, + multipliers: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let premium = premiums.as_slice()?; + let entry = entry_prices.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + + let n_legs = inst.len(); + if side.len() != n_legs + || opt_t.len() != n_legs + || strike.len() != n_legs + || premium.len() != n_legs + || entry.len() != n_legs + || qty.len() != n_legs + || mult.len() != n_legs + { + return Err(PyValueError::new_err( + "All leg arrays must have the same length", + )); + } + + let mut total = vec![0.0_f64; grid.len()]; + + for leg_idx in 0..n_legs { + let instrument = parse_instrument(inst[leg_idx])?; + let side_sign = parse_side(side[leg_idx])?.sign(); + let leg_scale = side_sign * qty[leg_idx] * mult[leg_idx]; + + match instrument { + Instrument::Option => { + let otype = parse_option_type(opt_t[leg_idx])?; + let k = strike[leg_idx]; + let p = premium[leg_idx]; + for (i, &s) in grid.iter().enumerate() { + let intrinsic = match otype { + OptionType::Call => (s - k).max(0.0), + OptionType::Put => (k - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - p); + } + } + Instrument::Future | Instrument::Stock => { + let e = entry[leg_idx]; + for (i, &s) in grid.iter().enumerate() { + total[i] += leg_scale * (s - e); + } + } + } + } + + Ok(total.into_pyarray(py)) +} + +/// Compute aggregate strategy payoff from Python leg objects. +/// +/// `legs` is expected to be a sequence of `PayoffLeg`-like objects +/// with attributes used by `ferro_ta.analysis.derivatives_payoff`. +#[pyfunction] +pub fn strategy_payoff_legs<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + legs: Bound<'py, PyTuple>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let mut total = vec![0.0_f64; grid.len()]; + + for leg in legs.iter() { + let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?; + let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign(); + let quantity = leg_attr_f64(&leg, "quantity")?; + let multiplier = leg_attr_f64(&leg, "multiplier")?; + let leg_scale = side_sign * quantity * multiplier; + + match instrument { + Instrument::Option => { + let otype_raw = + leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| { + PyValueError::new_err("Option payoff legs require option_type.") + })?; + let otype = parse_option_type_label(&otype_raw)?; + let strike = leg_attr_optional_f64(&leg, "strike")? + .ok_or_else(|| PyValueError::new_err("Option payoff legs require strike."))?; + let premium = leg_attr_f64(&leg, "premium")?; + + for (i, &s) in grid.iter().enumerate() { + let intrinsic = match otype { + OptionType::Call => (s - strike).max(0.0), + OptionType::Put => (strike - s).max(0.0), + }; + total[i] += leg_scale * (intrinsic - premium); + } + } + Instrument::Future | Instrument::Stock => { + let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| { + PyValueError::new_err("Futures/stock payoff legs require entry_price.") + })?; + for (i, &s) in grid.iter().enumerate() { + total[i] += leg_scale * (s - entry_price); + } + } + } + } + + Ok(total.into_pyarray(py)) +} + +/// Aggregate Greeks over multiple legs. +/// +/// Encodings match `strategy_payoff_dense`. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: PyReadonlyArray1<'_, i64>, + sides: PyReadonlyArray1<'_, i64>, + option_types: PyReadonlyArray1<'_, i64>, + strikes: PyReadonlyArray1<'_, f64>, + volatilities: PyReadonlyArray1<'_, f64>, + time_to_expiries: PyReadonlyArray1<'_, f64>, + rates: PyReadonlyArray1<'_, f64>, + carries: PyReadonlyArray1<'_, f64>, + quantities: PyReadonlyArray1<'_, f64>, + multipliers: PyReadonlyArray1<'_, f64>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let vol = volatilities.as_slice()?; + let tte = time_to_expiries.as_slice()?; + let rate = rates.as_slice()?; + let carry = carries.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + + let n_legs = inst.len(); + if side.len() != n_legs + || opt_t.len() != n_legs + || strike.len() != n_legs + || vol.len() != n_legs + || tte.len() != n_legs + || rate.len() != n_legs + || carry.len() != n_legs + || qty.len() != n_legs + || mult.len() != n_legs + { + return Err(PyValueError::new_err( + "All leg arrays must have the same length", + )); + } + + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for i in 0..n_legs { + let instrument = parse_instrument(inst[i])?; + let side_sign = parse_side(side[i])?.sign(); + let leg_scale = side_sign * qty[i] * mult[i]; + match instrument { + Instrument::Future | Instrument::Stock => { + delta += leg_scale; + } + Instrument::Option => { + if vol[i].is_nan() || tte[i].is_nan() { + return Err(PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + )); + } + let kind = match parse_option_type(opt_t[i])? { + OptionType::Call => ferro_ta_core::options::OptionKind::Call, + OptionType::Put => ferro_ta_core::options::OptionKind::Put, + }; + let greeks = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model: ferro_ta_core::options::PricingModel::BlackScholes, + underlying: spot, + strike: strike[i], + rate: rate[i], + carry: carry[i], + time_to_expiry: tte[i], + kind, + }, + volatility: vol[i], + }, + ); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + } + } + + Ok((delta, gamma, vega, theta, rho)) +} + +/// Aggregate Greeks from Python leg objects. +#[pyfunction] +pub fn aggregate_greeks_legs( + spot: f64, + legs: Bound<'_, PyTuple>, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let mut delta = 0.0_f64; + let mut gamma = 0.0_f64; + let mut vega = 0.0_f64; + let mut theta = 0.0_f64; + let mut rho = 0.0_f64; + + for leg in legs.iter() { + let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?; + let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign(); + let quantity = leg_attr_f64(&leg, "quantity")?; + let multiplier = leg_attr_f64(&leg, "multiplier")?; + let leg_scale = side_sign * quantity * multiplier; + + match instrument { + Instrument::Future | Instrument::Stock => { + delta += leg_scale; + } + Instrument::Option => { + let otype_raw = + leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require option_type for Greeks aggregation.", + ) + })?; + let otype = parse_option_type_label(&otype_raw)?; + let strike = leg_attr_optional_f64(&leg, "strike")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let volatility = leg_attr_optional_f64(&leg, "volatility")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let time_to_expiry = + leg_attr_optional_f64(&leg, "time_to_expiry")?.ok_or_else(|| { + PyValueError::new_err( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.", + ) + })?; + let rate = leg_attr_f64(&leg, "rate")?; + let carry = leg_attr_f64(&leg, "carry")?; + + let kind = match otype { + OptionType::Call => ferro_ta_core::options::OptionKind::Call, + OptionType::Put => ferro_ta_core::options::OptionKind::Put, + }; + let greeks = ferro_ta_core::options::greeks::model_greeks( + ferro_ta_core::options::OptionEvaluation { + contract: ferro_ta_core::options::OptionContract { + model: ferro_ta_core::options::PricingModel::BlackScholes, + underlying: spot, + strike, + rate, + carry, + time_to_expiry, + kind, + }, + volatility, + }, + ); + delta += leg_scale * greeks.delta; + gamma += leg_scale * greeks.gamma; + vega += leg_scale * greeks.vega; + theta += leg_scale * greeks.theta; + rho += leg_scale * greeks.rho; + } + } + } + + Ok((delta, gamma, vega, theta, rho)) +} + +/// Compute BSM-based strategy value over a spot grid (pre-expiry mark-to-market). +/// +/// Unlike `strategy_payoff_dense` (which uses intrinsic at expiry), this function +/// values each option leg using the Black-Scholes model price. Futures and stock +/// legs are valued the same as in `strategy_payoff_dense`. +/// +/// Delegates to `ferro_ta_core::options::payoff::strategy_value_grid`. +/// +/// NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;` +/// for this function to compile. +#[pyfunction] +#[allow(clippy::too_many_arguments)] +pub fn strategy_value_dense<'py>( + py: Python<'py>, + spot_grid: PyReadonlyArray1<'py, f64>, + instruments: PyReadonlyArray1<'py, i64>, + sides: PyReadonlyArray1<'py, i64>, + option_types: PyReadonlyArray1<'py, i64>, + strikes: PyReadonlyArray1<'py, f64>, + premiums: PyReadonlyArray1<'py, f64>, + entry_prices: PyReadonlyArray1<'py, f64>, + quantities: PyReadonlyArray1<'py, f64>, + multipliers: PyReadonlyArray1<'py, f64>, + time_to_expiries: PyReadonlyArray1<'py, f64>, + volatilities: PyReadonlyArray1<'py, f64>, + rates_per_leg: PyReadonlyArray1<'py, f64>, + carries_per_leg: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let grid = spot_grid.as_slice()?; + let inst = instruments.as_slice()?; + let side = sides.as_slice()?; + let opt_t = option_types.as_slice()?; + let strike = strikes.as_slice()?; + let premium = premiums.as_slice()?; + let entry = entry_prices.as_slice()?; + let qty = quantities.as_slice()?; + let mult = multipliers.as_slice()?; + let tte = time_to_expiries.as_slice()?; + let vol = volatilities.as_slice()?; + let rate = rates_per_leg.as_slice()?; + let carry = carries_per_leg.as_slice()?; + + let result = ferro_ta_core::options::payoff::strategy_value_grid( + grid, inst, side, opt_t, strike, premium, entry, qty, mult, tte, vol, rate, carry, + ); + + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/options/pricing.rs b/vendor/ferro-ta-main/src/options/pricing.rs new file mode 100644 index 0000000..dff3d43 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/pricing.rs @@ -0,0 +1,151 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, option_type = "call", dividend_yield = 0.0))] +pub fn bsm_price( + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, + dividend_yield: f64, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(ferro_ta_core::options::pricing::black_scholes_price( + spot, + strike, + rate, + dividend_yield, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))] +pub fn black76_price( + forward: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + volatility: f64, + option_type: &str, +) -> PyResult { + let kind = super::parse_option_kind(option_type)?; + Ok(ferro_ta_core::options::pricing::black_76_price( + forward, + strike, + rate, + time_to_expiry, + volatility, + kind, + )) +} + +#[pyfunction] +#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, dividend_yield, option_type = "call"))] +#[allow(clippy::too_many_arguments)] +pub fn bsm_price_batch<'py>( + py: Python<'py>, + spot: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + dividend_yield: PyReadonlyArray1<'py, f64>, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let spot = spot.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + let dividend_yield = dividend_yield.as_slice()?; + validation::validate_equal_length(&[ + (spot.len(), "spot"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + (dividend_yield.len(), "dividend_yield"), + ])?; + + let out: Vec = spot + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .zip(dividend_yield.iter()) + .map(|(((((&s, &k), &r), &t), &vol), &q)| { + ferro_ta_core::options::pricing::black_scholes_price(s, k, r, q, t, vol, kind) + }) + .collect(); + Ok(out.into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))] +#[allow(clippy::too_many_arguments)] +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + time_to_expiry: f64, + carry: f64, +) -> PyResult { + Ok(ferro_ta_core::options::pricing::put_call_parity_deviation( + call_price, + put_price, + spot, + strike, + rate, + carry, + time_to_expiry, + )) +} + +#[pyfunction] +#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))] +pub fn black76_price_batch<'py>( + py: Python<'py>, + forward: PyReadonlyArray1<'py, f64>, + strike: PyReadonlyArray1<'py, f64>, + rate: PyReadonlyArray1<'py, f64>, + time_to_expiry: PyReadonlyArray1<'py, f64>, + volatility: PyReadonlyArray1<'py, f64>, + option_type: &str, +) -> PyResult>> { + let kind = super::parse_option_kind(option_type)?; + let forward = forward.as_slice()?; + let strike = strike.as_slice()?; + let rate = rate.as_slice()?; + let time_to_expiry = time_to_expiry.as_slice()?; + let volatility = volatility.as_slice()?; + validation::validate_equal_length(&[ + (forward.len(), "forward"), + (strike.len(), "strike"), + (rate.len(), "rate"), + (time_to_expiry.len(), "time_to_expiry"), + (volatility.len(), "volatility"), + ])?; + + let out: Vec = forward + .iter() + .zip(strike.iter()) + .zip(rate.iter()) + .zip(time_to_expiry.iter()) + .zip(volatility.iter()) + .map(|((((&f, &k), &r), &t), &vol)| { + ferro_ta_core::options::pricing::black_76_price(f, k, r, t, vol, kind) + }) + .collect(); + Ok(out.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/options/realized_vol.rs b/vendor/ferro-ta-main/src/options/realized_vol.rs new file mode 100644 index 0000000..5925018 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/realized_vol.rs @@ -0,0 +1,115 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +use ferro_ta_core::options::realized_vol as core; + +#[pyfunction] +#[pyo3(signature = (close, window, trading_days = 252.0))] +pub fn close_to_close_vol<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::close_to_close_vol(close.as_slice()?, window, trading_days).into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (high, low, window, trading_days = 252.0))] +pub fn parkinson_vol<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok( + core::parkinson_vol(high.as_slice()?, low.as_slice()?, window, trading_days) + .into_pyarray(py), + ) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn garman_klass_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::garman_klass_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn rogers_satchell_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::rogers_satchell_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +#[pyfunction] +#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))] +#[allow(clippy::too_many_arguments)] +pub fn yang_zhang_vol<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + window: usize, + trading_days: f64, +) -> PyResult>> { + Ok(core::yang_zhang_vol( + open.as_slice()?, + high.as_slice()?, + low.as_slice()?, + close.as_slice()?, + window, + trading_days, + ) + .into_pyarray(py)) +} + +/// Returns a list of (window, min, p25, median, p75, max) tuples. +#[allow(clippy::type_complexity)] +#[pyfunction] +#[pyo3(signature = (close, windows, trading_days = 252.0))] +pub fn vol_cone( + close: PyReadonlyArray1<'_, f64>, + windows: Vec, + trading_days: f64, +) -> PyResult> { + let slices = core::vol_cone(close.as_slice()?, &windows, trading_days); + Ok(slices + .into_iter() + .map(|s| (s.window, s.min, s.p25, s.median, s.p75, s.max)) + .collect()) +} diff --git a/vendor/ferro-ta-main/src/options/surface.rs b/vendor/ferro-ta-main/src/options/surface.rs new file mode 100644 index 0000000..93b6d10 --- /dev/null +++ b/vendor/ferro-ta-main/src/options/surface.rs @@ -0,0 +1,65 @@ +use crate::validation; +use numpy::PyReadonlyArray1; +use pyo3::prelude::*; + +#[pyfunction] +#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, model = "bsm", rate = 0.0, carry = 0.0))] +pub fn smile_metrics<'py>( + strikes: PyReadonlyArray1<'py, f64>, + vols: PyReadonlyArray1<'py, f64>, + reference_price: f64, + time_to_expiry: f64, + model: &str, + rate: f64, + carry: f64, +) -> PyResult<(f64, f64, f64, f64, f64)> { + let strikes = strikes.as_slice()?; + let vols = vols.as_slice()?; + validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?; + let model = super::parse_pricing_model(model)?; + let metrics = ferro_ta_core::options::surface::smile_metrics( + strikes, + vols, + reference_price, + rate, + carry, + time_to_expiry, + model, + ); + Ok(( + metrics.atm_iv, + metrics.risk_reversal_25d, + metrics.butterfly_25d, + metrics.skew_slope, + metrics.convexity, + )) +} + +#[pyfunction] +pub fn term_structure_slope<'py>( + tenors: PyReadonlyArray1<'py, f64>, + atm_ivs: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let tenors = tenors.as_slice()?; + let atm_ivs = atm_ivs.as_slice()?; + validation::validate_equal_length(&[(tenors.len(), "tenors"), (atm_ivs.len(), "atm_ivs")])?; + Ok(ferro_ta_core::options::surface::term_structure_slope( + tenors, atm_ivs, + )) +} + +#[pyfunction] +#[pyo3(signature = (spot, iv, days_to_expiry, trading_days_per_year = 252.0))] +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> PyResult<(f64, f64)> { + Ok(ferro_ta_core::options::surface::expected_move( + spot, + iv, + days_to_expiry, + trading_days_per_year, + )) +} diff --git a/vendor/ferro-ta-main/src/overlap/bbands.rs b/vendor/ferro-ta-main/src/overlap/bbands.rs new file mode 100644 index 0000000..2f6dee4 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/bbands.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Bollinger Bands. Returns (upper, middle, lower). Middle is SMA; bands are ± nbdev * stddev. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdevup = 2.0, nbdevdn = 2.0))] +#[allow(clippy::type_complexity)] +pub fn bbands<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdevup: f64, + nbdevdn: f64, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + log::debug!("BBANDS: timeperiod={timeperiod}, n={}", prices.len()); + let (upper, middle, lower) = + ferro_ta_core::overlap::bbands(prices, timeperiod, nbdevup, nbdevdn); + Ok(( + upper.into_pyarray(py), + middle.into_pyarray(py), + lower.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/overlap/dema.rs b/vendor/ferro-ta-main/src/overlap/dema.rs new file mode 100644 index 0000000..6ff838c --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/dema.rs @@ -0,0 +1,40 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Double Exponential Moving Average. Converges after ~2*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn dema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup = 2 * (timeperiod - 1); + let mut ema1_vals = vec![f64::NAN; n]; + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i + 1 >= timeperiod { + ema1_vals[i] = v1; + let v2 = ema2.next(v1); + if i >= warmup { + result[i] = 2.0 * v1 - v2; + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/ema.rs b/vendor/ferro-ta-main/src/overlap/ema.rs new file mode 100644 index 0000000..2139024 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/ema.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Exponential Moving Average. Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn ema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("EMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::ema(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/kama.rs b/vendor/ferro-ta-main/src/overlap/kama.rs new file mode 100644 index 0000000..7eef036 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/kama.rs @@ -0,0 +1,43 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Kaufman Adaptive Moving Average. First value at index timeperiod-1. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn kama<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + if n < timeperiod { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let fast_sc = 2.0 / (2.0 + 1.0_f64); + let slow_sc = 2.0 / (30.0 + 1.0_f64); + + let mut result = vec![f64::NAN; n]; + let mut kama_val = prices[timeperiod - 1]; + result[timeperiod - 1] = kama_val; + + for i in timeperiod..n { + let direction = (prices[i] - prices[i - timeperiod]).abs(); + let mut volatility = 0.0_f64; + for j in 1..=timeperiod { + volatility += (prices[i - j + 1] - prices[i - j]).abs(); + } + let er = if volatility > 0.0 { + direction / volatility + } else { + 0.0 + }; + let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2); + kama_val += sc * (prices[i] - kama_val); + result[i] = kama_val; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/ma_mavp.rs b/vendor/ferro-ta-main/src/overlap/ma_mavp.rs new file mode 100644 index 0000000..a0e8082 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/ma_mavp.rs @@ -0,0 +1,58 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use super::{dema, ema, kama, sma, t3, tema, trima, wma}; + +/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30, matype = 0))] +pub fn ma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + matype: u8, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + match matype { + 0 => sma::sma_inner(py, close, timeperiod), + 1 => ema::ema(py, close, timeperiod), + 2 => wma::wma(py, close, timeperiod), + 3 => dema::dema(py, close, timeperiod), + 4 => tema::tema(py, close, timeperiod), + 5 => trima::trima(py, close, timeperiod), + 6 => kama::kama(py, close, timeperiod), + 7 => t3::t3(py, close, timeperiod, 0.7), + _ => Err(PyValueError::new_err( + "matype must be 0–7 (SMA/EMA/WMA/DEMA/TEMA/TRIMA/KAMA/T3)", + )), + } +} + +/// Moving Average with variable period per bar (SMA over period from periods array). +#[pyfunction] +#[pyo3(signature = (close, periods, minperiod = 2, maxperiod = 30))] +pub fn mavp<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + periods: PyReadonlyArray1<'py, f64>, + minperiod: usize, + maxperiod: usize, +) -> PyResult>> { + let prices = close.as_slice()?; + let per = periods.as_slice()?; + let n = prices.len(); + validation::validate_equal_length(&[(n, "close"), (per.len(), "periods")])?; + validation::validate_timeperiod(minperiod, "minperiod", 1)?; + validation::validate_timeperiod(maxperiod, "maxperiod", minperiod)?; + let mut result = vec![f64::NAN; n]; + for i in 0..n { + let p = (per[i].round() as usize).clamp(minperiod, maxperiod); + if i + 1 >= p { + let sum: f64 = prices[(i + 1 - p)..=i].iter().sum(); + result[i] = sum / p as f64; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/macd.rs b/vendor/ferro-ta-main/src/overlap/macd.rs new file mode 100644 index 0000000..2b8d1fd --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/macd.rs @@ -0,0 +1,57 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// MACD (EMA-based). Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn macd<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + validation::validate_timeperiod(signalperiod, "signalperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + log::debug!( + "MACD: fast={fastperiod}, slow={slowperiod}, signal={signalperiod}, n={}", + prices.len() + ); + let (macd_line, signal_line, histogram) = + ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod); + Ok(( + macd_line.into_pyarray(py), + signal_line.into_pyarray(py), + histogram.into_pyarray(py), + )) +} + +/// MACD with fixed 12/26 periods. Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, signalperiod = 9))] +#[allow(clippy::type_complexity)] +pub fn macdfix<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + signalperiod: usize, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + macd(py, close, 12, 26, signalperiod) +} diff --git a/vendor/ferro-ta-main/src/overlap/macdext.rs b/vendor/ferro-ta-main/src/overlap/macdext.rs new file mode 100644 index 0000000..e779abc --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/macdext.rs @@ -0,0 +1,116 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +fn compute_ma_slice(prices: &[f64], period: usize, matype: u8) -> Vec { + let n = prices.len(); + match matype { + 1 => { + if period == 0 { + return vec![f64::NAN; n]; + } + let k = 2.0 / (period as f64 + 1.0); + let mut result = vec![f64::NAN; n]; + let mut ema_val = prices[period - 1]; + result[period - 1] = ema_val; + for i in period..n { + ema_val = prices[i] * k + ema_val * (1.0 - k); + result[i] = ema_val; + } + result + } + 2 => { + if period == 0 { + return vec![f64::NAN; n]; + } + let weight_sum = (period * (period + 1) / 2) as f64; + let mut result = vec![f64::NAN; n]; + for i in (period - 1)..n { + let val: f64 = (0..period) + .map(|j| prices[i - j] * (period - j) as f64) + .sum(); + result[i] = val / weight_sum; + } + result + } + _ => { + if period == 0 { + return vec![f64::NAN; n]; + } + let mut result = vec![f64::NAN; n]; + for i in (period - 1)..n { + let sum: f64 = prices[(i + 1 - period)..=i].iter().sum(); + result[i] = sum / period as f64; + } + result + } + } +} + +/// MACD with configurable MA types for fast/slow/signal (matype 0–7). Returns (macd_line, signal_line, histogram). +#[pyfunction] +#[pyo3(signature = (close, fastperiod = 12, fastmatype = 1, slowperiod = 26, slowmatype = 1, signalperiod = 9, signalmatype = 1))] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn macdext<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + fastmatype: u8, + slowperiod: usize, + slowmatype: u8, + signalperiod: usize, + signalmatype: u8, +) -> PyResult<( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +)> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + validation::validate_timeperiod(signalperiod, "signalperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let prices = close.as_slice()?; + let n = prices.len(); + + let fast_ma = compute_ma_slice(prices, fastperiod, fastmatype); + let slow_ma = compute_ma_slice(prices, slowperiod, slowmatype); + + let mut macd_line = vec![f64::NAN; n]; + let macd_start = slowperiod - 1; + for i in macd_start..n { + if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() { + macd_line[i] = fast_ma[i] - slow_ma[i]; + } + } + + let macd_valid: Vec = macd_line[macd_start..].to_vec(); + let signal_slice = compute_ma_slice(&macd_valid, signalperiod, signalmatype); + + let mut signal_line = vec![f64::NAN; n]; + let warmup = macd_start + signalperiod - 1; + #[allow(clippy::needless_range_loop)] + for i in warmup..n { + let j = i - macd_start; + if j < signal_slice.len() && !signal_slice[j].is_nan() { + signal_line[i] = signal_slice[j]; + } + } + + let mut histogram = vec![f64::NAN; n]; + for i in 0..n { + if !macd_line[i].is_nan() && !signal_line[i].is_nan() { + histogram[i] = macd_line[i] - signal_line[i]; + } + } + + Ok(( + macd_line.into_pyarray(py), + signal_line.into_pyarray(py), + histogram.into_pyarray(py), + )) +} diff --git a/vendor/ferro-ta-main/src/overlap/mama.rs b/vendor/ferro-ta-main/src/overlap/mama.rs new file mode 100644 index 0000000..937d9fe --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/mama.rs @@ -0,0 +1,138 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// MESA Adaptive Moving Average. Returns (mama, fama). Uses Hilbert Transform–based period. +#[pyfunction] +#[pyo3(signature = (close, fastlimit = 0.5, slowlimit = 0.05))] +#[allow(clippy::type_complexity)] +pub fn mama<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + fastlimit: f64, + slowlimit: f64, +) -> PyResult<(Bound<'py, PyArray1>, Bound<'py, PyArray1>)> { + let prices = close.as_slice()?; + let n = prices.len(); + + let lookback = 32; + let mut mama_arr = vec![f64::NAN; n]; + let mut fama_arr = vec![f64::NAN; n]; + + if n <= lookback { + return Ok((mama_arr.into_pyarray(py), fama_arr.into_pyarray(py))); + } + + let mut smooth = vec![0.0f64; n]; + for i in 0..n { + smooth[i] = if i >= 3 { + (4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0 + } else { + prices[i] + }; + } + + let mut detrender = vec![0.0f64; n]; + let mut q1 = vec![0.0f64; n]; + let mut i1 = vec![0.0f64; n]; + let mut ji = vec![0.0f64; n]; + let mut jq = vec![0.0f64; n]; + let mut i2 = vec![0.0f64; n]; + let mut q2 = vec![0.0f64; n]; + let mut re = vec![0.0f64; n]; + let mut im = vec![0.0f64; n]; + let mut period = vec![0.0f64; n]; + let mut phase = vec![0.0f64; n]; + + let mut mama_val = prices[0]; + let mut fama_val = prices[0]; + + for i in 6..n { + let prev_period = period[i - 1].max(1.0); + let alpha = 0.075 * prev_period + 0.54; + + detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2] + - 0.5769 * smooth[i - 4] + - 0.0962 * smooth[i - 6]) + * alpha; + + if i >= 12 { + q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2] + - 0.5769 * detrender[i - 4] + - 0.0962 * detrender[i - 6]) + * alpha; + } + + if i >= 9 { + i1[i] = detrender[i - 3]; + } + + if i >= 15 { + ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6]) + * alpha; + } + + if i >= 18 { + jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6]) + * alpha; + } + + let i2_raw = i1[i] - jq[i]; + let q2_raw = q1[i] + ji[i]; + + let i2_prev = i2[i - 1]; + let q2_prev = q2[i - 1]; + i2[i] = 0.2 * i2_raw + 0.8 * i2_prev; + q2[i] = 0.2 * q2_raw + 0.8 * q2_prev; + + let re_raw = i2[i] * i2_prev + q2[i] * q2_prev; + let im_raw = i2[i] * q2_prev - q2[i] * i2_prev; + re[i] = 0.2 * re_raw + 0.8 * re[i - 1]; + im[i] = 0.2 * im_raw + 0.8 * im[i - 1]; + + let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 { + std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan() + } else { + prev_period + }; + + if p > 1.5 * prev_period { + p = 1.5 * prev_period; + } + if p < 0.67 * prev_period { + p = 0.67 * prev_period; + } + p = p.clamp(6.0, 50.0); + + period[i] = 0.2 * p + 0.8 * prev_period; + + let prev_phase = phase[i - 1]; + phase[i] = if i1[i] != 0.0 { + q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI + } else if q1[i] > 0.0 { + 90.0 + } else if q1[i] < 0.0 { + -90.0 + } else { + 0.0 + }; + + let mut delta_phase = prev_phase - phase[i]; + if delta_phase < 1.0 { + delta_phase = 1.0; + } + let adaptive_alpha = fastlimit / delta_phase; + let adaptive_alpha = adaptive_alpha.clamp(slowlimit, fastlimit); + + if i >= lookback { + mama_val = adaptive_alpha * prices[i] + (1.0 - adaptive_alpha) * mama_val; + fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val; + mama_arr[i] = mama_val; + fama_arr[i] = fama_val; + } else { + mama_val = prices[i]; + fama_val = prices[i]; + } + } + + Ok((mama_arr.into_pyarray(py), fama_arr.into_pyarray(py))) +} diff --git a/vendor/ferro-ta-main/src/overlap/midpoint.rs b/vendor/ferro-ta-main/src/overlap/midpoint.rs new file mode 100644 index 0000000..c157a1d --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/midpoint.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// Midpoint: (max(close) + min(close)) / 2 over the rolling window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn midpoint<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut max_ind = Maximum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let mx = max_ind.next(price); + let mn = min_ind.next(price); + if i + 1 >= timeperiod { + result[i] = (mx + mn) / 2.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/midprice.rs b/vendor/ferro-ta-main/src/overlap/midprice.rs new file mode 100644 index 0000000..6fb96b9 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/midprice.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +/// MidPrice: (highest high + lowest low) / 2 over the rolling window. +#[pyfunction] +#[pyo3(signature = (high, low, timeperiod = 14))] +pub fn midprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let mut max_ind = Maximum::new(timeperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let mut min_ind = Minimum::new(timeperiod) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, (&h, &l)) in highs.iter().zip(lows.iter()).enumerate() { + let mx = max_ind.next(h); + let mn = min_ind.next(l); + if i + 1 >= timeperiod { + result[i] = (mx + mn) / 2.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/mod.rs b/vendor/ferro-ta-main/src/overlap/mod.rs new file mode 100644 index 0000000..deab633 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/mod.rs @@ -0,0 +1,47 @@ +//! Overlap studies — moving averages and trend indicators. +//! Each indicator lives in its own file for maintainability. + +mod bbands; +mod dema; +mod ema; +mod kama; +mod ma_mavp; +mod macd; +mod macdext; +mod mama; +mod midpoint; +mod midprice; +mod sar; +mod sarext; +mod sma; +mod t3; +mod tema; +mod trima; +mod wma; + +pub use ma_mavp::{ma, mavp}; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::sma::sma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::ema::ema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::wma::wma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dema::dema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::tema::tema, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::trima::trima, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::kama::kama, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::t3::t3, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::bbands::bbands, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macd::macd, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macd::macdfix, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::sar::sar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::midpoint::midpoint, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::midprice::midprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(ma, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(mavp, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::mama::mama, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::sarext::sarext, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::macdext::macdext, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/overlap/sar.rs b/vendor/ferro-ta-main/src/overlap/sar.rs new file mode 100644 index 0000000..8fd5257 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/sar.rs @@ -0,0 +1,70 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Parabolic SAR. Same shape as TA-Lib; reversal history may differ slightly. +#[pyfunction] +#[pyo3(signature = (high, low, acceleration = 0.02, maximum = 0.2))] +pub fn sar<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + acceleration: f64, + maximum: f64, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + if n < 2 { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + + let mut is_rising = highs[1] >= highs[0]; + let mut af = acceleration; + let mut ep: f64; + let mut sar_val: f64; + + if is_rising { + sar_val = lows[0]; + ep = highs[1]; + } else { + sar_val = highs[0]; + ep = lows[1]; + } + result[1] = sar_val; + + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + + if is_rising { + sar_val = sar_val.min(lows[i - 1]).min(lows[i - 2]); + if lows[i] < sar_val { + is_rising = false; + sar_val = ep; + ep = lows[i]; + af = acceleration; + } else if highs[i] > ep { + ep = highs[i]; + af = (af + acceleration).min(maximum); + } + } else { + sar_val = sar_val.max(highs[i - 1]).max(highs[i - 2]); + if highs[i] > sar_val { + is_rising = true; + sar_val = ep; + ep = highs[i]; + af = acceleration; + } else if lows[i] < ep { + ep = lows[i]; + af = (af + acceleration).min(maximum); + } + } + result[i] = sar_val; + } + + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/sarext.rs b/vendor/ferro-ta-main/src/overlap/sarext.rs new file mode 100644 index 0000000..c11d8f2 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/sarext.rs @@ -0,0 +1,103 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Parabolic SAR Extended: SAR with configurable start value and long/short acceleration. +#[pyfunction] +#[pyo3(signature = (high, low, startvalue = 0.0, offsetonreverse = 0.0, accelerationinitlong = 0.02, accelerationlong = 0.02, accelerationmaxlong = 0.2, accelerationinitshort = 0.02, accelerationshort = 0.02, accelerationmaxshort = 0.2))] +#[allow(clippy::too_many_arguments)] +pub fn sarext<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + startvalue: f64, + offsetonreverse: f64, + accelerationinitlong: f64, + accelerationlong: f64, + accelerationmaxlong: f64, + accelerationinitshort: f64, + accelerationshort: f64, + accelerationmaxshort: f64, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + if n < 2 { + return Ok(vec![f64::NAN; n].into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + + let mut is_rising = highs[1] >= highs[0]; + + let (mut af, af_step, af_max) = if is_rising { + (accelerationinitlong, accelerationlong, accelerationmaxlong) + } else { + ( + accelerationinitshort, + accelerationshort, + accelerationmaxshort, + ) + }; + + let mut ep: f64; + let mut sar_val: f64; + + if is_rising { + sar_val = if startvalue != 0.0 { + startvalue + } else { + lows[0] + }; + ep = highs[1]; + } else { + sar_val = if startvalue != 0.0 { + -startvalue + } else { + highs[0] + }; + ep = lows[1]; + } + + result[1] = sar_val; + + let mut af_step_cur = af_step; + let mut af_max_cur = af_max; + + for i in 2..n { + let prev_sar = sar_val; + sar_val = prev_sar + af * (ep - prev_sar); + + if is_rising { + sar_val = sar_val.min(lows[i - 1]).min(lows[i - 2]); + if lows[i] < sar_val { + is_rising = false; + sar_val = ep + sar_val.abs() * offsetonreverse; + ep = lows[i]; + af = accelerationinitshort; + af_step_cur = accelerationshort; + af_max_cur = accelerationmaxshort; + } else if highs[i] > ep { + ep = highs[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } else { + sar_val = sar_val.max(highs[i - 1]).max(highs[i - 2]); + if highs[i] > sar_val { + is_rising = true; + sar_val = ep - sar_val.abs() * offsetonreverse; + ep = highs[i]; + af = accelerationinitlong; + af_step_cur = accelerationlong; + af_max_cur = accelerationmaxlong; + } else if lows[i] < ep { + ep = lows[i]; + af = (af + af_step_cur).min(af_max_cur); + } + } + result[i] = sar_val; + } + + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/sma.rs b/vendor/ferro-ta-main/src/overlap/sma.rs new file mode 100644 index 0000000..f28c384 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/sma.rs @@ -0,0 +1,29 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Inner SMA implementation (timeperiod already validated as usize). +/// Used by the PyO3 sma() and by ma() when matype=0. +pub fn sma_inner<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("SMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::sma(prices, timeperiod); + Ok(result.into_pyarray(py)) +} + +/// Simple Moving Average. Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn sma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: i64, +) -> PyResult>> { + let timeperiod = validation::parse_timeperiod(timeperiod, "timeperiod", 1)?; + sma_inner(py, close, timeperiod) +} diff --git a/vendor/ferro-ta-main/src/overlap/t3.rs b/vendor/ferro-ta-main/src/overlap/t3.rs new file mode 100644 index 0000000..d18c99a --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/t3.rs @@ -0,0 +1,46 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Tillson T3 (triple smoothed EMA). Converges after ~6*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, vfactor = 0.7))] +pub fn t3<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + vfactor: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut e = [0.0_f64; 6]; + let k = 2.0 / (timeperiod as f64 + 1.0); + + let v = vfactor; + let c1 = -(v * v * v); + let c2 = 3.0 * v * v + 3.0 * v * v * v; + let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v; + let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v; + + let warmup = 6 * (timeperiod - 1); + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + if i == 0 { + for ej in e.iter_mut() { + *ej = price; + } + } else { + e[0] += k * (price - e[0]); + for j in 1..6 { + e[j] += k * (e[j - 1] - e[j]); + } + } + if i >= warmup { + result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2]; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/tema.rs b/vendor/ferro-ta-main/src/overlap/tema.rs new file mode 100644 index 0000000..bde4309 --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/tema.rs @@ -0,0 +1,45 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::ExponentialMovingAverage; +use ta::Next; + +/// Triple Exponential Moving Average. Converges after ~3*(timeperiod-1) bars. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn tema<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut ema1 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema2 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut ema3 = ExponentialMovingAverage::new(timeperiod) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + + let warmup1 = timeperiod - 1; + let warmup2 = 2 * (timeperiod - 1); + let warmup3 = 3 * (timeperiod - 1); + let mut result = vec![f64::NAN; n]; + + for (i, &price) in prices.iter().enumerate() { + let v1 = ema1.next(price); + if i >= warmup1 { + let v2 = ema2.next(v1); + if i >= warmup2 { + let v3 = ema3.next(v2); + if i >= warmup3 { + result[i] = 3.0 * v1 - 3.0 * v2 + v3; + } + } + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/trima.rs b/vendor/ferro-ta-main/src/overlap/trima.rs new file mode 100644 index 0000000..8c7469e --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/trima.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Triangular Moving Average (triangle-weighted). Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn trima<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + + let mut weights = Vec::with_capacity(timeperiod); + let half = timeperiod.div_ceil(2); + for i in 1..=timeperiod { + let w = if i <= half { i } else { timeperiod + 1 - i }; + weights.push(w as f64); + } + let weight_sum: f64 = weights.iter().sum(); + + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let mut val = 0.0_f64; + for (j, &w) in weights.iter().enumerate() { + val += prices[i - (timeperiod - 1 - j)] * w; + } + result[i] = val / weight_sum; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/overlap/wma.rs b/vendor/ferro-ta-main/src/overlap/wma.rs new file mode 100644 index 0000000..7b7543c --- /dev/null +++ b/vendor/ferro-ta-main/src/overlap/wma.rs @@ -0,0 +1,19 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Weighted Moving Average (linear weights). Leading timeperiod-1 values are NaN. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 30))] +pub fn wma<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + log::debug!("WMA: timeperiod={timeperiod}, n={n}"); + let result = ferro_ta_core::overlap::wma(prices, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl2crows.rs b/vendor/ferro-ta-main/src/pattern/cdl2crows.rs new file mode 100644 index 0000000..1df0ddb --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl2crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl2crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl2crows(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3blackcrows.rs b/vendor/ferro-ta-main/src/pattern/cdl3blackcrows.rs new file mode 100644 index 0000000..ea305e7 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3blackcrows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3blackcrows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3blackcrows(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3inside.rs b/vendor/ferro-ta-main/src/pattern/cdl3inside.rs new file mode 100644 index 0000000..f7c33a6 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3inside.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3inside<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3inside(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3linestrike.rs b/vendor/ferro-ta-main/src/pattern/cdl3linestrike.rs new file mode 100644 index 0000000..7c599bf --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3linestrike.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3linestrike<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3linestrike(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3outside.rs b/vendor/ferro-ta-main/src/pattern/cdl3outside.rs new file mode 100644 index 0000000..39a8461 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3outside.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3outside<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3outside(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3starsinsouth.rs b/vendor/ferro-ta-main/src/pattern/cdl3starsinsouth.rs new file mode 100644 index 0000000..9ff6016 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3starsinsouth.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3starsinsouth<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3starsinsouth(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs b/vendor/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs new file mode 100644 index 0000000..69771c7 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdl3whitesoldiers.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdl3whitesoldiers<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdl3whitesoldiers(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlabandonedbaby.rs b/vendor/ferro-ta-main/src/pattern/cdlabandonedbaby.rs new file mode 100644 index 0000000..c6f2dac --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlabandonedbaby.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlabandonedbaby<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlabandonedbaby(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdladvanceblock.rs b/vendor/ferro-ta-main/src/pattern/cdladvanceblock.rs new file mode 100644 index 0000000..692b12b --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdladvanceblock.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdladvanceblock<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdladvanceblock(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlbelthold.rs b/vendor/ferro-ta-main/src/pattern/cdlbelthold.rs new file mode 100644 index 0000000..3b819b1 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlbelthold.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlbelthold<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlbelthold(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlbreakaway.rs b/vendor/ferro-ta-main/src/pattern/cdlbreakaway.rs new file mode 100644 index 0000000..ec298f9 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlbreakaway.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlbreakaway<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlbreakaway(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs b/vendor/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs new file mode 100644 index 0000000..b63138d --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlclosingmarubozu.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlclosingmarubozu<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlclosingmarubozu(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs b/vendor/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs new file mode 100644 index 0000000..5e719ac --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlconcealbabyswall.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlconcealbabyswall<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlconcealbabyswall(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlcounterattack.rs b/vendor/ferro-ta-main/src/pattern/cdlcounterattack.rs new file mode 100644 index 0000000..13c5be2 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlcounterattack.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlcounterattack<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlcounterattack(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdldarkcloudcover.rs b/vendor/ferro-ta-main/src/pattern/cdldarkcloudcover.rs new file mode 100644 index 0000000..049c279 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdldarkcloudcover.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldarkcloudcover<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldarkcloudcover(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdldoji.rs b/vendor/ferro-ta-main/src/pattern/cdldoji.rs new file mode 100644 index 0000000..0a2a875 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdldoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldoji(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdldojistar.rs b/vendor/ferro-ta-main/src/pattern/cdldojistar.rs new file mode 100644 index 0000000..6bcf3cb --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdldojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldojistar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdldragonflydoji.rs b/vendor/ferro-ta-main/src/pattern/cdldragonflydoji.rs new file mode 100644 index 0000000..74b6255 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdldragonflydoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdldragonflydoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdldragonflydoji(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlengulfing.rs b/vendor/ferro-ta-main/src/pattern/cdlengulfing.rs new file mode 100644 index 0000000..fcbf6d3 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlengulfing.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlengulfing<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlengulfing(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdleveningdojistar.rs b/vendor/ferro-ta-main/src/pattern/cdleveningdojistar.rs new file mode 100644 index 0000000..39115d2 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdleveningdojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdleveningdojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdleveningdojistar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdleveningstar.rs b/vendor/ferro-ta-main/src/pattern/cdleveningstar.rs new file mode 100644 index 0000000..2f6dc9a --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdleveningstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdleveningstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdleveningstar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs b/vendor/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs new file mode 100644 index 0000000..b481e49 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlgapsidesidewhite.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlgapsidesidewhite<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlgapsidesidewhite(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlgravestonedoji.rs b/vendor/ferro-ta-main/src/pattern/cdlgravestonedoji.rs new file mode 100644 index 0000000..0b7f919 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlgravestonedoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlgravestonedoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlgravestonedoji(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhammer.rs b/vendor/ferro-ta-main/src/pattern/cdlhammer.rs new file mode 100644 index 0000000..6bcc282 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhammer.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhammer<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhammer(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhangingman.rs b/vendor/ferro-ta-main/src/pattern/cdlhangingman.rs new file mode 100644 index 0000000..c52a4d2 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhangingman.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhangingman<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhangingman(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlharami.rs b/vendor/ferro-ta-main/src/pattern/cdlharami.rs new file mode 100644 index 0000000..cc16e4b --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlharami.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlharami<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlharami(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlharamicross.rs b/vendor/ferro-ta-main/src/pattern/cdlharamicross.rs new file mode 100644 index 0000000..bb947ee --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlharamicross.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlharamicross<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlharamicross(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhighwave.rs b/vendor/ferro-ta-main/src/pattern/cdlhighwave.rs new file mode 100644 index 0000000..889df79 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhighwave.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhighwave<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhighwave(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhikkake.rs b/vendor/ferro-ta-main/src/pattern/cdlhikkake.rs new file mode 100644 index 0000000..34e1712 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhikkake.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhikkake<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhikkake(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhikkakemod.rs b/vendor/ferro-ta-main/src/pattern/cdlhikkakemod.rs new file mode 100644 index 0000000..2866bb5 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhikkakemod.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhikkakemod<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhikkakemod(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlhomingpigeon.rs b/vendor/ferro-ta-main/src/pattern/cdlhomingpigeon.rs new file mode 100644 index 0000000..e3d08c8 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlhomingpigeon.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlhomingpigeon<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlhomingpigeon(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlidentical3crows.rs b/vendor/ferro-ta-main/src/pattern/cdlidentical3crows.rs new file mode 100644 index 0000000..a5eeaa6 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlidentical3crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlidentical3crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlidentical3crows(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlinneck.rs b/vendor/ferro-ta-main/src/pattern/cdlinneck.rs new file mode 100644 index 0000000..a43a910 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlinneck.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlinneck<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlinneck(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlinvertedhammer.rs b/vendor/ferro-ta-main/src/pattern/cdlinvertedhammer.rs new file mode 100644 index 0000000..4568de8 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlinvertedhammer.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlinvertedhammer<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlinvertedhammer(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlkicking.rs b/vendor/ferro-ta-main/src/pattern/cdlkicking.rs new file mode 100644 index 0000000..133ca2e --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlkicking.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlkicking<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlkicking(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlkickingbylength.rs b/vendor/ferro-ta-main/src/pattern/cdlkickingbylength.rs new file mode 100644 index 0000000..9be040c --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlkickingbylength.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlkickingbylength<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlkickingbylength(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlladderbottom.rs b/vendor/ferro-ta-main/src/pattern/cdlladderbottom.rs new file mode 100644 index 0000000..1cc9d88 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlladderbottom.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlladderbottom<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlladderbottom(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdllongleggeddoji.rs b/vendor/ferro-ta-main/src/pattern/cdllongleggeddoji.rs new file mode 100644 index 0000000..e9dc5c6 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdllongleggeddoji.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdllongleggeddoji<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdllongleggeddoji(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdllongline.rs b/vendor/ferro-ta-main/src/pattern/cdllongline.rs new file mode 100644 index 0000000..63bf7a7 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdllongline.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdllongline<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdllongline(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlmarubozu.rs b/vendor/ferro-ta-main/src/pattern/cdlmarubozu.rs new file mode 100644 index 0000000..0c146d6 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlmarubozu.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmarubozu<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmarubozu(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlmatchinglow.rs b/vendor/ferro-ta-main/src/pattern/cdlmatchinglow.rs new file mode 100644 index 0000000..7bc3cfe --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlmatchinglow.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmatchinglow<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmatchinglow(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlmathold.rs b/vendor/ferro-ta-main/src/pattern/cdlmathold.rs new file mode 100644 index 0000000..7e76e74 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlmathold.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmathold<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmathold(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlmorningdojistar.rs b/vendor/ferro-ta-main/src/pattern/cdlmorningdojistar.rs new file mode 100644 index 0000000..be45eb2 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlmorningdojistar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmorningdojistar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmorningdojistar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlmorningstar.rs b/vendor/ferro-ta-main/src/pattern/cdlmorningstar.rs new file mode 100644 index 0000000..ef2f68e --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlmorningstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlmorningstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlmorningstar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlonneck.rs b/vendor/ferro-ta-main/src/pattern/cdlonneck.rs new file mode 100644 index 0000000..3b3a933 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlonneck.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlonneck<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlonneck(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlpiercing.rs b/vendor/ferro-ta-main/src/pattern/cdlpiercing.rs new file mode 100644 index 0000000..7b862cb --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlpiercing.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlpiercing<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlpiercing(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlrickshawman.rs b/vendor/ferro-ta-main/src/pattern/cdlrickshawman.rs new file mode 100644 index 0000000..a10d3d7 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlrickshawman.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlrickshawman<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlrickshawman(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlrisefall3methods.rs b/vendor/ferro-ta-main/src/pattern/cdlrisefall3methods.rs new file mode 100644 index 0000000..a35d5ed --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlrisefall3methods.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlrisefall3methods<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlrisefall3methods(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlseparatinglines.rs b/vendor/ferro-ta-main/src/pattern/cdlseparatinglines.rs new file mode 100644 index 0000000..f59ec8b --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlseparatinglines.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlseparatinglines<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlseparatinglines(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlshootingstar.rs b/vendor/ferro-ta-main/src/pattern/cdlshootingstar.rs new file mode 100644 index 0000000..4d0d214 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlshootingstar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlshootingstar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlshootingstar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlshortline.rs b/vendor/ferro-ta-main/src/pattern/cdlshortline.rs new file mode 100644 index 0000000..b27dc52 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlshortline.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlshortline<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlshortline(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlspinningtop.rs b/vendor/ferro-ta-main/src/pattern/cdlspinningtop.rs new file mode 100644 index 0000000..909c9c5 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlspinningtop.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlspinningtop<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlspinningtop(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlstalledpattern.rs b/vendor/ferro-ta-main/src/pattern/cdlstalledpattern.rs new file mode 100644 index 0000000..9bca03f --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlstalledpattern.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlstalledpattern<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlstalledpattern(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlsticksandwich.rs b/vendor/ferro-ta-main/src/pattern/cdlsticksandwich.rs new file mode 100644 index 0000000..ba7ebff --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlsticksandwich.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlsticksandwich<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlsticksandwich(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdltakuri.rs b/vendor/ferro-ta-main/src/pattern/cdltakuri.rs new file mode 100644 index 0000000..7f9270f --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdltakuri.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltakuri<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltakuri(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdltasukigap.rs b/vendor/ferro-ta-main/src/pattern/cdltasukigap.rs new file mode 100644 index 0000000..640e690 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdltasukigap.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltasukigap<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltasukigap(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlthrusting.rs b/vendor/ferro-ta-main/src/pattern/cdlthrusting.rs new file mode 100644 index 0000000..4b01824 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlthrusting.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlthrusting<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlthrusting(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdltristar.rs b/vendor/ferro-ta-main/src/pattern/cdltristar.rs new file mode 100644 index 0000000..53311fa --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdltristar.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdltristar<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdltristar(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlunique3river.rs b/vendor/ferro-ta-main/src/pattern/cdlunique3river.rs new file mode 100644 index 0000000..379cd5f --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlunique3river.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlunique3river<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlunique3river(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs b/vendor/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs new file mode 100644 index 0000000..18cce70 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlupsidegap2crows.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlupsidegap2crows<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlupsidegap2crows(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs b/vendor/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs new file mode 100644 index 0000000..3664f92 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/cdlxsidegap3methods.rs @@ -0,0 +1,18 @@ +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +#[pyfunction] +pub fn cdlxsidegap3methods<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let result = ferro_ta_core::pattern::cdlxsidegap3methods(o, h, l, c); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/pattern/common.rs b/vendor/ferro-ta-main/src/pattern/common.rs new file mode 100644 index 0000000..9b12440 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/common.rs @@ -0,0 +1 @@ +// Common helpers are now in ferro_ta_core::pattern. This file is kept for mod declaration. diff --git a/vendor/ferro-ta-main/src/pattern/mod.rs b/vendor/ferro-ta-main/src/pattern/mod.rs new file mode 100644 index 0000000..c6acb01 --- /dev/null +++ b/vendor/ferro-ta-main/src/pattern/mod.rs @@ -0,0 +1,243 @@ +//! Candlestick pattern recognition (CDL*). One module per pattern for maintainability. + +mod common; + +mod cdl2crows; +mod cdl3blackcrows; +mod cdl3inside; +mod cdl3linestrike; +mod cdl3outside; +mod cdl3starsinsouth; +mod cdl3whitesoldiers; +mod cdlabandonedbaby; +mod cdladvanceblock; +mod cdlbelthold; +mod cdlbreakaway; +mod cdlclosingmarubozu; +mod cdlconcealbabyswall; +mod cdlcounterattack; +mod cdldarkcloudcover; +mod cdldoji; +mod cdldojistar; +mod cdldragonflydoji; +mod cdlengulfing; +mod cdleveningdojistar; +mod cdleveningstar; +mod cdlgapsidesidewhite; +mod cdlgravestonedoji; +mod cdlhammer; +mod cdlhangingman; +mod cdlharami; +mod cdlharamicross; +mod cdlhighwave; +mod cdlhikkake; +mod cdlhikkakemod; +mod cdlhomingpigeon; +mod cdlidentical3crows; +mod cdlinneck; +mod cdlinvertedhammer; +mod cdlkicking; +mod cdlkickingbylength; +mod cdlladderbottom; +mod cdllongleggeddoji; +mod cdllongline; +mod cdlmarubozu; +mod cdlmatchinglow; +mod cdlmathold; +mod cdlmorningdojistar; +mod cdlmorningstar; +mod cdlonneck; +mod cdlpiercing; +mod cdlrickshawman; +mod cdlrisefall3methods; +mod cdlseparatinglines; +mod cdlshootingstar; +mod cdlshortline; +mod cdlspinningtop; +mod cdlstalledpattern; +mod cdlsticksandwich; +mod cdltakuri; +mod cdltasukigap; +mod cdlthrusting; +mod cdltristar; +mod cdlunique3river; +mod cdlupsidegap2crows; +mod cdlxsidegap3methods; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::cdldoji::cdldoji, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlengulfing::cdlengulfing, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhammer::cdlhammer, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlshootingstar::cdlshootingstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlmarubozu::cdlmarubozu, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlspinningtop::cdlspinningtop, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmorningstar::cdlmorningstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdleveningstar::cdleveningstar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl2crows::cdl2crows, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3blackcrows::cdl3blackcrows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3whitesoldiers::cdl3whitesoldiers, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl3inside::cdl3inside, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdl3outside::cdl3outside, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdldojistar::cdldojistar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmorningdojistar::cdlmorningdojistar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdleveningdojistar::cdleveningdojistar, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlharami::cdlharami, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlharamicross::cdlharamicross, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3linestrike::cdl3linestrike, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdl3starsinsouth::cdl3starsinsouth, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlabandonedbaby::cdlabandonedbaby, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdladvanceblock::cdladvanceblock, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlbelthold::cdlbelthold, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlbreakaway::cdlbreakaway, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlclosingmarubozu::cdlclosingmarubozu, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlconcealbabyswall::cdlconcealbabyswall, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlcounterattack::cdlcounterattack, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdldarkcloudcover::cdldarkcloudcover, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdldragonflydoji::cdldragonflydoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlgapsidesidewhite::cdlgapsidesidewhite, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlgravestonedoji::cdlgravestonedoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhangingman::cdlhangingman, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhighwave::cdlhighwave, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlhikkake::cdlhikkake, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhikkakemod::cdlhikkakemod, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlhomingpigeon::cdlhomingpigeon, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlidentical3crows::cdlidentical3crows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlinneck::cdlinneck, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlinvertedhammer::cdlinvertedhammer, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlkicking::cdlkicking, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlkickingbylength::cdlkickingbylength, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlladderbottom::cdlladderbottom, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdllongleggeddoji::cdllongleggeddoji, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdllongline::cdllongline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlmatchinglow::cdlmatchinglow, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlmathold::cdlmathold, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlonneck::cdlonneck, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlpiercing::cdlpiercing, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlrickshawman::cdlrickshawman, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlrisefall3methods::cdlrisefall3methods, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlseparatinglines::cdlseparatinglines, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlshortline::cdlshortline, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlstalledpattern::cdlstalledpattern, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlsticksandwich::cdlsticksandwich, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltakuri::cdltakuri, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltasukigap::cdltasukigap, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdlthrusting::cdlthrusting, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::cdltristar::cdltristar, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlunique3river::cdlunique3river, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlupsidegap2crows::cdlupsidegap2crows, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::cdlxsidegap3methods::cdlxsidegap3methods, + m + )?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/portfolio/mod.rs b/vendor/ferro-ta-main/src/portfolio/mod.rs new file mode 100644 index 0000000..8ee9402 --- /dev/null +++ b/vendor/ferro-ta-main/src/portfolio/mod.rs @@ -0,0 +1,371 @@ +//! Portfolio Analytics — thin PyO3 wrappers delegating to `ferro_ta_core::portfolio`. +//! +//! Compute-intensive portfolio metrics implemented in Rust: +//! - `portfolio_volatility` — sqrt(w' Σ w) given weights and a covariance matrix +//! - `beta_series` — rolling or full beta of asset vs benchmark +//! - `drawdown_series` — per-bar drawdown and underwater series +//! - `correlation_matrix` — pairwise Pearson correlation (n_assets × n_assets) +//! - `relative_strength` — cumulative return ratio (asset / benchmark) +//! - `spread` — A - hedge * B +//! - `zscore_series` — rolling Z-score of a 1-D series +//! - `rolling_beta` — rolling beta (hedge ratio) of two series + +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +// --------------------------------------------------------------------------- +// portfolio_volatility +// --------------------------------------------------------------------------- + +/// Compute portfolio volatility: sqrt(w' Σ w). +/// +/// Parameters +/// ---------- +/// cov_matrix : 2-D float64 array, shape (n, n) — covariance matrix +/// weights : 1-D float64 array, length n — portfolio weights (need not sum to 1) +/// +/// Returns +/// ------- +/// float — portfolio volatility (annualisation is the caller's responsibility) +#[pyfunction] +pub fn portfolio_volatility<'py>( + cov_matrix: PyReadonlyArray2<'py, f64>, + weights: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let cov = cov_matrix.as_array(); + let w = weights.as_slice()?; + let n = w.len(); + let (rows, cols) = cov.dim(); + if rows != n || cols != n { + return Err(PyValueError::new_err(format!( + "cov_matrix must be ({n}, {n}), got ({rows}, {cols})" + ))); + } + // Convert ndarray rows to Vec> for core + let cov_rows: Vec> = (0..rows) + .map(|i| (0..cols).map(|j| cov[[i, j]]).collect()) + .collect(); + Ok(ferro_ta_core::portfolio::portfolio_volatility(&cov_rows, w)) +} + +// --------------------------------------------------------------------------- +// beta_full +// --------------------------------------------------------------------------- + +/// Compute the full-sample beta of `asset_returns` to `benchmark_returns`. +/// +/// Beta = Cov(asset, bench) / Var(bench) (OLS regression slope). +/// +/// Parameters +/// ---------- +/// asset_returns, benchmark_returns : 1-D float64 arrays (equal length, >= 2 elements) +/// +/// Returns +/// ------- +/// float — beta +#[pyfunction] +pub fn beta_full<'py>( + asset_returns: PyReadonlyArray1<'py, f64>, + benchmark_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult { + let a = asset_returns.as_slice()?; + let b = benchmark_returns.as_slice()?; + let n = a.len(); + if n < 2 || b.len() != n { + return Err(PyValueError::new_err( + "asset_returns and benchmark_returns must have equal length >= 2", + )); + } + Ok(ferro_ta_core::portfolio::beta_full(a, b)) +} + +// --------------------------------------------------------------------------- +// rolling_beta +// --------------------------------------------------------------------------- + +/// Compute rolling beta of `asset` vs `benchmark` over a sliding window. +/// +/// Parameters +/// ---------- +/// asset, benchmark : 1-D float64 arrays (equal length) +/// window : int — rolling window size (must be >= 2) +/// +/// Returns +/// ------- +/// 1-D float64 array — NaN for first `window-1` positions. +#[pyfunction] +#[pyo3(signature = (asset, benchmark, window))] +pub fn rolling_beta<'py>( + py: Python<'py>, + asset: PyReadonlyArray1<'py, f64>, + benchmark: PyReadonlyArray1<'py, f64>, + window: usize, +) -> PyResult>> { + if window < 2 { + return Err(PyValueError::new_err("window must be >= 2")); + } + let a = asset.as_slice()?; + let b = benchmark.as_slice()?; + let n = a.len(); + if n == 0 || b.len() != n { + return Err(PyValueError::new_err( + "asset and benchmark must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::rolling_beta(a, b, window); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// drawdown_series +// --------------------------------------------------------------------------- + +/// Compute the drawdown series and maximum drawdown for an equity/price series. +/// +/// Drawdown at bar i = (equity[i] - running_max) / running_max (always <= 0). +/// +/// Parameters +/// ---------- +/// equity : 1-D float64 array — equity or price series +/// +/// Returns +/// ------- +/// (drawdown, max_drawdown) +/// drawdown : 1-D float64 array (same length as equity) +/// max_drawdown : float — worst (most negative) drawdown observed +#[pyfunction] +pub fn drawdown_series<'py>( + py: Python<'py>, + equity: PyReadonlyArray1<'py, f64>, +) -> PyResult<(Bound<'py, PyArray1>, f64)> { + let eq = equity.as_slice()?; + if eq.is_empty() { + return Err(PyValueError::new_err("equity must be non-empty")); + } + let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(eq); + Ok((dd.into_pyarray(py), max_dd)) +} + +// --------------------------------------------------------------------------- +// correlation_matrix +// --------------------------------------------------------------------------- + +/// Compute the pairwise Pearson correlation matrix for a returns DataFrame. +/// +/// Parameters +/// ---------- +/// data : 2-D float64 array, shape (n_bars, n_assets) — returns per bar/asset +/// +/// Returns +/// ------- +/// 2-D float64 array, shape (n_assets, n_assets) — correlation matrix +#[pyfunction] +pub fn correlation_matrix<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, +) -> PyResult>> { + let arr = data.as_array(); + let (n_bars, n_assets) = arr.dim(); + if n_bars < 2 { + return Err(PyValueError::new_err("data must have at least 2 rows")); + } + // Core expects column vectors: data[j][i] = asset j at bar i + let columns: Vec> = (0..n_assets) + .map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect()) + .collect(); + let corr = ferro_ta_core::portfolio::correlation_matrix(&columns); + // Convert Vec> back to ndarray::Array2 + let mut result = Array2::::zeros((n_assets, n_assets)); + for j1 in 0..n_assets { + for j2 in 0..n_assets { + result[[j1, j2]] = corr[j1][j2]; + } + } + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// relative_strength +// --------------------------------------------------------------------------- + +/// Compute relative strength of an asset vs a benchmark. +/// +/// result[i] = (1 + asset_returns[i]).cumprod() / (1 + bench_returns[i]).cumprod() +/// starting from 1.0. +/// +/// Parameters +/// ---------- +/// asset_returns, benchmark_returns : 1-D float64 arrays (equal length) +/// Fractional returns per bar (e.g. 0.01 for +1%). +/// +/// Returns +/// ------- +/// 1-D float64 array — relative strength (ratio of cumulative returns). +#[pyfunction] +pub fn relative_strength<'py>( + py: Python<'py>, + asset_returns: PyReadonlyArray1<'py, f64>, + benchmark_returns: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let a = asset_returns.as_slice()?; + let b = benchmark_returns.as_slice()?; + let n = a.len(); + if n == 0 || b.len() != n { + return Err(PyValueError::new_err( + "asset_returns and benchmark_returns must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::relative_strength(a, b); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// spread +// --------------------------------------------------------------------------- + +/// Compute the spread between two series: A - hedge * B. +/// +/// Parameters +/// ---------- +/// a, b : 1-D float64 arrays (equal length) +/// hedge : float — hedge ratio (default 1.0) +/// +/// Returns +/// ------- +/// 1-D float64 array +#[pyfunction] +#[pyo3(signature = (a, b, hedge = 1.0))] +pub fn spread<'py>( + py: Python<'py>, + a: PyReadonlyArray1<'py, f64>, + b: PyReadonlyArray1<'py, f64>, + hedge: f64, +) -> PyResult>> { + let av = a.as_slice()?; + let bv = b.as_slice()?; + let n = av.len(); + if n == 0 || bv.len() != n { + return Err(PyValueError::new_err( + "a and b must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::spread(av, bv, hedge); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// ratio +// --------------------------------------------------------------------------- + +/// Compute the ratio between two series: A / B. +/// +/// Where B is 0, returns NaN. +#[pyfunction] +pub fn ratio<'py>( + py: Python<'py>, + a: PyReadonlyArray1<'py, f64>, + b: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let av = a.as_slice()?; + let bv = b.as_slice()?; + let n = av.len(); + if n == 0 || bv.len() != n { + return Err(PyValueError::new_err( + "a and b must be non-empty and equal length", + )); + } + let result = ferro_ta_core::portfolio::ratio(av, bv); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// zscore_series +// --------------------------------------------------------------------------- + +/// Compute the rolling Z-score of a 1-D series. +/// +/// Z[i] = (x[i] - mean(x[i-window+1..=i])) / std(x[i-window+1..=i]) +/// +/// Parameters +/// ---------- +/// x : 1-D float64 array +/// window : int — rolling window (must be >= 2) +/// +/// Returns +/// ------- +/// 1-D float64 array — NaN for first `window-1` positions. +#[pyfunction] +#[pyo3(signature = (x, window))] +pub fn zscore_series<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + window: usize, +) -> PyResult>> { + if window < 2 { + return Err(PyValueError::new_err("window must be >= 2")); + } + let xv = x.as_slice()?; + if xv.is_empty() { + return Err(PyValueError::new_err("x must be non-empty")); + } + let result = ferro_ta_core::portfolio::zscore_series(xv, window); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// compose_weighted +// --------------------------------------------------------------------------- + +/// Weighted combination of multiple signal columns. +/// +/// Parameters +/// ---------- +/// data : 2-D float64 array, shape (n_bars, n_signals) +/// weights : 1-D float64 array, length n_signals +/// +/// Returns +/// ------- +/// 1-D float64 array — weighted sum per bar +#[pyfunction] +pub fn compose_weighted<'py>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + weights: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let arr = data.as_array(); + let w = weights.as_slice()?; + let (n_bars, n_sigs) = arr.dim(); + if w.len() != n_sigs { + return Err(PyValueError::new_err(format!( + "weights length ({}) must equal number of signal columns ({})", + w.len(), + n_sigs + ))); + } + // Core expects column vectors: data[j][i] = signal j at bar i + let columns: Vec> = (0..n_sigs) + .map(|j| (0..n_bars).map(|i| arr[[i, j]]).collect()) + .collect(); + let result = ferro_ta_core::portfolio::compose_weighted(&columns, w); + Ok(result.into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(portfolio_volatility, m)?)?; + m.add_function(wrap_pyfunction!(beta_full, m)?)?; + m.add_function(wrap_pyfunction!(rolling_beta, m)?)?; + m.add_function(wrap_pyfunction!(drawdown_series, m)?)?; + m.add_function(wrap_pyfunction!(correlation_matrix, m)?)?; + m.add_function(wrap_pyfunction!(relative_strength, m)?)?; + m.add_function(wrap_pyfunction!(spread, m)?)?; + m.add_function(wrap_pyfunction!(ratio, m)?)?; + m.add_function(wrap_pyfunction!(zscore_series, m)?)?; + m.add_function(wrap_pyfunction!(compose_weighted, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/price_transform/avgprice.rs b/vendor/ferro-ta-main/src/price_transform/avgprice.rs new file mode 100644 index 0000000..c892553 --- /dev/null +++ b/vendor/ferro-ta-main/src/price_transform/avgprice.rs @@ -0,0 +1,27 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Average Price: (open + high + low + close) / 4. +#[pyfunction] +pub fn avgprice<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let opens = open.as_slice()?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = opens.len(); + validation::validate_equal_length(&[ + (n, "open"), + (highs.len(), "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::avgprice(opens, highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/price_transform/medprice.rs b/vendor/ferro-ta-main/src/price_transform/medprice.rs new file mode 100644 index 0000000..d47356b --- /dev/null +++ b/vendor/ferro-ta-main/src/price_transform/medprice.rs @@ -0,0 +1,18 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Median Price: (high + low) / 2. +#[pyfunction] +pub fn medprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[(n, "high"), (lows.len(), "low")])?; + let result = ferro_ta_core::price_transform::medprice(highs, lows); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/price_transform/mod.rs b/vendor/ferro-ta-main/src/price_transform/mod.rs new file mode 100644 index 0000000..7aadfd1 --- /dev/null +++ b/vendor/ferro-ta-main/src/price_transform/mod.rs @@ -0,0 +1,17 @@ +//! Price transformations — helper functions to synthesize OHLC arrays into single price arrays. +//! Each transform lives in its own file for maintainability. + +mod avgprice; +mod medprice; +mod typprice; +mod wclprice; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::avgprice::avgprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::medprice::medprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::typprice::typprice, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::wclprice::wclprice, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/price_transform/typprice.rs b/vendor/ferro-ta-main/src/price_transform/typprice.rs new file mode 100644 index 0000000..7ac07ac --- /dev/null +++ b/vendor/ferro-ta-main/src/price_transform/typprice.rs @@ -0,0 +1,24 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Typical Price: (high + low + close) / 3. +#[pyfunction] +pub fn typprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::typprice(highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/price_transform/wclprice.rs b/vendor/ferro-ta-main/src/price_transform/wclprice.rs new file mode 100644 index 0000000..ae3d466 --- /dev/null +++ b/vendor/ferro-ta-main/src/price_transform/wclprice.rs @@ -0,0 +1,24 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Weighted Close Price: (high + low + close * 2) / 4. +#[pyfunction] +pub fn wclprice<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::price_transform::wclprice(highs, lows, closes); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/regime/mod.rs b/vendor/ferro-ta-main/src/regime/mod.rs new file mode 100644 index 0000000..22e8445 --- /dev/null +++ b/vendor/ferro-ta-main/src/regime/mod.rs @@ -0,0 +1,80 @@ +//! Regime detection and structural breaks (thin PyO3 wrapper over ferro_ta_core::regime). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use crate::validation; + +/// Label each bar as **trend** (1) or **range** (0) based on ADX level. +#[pyfunction] +pub fn regime_adx<'py>( + py: Python<'py>, + adx: PyReadonlyArray1<'py, f64>, + threshold: f64, +) -> PyResult>> { + let a = adx.as_slice()?; + let result = ferro_ta_core::regime::regime_adx(a, threshold); + Ok(result.into_pyarray(py)) +} + +/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule. +#[pyfunction] +pub fn regime_combined<'py>( + py: Python<'py>, + adx: PyReadonlyArray1<'py, f64>, + atr: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> PyResult>> { + let a = adx.as_slice()?; + let r = atr.as_slice()?; + let c = close.as_slice()?; + let n = a.len(); + validation::validate_equal_length(&[(n, "adx"), (r.len(), "atr"), (c.len(), "close")])?; + let result = ferro_ta_core::regime::regime_combined(a, r, c, adx_threshold, atr_pct_threshold); + Ok(result.into_pyarray(py)) +} + +/// Detect structural breaks using a CUSUM approach. +#[pyfunction] +pub fn detect_breaks_cusum<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + window: usize, + threshold: f64, + slack: f64, +) -> PyResult>> { + validation::validate_timeperiod(window, "window", 2)?; + let s = series.as_slice()?; + let result = ferro_ta_core::regime::detect_breaks_cusum(s, window, threshold, slack); + Ok(result.into_pyarray(py)) +} + +/// Detect volatility regime breaks using rolling variance ratio. +#[pyfunction] +pub fn rolling_variance_break<'py>( + py: Python<'py>, + series: PyReadonlyArray1<'py, f64>, + short_window: usize, + long_window: usize, + threshold: f64, +) -> PyResult>> { + validation::validate_timeperiod(short_window, "short_window", 2)?; + if long_window <= short_window { + return Err(PyValueError::new_err("long_window must be > short_window")); + } + let s = series.as_slice()?; + let result = + ferro_ta_core::regime::rolling_variance_break(s, short_window, long_window, threshold); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(regime_adx, m)?)?; + m.add_function(wrap_pyfunction!(regime_combined, m)?)?; + m.add_function(wrap_pyfunction!(detect_breaks_cusum, m)?)?; + m.add_function(wrap_pyfunction!(rolling_variance_break, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/resampling/mod.rs b/vendor/ferro-ta-main/src/resampling/mod.rs new file mode 100644 index 0000000..4b99deb --- /dev/null +++ b/vendor/ferro-ta-main/src/resampling/mod.rs @@ -0,0 +1,90 @@ +//! Resampling — OHLCV resampling (thin PyO3 wrapper over ferro_ta_core::resampling). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +type Ohlcv5<'py> = ( + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, + Bound<'py, PyArray1>, +); + +/// Aggregate OHLCV data into volume bars of a fixed volume threshold. +#[pyfunction] +#[pyo3(signature = (open, high, low, close, volume, volume_threshold))] +pub fn volume_bars<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + volume_threshold: f64, +) -> PyResult> { + if volume_threshold <= 0.0 { + return Err(PyValueError::new_err("volume_threshold must be > 0")); + } + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + let n = o.len(); + if n == 0 || h.len() != n || l.len() != n || c.len() != n || v.len() != n { + return Err(PyValueError::new_err( + "All input arrays must be non-empty and have equal length", + )); + } + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::volume_bars(o, h, l, c, v, volume_threshold); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +/// Aggregate OHLCV bars by integer group labels. +#[pyfunction] +#[pyo3(signature = (open, high, low, close, volume, labels))] +pub fn ohlcv_agg<'py>( + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + labels: PyReadonlyArray1<'py, i64>, +) -> PyResult> { + let o = open.as_slice()?; + let h = high.as_slice()?; + let l = low.as_slice()?; + let c = close.as_slice()?; + let v = volume.as_slice()?; + let lbl = labels.as_slice()?; + let n = o.len(); + if n == 0 || h.len() != n || l.len() != n || c.len() != n || v.len() != n || lbl.len() != n { + return Err(PyValueError::new_err( + "All input arrays must be non-empty and have equal length", + )); + } + let (ro, rh, rl, rc, rv) = ferro_ta_core::resampling::ohlcv_agg(o, h, l, c, v, lbl); + Ok(( + ro.into_pyarray(py), + rh.into_pyarray(py), + rl.into_pyarray(py), + rc.into_pyarray(py), + rv.into_pyarray(py), + )) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(volume_bars, m)?)?; + m.add_function(wrap_pyfunction!(ohlcv_agg, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/signals/mod.rs b/vendor/ferro-ta-main/src/signals/mod.rs new file mode 100644 index 0000000..0a1a845 --- /dev/null +++ b/vendor/ferro-ta-main/src/signals/mod.rs @@ -0,0 +1,78 @@ +//! Signal processing helpers (thin PyO3 wrapper over ferro_ta_core::signals). + +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Compute the fractional rank of each element (1-based, ascending). +/// Ties receive the average of their rank positions. +#[pyfunction] +pub fn rank_series<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let xv = x.as_slice()?; + if xv.is_empty() { + return Err(PyValueError::new_err("x must be non-empty")); + } + let result = ferro_ta_core::signals::rank_values(xv); + Ok(result.into_pyarray(py)) +} + +/// Compute rank-based composite scores for a 2-D signal matrix. +/// Each column is ranked independently, per-row ranks are summed. +#[pyfunction] +pub fn compose_rank<'py>( + py: Python<'py>, + signals: PyReadonlyArray2<'py, f64>, +) -> PyResult>> { + let arr = signals.as_array(); + let (n_bars, n_sigs) = arr.dim(); + if n_bars == 0 || n_sigs == 0 { + return Err(PyValueError::new_err( + "signals must be a non-empty 2-D array", + )); + } + + let scores = py.allow_threads(|| { + let columns: Vec> = (0..n_sigs) + .map(|sig_idx| arr.column(sig_idx).iter().copied().collect()) + .collect(); + let col_refs: Vec<&[f64]> = columns.iter().map(|c| c.as_slice()).collect(); + ferro_ta_core::signals::compose_rank(&col_refs) + }); + + Ok(scores.into_pyarray(py)) +} + +/// Return the indices of the N largest values in `x`. +#[pyfunction] +pub fn top_n_indices<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + n: usize, +) -> PyResult>> { + let xv = x.as_slice()?; + let result = ferro_ta_core::signals::top_n_indices(xv, n); + Ok(result.into_pyarray(py)) +} + +/// Return the indices of the N smallest values in `x`. +#[pyfunction] +pub fn bottom_n_indices<'py>( + py: Python<'py>, + x: PyReadonlyArray1<'py, f64>, + n: usize, +) -> PyResult>> { + let xv = x.as_slice()?; + let result = ferro_ta_core::signals::bottom_n_indices(xv, n); + Ok(result.into_pyarray(py)) +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(rank_series, m)?)?; + m.add_function(wrap_pyfunction!(compose_rank, m)?)?; + m.add_function(wrap_pyfunction!(top_n_indices, m)?)?; + m.add_function(wrap_pyfunction!(bottom_n_indices, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/statistic/beta.rs b/vendor/ferro-ta-main/src/statistic/beta.rs new file mode 100644 index 0000000..fc1c333 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/beta.rs @@ -0,0 +1,146 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn price_return(curr: f64, prev: f64) -> f64 { + if prev != 0.0 { + curr / prev - 1.0 + } else { + f64::NAN + } +} + +fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { + let n = x.len(); + let mut result = vec![f64::NAN; n]; + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) { + let start = end - timeperiod; + let mut rx = vec![0.0_f64; timeperiod]; + let mut ry = vec![0.0_f64; timeperiod]; + for offset in 0..timeperiod { + let prev = start + offset; + let curr = prev + 1; + rx[offset] = price_return(x[curr], x[prev]); + ry[offset] = price_return(y[curr], y[prev]); + } + let mean_x = rx.iter().sum::() / timeperiod as f64; + let mean_y = ry.iter().sum::() / timeperiod as f64; + let cov = rx + .iter() + .zip(ry.iter()) + .map(|(&lhs, &rhs)| (lhs - mean_x) * (rhs - mean_y)) + .sum::() + / timeperiod as f64; + let var_x = rx + .iter() + .map(|&value| (value - mean_x).powi(2)) + .sum::() + / timeperiod as f64; + *slot = if var_x != 0.0 { cov / var_x } else { f64::NAN }; + } + result +} + +/// Beta: regression of *real1* daily returns on *real0* daily returns over a +/// rolling window of *timeperiod* return pairs. +/// +/// Matches TA-Lib's algorithm: +/// - For bar *i* (output index *i*): use `timeperiod` pairs of consecutive +/// price returns from the window ending at bar *i*. +/// - Return for bar t: r_x[t] = x[t]/x[t-1] - 1 (similarly for y). +/// - beta = Cov(r_y, r_x) / Var(r_x) (sample, divided by timeperiod). +/// - First valid output is at index `timeperiod` (needs `timeperiod+1` bars). +#[pyfunction] +#[pyo3(signature = (real0, real1, timeperiod = 5))] +pub fn beta<'py>( + py: Python<'py>, + real0: PyReadonlyArray1<'py, f64>, + real1: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let x = real0.as_slice()?; + let y = real1.as_slice()?; + let n = x.len(); + validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; + + if x.iter().any(|value| !value.is_finite()) || y.iter().any(|value| !value.is_finite()) { + return Ok(beta_fallback(x, y, timeperiod).into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + if n <= timeperiod { + return Ok(result.into_pyarray(py)); + } + + let rx: Vec = x + .windows(2) + .map(|window| price_return(window[1], window[0])) + .collect(); + let ry: Vec = y + .windows(2) + .map(|window| price_return(window[1], window[0])) + .collect(); + + let period = timeperiod as f64; + let mut invalid_pairs = 0_usize; + let mut sum_rx = 0.0_f64; + let mut sum_ry = 0.0_f64; + let mut sum_rx2 = 0.0_f64; + let mut sum_rxry = 0.0_f64; + + for idx in 0..timeperiod { + let ret_x = rx[idx]; + let ret_y = ry[idx]; + if ret_x.is_finite() && ret_y.is_finite() { + sum_rx += ret_x; + sum_ry += ret_y; + sum_rx2 += ret_x * ret_x; + sum_rxry += ret_x * ret_y; + } else { + invalid_pairs += 1; + } + } + + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) { + *slot = if invalid_pairs == 0 { + let denom = period * sum_rx2 - sum_rx * sum_rx; + if denom != 0.0 { + (period * sum_rxry - sum_rx * sum_ry) / denom + } else { + f64::NAN + } + } else { + f64::NAN + }; + + if end + 1 < n { + let outgoing = end - timeperiod; + let incoming = end; + + let outgoing_x = rx[outgoing]; + let outgoing_y = ry[outgoing]; + if outgoing_x.is_finite() && outgoing_y.is_finite() { + sum_rx -= outgoing_x; + sum_ry -= outgoing_y; + sum_rx2 -= outgoing_x * outgoing_x; + sum_rxry -= outgoing_x * outgoing_y; + } else { + invalid_pairs -= 1; + } + + let incoming_x = rx[incoming]; + let incoming_y = ry[incoming]; + if incoming_x.is_finite() && incoming_y.is_finite() { + sum_rx += incoming_x; + sum_ry += incoming_y; + sum_rx2 += incoming_x * incoming_x; + sum_rxry += incoming_x * incoming_y; + } else { + invalid_pairs += 1; + } + } + } + + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/statistic/common.rs b/vendor/ferro-ta-main/src/statistic/common.rs new file mode 100644 index 0000000..d833554 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/common.rs @@ -0,0 +1,70 @@ +/// Rolling linear regression: returns (slope, intercept) for the given window. +pub(super) fn linreg(window: &[f64]) -> (f64, f64) { + let n = window.len() as f64; + let sum_x: f64 = (0..window.len()).map(|i| i as f64).sum(); + let sum_y: f64 = window.iter().sum(); + let sum_xy: f64 = window.iter().enumerate().map(|(i, &y)| i as f64 * y).sum(); + let sum_x2: f64 = (0..window.len()).map(|i| (i as f64).powi(2)).sum(); + let denom = n * sum_x2 - sum_x * sum_x; + let slope = if denom != 0.0 { + (n * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / n; + (slope, intercept) +} + +pub(crate) fn rolling_linreg_apply(prices: &[f64], timeperiod: usize, mut map: F) -> Vec +where + F: FnMut(f64, f64) -> f64, +{ + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + if timeperiod == 0 || n < timeperiod { + return result; + } + + if prices.iter().any(|value| !value.is_finite()) { + for end in (timeperiod - 1)..n { + let window = &prices[(end + 1 - timeperiod)..=end]; + let (slope, intercept) = linreg(window); + result[end] = map(slope, intercept); + } + return result; + } + + let period = timeperiod as f64; + let last_x = (timeperiod - 1) as f64; + let sum_x = last_x * period / 2.0; + let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0; + let denom = period * sum_x2 - sum_x * sum_x; + + let mut sum_y = prices[..timeperiod].iter().sum::(); + let mut sum_xy = prices[..timeperiod] + .iter() + .enumerate() + .map(|(idx, &value)| idx as f64 * value) + .sum::(); + + for end in (timeperiod - 1)..n { + let slope = if denom != 0.0 { + (period * sum_xy - sum_x * sum_y) / denom + } else { + 0.0 + }; + let intercept = (sum_y - slope * sum_x) / period; + result[end] = map(slope, intercept); + + if end + 1 < n { + let outgoing = prices[end + 1 - timeperiod]; + let incoming = prices[end + 1]; + let prev_sum_y = sum_y; + + sum_y = prev_sum_y - outgoing + incoming; + sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming; + } + } + + result +} diff --git a/vendor/ferro-ta-main/src/statistic/correl.rs b/vendor/ferro-ta-main/src/statistic/correl.rs new file mode 100644 index 0000000..0070569 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/correl.rs @@ -0,0 +1,101 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +fn correl_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { + let n = x.len(); + let mut result = vec![f64::NAN; n]; + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) { + let wx = &x[(end + 1 - timeperiod)..=end]; + let wy = &y[(end + 1 - timeperiod)..=end]; + let mean_x = wx.iter().sum::() / timeperiod as f64; + let mean_y = wy.iter().sum::() / timeperiod as f64; + let cov = wx + .iter() + .zip(wy.iter()) + .map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y)) + .sum::(); + let std_x = wx + .iter() + .map(|&xi| (xi - mean_x).powi(2)) + .sum::() + .sqrt(); + let std_y = wy + .iter() + .map(|&yi| (yi - mean_y).powi(2)) + .sum::() + .sqrt(); + let denom = std_x * std_y; + *slot = if denom != 0.0 { cov / denom } else { f64::NAN }; + } + result +} + +/// Pearson correlation coefficient between two series over the rolling window. +#[pyfunction] +#[pyo3(signature = (real0, real1, timeperiod = 30))] +pub fn correl<'py>( + py: Python<'py>, + real0: PyReadonlyArray1<'py, f64>, + real1: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let x = real0.as_slice()?; + let y = real1.as_slice()?; + let n = x.len(); + validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; + + if x.iter().any(|value| !value.is_finite()) || y.iter().any(|value| !value.is_finite()) { + return Ok(correl_fallback(x, y, timeperiod).into_pyarray(py)); + } + + let mut result = vec![f64::NAN; n]; + if n < timeperiod { + return Ok(result.into_pyarray(py)); + } + + let period = timeperiod as f64; + let mut sum_x = x[..timeperiod].iter().sum::(); + let mut sum_y = y[..timeperiod].iter().sum::(); + let mut sum_x2 = x[..timeperiod] + .iter() + .map(|value| value * value) + .sum::(); + let mut sum_y2 = y[..timeperiod] + .iter() + .map(|value| value * value) + .sum::(); + let mut sum_xy = x[..timeperiod] + .iter() + .zip(y[..timeperiod].iter()) + .map(|(&lhs, &rhs)| lhs * rhs) + .sum::(); + + for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) { + let denom_x = period * sum_x2 - sum_x * sum_x; + let denom_y = period * sum_y2 - sum_y * sum_y; + *slot = if denom_x > 0.0 && denom_y > 0.0 { + (period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt() + } else { + f64::NAN + }; + + if end + 1 < n { + let outgoing = end + 1 - timeperiod; + let incoming = end + 1; + + let outgoing_x = x[outgoing]; + let outgoing_y = y[outgoing]; + let incoming_x = x[incoming]; + let incoming_y = y[incoming]; + + sum_x += incoming_x - outgoing_x; + sum_y += incoming_y - outgoing_y; + sum_x2 += incoming_x * incoming_x - outgoing_x * outgoing_x; + sum_y2 += incoming_y * incoming_y - outgoing_y * outgoing_y; + sum_xy += incoming_x * incoming_y - outgoing_x * outgoing_y; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/statistic/dtw.rs b/vendor/ferro-ta-main/src/statistic/dtw.rs new file mode 100644 index 0000000..30b469b --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/dtw.rs @@ -0,0 +1,98 @@ +use ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use rayon::prelude::*; + +/// Dynamic Time Warping — distance and optimal warping path between two 1-D series. +/// +/// Returns a tuple `(distance, path)` where `path` is a NumPy array of shape +/// `(N, 2)` containing `(i, j)` index pairs from `(0, 0)` to `(n-1, m-1)`. +/// +/// Local cost: `|series1[i] - series2[j]|` (Euclidean, matches `dtaidistance`). +#[pyfunction] +#[pyo3(signature = (series1, series2, window = None))] +pub fn dtw<'py>( + py: Python<'py>, + series1: PyReadonlyArray1<'py, f64>, + series2: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult<(f64, Bound<'py, PyArray2>)> { + let s1 = series1.as_slice()?; + let s2 = series2.as_slice()?; + if s1.is_empty() || s2.is_empty() { + return Err(PyValueError::new_err( + "series1 and series2 must not be empty", + )); + } + let (dist, path) = ferro_ta_core::statistic::dtw_path(s1, s2, window); + let n = path.len(); + let flat: Vec = path.iter().flat_map(|&(i, j)| [i, j]).collect(); + let arr = + Array2::from_shape_vec((n, 2), flat).map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok((dist, arr.into_pyarray(py))) +} + +/// Dynamic Time Warping — distance only (faster, no path reconstruction). +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Use this when you only need the distance, not the alignment path. +#[pyfunction] +#[pyo3(signature = (series1, series2, window = None))] +pub fn dtw_distance<'py>( + _py: Python<'py>, + series1: PyReadonlyArray1<'py, f64>, + series2: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult { + let s1 = series1.as_slice()?; + let s2 = series2.as_slice()?; + if s1.is_empty() || s2.is_empty() { + return Err(PyValueError::new_err( + "series1 and series2 must not be empty", + )); + } + Ok(ferro_ta_core::statistic::dtw_distance(s1, s2, window)) +} + +/// Batch Dynamic Time Warping — compute DTW distance from each row of a 2-D matrix +/// to a single reference series, in parallel. +/// +/// Parameters +/// ---------- +/// matrix : np.ndarray, shape (N, L) +/// N time series of length L. Each row is compared against `reference`. +/// reference : np.ndarray, shape (L,) +/// The reference series. +/// window : int, optional +/// Sakoe-Chiba band width. `None` = unconstrained. +/// +/// Returns +/// ------- +/// np.ndarray, shape (N,) +/// DTW distances, one per row. +#[pyfunction] +#[pyo3(signature = (matrix, reference, window = None))] +pub fn batch_dtw<'py>( + py: Python<'py>, + matrix: PyReadonlyArray2<'py, f64>, + reference: PyReadonlyArray1<'py, f64>, + window: Option, +) -> PyResult>> { + let mat = matrix.as_array(); + let ref_slice = reference.as_slice()?; + + if ref_slice.is_empty() { + return Err(PyValueError::new_err("reference must not be empty")); + } + + let (n_rows, _) = mat.dim(); + let rows: Vec> = (0..n_rows).map(|i| mat.row(i).to_vec()).collect(); + + let result: Vec = rows + .par_iter() + .map(|series| ferro_ta_core::statistic::dtw_distance(series, ref_slice, window)) + .collect(); + + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/statistic/linearreg.rs b/vendor/ferro-ta-main/src/statistic/linearreg.rs new file mode 100644 index 0000000..e22e7ec --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/linearreg.rs @@ -0,0 +1,81 @@ +use super::common::rolling_linreg_apply; +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; +use std::f64::consts::PI; + +/// Linear regression fitted value at the last point of the window. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let last_x = (timeperiod - 1) as f64; + let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| { + intercept + slope * last_x + }); + Ok(result.into_pyarray(py)) +} + +/// Slope of the rolling linear regression line. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_slope<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope); + Ok(result.into_pyarray(py)) +} + +/// Intercept of the rolling linear regression line. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_intercept<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |_, intercept| intercept); + Ok(result.into_pyarray(py)) +} + +/// Angle of the regression line in degrees (atan(slope) * 180/π). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn linearreg_angle<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope.atan() * 180.0 / PI); + Ok(result.into_pyarray(py)) +} + +/// Time series forecast: linear regression extrapolated one period ahead. +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 14))] +pub fn tsf<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let forecast_x = timeperiod as f64; + let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| { + intercept + slope * forecast_x + }); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/statistic/mod.rs b/vendor/ferro-ta-main/src/statistic/mod.rs new file mode 100644 index 0000000..bcef6e7 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/mod.rs @@ -0,0 +1,31 @@ +//! Statistic functions — rolling window statistical operations on price data. +//! Each function (or closely related group) lives in its own file. + +mod beta; +pub(crate) mod common; +mod correl; +mod dtw; +mod linearreg; +mod stddev; +mod var; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::stddev::stddev, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::var::var, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg_slope, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::linearreg::linearreg_intercept, + m + )?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::linearreg_angle, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw_distance, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::dtw::batch_dtw, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/statistic/stddev.rs b/vendor/ferro-ta-main/src/statistic/stddev.rs new file mode 100644 index 0000000..d918fd3 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/stddev.rs @@ -0,0 +1,30 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use ta::indicators::StandardDeviation; +use ta::Next; + +/// Standard deviation over a rolling window; scaled by nbdev (default 1.0). +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdev = 1.0))] +pub fn stddev<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdev: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut indicator = + StandardDeviation::new(timeperiod).map_err(|e| PyValueError::new_err(e.to_string()))?; + let mut result = vec![f64::NAN; n]; + for (i, &price) in prices.iter().enumerate() { + let val = indicator.next(price); + if i + 1 >= timeperiod { + result[i] = val * nbdev; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/statistic/var.rs b/vendor/ferro-ta-main/src/statistic/var.rs new file mode 100644 index 0000000..de81e12 --- /dev/null +++ b/vendor/ferro-ta-main/src/statistic/var.rs @@ -0,0 +1,26 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Rolling variance; scaled by nbdev². +#[pyfunction] +#[pyo3(signature = (close, timeperiod = 5, nbdev = 1.0))] +pub fn var<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, + nbdev: f64, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let prices = close.as_slice()?; + let n = prices.len(); + let mut result = vec![f64::NAN; n]; + for i in (timeperiod - 1)..n { + let window = &prices[(i + 1 - timeperiod)..=i]; + let mean: f64 = window.iter().sum::() / timeperiod as f64; + let variance: f64 = + window.iter().map(|x| (x - mean).powi(2)).sum::() / timeperiod as f64; + result[i] = variance * nbdev * nbdev; + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/streaming/mod.rs b/vendor/ferro-ta-main/src/streaming/mod.rs new file mode 100644 index 0000000..a0b3ab5 --- /dev/null +++ b/vendor/ferro-ta-main/src/streaming/mod.rs @@ -0,0 +1,385 @@ +//! Streaming / Incremental Indicators — bar-by-bar stateful classes. +//! +//! Thin PyO3 wrappers that delegate to `ferro_ta_core::streaming`. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use ferro_ta_core::streaming as core; + +// --------------------------------------------------------------------------- +// Helper: convert core StreamingError to PyValueError +// --------------------------------------------------------------------------- + +fn to_py_err(e: core::StreamingError) -> PyErr { + PyValueError::new_err(e.0) +} + +// --------------------------------------------------------------------------- +// StreamingSMA +// --------------------------------------------------------------------------- + +/// Simple Moving Average — O(1) per update via running sum. +/// +/// Returns NaN during the first `period - 1` bars. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingSMA { + inner: core::StreamingSMA, +} + +#[pymethods] +impl StreamingSMA { + #[new] + #[pyo3(signature = (period))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingSMA::new(period).map_err(to_py_err)?, + }) + } + + /// Add a new bar and return the current SMA (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + self.inner.update(value) + } + + /// Reset state to initial condition. + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingSMA(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingEMA +// --------------------------------------------------------------------------- + +/// Exponential Moving Average with SMA seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingEMA { + inner: core::StreamingEMA, +} + +#[pymethods] +impl StreamingEMA { + #[new] + #[pyo3(signature = (period))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingEMA::new(period).map_err(to_py_err)?, + }) + } + + /// Add a new bar and return the current EMA (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingEMA(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingRSI +// --------------------------------------------------------------------------- + +/// Relative Strength Index with TA-Lib–compatible Wilder seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingRSI { + inner: core::StreamingRSI, +} + +#[pymethods] +impl StreamingRSI { + #[new] + #[pyo3(signature = (period = 14))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingRSI::new(period).map_err(to_py_err)?, + }) + } + + /// Add a new close and return RSI in [0, 100] (NaN during warmup). + pub fn update(&mut self, value: f64) -> f64 { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingRSI(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingATR +// --------------------------------------------------------------------------- + +/// Average True Range with TA-Lib–compatible Wilder seeding. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingATR { + inner: core::StreamingATR, +} + +#[pymethods] +impl StreamingATR { + #[new] + #[pyo3(signature = (period = 14))] + pub fn new(period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingATR::new(period).map_err(to_py_err)?, + }) + } + + /// Add a new bar (high, low, close) and return ATR (NaN during warmup). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingATR(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingBBands +// --------------------------------------------------------------------------- + +/// Bollinger Bands — streaming variant using Welford's online algorithm. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingBBands { + inner: core::StreamingBBands, +} + +#[pymethods] +impl StreamingBBands { + #[new] + #[pyo3(signature = (period = 20, nbdevup = 2.0, nbdevdn = 2.0))] + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> PyResult { + Ok(Self { + inner: core::StreamingBBands::new(period, nbdevup, nbdevdn).map_err(to_py_err)?, + }) + } + + /// Add a new bar; return (upper, middle, lower). NaN tuple during warmup. + pub fn update(&mut self, value: f64) -> (f64, f64, f64) { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingBBands(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingMACD +// --------------------------------------------------------------------------- + +/// MACD — fast EMA, slow EMA, signal EMA. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingMACD { + inner: core::StreamingMACD, +} + +#[pymethods] +impl StreamingMACD { + #[new] + #[pyo3(signature = (fastperiod = 12, slowperiod = 26, signalperiod = 9))] + pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> PyResult { + Ok(Self { + inner: core::StreamingMACD::new(fastperiod, slowperiod, signalperiod) + .map_err(to_py_err)?, + }) + } + + /// Add a new close; return (macd_line, signal_line, histogram). + pub fn update(&mut self, value: f64) -> (f64, f64, f64) { + self.inner.update(value) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + format!( + "StreamingMACD(fastperiod={}, slowperiod={}, signalperiod={})", + self.inner.fast_period(), + self.inner.slow_period(), + self.inner.signal_period() + ) + } +} + +// --------------------------------------------------------------------------- +// StreamingStoch +// --------------------------------------------------------------------------- + +/// Slow Stochastic (SMA-smoothed). +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingStoch { + inner: core::StreamingStoch, +} + +#[pymethods] +impl StreamingStoch { + #[new] + #[pyo3(signature = (fastk_period = 5, slowk_period = 3, slowd_period = 3))] + pub fn new(fastk_period: usize, slowk_period: usize, slowd_period: usize) -> PyResult { + Ok(Self { + inner: core::StreamingStoch::new(fastk_period, slowk_period, slowd_period) + .map_err(to_py_err)?, + }) + } + + /// Add a new bar (high, low, close); return (slowk, slowd). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, f64) { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + format!("StreamingStoch(fastk_period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// StreamingVWAP +// --------------------------------------------------------------------------- + +/// Cumulative Volume Weighted Average Price. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingVWAP { + inner: core::StreamingVWAP, +} + +impl Default for StreamingVWAP { + fn default() -> Self { + Self { + inner: core::StreamingVWAP::new(), + } + } +} + +#[pymethods] +impl StreamingVWAP { + #[new] + pub fn new() -> Self { + Self::default() + } + + /// Add a new bar (high, low, close, volume) and return cumulative VWAP. + pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 { + self.inner.update(high, low, close, volume) + } + + /// Reset for a new session. + pub fn reset(&mut self) { + self.inner.reset(); + } + + fn __repr__(&self) -> String { + "StreamingVWAP()".to_string() + } +} + +// --------------------------------------------------------------------------- +// StreamingSupertrend +// --------------------------------------------------------------------------- + +/// ATR-based Supertrend — streaming variant. +#[pyclass(module = "ferro_ta._ferro_ta")] +pub struct StreamingSupertrend { + inner: core::StreamingSupertrend, +} + +#[pymethods] +impl StreamingSupertrend { + #[new] + #[pyo3(signature = (period = 7, multiplier = 3.0))] + pub fn new(period: usize, multiplier: f64) -> PyResult { + Ok(Self { + inner: core::StreamingSupertrend::new(period, multiplier).map_err(to_py_err)?, + }) + } + + /// Add a new bar (high, low, close); return (supertrend_line, direction). + pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, i8) { + self.inner.update(high, low, close) + } + + pub fn reset(&mut self) { + self.inner.reset(); + } + + #[getter] + pub fn period(&self) -> usize { + self.inner.period() + } + + fn __repr__(&self) -> String { + format!("StreamingSupertrend(period={})", self.inner.period()) + } +} + +// --------------------------------------------------------------------------- +// register +// --------------------------------------------------------------------------- + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/validation.rs b/vendor/ferro-ta-main/src/validation.rs new file mode 100644 index 0000000..783cb89 --- /dev/null +++ b/vendor/ferro-ta-main/src/validation.rs @@ -0,0 +1,57 @@ +//! Validation helpers used by PyO3 functions. They raise PyValueError with +//! messages that match the Python check_* helpers; the Python wrapper layer +//! converts these to FerroTAValueError / FerroTAInputError via _normalize_rust_error. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Parse a period parameter from Python (signed int). Validates >= minimum, +/// returns Ok(usize). Use this for PyO3 signatures that take `i64` so negative +/// or out-of-range values are caught in Rust with a clear error. +pub fn parse_timeperiod(value: i64, name: &str, minimum: i64) -> PyResult { + if value < minimum { + return Err(PyValueError::new_err(format!( + "{} must be >= {}, got {}", + name, minimum, value + ))); + } + let u = usize::try_from(value).map_err(|_| { + PyValueError::new_err(format!("{} must be >= {}, got {}", name, minimum, value)) + })?; + Ok(u) +} + +/// Validate that a period parameter is >= minimum. On failure raises PyValueError +/// (message format matches Python check_timeperiod; Python normalizes to FerroTAValueError). +pub fn validate_timeperiod(value: usize, name: &str, minimum: usize) -> PyResult<()> { + if value < minimum { + return Err(PyValueError::new_err(format!( + "{} must be >= {}, got {}", + name, minimum, value + ))); + } + Ok(()) +} + +/// Validate that all named lengths are equal. On failure raises PyValueError +/// (message includes "same length" so Python normalizes to FerroTAInputError). +pub fn validate_equal_length(lengths_and_names: &[(usize, &str)]) -> PyResult<()> { + if lengths_and_names.len() < 2 { + return Ok(()); + } + let first = lengths_and_names[0].0; + for (len, _name) in lengths_and_names.iter().skip(1) { + if *len != first { + let detail = lengths_and_names + .iter() + .map(|(l, n)| format!("{}={}", n, l)) + .collect::>() + .join(", "); + return Err(PyValueError::new_err(format!( + "All input arrays must have the same length. Got: {}", + detail + ))); + } + } + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/volatility/atr.rs b/vendor/ferro-ta-main/src/volatility/atr.rs new file mode 100644 index 0000000..7c84ff8 --- /dev/null +++ b/vendor/ferro-ta-main/src/volatility/atr.rs @@ -0,0 +1,31 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Average True Range using TA-Lib–compatible Wilder smoothing. +/// +/// Seeding: ATR[period] = SMA of TR[1..=period] (ignoring bar-0 TR which TA-Lib also skips). +/// Subsequent values: ATR[i] = (ATR[i-1] * (period-1) + TR[i]) / period. +/// Returns NaN for indices 0 through `timeperiod - 1`. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn atr<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let result = ferro_ta_core::volatility::atr(highs, lows, closes, timeperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/volatility/common.rs b/vendor/ferro-ta-main/src/volatility/common.rs new file mode 100644 index 0000000..7f8d914 --- /dev/null +++ b/vendor/ferro-ta-main/src/volatility/common.rs @@ -0,0 +1,17 @@ +/// Compute True Range for all bars. +/// Bar 0 uses H-L; subsequent bars use TA-Lib formula. +pub(super) fn compute_tr(highs: &[f64], lows: &[f64], closes: &[f64]) -> Vec { + let n = highs.len(); + let mut tr = vec![0.0_f64; n]; + if n == 0 { + return tr; + } + tr[0] = highs[0] - lows[0]; + for i in 1..n { + let hl = highs[i] - lows[i]; + let hpc = (highs[i] - closes[i - 1]).abs(); + let lpc = (lows[i] - closes[i - 1]).abs(); + tr[i] = hl.max(hpc).max(lpc); + } + tr +} diff --git a/vendor/ferro-ta-main/src/volatility/mod.rs b/vendor/ferro-ta-main/src/volatility/mod.rs new file mode 100644 index 0000000..2e5a233 --- /dev/null +++ b/vendor/ferro-ta-main/src/volatility/mod.rs @@ -0,0 +1,15 @@ +//! Volatility indicators — measure the magnitude of price fluctuations. +//! Each indicator lives in its own file for maintainability. + +mod atr; +mod natr; +mod trange; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::trange::trange, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::atr::atr, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::natr::natr, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/volatility/natr.rs b/vendor/ferro-ta-main/src/volatility/natr.rs new file mode 100644 index 0000000..9d1a824 --- /dev/null +++ b/vendor/ferro-ta-main/src/volatility/natr.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Normalized ATR: (ATR / close) * 100. Same warmup as ATR. +#[pyfunction] +#[pyo3(signature = (high, low, close, timeperiod = 14))] +pub fn natr<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + timeperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + // Reuse the ATR core; divide by close to get NATR (saves duplicate TR computation). + let atr_vals = ferro_ta_core::volatility::atr(highs, lows, closes, timeperiod); + let mut result = vec![f64::NAN; n]; + for i in timeperiod..n { + if !atr_vals[i].is_nan() && closes[i] != 0.0 { + result[i] = (atr_vals[i] / closes[i]) * 100.0; + } + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/volatility/trange.rs b/vendor/ferro-ta-main/src/volatility/trange.rs new file mode 100644 index 0000000..23c18e9 --- /dev/null +++ b/vendor/ferro-ta-main/src/volatility/trange.rs @@ -0,0 +1,34 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// True Range: max(high - low, |high - prev_close|, |low - prev_close|). Bar 0 uses high - low. +#[pyfunction] +pub fn trange<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + ])?; + let mut result = vec![f64::NAN; n]; + if n == 0 { + return Ok(result.into_pyarray(py)); + } + result[0] = highs[0] - lows[0]; + for i in 1..n { + let hl = highs[i] - lows[i]; + let hpc = (highs[i] - closes[i - 1]).abs(); + let lpc = (lows[i] - closes[i - 1]).abs(); + result[i] = hl.max(hpc).max(lpc); + } + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/volume/ad.rs b/vendor/ferro-ta-main/src/volume/ad.rs new file mode 100644 index 0000000..995cedd --- /dev/null +++ b/vendor/ferro-ta-main/src/volume/ad.rs @@ -0,0 +1,27 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// Chaikin Accumulation/Distribution Line. +#[pyfunction] +pub fn ad<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + let result = ferro_ta_core::volume::ad(highs, lows, closes, vols); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/volume/adosc.rs b/vendor/ferro-ta-main/src/volume/adosc.rs new file mode 100644 index 0000000..2a62874 --- /dev/null +++ b/vendor/ferro-ta-main/src/volume/adosc.rs @@ -0,0 +1,38 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD. +#[pyfunction] +#[pyo3(signature = (high, low, close, volume, fastperiod = 3, slowperiod = 10))] +pub fn adosc<'py>( + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + fastperiod: usize, + slowperiod: usize, +) -> PyResult>> { + validation::validate_timeperiod(fastperiod, "fastperiod", 1)?; + validation::validate_timeperiod(slowperiod, "slowperiod", 1)?; + if fastperiod >= slowperiod { + return Err(PyValueError::new_err( + "fastperiod must be less than slowperiod", + )); + } + let highs = high.as_slice()?; + let lows = low.as_slice()?; + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = highs.len(); + validation::validate_equal_length(&[ + (n, "high"), + (lows.len(), "low"), + (closes.len(), "close"), + (vols.len(), "volume"), + ])?; + let result = ferro_ta_core::volume::adosc(highs, lows, closes, vols, fastperiod, slowperiod); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/src/volume/mod.rs b/vendor/ferro-ta-main/src/volume/mod.rs new file mode 100644 index 0000000..253faaf --- /dev/null +++ b/vendor/ferro-ta-main/src/volume/mod.rs @@ -0,0 +1,15 @@ +//! Volume indicators — require volume data to measure buying and selling pressure. +//! Each indicator lives in its own file for maintainability. + +mod ad; +mod adosc; +mod obv; + +use pyo3::prelude::*; + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(pyo3::wrap_pyfunction!(self::ad::ad, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::adosc::adosc, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(self::obv::obv, m)?)?; + Ok(()) +} diff --git a/vendor/ferro-ta-main/src/volume/obv.rs b/vendor/ferro-ta-main/src/volume/obv.rs new file mode 100644 index 0000000..480e87a --- /dev/null +++ b/vendor/ferro-ta-main/src/volume/obv.rs @@ -0,0 +1,18 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +/// On Balance Volume: cumulates volume * sign(close - prev_close). +#[pyfunction] +pub fn obv<'py>( + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, +) -> PyResult>> { + let closes = close.as_slice()?; + let vols = volume.as_slice()?; + let n = closes.len(); + validation::validate_equal_length(&[(n, "close"), (vols.len(), "volume")])?; + let result = ferro_ta_core::volume::obv(closes, vols); + Ok(result.into_pyarray(py)) +} diff --git a/vendor/ferro-ta-main/tests/conftest.py b/vendor/ferro-ta-main/tests/conftest.py new file mode 100644 index 0000000..2906a5d --- /dev/null +++ b/vendor/ferro-ta-main/tests/conftest.py @@ -0,0 +1,101 @@ +""" +Shared pytest fixtures for all test modules. + +This module provides session-scoped fixtures to avoid duplicated data setup +across multiple test files. All fixtures use seeded RNG for reproducibility. +""" + +from __future__ import annotations + +import pathlib + +import numpy as np +import pandas as pd +import pytest + + +@pytest.fixture(scope="session") +def ohlcv_500(): + """500-bar seeded OHLCV data, always the same across all test files. + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 500. + + Seeded with RNG seed=42 for reproducibility. + """ + rng = np.random.default_rng(42) + n = 500 + + # Generate realistic price movement + close = 100.0 + np.cumsum(rng.standard_normal(n) * 0.5) + high = close + rng.uniform(0.1, 1.5, n) + low = close - rng.uniform(0.1, 1.5, n) + open_ = close + rng.standard_normal(n) * 0.3 + volume = rng.uniform(500.0, 5000.0, n) + + return { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + } + + +@pytest.fixture(scope="session") +def ohlcv_real(): + """Real data from tests/fixtures/ohlcv_daily.csv (252 bars). + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 252. + + This is real market data for integration testing. + """ + fixture_path = pathlib.Path(__file__).parent / "fixtures" / "ohlcv_daily.csv" + + if not fixture_path.exists(): + pytest.skip(f"Fixture file not found: {fixture_path}") + + df = pd.read_csv(fixture_path) + + # Ensure required columns exist + required_cols = ["open", "high", "low", "close", "volume"] + for col in required_cols: + if col not in df.columns: + pytest.skip(f"Required column '{col}' not found in fixture") + + return { + "open": df["open"].to_numpy(dtype=np.float64), + "high": df["high"].to_numpy(dtype=np.float64), + "low": df["low"].to_numpy(dtype=np.float64), + "close": df["close"].to_numpy(dtype=np.float64), + "volume": df["volume"].to_numpy(dtype=np.float64), + } + + +@pytest.fixture(scope="session") +def ohlcv_100(): + """100-bar seeded OHLCV data for quick tests. + + Returns a dictionary with keys: open, high, low, close, volume. + All arrays are numpy float64 arrays of length 100. + + Seeded with RNG seed=42 for reproducibility. + """ + rng = np.random.default_rng(42) + n = 100 + + # Generate realistic price movement + close = 44.0 + np.cumsum(rng.standard_normal(n) * 0.5) + high = close + rng.uniform(0.1, 1.0, n) + low = close - rng.uniform(0.1, 1.0, n) + open_ = close + rng.standard_normal(n) * 0.2 + volume = rng.uniform(500.0, 2000.0, n) + + return { + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + } diff --git a/vendor/ferro-ta-main/tests/fixtures/ohlcv_daily.csv b/vendor/ferro-ta-main/tests/fixtures/ohlcv_daily.csv new file mode 100644 index 0000000..6bc76de --- /dev/null +++ b/vendor/ferro-ta-main/tests/fixtures/ohlcv_daily.csv @@ -0,0 +1,253 @@ +bar,open,high,low,close,volume +1,100.0,101.0096,100.11,100.4871,628918 +2,100.4871,98.2117,97.514,97.5764,1546052 +3,97.5764,97.151,96.7285,97.1428,1250527 +4,97.1428,98.3378,97.7512,98.3053,1056197 +5,98.3053,99.4496,98.8416,99.0242,1831834 +6,99.0242,100.3838,100.2659,100.3587,595725 +7,100.3587,99.9711,99.287,99.3638,1516878 +8,99.3638,99.1319,98.6881,98.8687,1667575 +9,98.8687,99.7248,98.4449,99.5105,1049963 +10,99.5105,99.1776,98.4715,98.7757,1951264 +11,98.7757,100.5353,100.056,100.4781,995294 +12,100.4781,101.866,101.2132,101.4888,840364 +13,101.4888,100.6228,100.4475,100.5061,1847131 +14,100.5061,101.9639,101.5043,101.85,932492 +15,101.85,102.1313,101.6619,101.9838,1207350 +16,101.9838,101.7642,101.2011,101.5254,1557748 +17,101.5254,101.8928,100.699,101.1368,1481643 +18,101.1368,98.7793,98.5339,98.6142,1206644 +19,98.6142,99.8648,99.1162,99.5109,1404257 +20,99.5109,99.2747,98.7561,98.8506,546226 +21,98.8506,97.5383,96.5429,96.9888,1783506 +22,96.9888,97.5607,97.0174,97.2251,922075 +23,97.2251,97.7904,97.3347,97.4854,1126938 +24,97.4854,96.722,96.3625,96.5468,1721030 +25,96.5468,95.0748,94.6213,94.8439,1850932 +26,94.8439,95.7696,95.2384,95.5563,1251567 +27,95.5563,95.6458,95.4058,95.4438,1410794 +28,95.4438,94.0184,92.9349,93.4007,1042718 +29,93.4007,94.4143,93.8111,93.9888,1646344 +30,93.9888,93.8595,93.0782,93.5147,1953764 +31,93.5147,93.6976,93.0965,93.2546,691365 +32,93.2546,91.0637,90.7583,90.8663,1183664 +33,90.8663,90.7351,90.0513,90.0838,1239143 +34,90.0838,90.4351,89.701,90.4252,1579194 +35,90.4252,90.5889,90.0469,90.1277,1680329 +36,90.1277,92.3764,91.8281,91.9922,562421 +37,91.9922,94.598,93.7382,94.039,949989 +38,94.039,94.1611,93.2204,93.5174,1887680 +39,93.5174,93.9193,92.7603,93.2337,1200661 +40,93.2337,95.3766,93.058,94.4338,1674102 +41,94.4338,95.5194,94.0359,95.0491,1711396 +42,95.0491,94.1137,93.6312,93.9186,900463 +43,93.9186,94.2481,93.6748,93.7484,1314240 +44,93.7484,93.0932,92.0957,92.3202,710373 +45,92.3202,93.0891,92.2133,92.2734,981019 +46,92.2734,92.1528,91.168,91.61,661611 +47,91.61,91.6056,90.2687,90.6409,1190050 +48,90.6409,89.8571,89.2684,89.4405,1712377 +49,89.4405,89.2754,88.965,89.2572,771052 +50,89.2572,89.2182,88.1082,88.6748,746911 +51,88.6748,89.6331,88.8598,88.931,835126 +52,88.931,89.9223,89.2409,89.3389,1840169 +53,89.3389,89.3063,88.6247,88.8151,1604926 +54,88.8151,89.6312,88.4764,89.0858,1620184 +55,89.0858,92.1057,91.2367,91.3187,1620109 +56,91.3187,93.8646,92.9303,93.3479,768902 +57,93.3479,94.4627,94.2743,94.2767,1150548 +58,94.2767,95.4494,94.7394,94.7824,715846 +59,94.7824,96.7076,95.703,95.7263,1891253 +60,95.7263,94.4177,93.8479,94.0049,1541393 +61,94.0049,95.9568,95.31,95.3246,1150753 +62,95.3246,96.2843,95.8944,96.0517,685630 +63,96.0517,97.7877,96.8732,97.5253,1281534 +64,97.5253,96.6445,96.4194,96.5365,929669 +65,96.5365,97.3461,96.7773,96.8211,1623239 +66,96.8211,101.4495,100.0776,100.5064,1128671 +67,100.5064,100.8357,99.8076,100.1032,1770469 +68,100.1032,102.3156,101.6105,101.9439,526755 +69,101.9439,98.7712,98.1667,98.691,1899835 +70,98.691,102.2637,101.5465,101.9937,1801608 +71,101.9937,102.7755,101.1328,101.8801,1296524 +72,101.8801,100.4913,99.0253,99.9432,871759 +73,99.9432,104.9395,104.1362,104.3283,1036791 +74,104.3283,107.5932,106.9336,107.0649,719259 +75,107.0649,108.5809,108.1416,108.3454,1366123 +76,108.3454,106.2699,106.0021,106.1435,730420 +77,106.1435,106.8408,106.2938,106.545,1273993 +78,106.545,107.0132,106.7969,106.8253,1521071 +79,106.8253,107.243,106.6884,106.8575,1239625 +80,106.8575,112.5661,110.5238,111.4002,772247 +81,111.4002,112.5059,111.4549,112.0783,639981 +82,112.0783,112.8239,111.5719,112.5537,1750187 +83,112.5537,114.4126,113.5195,114.1532,981044 +84,114.1532,114.6691,114.4716,114.6391,1703038 +85,114.6391,114.6397,114.4082,114.4955,1950475 +86,114.4955,110.2522,110.0246,110.1218,1786358 +87,110.1218,110.8495,109.6719,110.6437,747681 +88,110.6437,114.4145,113.0533,113.5437,1125334 +89,113.5437,113.0529,112.4233,113.0183,853169 +90,113.0183,115.5919,114.2578,115.2561,945314 +91,115.2561,117.5838,116.3032,117.3263,1233879 +92,117.3263,118.9948,118.7791,118.8186,1478881 +93,118.8186,118.1932,116.9184,117.6113,1396167 +94,117.6113,117.9972,116.8698,117.3102,1542197 +95,117.3102,120.9896,119.6016,120.5491,1585124 +96,120.5491,118.773,117.5709,118.466,706111 +97,118.466,120.0525,119.0281,119.6344,635158 +98,119.6344,117.8814,117.3039,117.4872,880075 +99,117.4872,119.9224,118.1887,119.5584,1518914 +100,119.5584,119.9713,118.756,119.7235,1283947 +101,119.7235,116.3971,115.7113,116.0541,1678933 +102,116.0541,118.8769,118.156,118.6583,675655 +103,118.6583,118.1565,117.049,117.6777,1607346 +104,117.6777,118.2864,117.6168,118.1268,1847549 +105,118.1268,117.9907,117.6748,117.9008,629388 +106,117.9008,116.1367,115.2044,116.0641,1904191 +107,116.0641,115.2764,114.6883,114.8044,1419013 +108,114.8044,115.6217,114.3665,114.8585,1219054 +109,114.8585,116.6657,114.9893,116.3433,1816902 +110,116.3433,113.7872,112.6234,112.8818,1455610 +111,112.8818,113.8388,112.1922,113.0983,1146952 +112,113.0983,110.8541,110.173,110.4157,943038 +113,110.4157,111.4708,110.5307,111.317,1086389 +114,111.317,111.704,111.1852,111.4412,1995551 +115,111.4412,112.7259,112.2199,112.648,1521092 +116,112.648,113.9776,113.4754,113.5774,1845550 +117,113.5774,114.4142,113.7224,113.9468,933362 +118,113.9468,113.5933,113.0557,113.5266,1484403 +119,113.5266,110.8808,109.3502,110.1666,931699 +120,110.1666,109.4876,108.9235,109.0789,1774113 +121,109.0789,110.0312,108.7087,109.4352,1169825 +122,109.4352,108.9373,108.1551,108.205,560226 +123,108.205,107.1959,106.0871,106.8783,579538 +124,106.8783,106.7636,105.4215,106.3364,789562 +125,106.3364,104.3682,103.6217,104.1636,506568 +126,104.1636,104.942,103.5074,103.9901,563525 +127,103.9901,103.3527,102.5262,103.0775,1387944 +128,103.0775,103.8682,103.5995,103.7701,1187840 +129,103.7701,105.0537,104.1857,104.3645,1127552 +130,104.3645,106.2423,105.4754,106.0665,1367025 +131,106.0665,106.7783,106.5222,106.7083,1870573 +132,106.7083,110.103,109.9766,110.0648,1211253 +133,110.0648,112.3477,111.6883,111.8747,1101175 +134,111.8747,112.9035,111.5414,112.435,1451943 +135,112.435,109.5814,108.7227,108.9726,948006 +136,108.9726,112.7922,111.6323,112.1719,1127600 +137,112.1719,113.0201,112.6302,112.7906,1380725 +138,112.7906,114.0651,113.2662,113.8719,1478649 +139,113.8719,111.9591,110.5015,111.9556,1942656 +140,111.9556,114.4484,114.0638,114.396,1269108 +141,114.396,114.4303,113.8873,114.3329,868882 +142,114.3329,113.2826,112.2941,112.9068,971676 +143,112.9068,113.724,113.3037,113.5614,1231340 +144,113.5614,116.2439,114.9959,115.1891,759491 +145,115.1891,112.6195,111.0216,111.7839,1563763 +146,111.7839,111.442,108.9715,110.611,730419 +147,110.611,111.0706,109.5039,109.7425,1136705 +148,109.7425,111.4037,110.3767,110.541,966442 +149,110.541,110.7868,110.3012,110.7391,1634789 +150,110.7391,112.2675,111.3802,111.8825,1287632 +151,111.8825,115.2059,114.1409,114.6498,1702445 +152,114.6498,115.1578,114.1021,114.3551,1985482 +153,114.3551,116.9621,116.7344,116.8853,1219577 +154,116.8853,116.8333,116.0253,116.2861,1203990 +155,116.2861,118.0268,116.7367,117.11,791580 +156,117.11,120.5879,118.6928,119.1614,1235296 +157,119.1614,116.6627,116.0388,116.1827,1334259 +158,116.1827,115.8681,115.6583,115.776,1473723 +159,115.776,114.4624,113.6813,114.0593,1897091 +160,114.0593,115.2162,113.875,115.0456,1766718 +161,115.0456,115.1063,114.4969,114.9299,680249 +162,114.9299,113.3203,112.7791,112.8189,1775411 +163,112.8189,113.4475,112.9844,113.4185,512820 +164,113.4185,114.802,114.336,114.7105,1008283 +165,114.7105,115.7973,114.8509,115.6885,1349785 +166,115.6885,116.7006,116.0677,116.178,510971 +167,116.178,114.437,112.9682,113.6776,1977149 +168,113.6776,115.7091,113.6704,114.3314,1642148 +169,114.3314,114.4723,113.4552,113.8619,1260674 +170,113.8619,112.355,111.7547,111.8865,1598620 +171,111.8865,115.3245,114.4812,114.624,1732205 +172,114.624,112.5346,111.5815,111.8184,1600630 +173,111.8184,111.4297,110.8447,110.9788,987890 +174,110.9788,112.1931,111.1572,111.87,1966875 +175,111.87,111.2033,110.5506,110.8505,1166710 +176,110.8505,112.5696,111.3285,111.6288,1733874 +177,111.6288,110.0597,109.1479,109.9304,1485539 +178,109.9304,109.33,108.2456,108.6334,1660581 +179,108.6334,109.3945,108.2684,109.0597,850479 +180,109.0597,109.2917,108.8068,109.145,1787187 +181,109.145,110.2423,109.7227,109.949,1415919 +182,109.949,109.572,107.3938,108.2658,1507419 +183,108.2658,112.6662,111.3495,112.4384,1173457 +184,112.4384,112.0957,111.366,111.9894,1095410 +185,111.9894,113.2929,112.1522,112.9966,1137332 +186,112.9966,116.6098,116.3131,116.5097,1711147 +187,116.5097,116.6542,115.2901,116.2964,590435 +188,116.2964,116.7187,115.5495,115.6502,1088553 +189,115.6502,119.2327,118.2301,118.5783,1332214 +190,118.5783,117.1719,116.3583,117.0681,526503 +191,117.0681,117.5205,116.5244,116.5346,1254666 +192,116.5346,116.0185,114.9821,115.9712,1311284 +193,115.9712,113.4682,112.8995,113.1954,1975988 +194,113.1954,114.718,114.1531,114.562,1613476 +195,114.562,112.8029,112.1372,112.3335,1020523 +196,112.3335,113.2535,112.797,113.0161,1811471 +197,113.0161,113.2275,112.4145,112.767,1924901 +198,112.767,112.1626,111.6509,111.919,1992052 +199,111.919,114.1288,113.0203,113.2801,978423 +200,113.2801,113.9877,113.3059,113.8478,1933024 +201,113.8478,110.4914,109.6061,110.0366,576872 +202,110.0366,108.9754,108.37,108.479,869099 +203,108.479,110.4578,109.754,110.3226,1453345 +204,110.3226,112.6389,111.5816,112.0919,1076756 +205,112.0919,113.1899,112.6986,113.0647,727226 +206,113.0647,114.8801,114.2817,114.2886,502491 +207,114.2886,114.6545,113.0708,113.9617,1115201 +208,113.9617,113.9756,113.3414,113.3959,1146990 +209,113.3959,116.664,114.4855,115.3486,1794909 +210,115.3486,118.078,117.4591,117.9114,1524887 +211,117.9114,116.5792,115.6707,115.9302,787035 +212,115.9302,117.029,116.7566,116.9192,725681 +213,116.9192,117.6875,116.7586,117.2316,730334 +214,117.2316,113.8826,112.6421,113.315,665944 +215,113.315,112.6522,111.559,111.6142,921000 +216,111.6142,114.9533,114.4442,114.6573,1780054 +217,114.6573,113.1621,112.9863,113.1556,1335402 +218,113.1556,117.7083,116.0043,116.667,1181686 +219,116.667,115.5825,114.7224,115.0905,669450 +220,115.0905,117.655,116.8484,117.2824,1401100 +221,117.2824,118.6399,117.3818,118.4381,1075002 +222,118.4381,118.2373,116.4975,117.4855,528551 +223,117.4855,121.3871,120.4138,120.5677,721909 +224,120.5677,120.748,119.9662,120.6257,1390745 +225,120.6257,121.4168,120.5896,121.3802,897482 +226,121.3802,122.3614,120.7292,121.2714,506011 +227,121.2714,121.4795,121.3018,121.3286,1122569 +228,121.3286,119.4852,118.9497,119.3951,1304300 +229,119.3951,117.4912,116.9624,117.1917,1965063 +230,117.1917,117.5937,116.3915,117.0112,1754367 +231,117.0112,118.8699,118.3446,118.8468,777871 +232,118.8468,119.7234,118.7276,119.4968,1330311 +233,119.4968,121.5846,120.7484,121.5043,724676 +234,121.5043,122.0598,121.5843,121.9366,1128908 +235,121.9366,122.7443,122.0513,122.4358,1658325 +236,122.4358,121.8828,121.5679,121.7342,1834484 +237,121.7342,119.9781,118.8297,119.6901,1879863 +238,119.6901,120.9963,119.0242,120.2973,1788474 +239,120.2973,116.9908,116.9557,116.9738,1119501 +240,116.9738,120.7315,118.9034,119.1393,1275529 +241,119.1393,122.6532,121.7338,121.9654,1549235 +242,121.9654,120.5622,119.5067,120.3658,1798358 +243,120.3658,123.9271,122.5409,123.7193,1982033 +244,123.7193,127.0702,126.9277,127.0109,1065717 +245,127.0109,129.7757,127.2016,127.7453,1077013 +246,127.7453,129.875,129.323,129.6404,1113709 +247,129.6404,131.4666,131.2631,131.4462,1653376 +248,131.4462,132.4301,130.8694,132.1493,696663 +249,132.1493,134.1658,132.5474,133.5102,1209319 +250,133.5102,134.1821,133.1628,133.7184,1630256 +251,133.7184,131.3331,130.6832,131.0665,1182053 +252,131.0665,132.3175,131.1772,131.654,889159 diff --git a/vendor/ferro-ta-main/tests/integration/conftest.py b/vendor/ferro-ta-main/tests/integration/conftest.py new file mode 100644 index 0000000..d06a4a1 --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/conftest.py @@ -0,0 +1,7 @@ +""" +Integration test conftest — inherits shared fixtures from tests/conftest.py. + +pytest automatically loads parent conftest.py files, so all fixtures +defined in tests/conftest.py (ohlcv_500, ohlcv_100, ohlcv_real) are +available here without any explicit import. +""" diff --git a/vendor/ferro-ta-main/tests/integration/test_cross_surface_manifest.py b/vendor/ferro-ta-main/tests/integration/test_cross_surface_manifest.py new file mode 100644 index 0000000..653593d --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_cross_surface_manifest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +if str(ROOT / "python") not in sys.path: + sys.path.insert(0, str(ROOT / "python")) +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from build_api_manifest import build_manifest + + +def test_api_manifest_is_deterministic_and_current() -> None: + manifest_path = ROOT / "docs" / "api_manifest.json" + assert manifest_path.exists(), "docs/api_manifest.json is missing" + + expected = build_manifest(ROOT, include_runtime_metadata=False) + actual = json.loads(manifest_path.read_text(encoding="utf-8")) + + assert actual == expected diff --git a/vendor/ferro-ta-main/tests/integration/test_integration.py b/vendor/ferro-ta-main/tests/integration/test_integration.py new file mode 100644 index 0000000..301d133 --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_integration.py @@ -0,0 +1,341 @@ +""" +Integration tests using the synthetic OHLCV fixture in tests/fixtures/. + +These tests verify that: +- All major indicator categories produce finite output on realistic data. +- Output lengths match the input length. +- Error codes and suggestion hints are included in exception messages. +- ferro_ta.indicators() and ferro_ta.info() work correctly. +- Logging utilities (enable_debug, log_call, benchmark) work correctly. +""" + +from __future__ import annotations + +import csv +import logging +from pathlib import Path + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Load the OHLCV fixture +# --------------------------------------------------------------------------- + +FIXTURE_PATH = Path(__file__).parent.parent / "fixtures" / "ohlcv_daily.csv" + + +def _load_fixture() -> tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray +]: + """Return (open, high, low, close, volume) as float64 arrays.""" + rows = [] + with open(FIXTURE_PATH, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + rows.append(row) + open_ = np.array([float(r["open"]) for r in rows]) + high = np.array([float(r["high"]) for r in rows]) + low = np.array([float(r["low"]) for r in rows]) + close = np.array([float(r["close"]) for r in rows]) + volume = np.array([float(r["volume"]) for r in rows]) + return open_, high, low, close, volume + + +@pytest.fixture(scope="module") +def ohlcv(): + return _load_fixture() + + +# --------------------------------------------------------------------------- +# Fixture sanity +# --------------------------------------------------------------------------- + + +def test_fixture_loads(ohlcv): + o, h, l, c, v = ohlcv + assert len(c) == 252 + assert np.all(h >= l) + assert np.all(v > 0) + + +# --------------------------------------------------------------------------- +# Overlap indicators on real OHLCV data +# --------------------------------------------------------------------------- + + +def test_sma_on_fixture(ohlcv): + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + result = SMA(close, timeperiod=20) + assert len(result) == len(close) + # First 19 values should be NaN, rest finite + assert np.all(np.isnan(result[:19])) + assert np.all(np.isfinite(result[19:])) + + +def test_ema_on_fixture(ohlcv): + from ferro_ta import EMA + + _, _, _, close, _ = ohlcv + result = EMA(close, timeperiod=14) + assert len(result) == len(close) + assert np.all(np.isfinite(result[13:])) + + +def test_bbands_on_fixture(ohlcv): + from ferro_ta import BBANDS + + _, _, _, close, _ = ohlcv + upper, mid, lower = BBANDS(close, timeperiod=20) + assert len(upper) == len(close) + assert np.all(upper[19:] >= mid[19:]) + assert np.all(mid[19:] >= lower[19:]) + + +# --------------------------------------------------------------------------- +# Momentum indicators +# --------------------------------------------------------------------------- + + +def test_rsi_on_fixture(ohlcv): + from ferro_ta import RSI + + _, _, _, close, _ = ohlcv + result = RSI(close, timeperiod=14) + assert len(result) == len(close) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + +def test_macd_on_fixture(ohlcv): + from ferro_ta import MACD + + _, _, _, close, _ = ohlcv + macd, signal, hist = MACD(close) + assert len(macd) == len(close) + + +def test_adx_on_fixture(ohlcv): + from ferro_ta import ADX + + _, high, low, close, _ = ohlcv + result = ADX(high, low, close, timeperiod=14) + assert len(result) == len(close) + + +def test_stoch_on_fixture(ohlcv): + from ferro_ta import STOCH + + _, high, low, close, _ = ohlcv + slowk, slowd = STOCH(high, low, close) + assert len(slowk) == len(close) + + +# --------------------------------------------------------------------------- +# Volatility indicators +# --------------------------------------------------------------------------- + + +def test_atr_on_fixture(ohlcv): + from ferro_ta import ATR + + _, high, low, close, _ = ohlcv + result = ATR(high, low, close, timeperiod=14) + assert len(result) == len(close) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + +# --------------------------------------------------------------------------- +# Volume indicators +# --------------------------------------------------------------------------- + + +def test_obv_on_fixture(ohlcv): + from ferro_ta import OBV + + _, _, _, close, volume = ohlcv + result = OBV(close, volume) + assert len(result) == len(close) + assert np.all(np.isfinite(result)) + + +# --------------------------------------------------------------------------- +# Error handling — error codes and suggestion hints +# --------------------------------------------------------------------------- + + +def test_value_error_has_code(): + from ferro_ta.core.exceptions import FerroTAValueError, check_timeperiod + + with pytest.raises(FerroTAValueError) as exc_info: + check_timeperiod(0, "timeperiod", minimum=1) + exc = exc_info.value + assert exc.code == "FTERR001" + assert "FTERR001" in str(exc) + assert exc.suggestion is not None + assert "Suggestion" in str(exc) + + +def test_input_error_length_mismatch_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length + + with pytest.raises(FerroTAInputError) as exc_info: + check_equal_length(open=np.array([1.0, 2.0]), close=np.array([1.0])) + exc = exc_info.value + assert exc.code == "FTERR004" + assert "Suggestion" in str(exc) + + +def test_input_error_too_short_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_min_length + + with pytest.raises(FerroTAInputError) as exc_info: + check_min_length(np.array([1.0]), 10, "close") + exc = exc_info.value + assert exc.code == "FTERR003" + assert "Suggestion" in str(exc) + + +def test_finite_check_error_has_code(): + from ferro_ta.core.exceptions import FerroTAInputError, check_finite + + arr = np.array([1.0, float("nan"), 3.0]) + with pytest.raises(FerroTAInputError) as exc_info: + check_finite(arr, "close") + exc = exc_info.value + assert exc.code == "FTERR005" + assert "Suggestion" in str(exc) + + +# --------------------------------------------------------------------------- +# API discovery +# --------------------------------------------------------------------------- + + +def test_indicators_returns_list(): + import ferro_ta + + result = ferro_ta.indicators() + assert isinstance(result, list) + assert len(result) > 20 + names = [d["name"] for d in result] + assert "SMA" in names + assert "RSI" in names + assert "ATR" in names + + +def test_methods_returns_public_callables(): + import ferro_ta + + result = ferro_ta.methods() + assert isinstance(result, list) + assert any(d["name"] == "SMA" and d["category"] == "top_level" for d in result) + assert any( + d["name"] == "option_price" and d["category"] == "options" for d in result + ) + + +def test_about_reports_version_and_counts(): + import ferro_ta + + meta = ferro_ta.about() + assert meta["version"] == ferro_ta.__version__ + assert meta["indicator_count"] > 20 + assert meta["method_count"] >= meta["indicator_count"] + assert "__version__" in meta["top_level_exports"] + + +def test_indicators_filter_by_category(): + import ferro_ta + + overlap = ferro_ta.indicators(category="overlap") + assert all(d["category"] == "overlap" for d in overlap) + assert any(d["name"] == "SMA" for d in overlap) + + +def test_info_by_function(): + import ferro_ta + + d = ferro_ta.info(ferro_ta.SMA) + assert d["name"] == "SMA" + assert "close" in d["params"] + assert "timeperiod" in d["params"] + assert isinstance(d["doc"], str) + + +def test_info_by_string(): + import ferro_ta + + d = ferro_ta.info("EMA") + assert d["name"] == "EMA" + + +def test_info_unknown_raises(): + import ferro_ta + + with pytest.raises(ValueError, match="No indicator named"): + ferro_ta.info("DOES_NOT_EXIST") + + +# --------------------------------------------------------------------------- +# Logging utilities +# --------------------------------------------------------------------------- + + +def test_get_logger_returns_logger(): + import ferro_ta + + logger = ferro_ta.get_logger() + assert isinstance(logger, logging.Logger) + assert logger.name == "ferro_ta" + + +def test_enable_disable_debug(): + import ferro_ta + + ferro_ta.enable_debug() + assert ferro_ta.get_logger().level == logging.DEBUG + ferro_ta.disable_debug() + assert ferro_ta.get_logger().level == logging.WARNING + + +def test_debug_mode_context_manager(): + import ferro_ta + + with ferro_ta.debug_mode() as logger: + assert logger.level == logging.DEBUG + # After context, should be restored + assert ferro_ta.get_logger().level == logging.WARNING + + +def test_log_call_returns_result(ohlcv): + import ferro_ta + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + result = ferro_ta.log_call(SMA, close, timeperiod=10) + assert len(result) == len(close) + + +def test_benchmark_returns_stats(ohlcv): + import ferro_ta + from ferro_ta import SMA + + _, _, _, close, _ = ohlcv + stats = ferro_ta.benchmark(SMA, close, timeperiod=10, n=5, warmup=1) + assert "mean_ms" in stats + assert stats["mean_ms"] > 0 + assert stats["n"] == 5 + + +def test_traced_decorator(): + import ferro_ta + + @ferro_ta.traced + def dummy(x): + return x * 2 + + assert dummy(21) == 42 diff --git a/vendor/ferro-ta-main/tests/integration/test_streaming_accuracy.py b/vendor/ferro-ta-main/tests/integration/test_streaming_accuracy.py new file mode 100644 index 0000000..ea3f67e --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_streaming_accuracy.py @@ -0,0 +1,540 @@ +""" +Streaming accuracy tests: bar-by-bar == batch (Priority 3 - no optional deps). + +Core claim: "bar-by-bar streaming == batch." Any divergence is a genuine bug. + +This module validates that streaming (incremental) and batch (vectorized) modes +produce identical results within strict tolerances. + +Pattern for each test: +1. Compute batch: batch_out = ferro_ta.INDICATOR(...) +2. Feed bar-by-bar: streamer = StreamingINDICATOR(...); [streamer.update(...) for bar in data] +3. Assert: np.allclose(stream_arr, batch_arr, equal_nan=True, atol=1e-12) + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ferro_ta +from ferro_ta.data.streaming import ( + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, +) + +# --------------------------------------------------------------------------- +# Test Data (seeded for reproducibility) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 + +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +HIGH = CLOSE + RNG.uniform(0.1, 1.0, N) +LOW = CLOSE - RNG.uniform(0.1, 1.0, N) +OPEN = CLOSE + RNG.standard_normal(N) * 0.2 +VOLUME = RNG.uniform(500.0, 2000.0, N) + + +# --------------------------------------------------------------------------- +# StreamingSMA Tests +# --------------------------------------------------------------------------- + + +class TestStreamingSMA: + """StreamingSMA vs ferro_ta.SMA — atol=1e-12 (identical arithmetic).""" + + @pytest.mark.parametrize("period", [5, 10, 20, 50]) + def test_streaming_matches_batch(self, period): + """Streaming SMA should match batch SMA exactly.""" + # Batch + batch_out = ferro_ta.SMA(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingSMA(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-12) + + def test_warmup_produces_nan(self): + """First period-1 updates should return NaN.""" + period = 10 + streamer = StreamingSMA(period=period) + + for i in range(period - 1): + val = streamer.update(CLOSE[i]) + assert np.isnan(val), f"Expected NaN at index {i}, got {val}" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 10 + streamer = StreamingSMA(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingEMA Tests +# --------------------------------------------------------------------------- + + +class TestStreamingEMA: + """StreamingEMA vs ferro_ta.EMA — atol=1e-12 (same recursive formula, same seed).""" + + @pytest.mark.parametrize("period", [5, 10, 20, 50]) + def test_streaming_matches_batch(self, period): + """Streaming EMA should match batch EMA exactly.""" + # Batch + batch_out = ferro_ta.EMA(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingEMA(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-12) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 10 + streamer = StreamingEMA(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingRSI Tests +# --------------------------------------------------------------------------- + + +class TestStreamingRSI: + """StreamingRSI vs ferro_ta.RSI — atol=1e-10; also verify range [0, 100].""" + + @pytest.mark.parametrize("period", [7, 14, 21]) + def test_streaming_matches_batch(self, period): + """Streaming RSI should match batch RSI.""" + # Batch + batch_out = ferro_ta.RSI(CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingRSI(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_rsi_range_zero_to_hundred(self): + """RSI values should be in range [0, 100].""" + period = 14 + streamer = StreamingRSI(period=period) + stream_out = np.array([streamer.update(c) for c in CLOSE]) + + # Filter out NaN values + valid = stream_out[~np.isnan(stream_out)] + + assert np.all(valid >= 0.0), "RSI should be >= 0" + assert np.all(valid <= 100.0), "RSI should be <= 100" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 14 + streamer = StreamingRSI(period=period) + + # First pass + first_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + # Reset and second pass + streamer.reset() + second_pass = np.array([streamer.update(c) for c in CLOSE[:50]]) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-12) + + +# --------------------------------------------------------------------------- +# StreamingATR Tests +# --------------------------------------------------------------------------- + + +class TestStreamingATR: + """StreamingATR vs ferro_ta.ATR — atol=1e-10; verify positive values.""" + + @pytest.mark.parametrize("period", [7, 14, 21]) + def test_streaming_matches_batch(self, period): + """Streaming ATR should match batch ATR in the converged (post-warmup) region. + + Note: streaming ATR uses a different initialization seed than batch ATR, so + values may differ during the early warmup bars. The tail (last 30%) converges + to identical values. We compare the full overlap region with atol=0.05 to + capture any remaining seeding difference without false-positives. + """ + # Batch + batch_out = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=period) + + # Streaming + streamer = StreamingATR(period=period) + stream_out = np.array( + [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + ) + + # Compare only the overlap region where both arrays are valid + mask = np.isfinite(batch_out) & np.isfinite(stream_out) + assert np.allclose(stream_out[mask], batch_out[mask], atol=0.05) + """ATR values should be non-negative.""" + period = 14 + streamer = StreamingATR(period=period) + stream_out = np.array( + [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + ) + + # Filter out NaN values + valid = stream_out[~np.isnan(stream_out)] + + assert np.all(valid >= 0.0), "ATR should be non-negative" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 14 + streamer = StreamingATR(period=period) + + # First pass + first_pass = np.array( + [ + streamer.update(h, l, c) + for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + ) + + # Reset and second pass + streamer.reset() + second_pass = np.array( + [ + streamer.update(h, l, c) + for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + ) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-12) + + +# --------------------------------------------------------------------------- +# StreamingBBands Tests +# --------------------------------------------------------------------------- + + +class TestStreamingBBands: + """StreamingBBands vs ferro_ta.BBANDS — atol=1e-10 for all 3 bands.""" + + @pytest.mark.parametrize("period", [10, 20, 30]) + def test_streaming_matches_batch(self, period): + """Streaming BBands middle band matches batch exactly; bands within expected range. + + Note: the streaming BBands Rust implementation uses sample std (ddof=1) while + the batch BBANDS (TA-Lib convention) uses population std (ddof=0). The middle + band (SMA) is identical. Upper/lower differ by a ~sqrt(N/(N-1)) factor; we + verify proximity with atol=0.2 and confirm internal consistency separately. + """ + # Batch + batch_upper, batch_middle, batch_lower = ferro_ta.BBANDS( + CLOSE, timeperiod=period + ) + + # Streaming + streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0) + stream_results = [streamer.update(c) for c in CLOSE] + stream_upper = np.array([r[0] for r in stream_results]) + stream_middle = np.array([r[1] for r in stream_results]) + stream_lower = np.array([r[2] for r in stream_results]) + + # Compare only overlapping valid region + mask = np.isfinite(batch_middle) + # Middle band (SMA) must match exactly + assert np.allclose(stream_middle[mask], batch_middle[mask], atol=1e-10), ( + "BBands middle (SMA) must match batch exactly" + ) + # Upper/lower: streaming uses sample std; batch uses population std — use atol=0.2 + assert np.allclose(stream_upper[mask], batch_upper[mask], atol=0.2) + assert np.allclose(stream_lower[mask], batch_lower[mask], atol=0.2) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 20 + streamer = StreamingBBands(period=period, nbdevup=2.0, nbdevdn=2.0) + + # First pass + first_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Reset and second pass + streamer.reset() + second_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Compare all three bands + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingMACD Tests +# --------------------------------------------------------------------------- + + +class TestStreamingMACD: + """StreamingMACD vs ferro_ta.MACD — atol=1e-10; also verify histogram identity.""" + + def test_streaming_matches_batch(self): + """Streaming MACD should match batch MACD.""" + # Batch + batch_macd, batch_signal, batch_hist = ferro_ta.MACD( + CLOSE, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # Streaming + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + stream_results = [streamer.update(c) for c in CLOSE] + stream_macd = np.array([r[0] for r in stream_results]) + stream_signal = np.array([r[1] for r in stream_results]) + stream_hist = np.array([r[2] for r in stream_results]) + + # Streaming MACD starts computing sooner (fewer NaN warmup bars due to EMA seeding). + # Values where batch is valid are identical to batch values within floating-point. + mask = np.isfinite(batch_macd) + assert np.allclose(stream_macd[mask], batch_macd[mask], atol=1e-8) + assert np.allclose(stream_signal[mask], batch_signal[mask], atol=1e-8) + assert np.allclose(stream_hist[mask], batch_hist[mask], atol=1e-8) + + def test_histogram_identity(self): + """histogram should always equal macd - signal.""" + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + stream_results = [streamer.update(c) for c in CLOSE] + stream_macd = np.array([r[0] for r in stream_results]) + stream_signal = np.array([r[1] for r in stream_results]) + stream_hist = np.array([r[2] for r in stream_results]) + + expected_hist = stream_macd - stream_signal + assert np.allclose(stream_hist, expected_hist, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingMACD(fastperiod=12, slowperiod=26, signalperiod=9) + + # First pass + first_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Reset and second pass + streamer.reset() + second_pass = [streamer.update(c) for c in CLOSE[:50]] + + # Compare all three outputs + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingStoch Tests +# --------------------------------------------------------------------------- + + +class TestStreamingStoch: + """StreamingStoch vs ferro_ta.STOCH — atol=1e-10; verify [0, 100] range.""" + + def test_streaming_matches_batch(self): + """Streaming Stochastic should match batch Stochastic.""" + # Batch + batch_slowk, batch_slowd = ferro_ta.STOCH( + HIGH, LOW, CLOSE, fastk_period=5, slowk_period=3, slowd_period=3 + ) + + # Streaming + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_slowk = np.array([r[0] for r in stream_results]) + stream_slowd = np.array([r[1] for r in stream_results]) + + # Streaming Stoch starts computing sooner (fewer NaN warmup bars). + # Values where batch is valid match exactly. + mask = np.isfinite(batch_slowk) + assert np.allclose(stream_slowk[mask], batch_slowk[mask], atol=1e-8) + assert np.allclose(stream_slowd[mask], batch_slowd[mask], atol=1e-8) + + def test_stoch_range_zero_to_hundred(self): + """Stochastic values should be in range [0, 100].""" + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_slowk = np.array([r[0] for r in stream_results]) + stream_slowd = np.array([r[1] for r in stream_results]) + + # Filter out NaN values + valid_k = stream_slowk[~np.isnan(stream_slowk)] + valid_d = stream_slowd[~np.isnan(stream_slowd)] + + assert np.all(valid_k >= 0.0), "slowk should be >= 0" + assert np.all(valid_k <= 100.0), "slowk should be <= 100" + assert np.all(valid_d >= 0.0), "slowd should be >= 0" + assert np.all(valid_d <= 100.0), "slowd should be <= 100" + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingStoch(fastk_period=5, slowk_period=3, slowd_period=3) + + # First pass + first_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Reset and second pass + streamer.reset() + second_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Compare + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) + + +# --------------------------------------------------------------------------- +# StreamingVWAP Tests +# --------------------------------------------------------------------------- + + +class TestStreamingVWAP: + """StreamingVWAP vs ferro_ta.VWAP — atol=1e-10.""" + + def test_streaming_matches_batch_cumulative(self): + """Streaming VWAP (cumulative) should match batch VWAP.""" + # Batch (cumulative: timeperiod=0) + batch_out = ferro_ta.VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=0) + + # Streaming (cumulative) + streamer = StreamingVWAP() + stream_out = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) + ] + ) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_streaming_matches_batch_rolling(self): + """Streaming VWAP (cumulative) matches batch cumulative VWAP.""" + # StreamingVWAP is cumulative only; compare against batch cumulative + batch_out = ferro_ta.VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=0) + + # Streaming (cumulative) + streamer = StreamingVWAP() + stream_out = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH, LOW, CLOSE, VOLUME) + ] + ) + + # Compare + assert np.allclose(stream_out, batch_out, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + streamer = StreamingVWAP() + + # First pass + first_pass = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50]) + ] + ) + + # Reset and second pass + streamer.reset() + second_pass = np.array( + [ + streamer.update(h, l, c, v) + for h, l, c, v in zip(HIGH[:50], LOW[:50], CLOSE[:50], VOLUME[:50]) + ] + ) + + assert np.allclose(first_pass, second_pass, equal_nan=True, atol=1e-14) + + +# --------------------------------------------------------------------------- +# StreamingSupertrend Tests +# --------------------------------------------------------------------------- + + +class TestStreamingSupertrend: + """StreamingSupertrend vs ferro_ta.SUPERTREND — atol=1e-10.""" + + def test_streaming_matches_batch(self): + """Streaming SUPERTREND should match batch SUPERTREND.""" + period = 7 + multiplier = 3.0 + + # Batch + batch_line, batch_dir = ferro_ta.SUPERTREND( + HIGH, LOW, CLOSE, timeperiod=period, multiplier=multiplier + ) + + # Streaming + streamer = StreamingSupertrend(period=period, multiplier=multiplier) + stream_results = [streamer.update(h, l, c) for h, l, c in zip(HIGH, LOW, CLOSE)] + stream_line = np.array([r[0] for r in stream_results]) + stream_dir = np.array([r[1] for r in stream_results]) + + # Compare + assert np.allclose(stream_line, batch_line, equal_nan=True, atol=1e-10) + assert np.allclose(stream_dir, batch_dir, equal_nan=True, atol=1e-10) + + def test_reset_gives_same_result(self): + """Reset and re-feed should give identical output.""" + period = 7 + multiplier = 3.0 + streamer = StreamingSupertrend(period=period, multiplier=multiplier) + + # First pass + first_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Reset and second pass + streamer.reset() + second_pass = [ + streamer.update(h, l, c) for h, l, c in zip(HIGH[:50], LOW[:50], CLOSE[:50]) + ] + + # Compare + for i in range(len(first_pass)): + assert np.allclose( + first_pass[i], second_pass[i], equal_nan=True, atol=1e-14 + ) diff --git a/vendor/ferro-ta-main/tests/integration/test_vs_pandas_ta.py b/vendor/ferro-ta-main/tests/integration/test_vs_pandas_ta.py new file mode 100644 index 0000000..0b1f66a --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_vs_pandas_ta.py @@ -0,0 +1,711 @@ +""" +Comparison tests: ferro_ta vs pandas-ta (Priority 4 - requires pandas-ta). + +This module validates ferro_ta against pandas-ta for indicators, using 500-bar data +for proper convergence of EMA-seeded indicators. Documents known formula differences +and expected tolerances. + +Requirements +------------ +Install pandas-ta before running these tests:: + + pip install pandas-ta + +The tests are automatically skipped when pandas-ta is not installed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when pandas-ta is not available +# --------------------------------------------------------------------------- + +pandas_ta = pytest.importorskip( + "pandas_ta", reason="pandas-ta not installed; skipping comparison tests" +) +pd = pytest.importorskip("pandas", reason="pandas required for pandas-ta") + +import ferro_ta # noqa: E402 + +# --------------------------------------------------------------------------- +# Shared test data from conftest.py +# --------------------------------------------------------------------------- + +# Use shared 500-bar fixture from conftest.py + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _nan_count(arr: np.ndarray) -> int: + """Return count of NaN values.""" + return int(np.sum(np.isnan(arr))) + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose( + a: np.ndarray, b: np.ndarray, atol: float = 1e-6, tail_fraction: float = 1.0 +) -> bool: + """Compare arrays within tolerance, optionally only comparing tail. + + Parameters + ---------- + a, b : np.ndarray + Arrays to compare + atol : float + Absolute tolerance + tail_fraction : float + Fraction of tail to compare (1.0 = all, 0.3 = last 30%) + + Returns + ------- + bool + True if arrays match within tolerance + """ + mask = _valid_mask(a, b) + if not mask.any(): + return False + + if tail_fraction < 1.0: + # Only compare last tail_fraction of data + n = len(a) + start_idx = int(n * (1 - tail_fraction)) + mask[:start_idx] = False + + if not mask.any(): + return False + + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMAVsPandasTA: + """SMA — Exact match (deterministic).""" + + def test_sma_exact_match(self, ohlcv_500): + """SMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.SMA(close, timeperiod=period) + pt = pandas_ta.sma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestEMAVsPandasTA: + """EMA — Tail 30% match (seed difference). + + ferro_ta starts EMA from bar 0, pandas-ta may use SMA seed. + After 350+ bars of decay, values should converge. + """ + + def test_ema_tail_convergence(self, ohlcv_500): + """EMA should converge in tail 30% of data.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.EMA(close, timeperiod=period) + pt = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + + # Compare only last 30% + assert _allclose(ft, pt, atol=1e-4, tail_fraction=0.3) + + def test_ema_shorter_period_tighter(self, ohlcv_500): + """Shorter period EMA should have tighter convergence.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.EMA(close, timeperiod=period) + pt = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + + # Shorter period converges faster + assert _allclose(ft, pt, atol=1e-5, tail_fraction=0.3) + + +class TestWMAVsPandasTA: + """WMA — Exact match (deterministic).""" + + def test_wma_exact_match(self, ohlcv_500): + """WMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.WMA(close, timeperiod=period) + pt = pandas_ta.wma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestBBANDSVsPandasTA: + """BBANDS — Approximate match (ferro_ta uses population std; pandas-ta uses sample std).""" + + def test_bbands_approximate_match(self, ohlcv_500): + """BBANDS middle band matches exactly; upper/lower match within std-formula tolerance. + + ferro_ta follows TA-Lib convention: std = population std (ddof=0). + pandas-ta uses sample std (ddof=1). Middle band (SMA) is identical. + Upper/lower differ by a sqrt(N/(N-1)) factor (~0.5% for N=20), capped at atol=0.1. + """ + close = ohlcv_500["close"] + period = 20 + + ft_upper, ft_middle, ft_lower = ferro_ta.BBANDS( + close, timeperiod=period, nbdevup=2.0, nbdevdn=2.0 + ) + + # pandas-ta >= 0.3 returns columns named BBL_{period}_{std}_{std} + pt_bbands = pandas_ta.bbands(pd.Series(close), length=period, std=2.0) + # Locate columns robustly (column names vary across pandas-ta versions) + lower_col = next(c for c in pt_bbands.columns if c.startswith("BBL_")) + middle_col = next(c for c in pt_bbands.columns if c.startswith("BBM_")) + upper_col = next(c for c in pt_bbands.columns if c.startswith("BBU_")) + pt_lower = pt_bbands[lower_col].to_numpy() + pt_middle = pt_bbands[middle_col].to_numpy() + pt_upper = pt_bbands[upper_col].to_numpy() + + # Middle band (SMA) must be identical + assert _allclose(ft_middle, pt_middle, atol=1e-8), ( + "BBands middle (SMA) must match" + ) + # Upper/lower: differ due to ddof=0 vs ddof=1 + assert _allclose(ft_upper, pt_upper, atol=0.1) + assert _allclose(ft_lower, pt_lower, atol=0.1) + + +class TestTRIMAVsPandasTA: + """TRIMA — Approximate match (implementations differ slightly in boundary handling).""" + + def test_trima_approximate_match(self, ohlcv_500): + """TRIMA should be close to pandas-ta (both are SMA-of-SMA but boundary handling differs). + + Note: ferro_ta follows TA-Lib's TRIMA formula while pandas-ta uses a slightly + different implementation. Observed max difference is ~0.4 price units on + typical equity prices (~100), which is < 0.5%. We verify tail convergence + with atol=0.5 and confirm correct NaN warm-up length. + """ + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.TRIMA(close, timeperiod=period) + pt = pandas_ta.trima(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=0.5, tail_fraction=0.5) + + +class TestMACDVsPandasTA: + """MACD — Tail 30% match (EMA seed difference).""" + + def test_macd_tail_convergence(self, ohlcv_500): + """MACD should converge in tail 30% of data.""" + close = ohlcv_500["close"] + + ft_macd, ft_signal, ft_hist = ferro_ta.MACD( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # pandas-ta returns DataFrame + pt_macd = pandas_ta.macd(pd.Series(close), fast=12, slow=26, signal=9) + pt_macd_line = pt_macd["MACD_12_26_9"].to_numpy() + pt_signal_line = pt_macd["MACDs_12_26_9"].to_numpy() + pt_hist = pt_macd["MACDh_12_26_9"].to_numpy() + + # Compare tail 30% + assert _allclose(ft_macd, pt_macd_line, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_signal, pt_signal_line, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_hist, pt_hist, atol=1e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSIVsPandasTA: + """RSI — Tail 30% match (Wilder seed difference).""" + + def test_rsi_tail_convergence(self, ohlcv_500): + """RSI should converge in tail 30% of data.""" + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.RSI(close, timeperiod=period) + pt = pandas_ta.rsi(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-3, tail_fraction=0.3) + + +class TestSTOCHVsPandasTA: + """STOCH — Tail 30% match.""" + + def test_stoch_tail_convergence(self, ohlcv_500): + """Stochastic should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_slowk, ft_slowd = ferro_ta.STOCH( + high, low, close, fastk_period=14, slowk_period=3, slowd_period=3 + ) + + # pandas-ta returns DataFrame + pt_stoch = pandas_ta.stoch( + pd.Series(high), pd.Series(low), pd.Series(close), k=14, d=3, smooth_k=3 + ) + pt_slowk = pt_stoch["STOCHk_14_3_3"].to_numpy() + pt_slowd = pt_stoch["STOCHd_14_3_3"].to_numpy() + + assert _allclose(ft_slowk, pt_slowk, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_slowd, pt_slowd, atol=1e-2, tail_fraction=0.3) + + +class TestCCIVsPandasTA: + """CCI — Exact match (deterministic rolling formula).""" + + def test_cci_exact_match(self, ohlcv_500): + """CCI should match manually-computed reference (pandas-ta CCI has a formula bug).""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.CCI(high, low, close, timeperiod=period) + + # Compute CCI manually: (TP - SMA(TP)) / (0.015 * MeanAbsDev(TP)) + tp = (pd.Series(high) + pd.Series(low) + pd.Series(close)) / 3.0 + mean_tp = tp.rolling(period).mean() + mad_tp = tp.rolling(period).apply( + lambda x: np.mean(np.abs(x - x.mean())), raw=True + ) + pt = ((tp - mean_tp) / (0.015 * mad_tp)).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestWILLRVsPandasTA: + """WILLR — Exact match (deterministic).""" + + def test_willr_exact_match(self, ohlcv_500): + """Williams %R should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.WILLR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.willr(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestMOMVsPandasTA: + """MOM — Exact match.""" + + def test_mom_exact_match(self, ohlcv_500): + """MOM should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.MOM(close, timeperiod=period) + pt = pandas_ta.mom(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestROCVsPandasTA: + """ROC — Exact match.""" + + def test_roc_exact_match(self, ohlcv_500): + """ROC should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 10 + + ft = ferro_ta.ROC(close, timeperiod=period) + pt = pandas_ta.roc(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestMFIVsPandasTA: + """MFI — Exact match.""" + + def test_mfi_exact_match(self, ohlcv_500): + """MFI should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 14 + + ft = ferro_ta.MFI(high, low, close, volume, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close, "volume": volume}) + pt = df.ta.mfi(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestAROONVsPandasTA: + """AROON — Exact match.""" + + def test_aroon_exact_match(self, ohlcv_500): + """AROON should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + period = 14 + + ft_down, ft_up = ferro_ta.AROON(high, low, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low}) + pt_aroon = df.ta.aroon(length=period) + pt_down = pt_aroon[f"AROOND_{period}"].to_numpy() + pt_up = pt_aroon[f"AROONU_{period}"].to_numpy() + + assert _allclose(ft_down, pt_down, atol=1e-8) + assert _allclose(ft_up, pt_up, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Volume/Volatility +# --------------------------------------------------------------------------- + + +class TestOBVVsPandasTA: + """OBV — Incremental match (offset constant, verify diffs).""" + + def test_obv_incremental_match(self, ohlcv_500): + """OBV differences should match (absolute values may have offset).""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + + ft = ferro_ta.OBV(close, volume) + + df = pd.DataFrame({"close": close, "volume": volume}) + pt = df.ta.obv().to_numpy() + + # OBV can have different starting values, compare differences + ft_diff = np.diff(ft) + pt_diff = np.diff(pt) + + # Remove NaN values from comparison + mask = ~np.isnan(ft_diff) & ~np.isnan(pt_diff) + assert np.allclose(ft_diff[mask], pt_diff[mask], atol=1e-8) + + +class TestATRVsPandasTA: + """ATR — Tail 30% match (Wilder seed difference).""" + + def test_atr_tail_convergence(self, ohlcv_500): + """ATR should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ATR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.atr(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-2, tail_fraction=0.3) + + +class TestADXVsPandasTA: + """ADX — Tail 30% match (two levels of Wilder smoothing).""" + + def test_adx_tail_convergence(self, ohlcv_500): + """ADX should converge in tail 30% of data.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ADX(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.adx(length=period)[f"ADX_{period}"].to_numpy() + + assert _allclose(ft, pt, atol=5e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Extended Indicators (no prior validation) +# --------------------------------------------------------------------------- + + +class TestVWAPVsPandasTA: + """VWAP — Validate rolling VWAP against a reference numpy implementation.""" + + def test_vwap_rolling_match(self, ohlcv_500): + """Rolling VWAP should match a reference implementation using numpy.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 20 + + ft = ferro_ta.VWAP(high, low, close, volume, timeperiod=period) + + # Reference: rolling VWAP = sum(typical_price * volume, N) / sum(volume, N) + tp = (np.array(high) + np.array(low) + np.array(close)) / 3.0 + vol = np.array(volume) + n = len(tp) + ref = np.full(n, np.nan) + for i in range(period - 1, n): + w = tp[i - period + 1 : i + 1] + v = vol[i - period + 1 : i + 1] + ref[i] = np.dot(w, v) / v.sum() + + assert _allclose(ft, ref, atol=1e-8) + + +class TestDONCHIANVsPandasTA: + """DONCHIAN — Exact match (rolling max(H), min(L), mean).""" + + def test_donchian_exact_match(self, ohlcv_500): + """Donchian Channels should match pandas-ta exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + period = 20 + + ft_upper, ft_middle, ft_lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": ohlcv_500["close"]}) + pt_donchian = df.ta.donchian(lower_length=period, upper_length=period) + pt_lower = pt_donchian[f"DCL_{period}_{period}"].to_numpy() + pt_middle = pt_donchian[f"DCM_{period}_{period}"].to_numpy() + pt_upper = pt_donchian[f"DCU_{period}_{period}"].to_numpy() + + assert _allclose(ft_upper, pt_upper, atol=1e-8) + assert _allclose(ft_middle, pt_middle, atol=1e-8) + assert _allclose(ft_lower, pt_lower, atol=1e-8) + + +class TestHULL_MAVsPandasTA: + """HULL_MA — Exact match (WMA composition: deterministic).""" + + def test_hull_ma_exact_match(self, ohlcv_500): + """Hull MA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + period = 16 + + ft = ferro_ta.HULL_MA(close, timeperiod=period) + pt = pandas_ta.hma(pd.Series(close), length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestICHIMOKUVsPandasTA: + """ICHIMOKU — Exact match for tenkan/kijun (rolling midpoint formula).""" + + def test_ichimoku_tenkan_kijun_match(self, ohlcv_500): + """Ichimoku tenkan and kijun should match pandas-ta.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_tenkan, ft_kijun, ft_senkou_a, ft_senkou_b, ft_chikou = ferro_ta.ICHIMOKU( + high, + low, + close, + tenkan_period=9, + kijun_period=26, + senkou_b_period=52, + displacement=26, + ) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt_ichimoku = df.ta.ichimoku(tenkan=9, kijun=26, senkou=52)[0] + pt_tenkan = pt_ichimoku["ITS_9"].to_numpy() + pt_kijun = pt_ichimoku["IKS_26"].to_numpy() + + assert _allclose(ft_tenkan, pt_tenkan, atol=1e-8) + assert _allclose(ft_kijun, pt_kijun, atol=1e-8) + + +class TestKELTNER_CHANNELSVsPandasTA: + """KELTNER_CHANNELS — Tail 30% match (Middle=EMA, bands=EMA±mult*ATR).""" + + def test_keltner_tail_convergence(self, ohlcv_500): + """Keltner Channels should converge in tail 30%.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 20 + atr_period = 10 + multiplier = 2.0 + + ft_upper, ft_middle, ft_lower = ferro_ta.KELTNER_CHANNELS( + high, + low, + close, + timeperiod=period, + atr_period=atr_period, + multiplier=multiplier, + ) + + # Compute manually using pandas_ta EMA and ATR to match ferro_ta's exact formula + pt_ema = pandas_ta.ema(pd.Series(close), length=period).to_numpy() + pt_atr = pandas_ta.atr( + pd.Series(high), pd.Series(low), pd.Series(close), length=atr_period + ).to_numpy() + pt_upper = pt_ema + multiplier * pt_atr + pt_middle = pt_ema + pt_lower = pt_ema - multiplier * pt_atr + + assert _allclose(ft_upper, pt_upper, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_middle, pt_middle, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_lower, pt_lower, atol=1e-2, tail_fraction=0.3) + + +class TestVWMAVsPandasTA: + """VWMA — Exact match (sum(c*v)/sum(v)).""" + + def test_vwma_exact_match(self, ohlcv_500): + """VWMA should match pandas-ta exactly.""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + period = 20 + + ft = ferro_ta.VWMA(close, volume, timeperiod=period) + + df = pd.DataFrame({"close": close, "volume": volume}) + pt = df.ta.vwma(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-8) + + +class TestCHOPPINESS_INDEXVsPandasTA: + """CHOPPINESS_INDEX — Close match (log10-based formula).""" + + def test_choppiness_index_close_match(self, ohlcv_500): + """Choppiness Index should match pandas-ta closely.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.CHOPPINESS_INDEX(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt = df.ta.chop(length=period).to_numpy() + + assert _allclose(ft, pt, atol=1e-4) + + +class TestSUPERTRENDVsPandasTA: + """SUPERTREND — Direction >80% agreement (path-dependent, ATR seeding differs).""" + + def test_supertrend_direction_agreement(self, ohlcv_500): + """SUPERTREND direction should agree >80% of the time.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 7 + multiplier = 3.0 + + ft_line, ft_dir = ferro_ta.SUPERTREND( + high, low, close, timeperiod=period, multiplier=multiplier + ) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + pt_supertrend = df.ta.supertrend(length=period, multiplier=multiplier) + pt_dir = pt_supertrend[f"SUPERTd_{period}_{multiplier}"].to_numpy() + + # Convert directions to same format (1 = up, -1 = down) + # pandas-ta: 1 = uptrend, -1 = downtrend + # ferro_ta: 1 = uptrend, -1 = downtrend (assuming same convention) + + # Remove NaN values + mask = ~np.isnan(ft_dir) & ~np.isnan(pt_dir) + agreement_rate = np.mean(ft_dir[mask] == pt_dir[mask]) + + assert agreement_rate > 0.80, f"Direction agreement rate: {agreement_rate:.2%}" + + +class TestCHANDELIER_EXITVsPandasTA: + """CHANDELIER_EXIT — Exact structure (rolling_max(H)-mult*ATR).""" + + def test_chandelier_exit_structure_match(self, ohlcv_500): + """Chandelier Exit should match pandas-ta structure.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 22 + multiplier = 3.0 + + ft_long, ft_short = ferro_ta.CHANDELIER_EXIT( + high, low, close, timeperiod=period, multiplier=multiplier + ) + + # Compute manually: long = rolling_max(H, n) - mult*ATR; short = rolling_min(L, n) + mult*ATR + pt_atr = pandas_ta.atr( + pd.Series(high), pd.Series(low), pd.Series(close), length=period + ).to_numpy() + rolling_high = pd.Series(high).rolling(period).max().to_numpy() + rolling_low = pd.Series(low).rolling(period).min().to_numpy() + pt_long = rolling_high - multiplier * pt_atr + pt_short = rolling_low + multiplier * pt_atr + + assert _allclose(ft_long, pt_long, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_short, pt_short, atol=1e-2, tail_fraction=0.3) + + +class TestPIVOT_POINTSVsPandasTA: + """PIVOT_POINTS — Exact match for Classic (arithmetic formula).""" + + def test_pivot_points_classic_exact(self, ohlcv_500): + """Classic Pivot Points should match manually-computed reference.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_pivot, ft_r1, ft_s1, ft_r2, ft_s2 = ferro_ta.PIVOT_POINTS( + high, low, close, method="classic" + ) + + # ferro_ta PIVOT_POINTS uses previous bar's H/L/C (1-bar forward shift). + # Reference values are computed from bar i-1 to match index i output. + pivot = np.empty_like(high, dtype=float) + pivot[0] = np.nan + pivot[1:] = (high[:-1] + low[:-1] + close[:-1]) / 3.0 + + r1 = np.empty_like(high, dtype=float) + r1[0] = np.nan + r1[1:] = 2 * pivot[1:] - low[:-1] + + s1 = np.empty_like(high, dtype=float) + s1[0] = np.nan + s1[1:] = 2 * pivot[1:] - high[:-1] + + r2 = np.empty_like(high, dtype=float) + r2[0] = np.nan + r2[1:] = pivot[1:] + (high[:-1] - low[:-1]) + + s2 = np.empty_like(high, dtype=float) + s2[0] = np.nan + s2[1:] = pivot[1:] - (high[:-1] - low[:-1]) + + assert _allclose(ft_pivot, pivot, atol=1e-8) + assert _allclose(ft_r1, r1, atol=1e-8) + assert _allclose(ft_s1, s1, atol=1e-8) + assert _allclose(ft_r2, r2, atol=1e-8) + assert _allclose(ft_s2, s2, atol=1e-8) diff --git a/vendor/ferro-ta-main/tests/integration/test_vs_ta.py b/vendor/ferro-ta-main/tests/integration/test_vs_ta.py new file mode 100644 index 0000000..65e9fee --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_vs_ta.py @@ -0,0 +1,291 @@ +""" +Comparison tests: ferro_ta vs ta (Bukosabino's library) (Priority 5 - requires ta). + +Secondary cross-check using Bukosabino's ta library. Validates same indicators +from a second independent implementation. This is shorter (~200 lines) and +focused on highest-value duplicates. + +Requirements +------------ +Install ta before running these tests:: + + pip install ta + +The tests are automatically skipped when ta is not installed. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when ta is not available +# --------------------------------------------------------------------------- + +ta = pytest.importorskip( + "ta", reason="ta library not installed; skipping comparison tests" +) +pd = pytest.importorskip("pandas", reason="pandas required for ta") + +import ferro_ta # noqa: E402 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose( + a: np.ndarray, b: np.ndarray, atol: float = 1e-6, tail_fraction: float = 1.0 +) -> bool: + """Compare arrays within tolerance, optionally only comparing tail.""" + mask = _valid_mask(a, b) + if not mask.any(): + return False + + if tail_fraction < 1.0: + n = len(a) + start_idx = int(n * (1 - tail_fraction)) + mask[:start_idx] = False + + if not mask.any(): + return False + + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMAVsTA: + """SMA — Exact match.""" + + def test_sma_exact_match(self, ohlcv_500): + """SMA should match ta library exactly.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.SMA(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.SMAIndicator(close=df["close"], window=period) + ta_result = ta_indicator.sma_indicator().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-8) + + +class TestEMAVsTA: + """EMA — Tail 30% match.""" + + def test_ema_tail_convergence(self, ohlcv_500): + """EMA should converge in tail 30%.""" + close = ohlcv_500["close"] + period = 20 + + ft = ferro_ta.EMA(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.EMAIndicator(close=df["close"], window=period) + ta_result = ta_indicator.ema_indicator().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-4, tail_fraction=0.3) + + +class TestBBANDSVsTA: + """BBANDS — Exact match.""" + + def test_bbands_exact_match(self, ohlcv_500): + """Bollinger Bands should match ta library exactly.""" + close = ohlcv_500["close"] + period = 20 + nbdev = 2.0 + + ft_upper, ft_middle, ft_lower = ferro_ta.BBANDS( + close, timeperiod=period, nbdevup=nbdev, nbdevdn=nbdev + ) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.volatility.BollingerBands( + close=df["close"], window=period, window_dev=nbdev + ) + ta_upper = ta_indicator.bollinger_hband().to_numpy() + ta_middle = ta_indicator.bollinger_mavg().to_numpy() + ta_lower = ta_indicator.bollinger_lband().to_numpy() + + assert _allclose(ft_upper, ta_upper, atol=1e-8) + assert _allclose(ft_middle, ta_middle, atol=1e-8) + assert _allclose(ft_lower, ta_lower, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSIVsTA: + """RSI — Tail 30% match.""" + + def test_rsi_tail_convergence(self, ohlcv_500): + """RSI should converge in tail 30%.""" + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.RSI(close, timeperiod=period) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.momentum.RSIIndicator(close=df["close"], window=period) + ta_result = ta_indicator.rsi().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-3, tail_fraction=0.3) + + +class TestMACDVsTA: + """MACD — Tail 30% match.""" + + def test_macd_tail_convergence(self, ohlcv_500): + """MACD should converge in tail 30%.""" + close = ohlcv_500["close"] + + ft_macd, ft_signal, ft_hist = ferro_ta.MACD( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + df = pd.DataFrame({"close": close}) + ta_indicator = ta.trend.MACD( + close=df["close"], window_slow=26, window_fast=12, window_sign=9 + ) + ta_macd = ta_indicator.macd().to_numpy() + ta_signal = ta_indicator.macd_signal().to_numpy() + ta_hist = ta_indicator.macd_diff().to_numpy() + + assert _allclose(ft_macd, ta_macd, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_signal, ta_signal, atol=1e-2, tail_fraction=0.3) + assert _allclose(ft_hist, ta_hist, atol=1e-2, tail_fraction=0.3) + + +class TestSTOCHVsTA: + """STOCH — Structural validation (algorithms are incompatible with ta library). + + Note: the ``ta`` library's StochasticOscillator uses simple rolling-mean (SMA) + smoothing, while ferro_ta follows TA-Lib and applies Wilder's exponential smoothing. + The two approaches produce values that diverge by up to 30 percentage points, so + a direct numeric comparison is meaningless. Instead we validate structural + properties that every correct STOCH implementation must satisfy. + """ + + def test_stoch_structural_properties(self, ohlcv_500): + """STOCH output satisfies range and warm-up constraints.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + + ft_slowk, ft_slowd = ferro_ta.STOCH( + high, low, close, fastk_period=14, slowk_period=3, slowd_period=3 + ) + + # Values in valid region must be within [0, 100] + valid_k = ft_slowk[np.isfinite(ft_slowk)] + valid_d = ft_slowd[np.isfinite(ft_slowd)] + assert len(valid_k) > 0, "STOCH slowk should have valid values" + assert len(valid_d) > 0, "STOCH slowd should have valid values" + assert np.all(valid_k >= 0.0) and np.all(valid_k <= 100.0), ( + "STOCH slowk must be in [0, 100]" + ) + assert np.all(valid_d >= 0.0) and np.all(valid_d <= 100.0), ( + "STOCH slowd must be in [0, 100]" + ) + + # Warm-up: TA-Lib STOCH NaN count = fastk_period + slowk_period - 1 + expected_nan = ( + 14 + 3 + 1 - 1 + ) # = fastk_period + slowk_period (TA-Lib convention) + actual_nan_k = int(np.sum(np.isnan(ft_slowk))) + assert actual_nan_k == expected_nan, ( + f"STOCH slowk NaN warmup: expected {expected_nan}, got {actual_nan_k}" + ) + + +class TestWILLRVsTA: + """WILLR — Exact match.""" + + def test_willr_exact_match(self, ohlcv_500): + """Williams %R should match ta library exactly.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.WILLR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + ta_indicator = ta.momentum.WilliamsRIndicator( + high=df["high"], low=df["low"], close=df["close"], lbp=period + ) + ta_result = ta_indicator.williams_r().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-8) + + +# --------------------------------------------------------------------------- +# Volatility +# --------------------------------------------------------------------------- + + +class TestATRVsTA: + """ATR — Tail 30% match.""" + + def test_atr_tail_convergence(self, ohlcv_500): + """ATR should converge in tail 30%.""" + high = ohlcv_500["high"] + low = ohlcv_500["low"] + close = ohlcv_500["close"] + period = 14 + + ft = ferro_ta.ATR(high, low, close, timeperiod=period) + + df = pd.DataFrame({"high": high, "low": low, "close": close}) + ta_indicator = ta.volatility.AverageTrueRange( + high=df["high"], low=df["low"], close=df["close"], window=period + ) + ta_result = ta_indicator.average_true_range().to_numpy() + + assert _allclose(ft, ta_result, atol=1e-2, tail_fraction=0.3) + + +# --------------------------------------------------------------------------- +# Volume +# --------------------------------------------------------------------------- + + +class TestOBVVsTA: + """OBV — Incremental match.""" + + def test_obv_incremental_match(self, ohlcv_500): + """OBV differences should match.""" + close = ohlcv_500["close"] + volume = ohlcv_500["volume"] + + ft = ferro_ta.OBV(close, volume) + + df = pd.DataFrame({"close": close, "volume": volume}) + ta_indicator = ta.volume.OnBalanceVolumeIndicator( + close=df["close"], volume=df["volume"] + ) + ta_result = ta_indicator.on_balance_volume().to_numpy() + + # Compare differences (OBV can have different starting values) + ft_diff = np.diff(ft) + ta_diff = np.diff(ta_result) + + mask = ~np.isnan(ft_diff) & ~np.isnan(ta_diff) + assert np.allclose(ft_diff[mask], ta_diff[mask], atol=1e-8) diff --git a/vendor/ferro-ta-main/tests/integration/test_vs_talib.py b/vendor/ferro-ta-main/tests/integration/test_vs_talib.py new file mode 100644 index 0000000..756762d --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_vs_talib.py @@ -0,0 +1,2178 @@ +""" +Comparison tests: ferro_ta vs TA-Lib (ta-lib Python wrapper). + +This module verifies that ferro_ta is a drop-in replacement for TA-Lib by +comparing the outputs of every shared indicator for: + + * **Shape compatibility** — same output length and NaN count (or ±1 where a + documented off-by-one exists). + * **Value accuracy** — exact match within floating-point tolerance where the + algorithms are identical; range / convergence checks where initialization + differs. + +Known differences are documented next to each test so consumers know what +to expect when migrating from TA-Lib. + +Requirements +------------ +Install ta-lib before running these tests:: + + pip install ta-lib + +The tests are automatically skipped when ta-lib is not installed, so the +main CI pipeline never fails because of a missing optional dependency. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Skip the whole module when ta-lib is not available +# --------------------------------------------------------------------------- + +talib = pytest.importorskip( + "talib", reason="ta-lib not installed; skipping comparison tests" +) + +import ferro_ta # noqa: E402 (import after potential skip) + +# --------------------------------------------------------------------------- +# Shared realistic OHLCV data (500 bars for proper convergence) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) + +N = 500 # Increased from 100 to 500 for proper EMA/RSI/ATR convergence +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +HIGH = CLOSE + RNG.uniform(0.1, 1.0, N) +LOW = CLOSE - RNG.uniform(0.1, 1.0, N) +OPEN = CLOSE + RNG.standard_normal(N) * 0.2 +VOLUME = RNG.uniform(500.0, 2000.0, N) + +# Simple monotonically increasing series used for deterministic checks +LINEAR = np.arange(1.0, N + 1.0, dtype=np.float64) +LINEAR_HIGH = LINEAR + 0.5 +LINEAR_LOW = LINEAR - 0.5 +LINEAR_OPEN = LINEAR - 0.2 +LINEAR_VOL = np.ones(N) * 1000.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Minimum fraction of values that must agree in sign for correlated indicators. +SIGN_AGREEMENT_THRESHOLD = 0.8 + +# Per-pattern candlestick agreement thresholds. +# Most patterns use 0.80; patterns with known definition differences from TA-Lib +# use lower thresholds with a documented reason. +CDL_AGREEMENT_THRESHOLDS: dict[str, float] = { + # Body/shadow ratio thresholds differ between ferro_ta and TA-Lib + "CDLHIGHWAVE": 0.65, # Shadow length threshold differs; 69% observed + "CDLLONGLEGGEDDOJI": 0.70, # Long-leg threshold differs; 75% observed + "CDLSHORTLINE": 0.20, # Body-size cutoff definition completely differs; 25% observed + "CDLSPINNINGTOP": 0.75, # Body ratio threshold differs; 78% observed + "CDLDOJI": 0.85, # Shadow ratio precision differs; 86% observed +} + + +def _nan_count(arr: np.ndarray) -> int: + return int(np.sum(np.isnan(arr))) + + +def _valid_mask(*arrays: np.ndarray) -> np.ndarray: + """Return boolean mask for positions where *all* arrays are finite.""" + mask = np.ones(len(arrays[0]), dtype=bool) + for a in arrays: + mask &= ~np.isnan(a) + return mask + + +def _allclose(a: np.ndarray, b: np.ndarray, atol: float = 1e-6) -> bool: + mask = _valid_mask(a, b) + if not mask.any(): + return False + return bool(np.allclose(a[mask], b[mask], atol=atol)) + + +# --------------------------------------------------------------------------- +# Overlap Studies +# --------------------------------------------------------------------------- + + +class TestSMA: + """SMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=10) + ta = talib.SMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=10) + ta = talib.SMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.SMA(CLOSE, timeperiod=5) + ta = talib.SMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + +class TestEMA: + """EMA — shape matches; values differ slightly in the warmup region. + + ferro_ta seeds the EMA from the very first data point using the standard + recursive formula, while TA-Lib seeds the first EMA value with the SMA + of the initial ``timeperiod`` bars. After enough bars the two series + converge. We verify: + + * Same NaN count (warmup length is identical). + * After the series converge (last 30 % of the output) values agree. + """ + + def test_nan_count_match(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=10) + ta = talib.EMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + ta = talib.EMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + """After convergence (tail 30%), EMA should be very close with 500 bars.""" + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + ta = talib.EMA(CLOSE, timeperiod=5) + # With 500 bars, compare last 30% with tighter tolerance + tail_start = int(N * 0.7) + assert np.allclose( + ft[tail_start:], ta[tail_start:], atol=1e-5 + ) # Tightened from 1e-3 + + def test_values_finite_and_reasonable(self): + ft = ferro_ta.EMA(CLOSE, timeperiod=5) + finite = ft[~np.isnan(ft)] + assert finite.min() > 0 + assert finite.max() < 1000 + + +class TestWMA: + """WMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WMA(CLOSE, timeperiod=10) + ta = talib.WMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.WMA(CLOSE, timeperiod=10) + ta = talib.WMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestDEMA: + """DEMA — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.DEMA(CLOSE, timeperiod=5) + ta = talib.DEMA(CLOSE, timeperiod=5) + mid = N // 2 + assert np.allclose(ft[mid:], ta[mid:], atol=1e-2) + + +class TestTEMA: + """TEMA — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.TEMA(CLOSE, timeperiod=5) + ta = talib.TEMA(CLOSE, timeperiod=5) + mid = N // 2 + assert np.allclose(ft[mid:], ta[mid:], atol=1e-2) + + +class TestTRIMA: + """TRIMA — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TRIMA(CLOSE, timeperiod=10) + ta = talib.TRIMA(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.TRIMA(CLOSE, timeperiod=10) + ta = talib.TRIMA(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestKAMA: + """KAMA — values match after the first bar. + + TA-Lib marks index ``timeperiod - 1`` as NaN (the last element of the + seed window), while ferro_ta emits a value there. All subsequent values + are identical. + """ + + def test_values_match_after_seed(self): + ft = ferro_ta.KAMA(CLOSE, timeperiod=10) + ta = talib.KAMA(CLOSE, timeperiod=10) + # Skip the one bar where TA-Lib is still NaN + start = max(_nan_count(ft), _nan_count(ta)) + 1 + assert np.allclose(ft[start:], ta[start:], atol=1e-8) + + def test_output_length_match(self): + ft = ferro_ta.KAMA(CLOSE, timeperiod=10) + ta = talib.KAMA(CLOSE, timeperiod=10) + assert len(ft) == len(ta) + + +class TestT3: + """T3 — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + def test_values_converge(self): + ft = ferro_ta.T3(CLOSE, timeperiod=5) + ta = talib.T3(CLOSE, timeperiod=5) + # With 500 bars, use last 30% with tighter tolerance + tail_start = int(N * 0.7) + assert np.allclose( + ft[tail_start:], ta[tail_start:], atol=1e-3 + ) # Tightened from 5e-2 + + +class TestBBANDS: + """BBANDS — exact match.""" + + def test_values_match(self): + upper_ft, mid_ft, lower_ft = ferro_ta.BBANDS( + CLOSE, timeperiod=10, nbdevup=2.0, nbdevdn=2.0 + ) + upper_ta, mid_ta, lower_ta = talib.BBANDS( + CLOSE, timeperiod=10, nbdevup=2.0, nbdevdn=2.0 + ) + assert _allclose(upper_ft, upper_ta) + assert _allclose(mid_ft, mid_ta) + assert _allclose(lower_ft, lower_ta) + + def test_nan_count_match(self): + upper_ft, _, _ = ferro_ta.BBANDS(CLOSE, timeperiod=10) + upper_ta, _, _ = talib.BBANDS(CLOSE, timeperiod=10) + assert _nan_count(upper_ft) == _nan_count(upper_ta) + + def test_output_length_match(self): + upper_ft, _, _ = ferro_ta.BBANDS(CLOSE, timeperiod=5) + upper_ta, _, _ = talib.BBANDS(CLOSE, timeperiod=5) + assert len(upper_ft) == len(upper_ta) + + +class TestMACD: + """MACD — shape matches; values differ (EMA-based initialization). + + The MACD line, signal, and histogram converge after sufficient warmup. + The histogram relationship (macd - signal) is preserved in both. + """ + + def test_nan_count_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACD( + CLOSE, fastperiod=3, slowperiod=6, signalperiod=2 + ) + ta_m, ta_s, ta_h = talib.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + assert _nan_count(ft_m) == _nan_count(ta_m) + + def test_output_length_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACD(CLOSE) + ta_m, ta_s, ta_h = talib.MACD(CLOSE) + assert len(ft_m) == len(ta_m) == len(CLOSE) + + def test_histogram_relationship(self): + """Histogram = MACD line − signal line (must hold for both libraries).""" + for fn, lib in [(ferro_ta.MACD, "ferro_ta"), (talib.MACD, "talib")]: + m, s, h = fn(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + mask = _valid_mask(m, s, h) + assert np.allclose(h[mask], m[mask] - s[mask], atol=1e-10), ( + f"{lib} histogram mismatch" + ) + + def test_values_converge(self): + ft_m, _, _ = ferro_ta.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + ta_m, _, _ = talib.MACD(CLOSE, fastperiod=3, slowperiod=6, signalperiod=2) + assert np.allclose(ft_m[-N // 4 :], ta_m[-N // 4 :], atol=1e-2) + + +class TestMACDFIX: + """MACDFIX — shape matches; values differ (EMA-based initialization).""" + + def test_nan_count_match(self): + ft_m, ft_s, ft_h = ferro_ta.MACDFIX(CLOSE) + ta_m, ta_s, ta_h = talib.MACDFIX(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + + def test_output_length_match(self): + ft_m, _, _ = ferro_ta.MACDFIX(CLOSE) + ta_m, _, _ = talib.MACDFIX(CLOSE) + assert len(ft_m) == len(ta_m) + + +class TestSAR: + """SAR — same output length; values may differ due to reversal history. + + Known difference: Parabolic SAR reversal history can diverge from TA-Lib + due to floating-point accumulation in early bars. Output shape (length, + NaN count) matches exactly. + """ + + def test_output_length_match(self): + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + assert _nan_count(ft) == _nan_count(ta) + + def test_values_positive(self): + ft = ferro_ta.SAR(HIGH, LOW) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_correlation_above_threshold(self): + """Correlated with TA-Lib even if not exact (same algorithm, different accumulation).""" + ft = ferro_ta.SAR(HIGH, LOW) + ta = talib.SAR(HIGH, LOW) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft[mask], ta[mask])[0, 1]) + assert corr > 0.90, f"SAR correlation {corr:.3f} < 0.90" + + +class TestSAREXT: + """SAREXT — SAR Extended. Shape must match; values may differ. + + Known difference: Same as SAR — reversal history from TA-Lib diverges + due to floating-point accumulation. + """ + + def test_output_length_match(self): + ft = ferro_ta.SAREXT(HIGH, LOW) + ta = talib.SAREXT(HIGH, LOW) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.SAREXT(HIGH, LOW) + ta = talib.SAREXT(HIGH, LOW) + assert _nan_count(ft) == _nan_count(ta) + + +class TestMAMA: + """MAMA — MESA Adaptive Moving Average. + + Known difference: TA-Lib C applies slightly different floating-point rounding + in the adaptive factor clamp. The two series are highly correlated (r > 0.95) + and values converge after ~100 bars, but differ numerically in early bars. + Status: ⚠️ Corr. + """ + + def test_output_length_match(self): + ft_m, ft_f = ferro_ta.MAMA(CLOSE) + ta_m, ta_f = talib.MAMA(CLOSE) + assert len(ft_m) == len(ta_m) + assert len(ft_f) == len(ta_f) + + def test_nan_count_match(self): + ft_m, ft_f = ferro_ta.MAMA(CLOSE) + ta_m, ta_f = talib.MAMA(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + assert _nan_count(ft_f) == _nan_count(ta_f) + + def test_mama_correlated_with_talib(self): + """MAMA should be highly correlated with TA-Lib (r > 0.95).""" + ft_m, _ = ferro_ta.MAMA(CLOSE) + ta_m, _ = talib.MAMA(CLOSE) + mask = _valid_mask(ft_m, ta_m) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft_m[mask], ta_m[mask])[0, 1]) + assert corr > 0.95, f"MAMA correlation {corr:.3f} < 0.95" + + def test_fama_correlated_with_talib(self): + """FAMA should be correlated with TA-Lib (r > 0.80).""" + _, ft_f = ferro_ta.MAMA(CLOSE) + _, ta_f = talib.MAMA(CLOSE) + mask = _valid_mask(ft_f, ta_f) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft_f[mask], ta_f[mask])[0, 1]) + assert corr > 0.80, f"FAMA correlation {corr:.3f} < 0.80" + + def test_mama_converges_in_tail(self): + """After 100 bars the difference should be small (< 0.5% of price).""" + long_close = 44.0 + np.cumsum( + np.random.default_rng(99).standard_normal(200) * 0.5 + ) + ft_m, _ = ferro_ta.MAMA(long_close) + ta_m, _ = talib.MAMA(long_close) + mask = _valid_mask(ft_m, ta_m) + if mask.sum() >= 10: + tail = np.where(mask)[0][-min(10, mask.sum()) :] # last valid bars + diff = np.abs(ft_m[tail] - ta_m[tail]) + price_scale = np.abs(ta_m[tail]).mean() + assert (diff / price_scale).max() < 0.01, ( + f"MAMA tail relative diff: {(diff / price_scale).max():.4f}" + ) + + +class TestMIDPOINT: + """MIDPOINT — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MIDPOINT(CLOSE, timeperiod=5) + ta = talib.MIDPOINT(CLOSE, timeperiod=5) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MIDPOINT(CLOSE, timeperiod=5) + ta = talib.MIDPOINT(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +class TestMIDPRICE: + """MIDPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MIDPRICE(HIGH, LOW, timeperiod=5) + ta = talib.MIDPRICE(HIGH, LOW, timeperiod=5) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MIDPRICE(HIGH, LOW, timeperiod=5) + ta = talib.MIDPRICE(HIGH, LOW, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +# --------------------------------------------------------------------------- +# Momentum Indicators +# --------------------------------------------------------------------------- + + +class TestRSI: + """RSI — same NaN count and length; values differ due to Wilder smoothing seed. + + ferro_ta and TA-Lib use slightly different initializations for Wilder's + smoothed average gain/loss, leading to permanently different RSI values. + Both libraries produce values in [0, 100] with the same NaN structure. + """ + + def test_nan_count_match(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + for lib_rsi in [ferro_ta.RSI(CLOSE, 14), talib.RSI(CLOSE, 14)]: + finite = lib_rsi[~np.isnan(lib_rsi)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_same_direction(self): + """RSI should move in the same direction as TA-Lib (correlation > 0.9).""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.9 + + def test_values_converge_in_tail(self): + """With 500 bars, RSI should converge in tail 30%.""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + tail_start = int(N * 0.7) + mask = _valid_mask(ft[tail_start:], ta[tail_start:]) + if mask.any(): + assert np.allclose( + ft[tail_start:][mask], ta[tail_start:][mask], atol=1e-3 + ) # Added value comparison + + +class TestMOM: + """MOM — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MOM(CLOSE, timeperiod=10) + ta = talib.MOM(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.MOM(CLOSE, timeperiod=10) + ta = talib.MOM(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestROC: + """ROC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROC(CLOSE, timeperiod=10) + ta = talib.ROC(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ROC(CLOSE, timeperiod=10) + ta = talib.ROC(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestROCP: + """ROCP — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCP(CLOSE, timeperiod=10) + ta = talib.ROCP(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestROCR: + """ROCR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCR(CLOSE, timeperiod=10) + ta = talib.ROCR(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestROCR100: + """ROCR100 — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ROCR100(CLOSE, timeperiod=10) + ta = talib.ROCR100(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestWILLR: + """WILLR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_minus100_to_0(self): + ft = ferro_ta.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.WILLR(HIGH, LOW, CLOSE, timeperiod=14) + for arr in [ft, ta]: + finite = arr[~np.isnan(arr)] + assert all(-100.0 <= v <= 0.0 for v in finite) + + +class TestAROON: + """AROON — exact match.""" + + def test_values_match(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + ta_down, ta_up = talib.AROON(HIGH, LOW, timeperiod=14) + assert _allclose(ft_down, ta_down) and _allclose(ft_up, ta_up) + + def test_nan_count_match(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + ta_down, ta_up = talib.AROON(HIGH, LOW, timeperiod=14) + assert _nan_count(ft_down) == _nan_count(ta_down) + + def test_range_0_to_100(self): + ft_down, ft_up = ferro_ta.AROON(HIGH, LOW, timeperiod=14) + for arr in [ft_down, ft_up]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestAROONOSC: + """AROONOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AROONOSC(HIGH, LOW, timeperiod=14) + ta = talib.AROONOSC(HIGH, LOW, timeperiod=14) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.AROONOSC(HIGH, LOW, timeperiod=14) + ta = talib.AROONOSC(HIGH, LOW, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + +class TestCCI: + """CCI — same NaN count and shape; mean-absolute-deviation may differ. + + TA-Lib divides by 0.015 × MAD computed with the population formula. + ferro_ta may use a slightly different MAD implementation, producing + proportionally scaled but directionally identical values. + """ + + def test_nan_count_match(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_same_sign(self): + """CCI values should have the same sign as TA-Lib.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + # Both should agree on whether CCI is positive/negative + assert ( + np.sum(np.sign(ft[mask]) == np.sign(ta[mask])) + > SIGN_AGREEMENT_THRESHOLD * mask.sum() + ) + + def test_values_strongly_correlated(self): + """CCI values should be strongly correlated with TA-Lib values.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + +class TestBOP: + """BOP — exact match.""" + + def test_values_match(self): + ft = ferro_ta.BOP(OPEN, HIGH, LOW, CLOSE) + ta = talib.BOP(OPEN, HIGH, LOW, CLOSE) + assert _allclose(ft, ta) + + def test_output_length_match(self): + ft = ferro_ta.BOP(OPEN, HIGH, LOW, CLOSE) + ta = talib.BOP(OPEN, HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + +class TestMFI: + """MFI — values match on a well-constructed series. + + MFI (Money Flow Index) is computed from OHLCV and should agree exactly + when the typical prices and volumes are not degenerate. + """ + + def test_nan_count_match(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + ta = talib.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_match(self): + ft = ferro_ta.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + ta = talib.MFI(HIGH, LOW, CLOSE, VOLUME, timeperiod=14) + assert _allclose(ft, ta) + + +class TestSTOCHF: + """STOCHF — fast %K values match exactly. + + Note: ferro_ta uses ``fastk_period - 1`` NaNs while TA-Lib uses + ``fastk_period + fastd_period - 2`` NaNs (i.e., it waits for both %K + and %D to be valid before emitting anything). The overlapping valid + region is identical. + """ + + def test_fastk_values_match(self): + ft_k, ft_d = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + ta_k, ta_d = talib.STOCHF( + HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert _allclose(ft_k, ta_k) + + def test_output_length_match(self): + ft_k, _ = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + ta_k, _ = talib.STOCHF( + HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert len(ft_k) == len(ta_k) + + def test_range_0_to_100(self): + ft_k, ft_d = ferro_ta.STOCHF(HIGH, LOW, CLOSE, fastk_period=5, fastd_period=3) + for arr in [ft_k, ft_d]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestSTOCH: + """STOCH — same shape; slow %K may differ by EMA initialisation.""" + + def test_output_length_match(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + assert len(ft_k) == len(ta_k) + + def test_range_0_to_100(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + for arr in [ft_k, ft_d]: + finite = arr[~np.isnan(arr)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestSTOCHRSI: + """STOCHRSI — same length; NaN count may differ by up to 2. + + The RSI seed difference propagates into StochRSI. ferro_ta emits values + sooner (fewer NaN) than TA-Lib in some configurations. + """ + + def test_output_length_match(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert len(ft_k) == len(ta_k) + + def test_nan_count_within_tolerance(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3, fastd_matype=0 + ) + assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2 + + def test_range_0_to_100(self): + ft_k, _ = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + finite = ft_k[~np.isnan(ft_k)] + # Allow small numerical tolerance for float boundaries + assert all(-1e-9 <= v <= 100.0 + 1e-9 for v in finite) + + +class TestAPO: + """APO — shape matches; values differ (EMA-based when matype != SMA).""" + + def test_nan_count_match(self): + ft = ferro_ta.APO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.APO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.APO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.APO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert len(ft) == len(ta) + + +class TestPPO: + """PPO — ferro_ta returns (ppo, signal, histogram); TA-Lib returns only ppo. + + ferro_ta extends PPO with a signal line and histogram (similar to MACD), + while TA-Lib's PPO only returns the percentage-difference line. We verify + the output length and that all three ferro_ta arrays have valid shapes. + The ppo line converges toward the TA-Lib value after the EMA seed window. + """ + + def test_output_is_tuple_of_three(self): + result = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + assert isinstance(result, tuple) and len(result) == 3 + + def test_output_length_match(self): + ppo, signal, hist = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + ta = talib.PPO(CLOSE, fastperiod=12, slowperiod=26, matype=0) + assert len(ppo) == len(ta) + + def test_all_arrays_same_length(self): + ppo, signal, hist = ferro_ta.PPO(CLOSE, fastperiod=12, slowperiod=26) + assert len(ppo) == len(signal) == len(hist) == N + + def test_ppo_converges_to_talib(self): + """PPO line should be strongly correlated with TA-Lib's PPO output. + + Note: EMA seeding differences mean correlation is ~0.90 for short periods. + We verify > 0.85 to confirm same signal direction. + """ + ppo, _, _ = ferro_ta.PPO(CLOSE, fastperiod=3, slowperiod=6) + ta = talib.PPO(CLOSE, fastperiod=3, slowperiod=6, matype=0) + mask = _valid_mask(ppo, ta) + corr = np.corrcoef(ppo[mask], ta[mask])[0, 1] + assert corr > 0.85 + + """CMO — same NaN count and shape; values may differ slightly. + + Both libraries compute the Chande Momentum Oscillator as + (sum_up - sum_dn) / (sum_up + sum_dn) × 100, but use different rolling + window implementations (TA-Lib uses Wilder's smoothing for the gains/ + losses; ferro_ta uses a plain rolling sum). Values are strongly + correlated but not numerically identical. + """ + + def test_nan_count_match(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_minus100_to_100(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(-100.0 <= v <= 100.0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.CMO(CLOSE, timeperiod=14) + ta = talib.CMO(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.85 + + +class TestTRIX: + """TRIX — shape matches; values differ (triple EMA initialisation).""" + + def test_nan_count_match(self): + ft = ferro_ta.TRIX(CLOSE, timeperiod=5) + ta = talib.TRIX(CLOSE, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.TRIX(CLOSE, timeperiod=5) + ta = talib.TRIX(CLOSE, timeperiod=5) + assert len(ft) == len(ta) + + +class TestULTOSC: + """ULTOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ULTOSC( + HIGH, LOW, CLOSE, timeperiod1=7, timeperiod2=14, timeperiod3=28 + ) + ta = talib.ULTOSC( + HIGH, LOW, CLOSE, timeperiod1=7, timeperiod2=14, timeperiod3=28 + ) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ULTOSC(HIGH, LOW, CLOSE) + ta = talib.ULTOSC(HIGH, LOW, CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + +class TestADX: + """ADX — same shape; values differ on random data (Wilder smoothing seed). + + On monotonically trending data the values match TA-Lib exactly. On + random price series the Wilder's smoothing seed for ATR and DM causes + permanent divergence (values do not converge). + """ + + def test_nan_count_match(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + +class TestADXR: + """ADXR — same shape (±1 NaN); values differ (Wilder smoothing seed). + + ADXR = (ADX[t] + ADX[t - timeperiod]) / 2. The ADX values differ from + TA-Lib due to the Wilder smoothing seed, so ADXR differs too. + """ + + def test_output_length_match(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_strongly_correlated(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestDX: + """DX — same NaN count and shape; values differ on random data. + + DX = |+DI - -DI| / (+DI + -DI) × 100. The +DI and -DI values depend on + Wilder's smoothed ATR and DM, both of which have different seeds in + ferro_ta vs TA-Lib. Values are strongly correlated. + """ + + def test_nan_count_match(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_range_0_to_100(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestPLUSDI: + """PLUS_DI — same NaN count; values differ on random data (Wilder smoothing).""" + + def test_nan_count_match(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestMINUSDI: + """MINUS_DI — same NaN count; values differ on random data (Wilder smoothing).""" + + def test_nan_count_match(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_output_length_match(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestPLUSDM: + """PLUS_DM — values match in the non-degenerate (OHLCV) region.""" + + def test_output_length_match(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_non_negative(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +class TestMINUSDM: + """MINUS_DM — same length; NaN count may differ by 1 (Wilder smoothing seed).""" + + def test_output_length_match(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_non_negative(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v >= 0.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + + +class TestAD: + """AD — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AD(HIGH, LOW, CLOSE, VOLUME) + ta = talib.AD(HIGH, LOW, CLOSE, VOLUME) + assert _allclose(ft, ta) + + def test_output_length_match(self): + ft = ferro_ta.AD(HIGH, LOW, CLOSE, VOLUME) + ta = talib.AD(HIGH, LOW, CLOSE, VOLUME) + assert len(ft) == len(ta) + + +class TestADOSC: + """ADOSC — exact match.""" + + def test_values_match(self): + ft = ferro_ta.ADOSC(HIGH, LOW, CLOSE, VOLUME, fastperiod=3, slowperiod=10) + ta = talib.ADOSC(HIGH, LOW, CLOSE, VOLUME, fastperiod=3, slowperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.ADOSC(HIGH, LOW, CLOSE, VOLUME) + ta = talib.ADOSC(HIGH, LOW, CLOSE, VOLUME) + assert _nan_count(ft) == _nan_count(ta) + + +class TestOBV: + """OBV — values match after the first bar. + + TA-Lib starts OBV accumulation at the *first* bar (OBV[0] = volume[0] if + price rose, else -volume[0]). ferro_ta initialises OBV[0] = 0 and applies + the direction rule from bar 1 onward. All increments are identical; the + two series differ only by a constant offset equal to the first OBV value. + """ + + def test_output_length_match(self): + ft = ferro_ta.OBV(CLOSE, VOLUME) + ta = talib.OBV(CLOSE, VOLUME) + assert len(ft) == len(ta) + + def test_increments_match(self): + """Day-over-day OBV changes must be identical.""" + ft = ferro_ta.OBV(CLOSE, VOLUME) + ta = talib.OBV(CLOSE, VOLUME) + ft_diff = np.diff(ft) + ta_diff = np.diff(ta) + assert np.allclose(ft_diff, ta_diff, atol=1e-8) + + def test_no_nans(self): + ft = ferro_ta.OBV(CLOSE, VOLUME) + assert not np.any(np.isnan(ft)) + + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + + +class TestATR: + """ATR — same length; values differ (different Wilder smoothing seed). + + TA-Lib uses Wilder's smoothing and marks the very first ATR value (at + index ``timeperiod``) as NaN. ferro_ta emits a value there. The Wilder + recursion runs from a different seed, so values do not converge. Both + produce strongly correlated positive ATR values. + """ + + def test_output_length_match(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_positive(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestNATR: + """NATR — same shape tolerance as ATR; values differ (Wilder smoothing seed).""" + + def test_output_length_match(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_nan_count_within_one(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + assert abs(_nan_count(ft) - _nan_count(ta)) <= 1 + + def test_values_positive(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + finite = ft[~np.isnan(ft)] + assert all(v > 0 for v in finite) + + def test_values_strongly_correlated(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestTRANGE: + """TRANGE — values match. + + TA-Lib emits NaN at index 0 (no previous close to compute true range). + ferro_ta emits TRANGE[0] = high[0] − low[0] (high-low only, no prior + close). From index 1 onward the values are identical. + """ + + def test_output_length_match(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + ta = talib.TRANGE(HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + def test_values_match_after_first(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + ta = talib.TRANGE(HIGH, LOW, CLOSE) + assert np.allclose(ft[1:], ta[1:], atol=1e-8) + + def test_values_positive(self): + ft = ferro_ta.TRANGE(HIGH, LOW, CLOSE) + assert all(v > 0 for v in ft[1:]) + + +# --------------------------------------------------------------------------- +# Statistical Functions +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + """STDDEV — exact match.""" + + def test_values_match(self): + ft = ferro_ta.STDDEV(CLOSE, timeperiod=10) + ta = talib.STDDEV(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.STDDEV(CLOSE, timeperiod=10) + ta = talib.STDDEV(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestVAR: + """VAR — exact match.""" + + def test_values_match(self): + ft = ferro_ta.VAR(CLOSE, timeperiod=10) + ta = talib.VAR(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREG: + """LINEARREG — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG(CLOSE, timeperiod=10) + ta = talib.LINEARREG(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.LINEARREG(CLOSE, timeperiod=10) + ta = talib.LINEARREG(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestLINEARREGSlope: + """LINEARREG_SLOPE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_SLOPE(CLOSE, timeperiod=10) + ta = talib.LINEARREG_SLOPE(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREGIntercept: + """LINEARREG_INTERCEPT — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_INTERCEPT(CLOSE, timeperiod=10) + ta = talib.LINEARREG_INTERCEPT(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestLINEARREGAngle: + """LINEARREG_ANGLE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.LINEARREG_ANGLE(CLOSE, timeperiod=10) + ta = talib.LINEARREG_ANGLE(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + +class TestTSF: + """TSF — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TSF(CLOSE, timeperiod=10) + ta = talib.TSF(CLOSE, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.TSF(CLOSE, timeperiod=10) + ta = talib.TSF(CLOSE, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + +class TestBETA: + """BETA — same shape; algorithm differs from TA-Lib. + + ferro_ta computes a simplified rolling beta (covariance / variance of the + reference series), while TA-Lib uses the standard CAPM beta estimator. + Shape compatibility (NaN count, length) is verified; exact value match is + not expected. + """ + + def test_output_length_match(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta) + + +class TestCORREL: + """CORREL — exact match.""" + + def test_values_match(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + ta = talib.CORREL(CLOSE, HIGH, timeperiod=10) + assert _allclose(ft, ta) + + def test_nan_count_match(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + ta = talib.CORREL(CLOSE, HIGH, timeperiod=10) + assert _nan_count(ft) == _nan_count(ta) + + def test_range_minus1_to_1(self): + ft = ferro_ta.CORREL(CLOSE, HIGH, timeperiod=10) + finite = ft[~np.isnan(ft)] + assert all(-1.0 <= v <= 1.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- + + +class TestAVGPRICE: + """AVGPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.AVGPRICE(OPEN, HIGH, LOW, CLOSE) + ta = talib.AVGPRICE(OPEN, HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + def test_output_length_match(self): + assert len(ferro_ta.AVGPRICE(OPEN, HIGH, LOW, CLOSE)) == N + + +class TestMEDPRICE: + """MEDPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.MEDPRICE(HIGH, LOW) + ta = talib.MEDPRICE(HIGH, LOW) + assert np.allclose(ft, ta, atol=1e-10) + + +class TestTYPPRICE: + """TYPPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.TYPPRICE(HIGH, LOW, CLOSE) + ta = talib.TYPPRICE(HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + +class TestWCLPRICE: + """WCLPRICE — exact match.""" + + def test_values_match(self): + ft = ferro_ta.WCLPRICE(HIGH, LOW, CLOSE) + ta = talib.WCLPRICE(HIGH, LOW, CLOSE) + assert np.allclose(ft, ta, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- + + +class TestPatternShapeCompatibility: + """Patterns — same output length and dtype; values may differ. + + Pattern recognition algorithms depend heavily on thresholds and candle + body/shadow definitions. ferro_ta implements simplified versions of these + patterns. These tests verify that: + + * Output length matches TA-Lib. + * Values are restricted to {-100, 0, 100} (same convention as TA-Lib). + """ + + PATTERNS = [ + "CDLDOJI", + "CDLENGULFING", + "CDLHAMMER", + "CDLSHOOTINGSTAR", + "CDLMARUBOZU", + "CDLSPINNINGTOP", + "CDLMORNINGSTAR", + "CDLEVENINGSTAR", + "CDL2CROWS", + # Additional candlestick patterns + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLEVENINGDOJISTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHORTLINE", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", + ] + + @pytest.mark.parametrize("name", PATTERNS) + def test_output_length_match(self, name: str): + ft_fn = getattr(ferro_ta, name) + ta_fn = getattr(talib, name) + ft = ft_fn(OPEN, HIGH, LOW, CLOSE) + ta = ta_fn(OPEN, HIGH, LOW, CLOSE) + assert len(ft) == len(ta) + + @pytest.mark.parametrize("name", PATTERNS) + def test_valid_output_values(self, name: str): + ft_fn = getattr(ferro_ta, name) + ft = ft_fn(OPEN, HIGH, LOW, CLOSE) + assert all(v in (-100, 0, 100) for v in ft), ( + f"{name}: unexpected values {set(ft)}" + ) + + def test_cdlengulfing_values_match(self): + """CDLENGULFING matches TA-Lib exactly on random OHLCV data.""" + ft = ferro_ta.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + assert np.array_equal(ft, ta) + + +# --------------------------------------------------------------------------- +# Parity suite additions +# --------------------------------------------------------------------------- + + +class TestParitySuite: + """ + Comprehensive parity validation against TA-Lib. + + Covers: + * Large-dataset SMA equivalence (10,000 rows) + * Strict shape and dtype checks for MACD and BBANDS + * float32 input handling (should cast safely via _to_f64) + """ + + # 10,000-row synthetic OHLCV data + N_LARGE = 10_000 + _rng = np.random.default_rng(2024) + CLOSE_LARGE = 100.0 + np.cumsum(_rng.standard_normal(N_LARGE) * 0.5) + + def test_sma_10k_allclose(self): + """SMA on 10,000 rows must match TA-Lib within floating-point tolerance.""" + ft = ferro_ta.SMA(self.CLOSE_LARGE, timeperiod=30) + ta = talib.SMA(self.CLOSE_LARGE, timeperiod=30) + assert np.allclose(ft, ta, equal_nan=True), "SMA mismatch on 10k-row dataset" + + def test_macd_shape_and_dtype(self): + """MACD output must have correct shape and float64 dtype.""" + macd_line, signal, hist = ferro_ta.MACD(CLOSE) + assert macd_line.shape == (N,) + assert signal.shape == (N,) + assert hist.shape == (N,) + assert macd_line.dtype == np.float64 + assert signal.dtype == np.float64 + assert hist.dtype == np.float64 + + def test_bbands_shape_and_dtype(self): + """BBANDS output must have correct shape and float64 dtype.""" + upper, middle, lower = ferro_ta.BBANDS(CLOSE, timeperiod=20) + assert upper.shape == (N,) + assert middle.shape == (N,) + assert lower.shape == (N,) + assert upper.dtype == np.float64 + assert middle.dtype == np.float64 + assert lower.dtype == np.float64 + + def test_float32_input_casts_safely(self): + """Passing float32 arrays should cast to float64 silently (no error).""" + close32 = CLOSE.astype(np.float32) + # _to_f64 should cast — result must be finite and match float64 version + result = ferro_ta.SMA(close32, timeperiod=10) + expected = ferro_ta.SMA(CLOSE, timeperiod=10) + assert result.dtype == np.float64 + valid = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[valid], expected[valid], atol=1e-4) + + def test_macd_nan_count_vs_talib(self): + """MACD NaN counts must agree with TA-Lib (same warmup period).""" + ft_m, ft_s, ft_h = ferro_ta.MACD(CLOSE) + ta_m, ta_s, ta_h = talib.MACD(CLOSE) + assert _nan_count(ft_m) == _nan_count(ta_m) + assert _nan_count(ft_s) == _nan_count(ta_s) + + def test_bbands_values_match_talib(self): + """BBANDS must match TA-Lib exactly (SMA-based, no EMA seeding issue).""" + ft_u, ft_m, ft_l = ferro_ta.BBANDS(CLOSE, timeperiod=20) + ta_u, ta_m, ta_l = talib.BBANDS(CLOSE, timeperiod=20) + assert _allclose(ft_u, ta_u), "BBANDS upper mismatch" + assert _allclose(ft_m, ta_m), "BBANDS middle mismatch" + assert _allclose(ft_l, ta_l), "BBANDS lower mismatch" + + +# --------------------------------------------------------------------------- +# Numerical parity — RSI, ATR, NATR, CCI, BETA alignment +# --------------------------------------------------------------------------- + + +class TestNumericalParity: + """Verify RSI, ATR, NATR, CCI, BETA alignment with TA-Lib.""" + + def test_rsi_output_length_matches(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_rsi_nan_count_matches(self): + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta), ( + f"RSI NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_rsi_values_allclose(self): + """RSI values must match TA-Lib within tolerance after seeding.""" + ft = ferro_ta.RSI(CLOSE, timeperiod=14) + ta = talib.RSI(CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any(), "No valid bars to compare" + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"RSI max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_atr_output_length_matches(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_atr_nan_count_matches(self): + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta), ( + f"ATR NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_atr_values_allclose(self): + """ATR values must match TA-Lib within tolerance.""" + ft = ferro_ta.ATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"ATR max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_natr_values_allclose(self): + ft = ferro_ta.NATR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.NATR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-6) + + def test_cci_output_length_matches(self): + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_cci_values_allclose(self): + """CCI values must match TA-Lib exactly.""" + ft = ferro_ta.CCI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.CCI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + assert np.allclose(ft[mask], ta[mask], atol=1e-6), ( + f"CCI max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + def test_beta_output_length_matches(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert len(ft) == len(ta) + + def test_beta_nan_count_matches(self): + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + assert _nan_count(ft) == _nan_count(ta), ( + f"BETA NaN count: ferro_ta={_nan_count(ft)}, talib={_nan_count(ta)}" + ) + + def test_beta_values_close_to_talib(self): + """BETA values using returns-based regression must be close to TA-Lib.""" + ft = ferro_ta.BETA(CLOSE, HIGH, timeperiod=5) + ta = talib.BETA(CLOSE, HIGH, timeperiod=5) + mask = _valid_mask(ft, ta) + assert mask.any() + # TA-Lib BETA uses returns-based regression — allow small tolerance + assert np.allclose(ft[mask], ta[mask], atol=1e-8), ( + f"BETA max diff: {np.abs(ft[mask] - ta[mask]).max()}" + ) + + +# --------------------------------------------------------------------------- +# Math operators vs TA-Lib +# --------------------------------------------------------------------------- + + +class TestMathOperatorsVsTalib: + """Verify that math operator shims match TA-Lib exactly.""" + + def test_add_matches_talib(self): + ft = ferro_ta.ADD(CLOSE, HIGH) + ta = talib.ADD(CLOSE, HIGH) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sub_matches_talib(self): + ft = ferro_ta.SUB(HIGH, LOW) + ta = talib.SUB(HIGH, LOW) + assert np.allclose(ft, ta, equal_nan=True) + + def test_mult_matches_talib(self): + ft = ferro_ta.MULT(CLOSE, VOLUME) + ta = talib.MULT(CLOSE, VOLUME) + assert np.allclose(ft, ta, equal_nan=True) + + def test_div_matches_talib(self): + ft = ferro_ta.DIV(CLOSE, HIGH) + ta = talib.DIV(CLOSE, HIGH) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sum_matches_talib(self): + ft = ferro_ta.SUM(CLOSE, timeperiod=10) + ta = talib.SUM(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_max_matches_talib(self): + ft = ferro_ta.MAX(CLOSE, timeperiod=10) + ta = talib.MAX(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_min_matches_talib(self): + ft = ferro_ta.MIN(CLOSE, timeperiod=10) + ta = talib.MIN(CLOSE, timeperiod=10) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sin_matches_talib(self): + ft = ferro_ta.SIN(CLOSE) + ta = talib.SIN(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_cos_matches_talib(self): + ft = ferro_ta.COS(CLOSE) + ta = talib.COS(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_sqrt_matches_talib(self): + ft = ferro_ta.SQRT(CLOSE) + ta = talib.SQRT(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_exp_matches_talib(self): + ft = ferro_ta.EXP(LINEAR) + ta = talib.EXP(LINEAR) + assert np.allclose(ft, ta, equal_nan=True) + + def test_ln_matches_talib(self): + ft = ferro_ta.LN(CLOSE) + ta = talib.LN(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + def test_log10_matches_talib(self): + ft = ferro_ta.LOG10(CLOSE) + ta = talib.LOG10(CLOSE) + assert np.allclose(ft, ta, equal_nan=True) + + +# --------------------------------------------------------------------------- +# STOCH, STOCHRSI, ADX, DI, DM parity +# --------------------------------------------------------------------------- + + +class TestDirectionalMovementVsTalib: + """Verify ADX, DX, +DI, -DI, +DM, -DM are strongly correlated with TA-Lib. + + Wilder smoothing seed differs between ferro_ta and TA-Lib, so values are + not numerically identical but must be strongly correlated. + """ + + def test_plus_di_output_length(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_plus_di_nan_count(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_plus_di_values_strongly_correlated(self): + ft = ferro_ta.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.PLUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_minus_di_values_strongly_correlated(self): + ft = ferro_ta.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.MINUS_DI(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_plus_dm_output_length(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + assert len(ft) == len(ta) + + def test_plus_dm_values_strongly_correlated(self): + ft = ferro_ta.PLUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.PLUS_DM(HIGH, LOW, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_minus_dm_values_strongly_correlated(self): + ft = ferro_ta.MINUS_DM(HIGH, LOW, timeperiod=14) + ta = talib.MINUS_DM(HIGH, LOW, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_dx_values_strongly_correlated(self): + ft = ferro_ta.DX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.DX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_adx_output_length(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert len(ft) == len(ta) + + def test_adx_nan_count(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + assert _nan_count(ft) == _nan_count(ta) + + def test_adx_values_strongly_correlated(self): + ft = ferro_ta.ADX(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADX(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.99 + + def test_adxr_values_strongly_correlated(self): + ft = ferro_ta.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + ta = talib.ADXR(HIGH, LOW, CLOSE, timeperiod=14) + mask = _valid_mask(ft, ta) + assert mask.any() + corr = np.corrcoef(ft[mask], ta[mask])[0, 1] + assert corr > 0.95 + + +class TestSTOCHVsTalib: + """Verify STOCH and STOCHRSI match TA-Lib.""" + + def test_stoch_slowk_output_length(self): + ft_k, _ = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, _ = talib.STOCH(HIGH, LOW, CLOSE) + assert len(ft_k) == len(ta_k) + + def test_stoch_nan_count_matches(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + assert _nan_count(ft_k) == _nan_count(ta_k) + assert _nan_count(ft_d) == _nan_count(ta_d) + + def test_stoch_values_allclose(self): + ft_k, ft_d = ferro_ta.STOCH(HIGH, LOW, CLOSE) + ta_k, ta_d = talib.STOCH(HIGH, LOW, CLOSE) + mask_k = _valid_mask(ft_k, ta_k) + mask_d = _valid_mask(ft_d, ta_d) + assert mask_k.any() + assert np.allclose(ft_k[mask_k], ta_k[mask_k], atol=1e-8) + assert np.allclose(ft_d[mask_d], ta_d[mask_d], atol=1e-8) + + def test_stochrsi_output_length(self): + ft_k, _ = ferro_ta.STOCHRSI(CLOSE) + ta_k, _ = talib.STOCHRSI(CLOSE) + assert len(ft_k) == len(ta_k) + + def test_stochrsi_nan_count_matches(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + # RSI seed difference can yield ±2 NaN count (see TestSTOCHRSI) + assert abs(_nan_count(ft_k) - _nan_count(ta_k)) <= 2 + + def test_stochrsi_values_close(self): + ft_k, ft_d = ferro_ta.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + ta_k, ta_d = talib.STOCHRSI( + CLOSE, timeperiod=14, fastk_period=5, fastd_period=3 + ) + mask_k = _valid_mask(ft_k, ta_k) + assert mask_k.any() + assert np.allclose(ft_k[mask_k], ta_k[mask_k], atol=1e-8) + + +# --------------------------------------------------------------------------- +# MAMA, SAR/SAREXT, and HT_* cycle indicator tests +# +# These indicators are documented as ⚠️ Corr or ⚠️ Shape in the README because +# TA-Lib C uses slightly different floating-point accumulation and clamping +# order. Tests enforce shape parity and minimum correlation rather than +# exact allclose. +# --------------------------------------------------------------------------- + + +class TestHTTrendline: + """HT_TRENDLINE — 63-bar lookback; values correlated with TA-Lib. + + Known difference: Ehlers HT filter — same algorithm and 63-bar lookback; + values are correlated (r > 0.90) but not numerically identical due to + different clamp order in TA-Lib C source. + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_correlated_with_talib(self): + """HT_TRENDLINE should be highly correlated with TA-Lib output.""" + ft = ferro_ta.HT_TRENDLINE(CLOSE) + ta = talib.HT_TRENDLINE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + corr = float(np.corrcoef(ft[mask], ta[mask])[0, 1]) + assert corr > 0.90, f"HT_TRENDLINE correlation {corr:.3f} < 0.90" + + +class TestHTDCPeriod: + """HT_DCPERIOD — 63-bar lookback; shape parity enforced. + + Known difference: Dominant cycle period values correlated with TA-Lib + but not exact (same Ehlers algorithm, different floating-point accumulation). + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_DCPERIOD(CLOSE) + ta = talib.HT_DCPERIOD(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_within_tolerance(self): + ft = ferro_ta.HT_DCPERIOD(CLOSE) + ta = talib.HT_DCPERIOD(CLOSE) + # ferro_ta uses 63-bar lookback; TA-Lib may use different warmup + assert abs(_nan_count(ft) - _nan_count(ta)) <= 35 + + def test_period_in_reasonable_range(self): + """Period should typically be in [6, 50] for realistic price data.""" + ft = ferro_ta.HT_DCPERIOD(CLOSE) + valid = ft[~np.isnan(ft)] + assert valid.min() > 0 + assert valid.max() <= 100.0 # allow some slack + + +class TestHTDCPhase: + """HT_DCPHASE — 63-bar lookback; shape parity enforced.""" + + def test_output_length_match(self): + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_phase_sign_agreement(self): + """DC phase sign should agree with TA-Lib for some valid bars (Ehlers algo diff).""" + ft = ferro_ta.HT_DCPHASE(CLOSE) + ta = talib.HT_DCPHASE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + sign_agree = np.mean(np.sign(ft[mask]) == np.sign(ta[mask])) + # HT indicators use different warmup/accumulation vs TA-Lib + assert sign_agree >= 0.40, ( + f"HT_DCPHASE sign agreement {sign_agree:.2f} < 0.40" + ) + + +class TestHTPhasor: + """HT_PHASOR — 63-bar lookback; shape parity enforced. + + Returns (inphase, quadrature). Both components are correlated with TA-Lib. + """ + + def test_output_length_match(self): + ft_i, ft_q = ferro_ta.HT_PHASOR(CLOSE) + ta_i, ta_q = talib.HT_PHASOR(CLOSE) + assert len(ft_i) == len(ta_i) + assert len(ft_q) == len(ta_q) + + def test_nan_count_within_tolerance(self): + ft_i, ft_q = ferro_ta.HT_PHASOR(CLOSE) + ta_i, ta_q = talib.HT_PHASOR(CLOSE) + # ferro_ta uses 63-bar lookback; TA-Lib may use different warmup + assert abs(_nan_count(ft_i) - _nan_count(ta_i)) <= 35 + assert abs(_nan_count(ft_q) - _nan_count(ta_q)) <= 35 + + def test_inphase_sign_agreement(self): + """Inphase component sign should agree with TA-Lib for most valid bars.""" + ft_i, _ = ferro_ta.HT_PHASOR(CLOSE) + ta_i, _ = talib.HT_PHASOR(CLOSE) + mask = _valid_mask(ft_i, ta_i) + if mask.sum() >= 5: + sign_agree = np.mean(np.sign(ft_i[mask]) == np.sign(ta_i[mask])) + assert sign_agree >= SIGN_AGREEMENT_THRESHOLD + + +class TestHTSine: + """HT_SINE — 63-bar lookback; shape parity enforced. + + Returns (sine, leadsine). Values in [-1, 1]. + """ + + def test_output_length_match(self): + ft_s, ft_l = ferro_ta.HT_SINE(CLOSE) + ta_s, ta_l = talib.HT_SINE(CLOSE) + assert len(ft_s) == len(ta_s) + assert len(ft_l) == len(ta_l) + + def test_nan_count_match(self): + ft_s, ft_l = ferro_ta.HT_SINE(CLOSE) + ta_s, ta_l = talib.HT_SINE(CLOSE) + assert _nan_count(ft_s) == _nan_count(ta_s) + assert _nan_count(ft_l) == _nan_count(ta_l) + + def test_sine_range(self): + """Sine component should be in [-1.1, 1.1] (allow small numerical overshoot).""" + ft_s, _ = ferro_ta.HT_SINE(CLOSE) + valid = ft_s[~np.isnan(ft_s)] + assert valid.min() >= -1.1 + assert valid.max() <= 1.1 + + +class TestHTTrendMode: + """HT_TRENDMODE — 63-bar lookback; values are 0 or 1. + + Known difference: Boolean output derived from HT_DCPERIOD — may differ + from TA-Lib in first ~10 valid bars due to the same floating-point diff. + """ + + def test_output_length_match(self): + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + assert len(ft) == len(ta) + + def test_nan_count_match(self): + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + assert _nan_count(ft) == _nan_count(ta) + + def test_binary_output(self): + """TRENDMODE values must be 0 or 1 (or NaN for warmup).""" + ft = ferro_ta.HT_TRENDMODE(CLOSE) + valid = ft[~np.isnan(ft)] + assert set(valid.astype(int)).issubset({0, 1}) + + def test_sign_agreement_with_talib(self): + """Trend mode should agree with TA-Lib for majority of valid bars. + + Note: HT_TRENDMODE is highly sensitive to Hilbert Transform phase + accumulator initialization; the two implementations use different + precision for the adaptive period, so agreement is ~54%. We verify + > 50% to confirm the indicator is better-than-random. + """ + ft = ferro_ta.HT_TRENDMODE(CLOSE) + ta = talib.HT_TRENDMODE(CLOSE) + mask = _valid_mask(ft, ta) + if mask.sum() >= 5: + agree = np.mean(ft[mask] == ta[mask]) + assert agree >= 0.50, f"HT_TRENDMODE agreement {agree:.2f} < 0.50" + + +# --------------------------------------------------------------------------- +# Candlestick Pattern Agreement Tests +# --------------------------------------------------------------------------- + + +# List of all candlestick patterns to test +ALL_CDL_PATTERNS = [ + "CDL2CROWS", + "CDL3BLACKCROWS", + "CDL3INSIDE", + "CDL3LINESTRIKE", + "CDL3OUTSIDE", + "CDL3STARSINSOUTH", + "CDL3WHITESOLDIERS", + "CDLABANDONEDBABY", + "CDLADVANCEBLOCK", + "CDLBELTHOLD", + "CDLBREAKAWAY", + "CDLCLOSINGMARUBOZU", + "CDLCONCEALBABYSWALL", + "CDLCOUNTERATTACK", + "CDLDARKCLOUDCOVER", + "CDLDOJI", + "CDLDOJISTAR", + "CDLDRAGONFLYDOJI", + "CDLENGULFING", + "CDLEVENINGDOJISTAR", + "CDLEVENINGSTAR", + "CDLGAPSIDESIDEWHITE", + "CDLGRAVESTONEDOJI", + "CDLHAMMER", + "CDLHANGINGMAN", + "CDLHARAMI", + "CDLHARAMICROSS", + "CDLHIGHWAVE", + "CDLHIKKAKE", + "CDLHIKKAKEMOD", + "CDLHOMINGPIGEON", + "CDLIDENTICAL3CROWS", + "CDLINNECK", + "CDLINVERTEDHAMMER", + "CDLKICKING", + "CDLKICKINGBYLENGTH", + "CDLLADDERBOTTOM", + "CDLLONGLEGGEDDOJI", + "CDLLONGLINE", + "CDLMARUBOZU", + "CDLMATCHINGLOW", + "CDLMATHOLD", + "CDLMORNINGDOJISTAR", + "CDLMORNINGSTAR", + "CDLONNECK", + "CDLPIERCING", + "CDLRICKSHAWMAN", + "CDLRISEFALL3METHODS", + "CDLSEPARATINGLINES", + "CDLSHOOTINGSTAR", + "CDLSHORTLINE", + "CDLSPINNINGTOP", + "CDLSTALLEDPATTERN", + "CDLSTICKSANDWICH", + "CDLTAKURI", + "CDLTASUKIGAP", + "CDLTHRUSTING", + "CDLTRISTAR", + "CDLUNIQUE3RIVER", + "CDLUPSIDEGAP2CROWS", + "CDLXSIDEGAP3METHODS", +] + + +class TestCandlestickPatternAgreement: + """Pattern recognition: agreement rate tests. + + Candlestick patterns may have slightly different threshold parameters + between implementations. We validate >80% agreement rate for pattern + detection (non-zero output). + """ + + @pytest.mark.parametrize("pattern_name", ALL_CDL_PATTERNS) + def test_pattern_agreement_rate(self, pattern_name): + """Test that pattern agreement rate is > 80%.""" + # Get pattern functions + ft_func = getattr(ferro_ta, pattern_name, None) + ta_func = getattr(talib, pattern_name, None) + + if ft_func is None: + pytest.skip(f"ferro_ta.{pattern_name} not implemented") + if ta_func is None: + pytest.skip(f"talib.{pattern_name} not available") + + # Compute patterns + ft = ft_func(OPEN, HIGH, LOW, CLOSE) + ta = ta_func(OPEN, HIGH, LOW, CLOSE) + + # Check output length match + assert len(ft) == len(ta), f"{pattern_name}: length mismatch" + + # Compute agreement rate (exact match of output values) + # Patterns typically return 0, ±100, or ±200 + agreement = np.mean(ft == ta) + + # Use per-pattern threshold (some patterns have known definition differences) + threshold = CDL_AGREEMENT_THRESHOLDS.get(pattern_name, 0.80) + assert agreement > threshold, ( + f"{pattern_name}: agreement rate {agreement:.2%} < {threshold:.0%}" + ) + + def test_pattern_sample_doji(self): + """Spot check: CDLDOJI should have high agreement (known: shadow ratio precision differs).""" + ft = ferro_ta.CDLDOJI(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLDOJI(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + # ferro_ta uses slightly different shadow/body ratio threshold; 86% observed + assert agreement > 0.85 + + def test_pattern_sample_engulfing(self): + """Spot check: CDLENGULFING should have high agreement.""" + ft = ferro_ta.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLENGULFING(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + assert agreement > 0.80 + + def test_pattern_sample_hammer(self): + """Spot check: CDLHAMMER should have high agreement.""" + ft = ferro_ta.CDLHAMMER(OPEN, HIGH, LOW, CLOSE) + ta = talib.CDLHAMMER(OPEN, HIGH, LOW, CLOSE) + + agreement = np.mean(ft == ta) + assert agreement > 0.80 diff --git a/vendor/ferro-ta-main/tests/integration/test_wasm_node_conformance.py b/vendor/ferro-ta-main/tests/integration/test_wasm_node_conformance.py new file mode 100644 index 0000000..1f0f48b --- /dev/null +++ b/vendor/ferro-ta-main/tests/integration/test_wasm_node_conformance.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +import ferro_ta + +ROOT = Path(__file__).resolve().parents[2] +WASM_DIR = ROOT / "wasm" +PKG_JS = WASM_DIR / "pkg" / "ferro_ta_wasm.js" +SCRIPT = WASM_DIR / "conformance_node.js" + + +def _write_node_conformance_script(path: Path) -> None: + path.write_text( + """ +const wasm = require("./node/ferro_ta_wasm.js"); + +function toArray(x) { + return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v))); +} + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 46.21, 46.02, 45.78]); +const high = new Float64Array([44.71, 44.5, 44.6, 44.09, 44.79, 45.2, 45.44, 45.73, 46.01, 46.44, 46.21, 46.39, 46.53, 46.3, 46.12]); +const low = new Float64Array([43.9, 43.8, 43.9, 43.2, 43.9, 44.2, 44.6, 44.8, 45.2, 45.5, 45.4, 45.5, 45.7, 45.6, 45.4]); +const volume = new Float64Array([1200, 1320, 1250, 1460, 1500, 1670, 1720, 1810, 1900, 2020, 1980, 2100, 2170, 2140, 2080]); + +const payload = { + sma: toArray(wasm.sma(close, 5)), + ema: toArray(wasm.ema(close, 5)), + wma: toArray(wasm.wma(close, 5)), + rsi: toArray(wasm.rsi(close, 5)), + adx: toArray(wasm.adx(high, low, close, 5)), + mfi: toArray(wasm.mfi(high, low, close, volume, 5)), +}; + +process.stdout.write(JSON.stringify(payload)); +""".strip() + + "\n", + encoding="utf-8", + ) + + +def _run_node_conformance() -> dict[str, list[float | None]]: + if shutil.which("node") is None: + pytest.skip("node is required for wasm/node conformance test") + if not PKG_JS.exists(): + pytest.skip( + "wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`" + ) + + _write_node_conformance_script(SCRIPT) + try: + out = subprocess.check_output( + ["node", str(SCRIPT)], + cwd=WASM_DIR, + text=True, + ) + finally: + if SCRIPT.exists(): + SCRIPT.unlink() + return json.loads(out) + + +def _to_jsonable(arr: np.ndarray) -> list[float | None]: + vals = np.asarray(arr, dtype=np.float64) + return [None if np.isnan(x) else float(x) for x in vals] + + +def _assert_close_with_null_nan( + actual: list[float | None], + expected: list[float | None], + *, + atol: float, +) -> None: + assert len(actual) == len(expected) + a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64) + e = np.array( + [np.nan if v is None else float(v) for v in expected], dtype=np.float64 + ) + np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True) + + +def test_wasm_node_matches_python_core_indicators() -> None: + close = np.array( + [ + 44.34, + 44.09, + 44.15, + 43.61, + 44.33, + 44.83, + 45.10, + 45.42, + 45.84, + 46.08, + 45.89, + 46.03, + 46.21, + 46.02, + 45.78, + ], + dtype=np.float64, + ) + high = np.array( + [ + 44.71, + 44.50, + 44.60, + 44.09, + 44.79, + 45.20, + 45.44, + 45.73, + 46.01, + 46.44, + 46.21, + 46.39, + 46.53, + 46.30, + 46.12, + ], + dtype=np.float64, + ) + low = np.array( + [ + 43.90, + 43.80, + 43.90, + 43.20, + 43.90, + 44.20, + 44.60, + 44.80, + 45.20, + 45.50, + 45.40, + 45.50, + 45.70, + 45.60, + 45.40, + ], + dtype=np.float64, + ) + volume = np.array( + [ + 1200.0, + 1320.0, + 1250.0, + 1460.0, + 1500.0, + 1670.0, + 1720.0, + 1810.0, + 1900.0, + 2020.0, + 1980.0, + 2100.0, + 2170.0, + 2140.0, + 2080.0, + ], + dtype=np.float64, + ) + + node_payload = _run_node_conformance() + + py_expected = { + "sma": _to_jsonable(ferro_ta.SMA(close, 5)), + "ema": _to_jsonable(ferro_ta.EMA(close, 5)), + "wma": _to_jsonable(ferro_ta.WMA(close, 5)), + "rsi": _to_jsonable(ferro_ta.RSI(close, 5)), + "adx": _to_jsonable(ferro_ta.ADX(high, low, close, 5)), + "mfi": _to_jsonable(ferro_ta.MFI(high, low, close, volume, 5)), + } + + for name, expected in py_expected.items(): + assert name in node_payload + _assert_close_with_null_nan(node_payload[name], expected, atol=1e-9) diff --git a/vendor/ferro-ta-main/tests/unit/analysis/__init__.py b/vendor/ferro-ta-main/tests/unit/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py b/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py new file mode 100644 index 0000000..9b5229f --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_advanced.py @@ -0,0 +1,2017 @@ +"""Tests for the advanced backtesting engine. + +Covers all 10 test groups from the plan: +1. backtest_ohlcv_core +2. compute_performance_metrics +3. extract_trades +4. backtest_multi_asset_core +5. monte_carlo_bootstrap +6. walk_forward_indices +7. kelly_fraction / half_kelly_fraction +8. BacktestEngine (Python API) +9. walk_forward() (Python API) +10. monte_carlo() (Python API) +""" + +from __future__ import annotations + +import math + +import numpy as np +import numpy.testing as npt +import pytest +from ferro_ta._ferro_ta import ( + backtest_core, + backtest_multi_asset_core, + backtest_ohlcv_core, + compute_performance_metrics, + drawdown_series, + half_kelly_fraction, + kelly_fraction, + monte_carlo_bootstrap, + walk_forward_indices, +) +from ferro_ta._ferro_ta import ( + extract_trades_ohlcv as extract_trades, +) + +from ferro_ta.analysis.backtest import ( + AdvancedBacktestResult, + BacktestEngine, + BacktestResult, + MonteCarloResult, + PortfolioBacktestResult, + WalkForwardResult, + backtest, + backtest_portfolio, + monte_carlo, + rsi_strategy, + walk_forward, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_ohlcv(n: int = 100, seed: int = 42) -> tuple: + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.005, n)) + high = close * (1 + rng.uniform(0, 0.01, n)) + low = close * (1 - rng.uniform(0, 0.01, n)) + signals = np.where(np.arange(n) % 20 < 10, 1.0, -1.0).astype(np.float64) + return open_, high, low, close, signals + + +def _all_finite(arr: np.ndarray) -> bool: + return bool(np.all(np.isfinite(arr[~np.isnan(arr)]))) + + +# =========================================================================== +# Group 1: backtest_ohlcv_core +# =========================================================================== + + +class TestBacktestOhlcvCore: + def test_returns_five_arrays(self): + o, h, l, c, s = _make_ohlcv() + result = backtest_ohlcv_core(o, h, l, c, s) + assert len(result) == 5 + + def test_shapes_match_input(self): + o, h, l, c, s = _make_ohlcv(n=80) + pos, fp, br, sr, eq = backtest_ohlcv_core(o, h, l, c, s) + for arr in (pos, fp, br, sr, eq): + assert arr.shape == (80,) + + def test_equity_starts_at_one(self): + o, h, l, c, s = _make_ohlcv() + _, _, _, _, eq = backtest_ohlcv_core(o, h, l, c, s) + assert eq[0] == pytest.approx(1.0, abs=1e-9) + + def test_no_lookahead_bias(self): + """Position at bar 0 must always be 0 (signal not yet available).""" + o, h, l, c, s = _make_ohlcv() + pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + assert pos[0] == 0.0 + + def test_stop_loss_reduces_equity_relative_to_no_stop(self): + """With a tight stop-loss, equity should differ from no-stop run.""" + o, h, l, c, s = _make_ohlcv(n=200) + _, _, _, _, eq_no_stop = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_with_stop = backtest_ohlcv_core( + o, h, l, c, s, stop_loss_pct=0.005 + ) + # They should differ (stop-loss triggered on at least one bar) + assert not np.allclose(eq_no_stop, eq_with_stop) + + def test_fill_prices_nan_when_flat(self): + """fill_prices must be NaN whenever the position is 0.""" + o, h, l, c, s = _make_ohlcv() + pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + flat_mask = pos == 0.0 + assert np.all(np.isnan(fp[flat_mask])) + + def test_market_close_mode_different_from_open(self): + o, h, l, c, s = _make_ohlcv(n=150) + _, _, _, sr_open, _ = backtest_ohlcv_core( + o, h, l, c, s, fill_mode="market_open" + ) + _, _, _, sr_close, _ = backtest_ohlcv_core( + o, h, l, c, s, fill_mode="market_close" + ) + # Different fill modes → different returns + assert not np.allclose(sr_open, sr_close, equal_nan=True) + + def test_raises_on_mismatched_lengths(self): + o, h, l, c, s = _make_ohlcv() + with pytest.raises(Exception): + backtest_ohlcv_core(o[:-1], h, l, c, s) + + +# =========================================================================== +# Group 2: compute_performance_metrics +# =========================================================================== + + +class TestComputePerformanceMetrics: + EXPECTED_KEYS = { + "total_return", + "cagr", + "annualized_vol", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "avg_drawdown", + "max_drawdown_duration_bars", + "avg_drawdown_duration_bars", + "ulcer_index", + "omega_ratio", + "win_rate", + "profit_factor", + "r_expectancy", + "avg_win", + "avg_loss", + "tail_ratio", + "skewness", + "kurtosis", + "best_bar", + "worst_bar", + "n_trades", + } + + def _run(self, n: int = 200, seed: int = 0): + rng = np.random.default_rng(seed) + r = rng.standard_normal(n) * 0.01 + eq = np.cumprod(1 + r) + return compute_performance_metrics(r, eq) + + def test_all_expected_keys_present(self): + m = self._run() + assert self.EXPECTED_KEYS.issubset(set(m.keys())) + + def test_sharpe_all_positive_returns(self): + """Constant +1% daily returns → Sharpe = (annualised) > 0.""" + r = np.full(252, 0.01) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + assert m["sharpe"] > 0 + + def test_max_drawdown_matches_drawdown_series(self): + rng = np.random.default_rng(7) + r = rng.standard_normal(300) * 0.015 + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + _, max_dd_ref = drawdown_series(eq) + assert m["max_drawdown"] == pytest.approx(max_dd_ref, abs=1e-9) + + def test_cagr_formula(self): + r = np.full(252, 0.01) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + # Rust computes CAGR as (eq[-1]/eq[0])^(ppy/n) - 1, treating eq[0] as start equity + expected_cagr = (eq[-1] / eq[0]) ** (252.0 / len(r)) - 1.0 + assert m["cagr"] == pytest.approx(expected_cagr, rel=1e-6) + + def test_win_rate_between_0_and_1(self): + m = self._run() + assert 0.0 <= m["win_rate"] <= 1.0 + + def test_max_drawdown_nonpositive(self): + m = self._run() + assert m["max_drawdown"] <= 0.0 + + def test_total_return_sign(self): + r = np.full(100, 0.005) + eq = np.cumprod(1 + r) + m = compute_performance_metrics(r, eq) + assert m["total_return"] > 0.0 + + def test_raises_on_short_input(self): + with pytest.raises(Exception): + compute_performance_metrics(np.array([0.01]), np.array([1.01])) + + def test_raises_on_mismatched_lengths(self): + with pytest.raises(Exception): + compute_performance_metrics(np.ones(10) * 0.01, np.ones(20)) + + +# =========================================================================== +# Group 3: extract_trades +# =========================================================================== + + +class TestExtractTrades: + def _run_ohlcv(self, n: int = 100): + o, h, l, c, s = _make_ohlcv(n=n) + pos, fp, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + return pos, fp, h, l + + def test_returns_nine_arrays(self): + pos, fp, h, l = self._run_ohlcv() + result = extract_trades(pos, fp, h, l) + assert len(result) == 9 + + def test_all_arrays_same_length(self): + pos, fp, h, l = self._run_ohlcv(n=200) + arrays = extract_trades(pos, fp, h, l) + lengths = {len(a) for a in arrays} + assert len(lengths) == 1 # all same length + + def test_duration_bars_positive(self): + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, _, _, _, _, dur, _, _ = extract_trades(pos, fp, h, l) + assert np.all(dur >= 0) + + def test_exit_bar_gte_entry_bar(self): + pos, fp, h, l = self._run_ohlcv(n=200) + eb, xb, _, _, _, _, _, _, _ = extract_trades(pos, fp, h, l) + assert np.all(xb >= eb) + + def test_direction_is_plus_minus_one(self): + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, d, _, _, _, _, _, _ = extract_trades(pos, fp, h, l) + if len(d) > 0: + assert set(np.unique(d)).issubset({1.0, -1.0}) + + def test_mfe_gte_mae(self): + """MFE (best) must always be >= MAE (worst) within the trade.""" + pos, fp, h, l = self._run_ohlcv(n=200) + _, _, _, _, _, _, _, mae, mfe = extract_trades(pos, fp, h, l) + if len(mae) > 0: + assert np.all(mfe >= mae) + + def test_raises_on_mismatched_lengths(self): + pos, fp, h, l = self._run_ohlcv() + with pytest.raises(Exception): + extract_trades(pos[:-1], fp, h, l) + + +# =========================================================================== +# Group 4: backtest_multi_asset_core +# =========================================================================== + + +class TestBacktestMultiAssetCore: + def test_single_asset_matches_backtest_core(self): + """1-asset multi_asset == scalar backtest_core with same weights.""" + rng = np.random.default_rng(99) + n = 150 + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + signals = np.where(np.arange(n) % 15 < 7, 1.0, -1.0).astype(np.float64) + + # Single asset via multi_asset (weights = signals) + close2d = close.reshape(n, 1) + w2d = signals.reshape(n, 1) + ar, pr, pe = backtest_multi_asset_core(close2d, w2d) + + # Same via backtest_core + _, _, sr_ref, eq_ref = backtest_core(close, signals) + + npt.assert_allclose(pe, np.asarray(eq_ref), rtol=1e-6) + + def test_returns_shapes(self): + n, k = 100, 5 + rng = np.random.default_rng(0) + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100 + w2d = np.ones((n, k)) * 0.2 + ar, pr, pe = backtest_multi_asset_core(c2d, w2d) + assert ar.shape == (n, k) + assert pr.shape == (n,) + assert pe.shape == (n,) + + def test_parallel_equals_serial(self): + n, k = 120, 4 + rng = np.random.default_rng(1) + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100 + w2d = rng.choice([-1.0, 0.0, 1.0], size=(n, k)).astype(np.float64) + _, _, pe_par = backtest_multi_asset_core(c2d, w2d, parallel=True) + _, _, pe_ser = backtest_multi_asset_core(c2d, w2d, parallel=False) + npt.assert_allclose(pe_par, pe_ser, rtol=1e-10) + + def test_raises_on_mismatched_shapes(self): + c2d = np.ones((50, 3)) + w2d = np.ones((50, 4)) # wrong n_assets + with pytest.raises(Exception): + backtest_multi_asset_core(c2d, w2d) + + def test_equity_starts_at_one(self): + n, k = 50, 2 + c2d = np.ones((n, k)) * 100.0 + w2d = np.zeros((n, k)) + _, _, pe = backtest_multi_asset_core(c2d, w2d) + assert pe[0] == pytest.approx(1.0, abs=1e-9) + + +# =========================================================================== +# Group 5: monte_carlo_bootstrap +# =========================================================================== + + +class TestMonteCarloBootstrap: + def _returns(self, n: int = 200, seed: int = 5): + rng = np.random.default_rng(seed) + return rng.standard_normal(n) * 0.01 + + def test_output_shape(self): + r = self._returns() + mc = monte_carlo_bootstrap(r, n_sims=50) + assert mc.shape == (50, 200) + + def test_seed_reproducibility(self): + r = self._returns() + mc1 = monte_carlo_bootstrap(r, n_sims=100, seed=7) + mc2 = monte_carlo_bootstrap(r, n_sims=100, seed=7) + npt.assert_array_equal(mc1, mc2) + + def test_different_seeds_differ(self): + r = self._returns() + mc1 = monte_carlo_bootstrap(r, n_sims=50, seed=1) + mc2 = monte_carlo_bootstrap(r, n_sims=50, seed=2) + assert not np.allclose(mc1, mc2) + + def test_equity_starts_at_one(self): + r = self._returns() + mc = monte_carlo_bootstrap(r, n_sims=20) + # Bootstrap resamples returns randomly, so mc[:,0] = 1 + random_return + # All first-bar equity values must be in range of possible (1+r) values + possible_first_bar = set(np.round(1.0 + r, 12)) + for val in mc[:, 0]: + assert any(abs(val - p) < 1e-9 for p in possible_first_bar) + + def test_block_bootstrap_shape(self): + r = self._returns(n=100) + mc = monte_carlo_bootstrap(r, n_sims=30, block_size=5) + assert mc.shape == (30, 100) + + def test_raises_on_empty_input(self): + with pytest.raises(Exception): + monte_carlo_bootstrap(np.array([0.01]), n_sims=10) + + +# =========================================================================== +# Group 6: walk_forward_indices +# =========================================================================== + + +class TestWalkForwardIndices: + def test_output_shape(self): + idx = walk_forward_indices(500, 200, 50) + assert idx.ndim == 2 + assert idx.shape[1] == 4 + + def test_non_anchored_fixed_train_window(self): + idx = walk_forward_indices(400, 200, 50) + n_folds = idx.shape[0] + assert n_folds >= 2 + for fold in idx: + tr_len = fold[1] - fold[0] + assert tr_len == 200 + + def test_anchored_growing_train_window(self): + idx = walk_forward_indices(400, 150, 50, anchored=True) + for fold in idx: + assert fold[0] == 0 # always starts at 0 + train_lengths = idx[:, 1] - idx[:, 0] + assert train_lengths[-1] >= train_lengths[0] + + def test_no_test_fold_overlap(self): + idx = walk_forward_indices(500, 200, 50) + # Test intervals should be non-overlapping (step = test_bars by default) + for i in range(len(idx) - 1): + assert idx[i, 3] <= idx[i + 1, 2] + + def test_all_test_folds_within_bounds(self): + n = 600 + idx = walk_forward_indices(n, 200, 100) + assert np.all(idx[:, 0] >= 0) + assert np.all(idx[:, 3] <= n) + + def test_step_bars_parameter(self): + idx_default = walk_forward_indices(500, 200, 50) + idx_step = walk_forward_indices(500, 200, 50, step_bars=25) + # Smaller step → more folds + assert idx_step.shape[0] >= idx_default.shape[0] + + def test_raises_when_no_folds_fit(self): + with pytest.raises(Exception): + walk_forward_indices(100, 80, 80) # 80+80 > 100 + + +# =========================================================================== +# Group 7: kelly_fraction / half_kelly_fraction +# =========================================================================== + + +class TestKellyFraction: + def test_positive_expectancy(self): + k = kelly_fraction(0.6, 0.02, 0.01) + assert k > 0.0 + + def test_zero_edge_returns_zero(self): + """win_rate = loss_rate AND avg_win = avg_loss → Kelly = 0.""" + k = kelly_fraction(0.5, 0.01, 0.01) + assert k == pytest.approx(0.0, abs=1e-9) + + def test_negative_expectancy_clamped_to_zero(self): + k = kelly_fraction(0.3, 0.01, 0.02) + assert k == 0.0 + + def test_half_kelly_is_half_of_kelly(self): + k = kelly_fraction(0.6, 0.03, 0.015) + hk = half_kelly_fraction(0.6, 0.03, 0.015) + assert hk == pytest.approx(k / 2.0, rel=1e-9) + + def test_result_clamped_to_one(self): + k = kelly_fraction(0.99, 0.5, 0.001) + assert k <= 1.0 + + def test_raises_on_invalid_win_rate(self): + with pytest.raises(Exception): + kelly_fraction(1.5, 0.01, 0.01) + + def test_raises_on_nonpositive_avg_win(self): + with pytest.raises(Exception): + kelly_fraction(0.6, 0.0, 0.01) + + +# =========================================================================== +# Group 8: BacktestEngine (Python API) +# =========================================================================== + + +class TestBacktestEngine: + def _close(self, n: int = 200, seed: int = 10) -> np.ndarray: + rng = np.random.default_rng(seed) + return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + + def test_run_returns_advanced_result(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert isinstance(r, AdvancedBacktestResult) + + def test_advanced_result_is_backtest_result(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert isinstance(r, BacktestResult) + + def test_chaining_returns_self(self): + engine = BacktestEngine() + assert engine.with_commission(0.001) is engine + assert engine.with_slippage(5.0) is engine + assert engine.with_stop_loss(0.02) is engine + + def test_all_metric_keys_present(self): + c = self._close() + r = BacktestEngine().run(c, "rsi_30_70") + assert "sharpe" in r.metrics + assert "max_drawdown" in r.metrics + assert "cagr" in r.metrics + + def test_drawdown_series_shape(self): + c = self._close() + r = BacktestEngine().run(c) + assert r.drawdown_series.shape == c.shape + + def test_drawdown_series_nonpositive(self): + c = self._close() + r = BacktestEngine().run(c) + assert np.all(r.drawdown_series <= 0.0) + + def test_engine_close_only_matches_backtest_func(self): + c = self._close() + r_engine = BacktestEngine().run(c, "rsi_30_70") + r_func = backtest(c, strategy="rsi_30_70") + npt.assert_allclose(r_engine.equity, r_func.equity, rtol=1e-9) + + def test_ohlcv_mode_runs(self): + c = self._close() + h = c * 1.01 + l = c * 0.99 + o = c * 0.999 + r = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_stop_loss(0.02) + .run(c) + ) + assert r.equity.shape == c.shape + + def test_trades_dataframe_columns(self): + c = self._close() + r = BacktestEngine().run(c, "sma_crossover") + if r.trades is not None: + expected_cols = { + "entry_bar", + "exit_bar", + "direction", + "entry_price", + "exit_price", + "pnl_pct", + "duration_bars", + "mae", + "mfe", + } + assert expected_cols.issubset(set(r.trades.columns)) + + def test_invalid_fill_mode_raises(self): + with pytest.raises(Exception): + BacktestEngine().with_fill_mode("invalid") + + +# =========================================================================== +# Group 9: walk_forward() Python API +# =========================================================================== + + +class TestWalkForward: + def _setup(self, n: int = 400): + rng = np.random.default_rng(99) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + param_grid = [{"timeperiod": p} for p in [10, 14, 20]] + return close, param_grid + + def test_returns_walk_forward_result(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert isinstance(r, WalkForwardResult) + + def test_fold_count_matches_indices(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert len(r.fold_results) == r.fold_indices.shape[0] + + def test_oos_equity_length(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + total_test_bars = sum( + int(r.fold_indices[i, 3]) - int(r.fold_indices[i, 2]) + for i in range(len(r.fold_results)) + ) + assert len(r.oos_equity) == total_test_bars + + def test_oos_metrics_has_sharpe(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert "sharpe" in r.oos_metrics + + def test_anchored_mode(self): + c, pg = self._setup() + r = walk_forward( + c, rsi_strategy, pg, train_bars=200, test_bars=50, anchored=True + ) + # In anchored mode, training always starts at 0 + assert np.all(r.fold_indices[:, 0] == 0) + + def test_param_stability_populated(self): + c, pg = self._setup() + r = walk_forward(c, rsi_strategy, pg, train_bars=200, test_bars=50) + assert "timeperiod" in r.param_stability + assert "most_chosen" in r.param_stability["timeperiod"] + + +# =========================================================================== +# Group 10: monte_carlo() Python API +# =========================================================================== + + +class TestMonteCarlo: + def _result(self, n: int = 200): + rng = np.random.default_rng(77) + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + return BacktestEngine().run(c, "rsi_30_70") + + def test_returns_monte_carlo_result(self): + r = self._result() + mc = monte_carlo(r, n_sims=100) + assert isinstance(mc, MonteCarloResult) + + def test_equity_curves_shape(self): + r = self._result(n=150) + mc = monte_carlo(r, n_sims=80) + assert mc.equity_curves.shape == (80, 150) + + def test_confidence_bounds_cover_median(self): + r = self._result() + mc = monte_carlo(r, n_sims=500, confidence=0.95) + assert np.all(mc.confidence_lower <= mc.median_curve + 1e-9) + assert np.all(mc.confidence_upper >= mc.median_curve - 1e-9) + + def test_prob_profit_in_range(self): + r = self._result() + mc = monte_carlo(r, n_sims=200) + assert 0.0 <= mc.prob_profit <= 1.0 + + def test_accepts_raw_array(self): + rng = np.random.default_rng(3) + returns = rng.standard_normal(100) * 0.01 + mc = monte_carlo(returns, n_sims=50) + assert isinstance(mc, MonteCarloResult) + + def test_seed_reproducibility(self): + r = self._result() + mc1 = monte_carlo(r, n_sims=50, seed=1) + mc2 = monte_carlo(r, n_sims=50, seed=1) + npt.assert_array_equal(mc1.equity_curves, mc2.equity_curves) + + def test_var_is_low_percentile_of_terminal_equity(self): + r = self._result() + mc = monte_carlo(r, n_sims=1000, confidence=0.95) + # VaR = 5th percentile of terminal equity + expected_var = float(np.percentile(mc.terminal_equity, 5.0)) + assert mc.var == pytest.approx(expected_var, rel=1e-6) + + +# =========================================================================== +# Backward compatibility guard +# =========================================================================== + + +class TestBackwardCompat: + def test_backtest_still_returns_backtest_result(self): + rng = np.random.default_rng(0) + c = np.cumprod(1 + rng.standard_normal(100) * 0.01) * 100.0 + r = backtest(c, strategy="rsi_30_70") + assert type(r) is BacktestResult + + def test_portfolio_backtest_result(self): + rng = np.random.default_rng(0) + n, k = 100, 3 + c2d = np.cumprod(1 + rng.standard_normal((n, k)) * 0.01, axis=0) * 100.0 + w2d = np.ones((n, k)) / k + r = backtest_portfolio(c2d, w2d) + assert isinstance(r, PortfolioBacktestResult) + assert r.portfolio_equity.shape == (n,) + + +# =========================================================================== +# Sprint 1: Limit orders, time-based exit, pct_range slippage +# =========================================================================== + + +class TestLimitOrders: + """Tests for limit-price order fill logic in backtest_ohlcv_core.""" + + def _ohlcv(self): + n = 50 + rng = np.random.default_rng(7) + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.002, 0.008, n)) + low = close * (1 - rng.uniform(0.002, 0.008, n)) + return open_, high, low, close, n + + def test_limit_nan_behaves_like_market(self): + """NaN limit prices should give identical results to no limit array.""" + o, h, l, c, n = self._ohlcv() + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + lp_nan = np.full(n, np.nan) + + pos_mkt, fp_mkt, _, sr_mkt, eq_mkt = backtest_ohlcv_core(o, h, l, c, signals) + pos_lim, fp_lim, _, sr_lim, eq_lim = backtest_ohlcv_core( + o, h, l, c, signals, limit_prices=lp_nan + ) + npt.assert_array_almost_equal(pos_mkt, pos_lim) + npt.assert_array_almost_equal(sr_mkt, sr_lim) + npt.assert_array_almost_equal(eq_mkt, eq_lim) + + def test_buy_limit_fills_at_limit_price(self): + """Buy limit fills when low <= limit_price and uses limit as fill price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 102.0) + low = np.full(n, 98.0) + # Buy signal at bar 0, limit price 99 — low=98 <= 99 so should fill + signals = np.zeros(n) + signals[0] = 1.0 # want to go long at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 99.0 # limit for bar 1 execution + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + # Bar 1 should have a fill at 99.0 (the limit price) + assert fp[1] == pytest.approx(99.0, rel=1e-6) + + def test_buy_limit_not_hit_no_fill(self): + """Buy limit is not filled when low > limit_price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 102.0) + low = np.full(n, 98.0) # low=98 + signals = np.zeros(n) + signals[0] = 1.0 # go long at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 97.0 # limit=97, but low=98 > 97 → no fill + + pos, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + # Position should stay 0 at bar 1 (limit not hit) + assert pos[1] == pytest.approx(0.0) + assert np.isnan(fp[1]) + + def test_sell_limit_fills_when_high_hits(self): + """Sell limit fills when high >= limit_price.""" + n = 10 + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 103.0) + low = np.full(n, 97.0) + signals = np.zeros(n) + signals[0] = -1.0 # go short at bar 1 + limit_prices = np.full(n, np.nan) + limit_prices[0] = 102.0 # high=103 >= 102 → fill + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + fill_mode="market_close", + limit_prices=limit_prices, + ) + assert fp[1] == pytest.approx(102.0, rel=1e-6) + + def test_engine_with_limit_orders(self): + """BacktestEngine.with_limit_orders with NaN limits matches market orders.""" + o, h, l, c, n = self._ohlcv() + # NaN limit prices = market orders; result must match engine without limit array + limit_prices = np.full(n, np.nan) + + result_mkt = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + result_lim = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_limit_orders(limit_prices) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result_lim, AdvancedBacktestResult) + npt.assert_array_almost_equal(result_mkt.equity, result_lim.equity) + + +class TestMaxHold: + """Tests for time-based exit (max_hold_bars).""" + + def _flat_ohlcv(self, n=30): + close = np.ones(n) * 100.0 + open_ = close.copy() + high = close * 1.005 + low = close * 0.995 + signals = np.ones(n) # always long signal + return open_, high, low, close, signals + + def test_position_exits_after_n_bars(self): + """Position should be closed after max_hold_bars regardless of signal.""" + o, h, l, c, s = self._flat_ohlcv(n=20) + max_hold = 5 + pos, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=max_hold) + + # Find first entry + entry_bar = None + for i in range(len(pos)): + if pos[i] != 0.0: + entry_bar = i + break + + assert entry_bar is not None + # Position should be 0 at entry_bar + max_hold + exit_bar = entry_bar + max_hold + if exit_bar < len(pos): + assert pos[exit_bar] == pytest.approx(0.0), ( + f"Expected exit at bar {exit_bar}, pos={pos[exit_bar]}" + ) + + def test_max_hold_zero_is_disabled(self): + """max_hold_bars=0 should not affect behaviour (disabled).""" + o, h, l, c, s = self._flat_ohlcv(n=20) + pos_no_hold, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s) + pos_hold_0, _, _, _, _ = backtest_ohlcv_core(o, h, l, c, s, max_hold_bars=0) + npt.assert_array_almost_equal(pos_no_hold, pos_hold_0) + + def test_engine_with_max_hold(self): + """BacktestEngine.with_max_hold integrates correctly.""" + rng = np.random.default_rng(99) + n = 100 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 1.001 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_max_hold(5) + .run(c, strategy="rsi_30_70") + ) + assert isinstance(result, AdvancedBacktestResult) + + def test_max_hold_stop_takes_priority(self): + """A stop-loss that triggers before max_hold should exit early.""" + n = 20 + close = np.array([100.0] * 5 + [95.0] * 15) # price drops on bar 5 + open_ = close.copy() + high = close * 1.002 + low = np.array([100.0] * 5 + [93.0] * 15) # low hits stop at bar 5 + signals = np.ones(n) # always long + + pos_sl, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.05, + max_hold_bars=10, + ) + pos_hold, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.05, + ) + # Both should exit around the same time (stop triggers before hold limit) + # At least the stop-loss exit should happen — position goes to 0 before bar 10+1 + assert any(pos_sl[5:11] == 0.0), ( + "Stop-loss should have triggered before max_hold" + ) + + +class TestSlippagePctRange: + """Tests for pct_range slippage mode.""" + + def _ohlcv_wide_range(self, n=20): + """OHLCV with a wide bar range to make pct_range slippage measurable.""" + close = np.full(n, 100.0) + open_ = np.full(n, 100.0) + high = np.full(n, 110.0) # range = 10 (10%) + low = np.full(n, 90.0) + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + return open_, high, low, close, signals + + def test_pct_range_more_costly_than_zero_slippage(self): + """With wide bar range, pct_range slip should reduce final equity vs no slip.""" + o, h, l, c, s = self._ohlcv_wide_range() + _, _, _, _, eq_no_slip = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10) + # pct_range slippage = 0.10 × (110-90)/100 = 0.02 = 200bps per trade + assert eq_pct[-1] < eq_no_slip[-1] + + def test_pct_range_more_costly_than_bps_equivalent(self): + """pct_range with wide range should be costlier than modest bps slip.""" + o, h, l, c, s = self._ohlcv_wide_range() + # bps slip: 5bps = 0.05% of fill, small + _, _, _, _, eq_bps = backtest_ohlcv_core(o, h, l, c, s, slippage_bps=5.0) + # pct_range: 10% of 20-wide range = 2.0 absolute, or 2% of close=100 + _, _, _, _, eq_pct = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.10) + assert eq_pct[-1] < eq_bps[-1] + + def test_pct_range_zero_equals_no_slippage(self): + """slippage_pct_range=0 should give same result as no slippage.""" + o, h, l, c, s = self._ohlcv_wide_range() + _, _, _, _, eq_base = backtest_ohlcv_core(o, h, l, c, s) + _, _, _, _, eq_zero = backtest_ohlcv_core(o, h, l, c, s, slippage_pct_range=0.0) + npt.assert_array_almost_equal(eq_base, eq_zero) + + def test_engine_with_slippage_pct_range(self): + """BacktestEngine.with_slippage_pct_range integrates correctly.""" + rng = np.random.default_rng(17) + n = 80 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 1.001 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_slippage_pct_range(0.05) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result, AdvancedBacktestResult) + + +# =========================================================================== +# Group 11: Phase 1 Features (spread_bps, breakeven_stop, bracket order priority) +# =========================================================================== + + +from ferro_ta._ferro_ta import CommissionModel as RustCommissionModel + + +class TestPhase1Features: + """Tests for Phase 1 features: spread_bps, breakeven_pct, bracket order priority.""" + + def test_spread_bps_increases_cost(self): + """CommissionModel with spread_bps=10 should produce lower equity than spread_bps=0.""" + rng = np.random.default_rng(99) + n = 200 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.005 + l = c * 0.995 + o = c * 0.999 + signals = np.where(np.arange(n) % 20 < 10, 1.0, 0.0).astype(np.float64) + + # Build a commission model with spread_bps=0 + cm_no_spread = RustCommissionModel() + cm_no_spread.spread_bps = 0.0 + + # Build a commission model with spread_bps=10 + cm_with_spread = RustCommissionModel() + cm_with_spread.spread_bps = 10.0 + + _, _, _, _, eq_no_spread = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_no_spread + ) + _, _, _, _, eq_with_spread = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_with_spread + ) + + # Spread adds cost on each trade leg → should produce lower or equal final equity + assert eq_with_spread[-1] <= eq_no_spread[-1], ( + f"spread equity {eq_with_spread[-1]:.6f} should be <= no-spread equity {eq_no_spread[-1]:.6f}" + ) + + def test_spread_bps_getter_setter(self): + """CommissionModel spread_bps getter/setter round-trip works correctly.""" + m = RustCommissionModel() + assert m.spread_bps == 0.0 + m.spread_bps = 5.0 + assert m.spread_bps == pytest.approx(5.0) + + def test_spread_bps_total_cost(self): + """CommissionModel.total_cost includes spread cost at correct magnitude.""" + m = RustCommissionModel() + m.spread_bps = 20.0 # 20 bps total round-trip = 10 bps each leg + trade_value = 100_000.0 + cost = m.total_cost(trade_value, 1.0, True) + # Expected: 10 bps = 0.001 * 100_000 = 100 per leg + assert cost == pytest.approx(100.0, rel=1e-6) + + def test_breakeven_stop_prevents_loss(self): + """With breakeven_pct=0.02, after price rises 3% then falls, exit should be near entry.""" + # Build synthetic data: entry at bar 1, then price rises 3%, then falls below entry + # Bar layout: [100, 103, 103, 101, 99, 99, 99, 99, 99] + # We want a long signal from bar 0 onwards + n = 20 + # Create price data: starts at 100, rises to 103 at bar 3, then drops to 97 + close = np.array([100.0] * 3 + [103.0] * 3 + [97.0] * (n - 6), dtype=np.float64) + open_ = close.copy() + high = close * 1.002 + low = close * 0.998 + # Set high of bar 3 to clearly trigger breakeven (>= 103 = entry * 1.03) + # entry happens at bar 1 (open of bar 1 = 100), so entry_price ≈ 100 + # breakeven triggers when h >= 100 * 1.02 = 102 → triggers at bar 3 (close=103, high≥103) + high[3] = 103.5 # clearly above 102 (entry * 1.02) + # At bar 6, low drops below entry (100), breakeven stop should trigger + low[6] = 99.0 # below entry price 100 → breakeven stop fires + signals = np.ones(n, dtype=np.float64) # always long + + _, fp, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.0, + breakeven_pct=0.02, + ) + # Find first non-NaN fill price after the entry bar (entry at bar 1) + # Breakeven exit should happen at or near entry price (100), not at a big loss + exit_fps = fp[~np.isnan(fp)] + # The breakeven stop exit should be at entry_price (~100), not at 97 or lower + # Entry fill is at open of bar 1 = 100.0 + # After breakeven activates, stop = entry (~100). So exit fill should be ~100 + assert len(exit_fps) >= 1 + # The exit fill from breakeven should be close to entry price (within 1%) + # (first fill = entry, subsequent fills = exits) + if len(exit_fps) >= 2: + breakeven_exit = exit_fps[1] + assert breakeven_exit >= 99.0, ( + f"breakeven exit {breakeven_exit} should be >= 99 (near entry 100)" + ) + + def test_bracket_order_tp_fires_before_sl(self): + """When both SL and TP are breached in same bar, and open is near TP → TP fires.""" + # Long trade: entry at price 100 + # Bar where both trigger: open=109 (very close to TP=110), high=112, low=90 + # SL = 100*(1-0.10) = 90, TP = 100*(1+0.10) = 110 + # open=109 is closer to TP=110 (dist=1) than to SL=90 (dist=19) → TP fires + entry_price = 100.0 + close = np.array( + [entry_price, entry_price, entry_price, 108.0, 108.0], dtype=np.float64 + ) + open_ = np.array( + [entry_price, entry_price, entry_price, 109.0, 108.0], dtype=np.float64 + ) + high = np.array( + [entry_price, entry_price, entry_price, 112.0, 108.0], dtype=np.float64 + ) + low = np.array( + [entry_price, entry_price, entry_price, 88.0, 108.0], dtype=np.float64 + ) + signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64) + + _, fp, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.10, + take_profit_pct=0.10, + ) + # Bar 3 is where both trigger. TP=110. SL=90. Open=109 → TP fires. + # fill price at bar 3 should be ~110 (TP), not 90 (SL) + assert not np.isnan(fp[3]), "Expected a fill at bar 3" + tp_level = entry_price * 1.10 # 110 + assert fp[3] == pytest.approx(tp_level, rel=1e-6), ( + f"Expected TP fill at ~{tp_level}, got {fp[3]}" + ) + + def test_bracket_order_sl_fires_before_tp(self): + """When both SL and TP are breached in same bar, and open is near SL → SL fires.""" + # Long trade: entry at 100 + # Bar where both trigger: open=91 (very close to SL=90), high=112, low=88 + # SL=90, TP=110. open=91 is closer to SL=90 (dist=1) than to TP=110 (dist=19) → SL fires + entry_price = 100.0 + close = np.array( + [entry_price, entry_price, entry_price, 95.0, 95.0], dtype=np.float64 + ) + open_ = np.array( + [entry_price, entry_price, entry_price, 91.0, 95.0], dtype=np.float64 + ) + high = np.array( + [entry_price, entry_price, entry_price, 112.0, 95.0], dtype=np.float64 + ) + low = np.array( + [entry_price, entry_price, entry_price, 88.0, 95.0], dtype=np.float64 + ) + signals = np.array([0.0, 1.0, 1.0, 1.0, 0.0], dtype=np.float64) + + _, fp, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + stop_loss_pct=0.10, + take_profit_pct=0.10, + ) + # Bar 3: both SL(90) and TP(110) are triggered. open=91 is close to SL → SL fires. + assert not np.isnan(fp[3]), "Expected a fill at bar 3" + sl_level = entry_price * 0.90 # 90 + assert fp[3] == pytest.approx(sl_level, rel=1e-6), ( + f"Expected SL fill at ~{sl_level}, got {fp[3]}" + ) + + def test_breakeven_engine_integration(self): + """BacktestEngine.with_breakeven_stop integrates correctly.""" + rng = np.random.default_rng(77) + n = 150 + c = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + h = c * 1.01 + l = c * 0.99 + o = c * 0.999 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_breakeven_stop(0.02) + .run(c, strategy="sma_crossover", fast=5, slow=20) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + +# =========================================================================== +# Phase 2: Portfolio & Risk Features +# =========================================================================== + + +class TestPhase2Features: + """Tests for Phase 2: short borrow cost, margin/leverage, circuit breakers, + and portfolio constraints.""" + + # ----------------------------------------------------------------------- + # Helper: synthetic OHLCV with controllable direction + # ----------------------------------------------------------------------- + + def _make_short_ohlcv(self, n: int = 100, seed: int = 7) -> tuple: + """Produce OHLCV where the price trends downward (good for shorts).""" + rng = np.random.default_rng(seed) + # Steady downtrend + close = 100.0 * np.cumprod(1 - np.abs(rng.standard_normal(n)) * 0.005) + open_ = close * (1 + rng.uniform(-0.002, 0.002, n)) + high = np.maximum(open_, close) * (1 + rng.uniform(0, 0.003, n)) + low = np.minimum(open_, close) * (1 - rng.uniform(0, 0.003, n)) + # Always short + signals = np.full(n, -1.0, dtype=np.float64) + return open_, high, low, close, signals + + # ----------------------------------------------------------------------- + # 1. Short borrow cost + # ----------------------------------------------------------------------- + + def test_short_borrow_cost_reduces_equity(self): + """Short position with short_borrow_rate_annual=0.10 should produce lower + final equity than the same run with no borrow cost.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, signals = self._make_short_ohlcv(n=252) + + # Commission model without borrow cost + cm_no_borrow = CommissionModel() + + # Commission model with 10% annual borrow cost + cm_with_borrow = CommissionModel() + cm_with_borrow.short_borrow_rate_annual = 0.10 + + _, _, _, _, eq_no_borrow = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_no_borrow + ) + _, _, _, _, eq_with_borrow = backtest_ohlcv_core( + o, h, l, c, signals, commission=cm_with_borrow + ) + + # With borrow cost, final equity must be strictly lower + assert float(eq_with_borrow[-1]) < float(eq_no_borrow[-1]), ( + f"Expected borrow-cost equity {eq_with_borrow[-1]:.6f} < " + f"no-borrow equity {eq_no_borrow[-1]:.6f}" + ) + + def test_short_borrow_cost_getter_setter(self): + """CommissionModel.short_borrow_rate_annual getter/setter works.""" + from ferro_ta._ferro_ta import CommissionModel + + cm = CommissionModel() + assert cm.short_borrow_rate_annual == pytest.approx(0.0) + cm.short_borrow_rate_annual = 0.05 + assert cm.short_borrow_rate_annual == pytest.approx(0.05) + + def test_short_borrow_zero_rate_no_effect(self): + """With short_borrow_rate_annual=0, borrow cost should not affect equity.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, signals = self._make_short_ohlcv(n=50) + cm_zero = CommissionModel() + cm_zero.short_borrow_rate_annual = 0.0 + + _, _, _, sr1, eq1 = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, sr2, eq2 = backtest_ohlcv_core(o, h, l, c, signals, commission=cm_zero) + + npt.assert_allclose(eq1, eq2, rtol=1e-10) + + def test_short_borrow_engine_integration(self): + """BacktestEngine with commission model including short_borrow_rate_annual runs.""" + from ferro_ta._ferro_ta import CommissionModel + + o, h, l, c, sigs = self._make_short_ohlcv(n=80) + cm = CommissionModel() + cm.short_borrow_rate_annual = 0.08 + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_commission_model(cm) + .run(c, lambda x: np.full(len(x), -1.0)) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 2. Margin call force-close + # ----------------------------------------------------------------------- + + def test_margin_call_force_closes_position(self): + """A declining price sequence triggers a margin call and force-closes the long.""" + n = 20 + # Price drops sharply — enough to trigger a margin call on a long + open_ = np.ones(n) * 100.0 + high = np.ones(n) * 101.0 + low = np.ones(n) * 99.0 + close = np.ones(n) * 100.0 + + # After bar 5, price tanks sharply every bar + for i in range(5, n): + drop = 0.30 # 30% per bar — guaranteed to exceed margin + open_[i] = open_[i - 1] * (1 - drop) + high[i] = open_[i] * 1.001 + low[i] = open_[i] * 0.999 + close[i] = open_[i] + + # Always long + signals = np.ones(n, dtype=np.float64) + + # margin_ratio=0.2 means 20% margin (5x leverage) + # margin_call_pct=0.5 means call when equity hits 50% of initial margin + _, _, _, sr_margin, eq_margin = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + margin_ratio=0.2, + margin_call_pct=0.5, + ) + _, _, _, sr_no_margin, eq_no_margin = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + ) + + # Margin call should cause a forced exit, resulting in different equity + # (the margin version stops losses earlier) + assert not np.allclose(eq_margin, eq_no_margin), ( + "Expected margin call to alter equity curve" + ) + + def test_margin_disabled_when_ratio_zero(self): + """margin_ratio=0 should behave identically to not passing the parameter.""" + o, h, l, c, signals = _make_ohlcv(n=80) + + _, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, _, eq_zero_margin = backtest_ohlcv_core( + o, h, l, c, signals, margin_ratio=0.0 + ) + + npt.assert_allclose(eq_default, eq_zero_margin, rtol=1e-10) + + def test_margin_engine_builder(self): + """BacktestEngine.with_leverage builder sets parameters without error.""" + o, h, l, c, _ = _make_ohlcv(n=60) + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_leverage(margin_ratio=0.2, margin_call_pct=0.5) + .run(c, lambda x: np.ones(len(x))) + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 3. Total loss limit (circuit breaker) + # ----------------------------------------------------------------------- + + def test_total_loss_limit_halts_trading(self): + """total_loss_limit=0.10 should halt trading once equity drops 10%.""" + n = 100 + # Construct a losing price sequence: steady decline + close = 100.0 * np.cumprod(np.full(n, 0.99)) # -1% per bar + open_ = close * 1.001 + high = close * 1.005 + low = close * 0.995 + + # Always long (so position loses money as price falls) + signals = np.ones(n, dtype=np.float64) + + pos_with_limit, _, _, _, eq_with_limit = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + total_loss_limit=0.10, + ) + pos_no_limit, _, _, _, eq_no_limit = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + ) + + # After circuit break the position should be 0 + # Check that at some point positions go to 0 in the limited version + # while the unlimited version stays long + assert np.any(pos_with_limit == 0.0), ( + "Expected some bars with no position after circuit break" + ) + # Unlimited version should stay long throughout (except bar 0) + assert np.all(pos_no_limit[1:] == 1.0), "No-limit should stay long" + + def test_total_loss_limit_does_not_trip_with_no_loss(self): + """total_loss_limit does not trip on a profitable sequence.""" + n = 60 + close = 100.0 * np.cumprod(np.full(n, 1.005)) # +0.5% per bar + open_ = close * 0.999 + high = close * 1.003 + low = close * 0.997 + signals = np.ones(n, dtype=np.float64) + + pos, _, _, _, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + total_loss_limit=0.20, + ) + # No circuit break should fire; position stays long + assert np.all(pos[1:] == 1.0) + + # ----------------------------------------------------------------------- + # 4. Daily (per-bar) loss limit circuit breaker + # ----------------------------------------------------------------------- + + def test_daily_loss_limit_halts_after_large_bar_loss(self): + """A single large losing bar triggers the daily_loss_limit circuit breaker.""" + n = 30 + close = np.ones(n) * 100.0 + open_ = np.ones(n) * 100.0 + high = np.ones(n) * 101.0 + low = np.ones(n) * 99.0 + + # Create one very large losing bar at bar 10 (price drops 15%) + # Strategy is long, so this is a large loss + crash_bar = 10 + close[crash_bar] = close[crash_bar - 1] * 0.85 + open_[crash_bar] = close[crash_bar - 1] * 0.86 + high[crash_bar] = open_[crash_bar] * 1.001 + low[crash_bar] = close[crash_bar] * 0.999 + + signals = np.ones(n, dtype=np.float64) + + pos, _, _, sr, _ = backtest_ohlcv_core( + open_, + high, + low, + close, + signals, + daily_loss_limit=0.05, # 5% per-bar loss limit + ) + + # After the crash bar, circuit breaker should fire and position should go to 0 + # Check bars after crash_bar+1 have position 0 + assert np.any(pos[crash_bar + 1 :] == 0.0), ( + "Expected circuit breaker to zero out position after crash bar" + ) + + def test_daily_loss_limit_zero_is_disabled(self): + """daily_loss_limit=0 (default) should not change behavior.""" + o, h, l, c, signals = _make_ohlcv(n=80) + + _, _, _, _, eq_default = backtest_ohlcv_core(o, h, l, c, signals) + _, _, _, _, eq_zero_limit = backtest_ohlcv_core( + o, h, l, c, signals, daily_loss_limit=0.0 + ) + + npt.assert_allclose(eq_default, eq_zero_limit, rtol=1e-10) + + def test_loss_limits_engine_builder(self): + """BacktestEngine.with_loss_limits builder sets parameters.""" + o, h, l, c, _ = _make_ohlcv(n=80) + + result = ( + BacktestEngine() + .with_ohlcv(high=h, low=l, open_=o) + .with_loss_limits(daily=0.05, total=0.20) + .run(c, strategy="sma_crossover") + ) + assert isinstance(result, AdvancedBacktestResult) + assert np.all(np.isfinite(result.equity)) + + # ----------------------------------------------------------------------- + # 5. Portfolio constraints + # ----------------------------------------------------------------------- + + def test_portfolio_max_asset_weight_clamps_signal(self): + """max_asset_weight=0.5 should clamp signals from ±1 to ±0.5.""" + rng = np.random.default_rng(99) + n_bars, n_assets = 100, 3 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + # Alternating ±1 signals — shape (n_bars, n_assets) + row_flags = (np.arange(n_bars) % 10 < 5)[:, None] # (n, 1) + weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype( + np.float64 + ) + + # Run without constraint (unit signals) + asset_ret_unconstrained, port_ret_unconstrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_asset_weight=1.0, + ) + + # Run with max_asset_weight=0.5 + asset_ret_constrained, port_ret_constrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_asset_weight=0.5, + ) + + # Constrained returns should have smaller magnitude + assert np.abs(port_ret_constrained).sum() < np.abs( + port_ret_unconstrained + ).sum() or np.allclose( + np.abs(port_ret_constrained).sum(), + np.abs(port_ret_unconstrained).sum() * 0.5, + rtol=0.05, + ), "max_asset_weight=0.5 should reduce absolute returns by ~50%" + + def test_portfolio_max_gross_exposure_constrains_sum(self): + """max_gross_exposure=1.0 should limit total abs(weights).""" + rng = np.random.default_rng(55) + n_bars, n_assets = 80, 4 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + # Always long all assets = gross exposure of 4.0 + weights_2d = np.ones((n_bars, n_assets), dtype=np.float64) + + # With max_gross_exposure=1.0, total abs weight should be normalized to 1 + ar_constrained, pr_constrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + max_gross_exposure=1.0, + ) + ar_unconstrained, pr_unconstrained, _ = backtest_multi_asset_core( + np.ascontiguousarray(close_2d), + np.ascontiguousarray(weights_2d), + ) + + # Constrained portfolio should have ~1/4 the returns magnitude + ratio = np.abs(pr_constrained).sum() / (np.abs(pr_unconstrained).sum() + 1e-12) + assert ratio < 0.5, ( + f"Expected constrained to be much smaller, got ratio={ratio:.3f}" + ) + + def test_portfolio_constraints_engine_builder(self): + """BacktestEngine.with_portfolio_constraints stores the parameters.""" + engine = BacktestEngine().with_portfolio_constraints( + max_asset_weight=0.3, + max_gross_exposure=1.5, + max_net_exposure=0.5, + ) + assert engine._max_asset_weight == pytest.approx(0.3) + assert engine._max_gross_exposure == pytest.approx(1.5) + assert engine._max_net_exposure == pytest.approx(0.5) + + def test_backtest_portfolio_with_constraints(self): + """backtest_portfolio accepts portfolio constraint kwargs.""" + from ferro_ta.analysis.backtest import backtest_portfolio + + rng = np.random.default_rng(11) + n_bars, n_assets = 60, 2 + close_2d = ( + np.cumprod(1 + rng.standard_normal((n_bars, n_assets)) * 0.01, axis=0) + * 100.0 + ) + row_flags = (np.arange(n_bars) % 10 < 5)[:, None] + weights_2d = np.where(np.tile(row_flags, (1, n_assets)), 1.0, -1.0).astype( + np.float64 + ) + + result = backtest_portfolio( + close_2d, + weights_2d, + max_asset_weight=0.5, + max_gross_exposure=0.8, + ) + assert isinstance(result, PortfolioBacktestResult) + assert np.all(np.isfinite(result.portfolio_equity)) + + +# =========================================================================== +# Phase 3: Data & UX features +# =========================================================================== + + +class TestPhase3Features: + """Tests for Phase 3: resample, adjust, multitf, and plot modules.""" + + # ----------------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------------- + def _make_ohlcv(self, n=100, seed=42): + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.002, n)) + high = close * (1 + rng.uniform(0.001, 0.006, n)) + low = close * (1 - rng.uniform(0.001, 0.006, n)) + volume = rng.uniform(1_000, 10_000, n) + return open_, high, low, close, volume + + # ----------------------------------------------------------------------- + # 1. resample_ohlcv — factor=4, 20 bars → 5 coarse bars + # ----------------------------------------------------------------------- + def test_resample_ohlcv_factor4(self): + from ferro_ta.analysis.resample import resample_ohlcv + + o, h, l, c, v = self._make_ohlcv(n=20) + co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4) + + assert co.shape == (5,) + assert ch.shape == (5,) + assert cl.shape == (5,) + assert cc.shape == (5,) + assert cv.shape == (5,) + + # open = first bar of each group + for i in range(5): + assert co[i] == pytest.approx(o[i * 4]) + + # high = max of group + for i in range(5): + assert ch[i] == pytest.approx(h[i * 4 : i * 4 + 4].max()) + + # low = min of group + for i in range(5): + assert cl[i] == pytest.approx(l[i * 4 : i * 4 + 4].min()) + + # close = last bar of group + for i in range(5): + assert cc[i] == pytest.approx(c[i * 4 + 3]) + + # volume = sum of group + for i in range(5): + assert cv[i] == pytest.approx(v[i * 4 : i * 4 + 4].sum()) + + # ----------------------------------------------------------------------- + # 2. resample_ohlcv — non-divisible length: 22 bars, factor=4 → 5 coarse bars + # ----------------------------------------------------------------------- + def test_resample_ohlcv_non_divisible(self): + from ferro_ta.analysis.resample import resample_ohlcv + + o, h, l, c, v = self._make_ohlcv(n=22) + co, ch, cl, cc, cv = resample_ohlcv(o, h, l, c, v, factor=4) + + # 22 // 4 = 5 complete bars, last 2 fine bars are dropped + assert len(co) == 5 + assert len(ch) == 5 + + # ----------------------------------------------------------------------- + # 3. align_to_coarse — roundtrip test + # ----------------------------------------------------------------------- + def test_align_to_coarse_roundtrip(self): + from ferro_ta.analysis.resample import align_to_coarse + + coarse = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) + factor = 4 + n_fine = 20 + + fine = align_to_coarse(coarse, factor, n_fine) + + assert len(fine) == n_fine + + for i, val in enumerate(coarse): + expected = np.full(factor, val) + npt.assert_array_equal(fine[i * factor : i * factor + factor], expected) + + # ----------------------------------------------------------------------- + # 4. adjust_for_splits — 2-for-1 split at bar 50 in 100-bar series + # ----------------------------------------------------------------------- + def test_adjust_for_splits_halves_historical(self): + from ferro_ta.analysis.adjust import adjust_for_splits + + close = np.ones(100) * 100.0 + adjusted = adjust_for_splits(close, split_factors=[2.0], split_indices=[50]) + + # Prices before split (bars 0-49) should be halved + npt.assert_array_almost_equal(adjusted[:50], np.full(50, 50.0)) + # Prices from split onwards unchanged + npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 5. adjust_for_dividends — dividend at bar 50; prices before reduced + # ----------------------------------------------------------------------- + def test_adjust_for_dividends_reduces_historical(self): + from ferro_ta.analysis.adjust import adjust_for_dividends + + close = np.ones(100) * 100.0 + # bar 49 close = 100.0, dividend = 5.0 → factor = 95/100 = 0.95 + adjusted = adjust_for_dividends(close, dividends=[5.0], ex_date_indices=[50]) + + # Prices before ex-date should be scaled by 0.95 + expected_factor = (100.0 - 5.0) / 100.0 + npt.assert_array_almost_equal( + adjusted[:50], np.full(50, 100.0 * expected_factor) + ) + # Prices from ex-date onwards unchanged + npt.assert_array_almost_equal(adjusted[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 6. adjust_ohlcv — volume doubles on 2-for-1 split (inverse adjustment) + # ----------------------------------------------------------------------- + def test_adjust_ohlcv_volume_increases_on_split(self): + from ferro_ta.analysis.adjust import adjust_ohlcv + + n = 100 + close = np.ones(n) * 100.0 + open_ = close.copy() + high = close.copy() + low = close.copy() + volume = np.ones(n) * 1000.0 + + ao, ah, al, ac, av = adjust_ohlcv( + open_, + high, + low, + close, + volume, + split_factors=[2.0], + split_indices=[50], + ) + + # Volume before the split is multiplied by factor (2x) — more shares pre-split + npt.assert_array_almost_equal(av[:50], np.full(50, 2000.0)) + # Volume at or after split unchanged + npt.assert_array_almost_equal(av[50:], np.full(50, 1000.0)) + + # Prices before split halved + npt.assert_array_almost_equal(ac[:50], np.full(50, 50.0)) + npt.assert_array_almost_equal(ac[50:], np.full(50, 100.0)) + + # ----------------------------------------------------------------------- + # 7. MultiTimeframeEngine — runs on 200 fine bars, returns valid result + # ----------------------------------------------------------------------- + def test_multitf_engine_runs(self): + from ferro_ta.analysis.multitf import MultiTimeframeEngine + + rng = np.random.default_rng(99) + n_fine = 200 + close_fine = np.cumprod(1 + rng.standard_normal(n_fine) * 0.005) * 100.0 + + result = ( + MultiTimeframeEngine(factor=4) + .with_htf_strategy("rsi_30_70") + .run(close_fine) + ) + + assert isinstance(result, AdvancedBacktestResult) + assert len(result.equity) == n_fine + assert np.all(np.isfinite(result.equity)) + assert result.equity[0] == pytest.approx(1.0, rel=1e-6) + + # ----------------------------------------------------------------------- + # 8. plot_backtest — returns a plotly Figure (skip if plotly not installed) + # ----------------------------------------------------------------------- + def test_plot_backtest_returns_figure(self): + pytest.importorskip("plotly", reason="plotly not installed") + from plotly.graph_objects import Figure + + from ferro_ta.analysis.plot import plot_backtest + + rng = np.random.default_rng(7) + n = 100 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + high = close * 1.01 + low = close * 0.99 + open_ = close * 0.999 + + result = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, strategy="rsi_30_70") + ) + + fig = plot_backtest(result, show=False, return_fig=True) + + assert isinstance(fig, Figure) + + +# =========================================================================== +# Phase 4: Regime Detection, Portfolio Optimization, PaperTrader +# =========================================================================== + + +class TestPhase4Features: + """Tests for Phase 4 differentiation features.""" + + # ----------------------------------------------------------------------- + # Helpers + # ----------------------------------------------------------------------- + + def _make_close(self, n: int = 300, seed: int = 77) -> np.ndarray: + rng = np.random.default_rng(seed) + return np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + + def _make_ohlcv_local(self, n: int = 300, seed: int = 77): + rng = np.random.default_rng(seed) + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.005, n)) + high = close * (1 + rng.uniform(0, 0.01, n)) + low = close * (1 - rng.uniform(0, 0.01, n)) + return open_, high, low, close + + # ----------------------------------------------------------------------- + # 1. detect_volatility_regime + # ----------------------------------------------------------------------- + + def test_volatility_regime_labels_three_states(self): + from ferro_ta.analysis.regime import detect_volatility_regime + + close = self._make_close(300) + labels = detect_volatility_regime(close, window=20, n_regimes=3) + assert labels.shape == (300,) + valid_values = {-1, 0, 1, 2} + assert set(np.unique(labels)).issubset(valid_values) + # Some valid (non-warmup) bars should be labeled + assert np.any(labels >= 0) + + # ----------------------------------------------------------------------- + # 2. detect_trend_regime + # ----------------------------------------------------------------------- + + def test_trend_regime_bull_bear(self): + from ferro_ta.analysis.regime import detect_trend_regime + + # Uptrend: price steadily rising + n = 300 + close_up = np.linspace(100, 200, n) + labels_up = detect_trend_regime(close_up, fast=10, slow=50) + valid = labels_up[labels_up != 0] + assert len(valid) > 0, "Expected some labeled bars after warmup" + # Most valid bars should be bull (1) + bull_frac = (valid == 1).sum() / len(valid) + assert bull_frac > 0.5, ( + f"Expected mostly bull bars in uptrend, got {bull_frac:.2%}" + ) + + # Downtrend: price steadily declining + close_dn = np.linspace(200, 100, n) + labels_dn = detect_trend_regime(close_dn, fast=10, slow=50) + valid_dn = labels_dn[labels_dn != 0] + assert len(valid_dn) > 0 + bear_frac = (valid_dn == -1).sum() / len(valid_dn) + assert bear_frac > 0.5, ( + f"Expected mostly bear bars in downtrend, got {bear_frac:.2%}" + ) + + # ----------------------------------------------------------------------- + # 3. detect_combined_regime + # ----------------------------------------------------------------------- + + def test_combined_regime_states(self): + from ferro_ta.analysis.regime import detect_combined_regime + + close = self._make_close(500) + labels = detect_combined_regime(close, vol_window=20, fast=20, slow=50) + assert labels.shape == (500,) + valid_values = {-1, 0, 1, 2, 3, 4, 5} + assert set(np.unique(labels)).issubset(valid_values) + + # ----------------------------------------------------------------------- + # 4. RegimeFilter + # ----------------------------------------------------------------------- + + def test_regime_filter_zeros_disallowed(self): + from ferro_ta.analysis.regime import RegimeFilter, detect_combined_regime + + n = 500 + close = self._make_close(n) + signals = np.ones(n) + + # Only allow regime 0 (bull + low vol) + rf = RegimeFilter(allowed_regimes=[0], vol_window=20, fast=20, slow=50) + filtered = rf.filter(signals, close) + + regimes = detect_combined_regime(close, vol_window=20, fast=20, slow=50) + # Bars NOT in regime 0 should have filtered signal = 0 + disallowed_mask = regimes != 0 + assert np.all(filtered[disallowed_mask] == 0.0) + # Bars in regime 0 should retain their signal + allowed_mask = regimes == 0 + if np.any(allowed_mask): + assert np.all(filtered[allowed_mask] == 1.0) + + # ----------------------------------------------------------------------- + # 5. mean_variance_optimize + # ----------------------------------------------------------------------- + + def test_mean_variance_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import mean_variance_optimize + + rng = np.random.default_rng(0) + returns = rng.standard_normal((252, 4)) * 0.01 + w = mean_variance_optimize(returns) + assert w.shape == (4,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= -1e-9), "Weights should be non-negative (no short)" + + # ----------------------------------------------------------------------- + # 6. risk_parity_optimize + # ----------------------------------------------------------------------- + + def test_risk_parity_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import risk_parity_optimize + + rng = np.random.default_rng(1) + returns = rng.standard_normal((252, 3)) * 0.01 + w = risk_parity_optimize(returns) + assert w.shape == (3,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= 0.0) + + # ----------------------------------------------------------------------- + # 7. max_sharpe_optimize + # ----------------------------------------------------------------------- + + def test_max_sharpe_weights_sum_to_one(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import max_sharpe_optimize + + rng = np.random.default_rng(2) + returns = rng.standard_normal((252, 5)) * 0.01 + w = max_sharpe_optimize(returns) + assert w.shape == (5,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + assert np.all(w >= -1e-9) + + # ----------------------------------------------------------------------- + # 8. PortfolioOptimizer fluent builder + # ----------------------------------------------------------------------- + + def test_portfolio_optimizer_fluent(self): + pytest.importorskip("scipy", reason="scipy not installed") + from ferro_ta.analysis.optimize import PortfolioOptimizer + + rng = np.random.default_rng(3) + returns = rng.standard_normal((252, 3)) * 0.01 + + for method in ("min_variance", "risk_parity", "max_sharpe"): + w = ( + PortfolioOptimizer() + .with_method(method) + .with_lookback(100) + .optimize(returns) + ) + assert w.shape == (3,) + assert float(np.sum(w)) == pytest.approx(1.0, abs=1e-6) + + # ----------------------------------------------------------------------- + # 9. PaperTrader: basic fills + # ----------------------------------------------------------------------- + + def test_paper_trader_fills_on_signal(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(10) + n = 20 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.001, 0.005, n)) + low = close * (1 - rng.uniform(0.001, 0.005, n)) + + trader = PaperTrader(initial_capital=100_000) + signals = np.where(np.arange(n) % 6 < 3, 1.0, -1.0).astype(float) + + results = [] + for i in range(n): + r = trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + results.append(r) + + # Should have produced at least one fill after first bar + fills = [r for r in results if r.filled] + assert len(fills) > 0 + # Equity curve length should match bars + assert len(trader.equity_curve) == n + # Final equity should be finite + assert math.isfinite(trader.equity) + + # ----------------------------------------------------------------------- + # 10. PaperTrader: stop-loss triggers + # ----------------------------------------------------------------------- + + def test_paper_trader_stop_loss_triggers(self): + from ferro_ta.analysis.live import PaperTrader + + # Price rises on entry then falls sharply — SL should trigger + n = 20 + close = np.array( + [100.0] * 5 + + [98.0, 96.0, 94.0, 92.0, 90.0] # declining + + [88.0, 86.0, 84.0, 82.0, 80.0, 78.0, 76.0, 74.0, 72.0, 70.0], + dtype=float, + ) + open_ = close * 1.001 + high = close * 1.005 + low = close * 0.99 # Low drops to trigger SL + + sl_pct = 0.03 # 3% stop-loss + trader = PaperTrader(initial_capital=100_000, stop_loss_pct=sl_pct) + + # Signal: go long on bar 0 + signals = np.zeros(n) + signals[0] = 1.0 # enter long + + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + # With 3% SL and price dropping >3% below entry, we expect a trade to close + # Final position should be 0 (SL triggered exit) + assert trader.position == 0.0 or len(trader.trades) > 0 + + # ----------------------------------------------------------------------- + # 11. PaperTrader: reset clears state + # ----------------------------------------------------------------------- + + def test_paper_trader_reset_clears_state(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(20) + n = 30 + close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0 + open_ = close * 0.999 + high = close * 1.01 + low = close * 0.99 + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(float) + + trader = PaperTrader(initial_capital=50_000) + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + assert len(trader.equity_curve) > 0 + + trader.reset() + + assert trader.position == 0.0 + assert trader.equity == pytest.approx(1.0) + assert len(trader.trades) == 0 + assert len(trader.equity_curve) == 0 + assert trader.equity_abs == pytest.approx(50_000.0) + + # ----------------------------------------------------------------------- + # 12. PaperTrader equity matches backtest_ohlcv_core + # ----------------------------------------------------------------------- + + def test_paper_trader_equity_matches_backtest(self): + from ferro_ta.analysis.live import PaperTrader + + rng = np.random.default_rng(42) + n = 50 + close = np.cumprod(1 + rng.standard_normal(n) * 0.005) * 100.0 + open_ = close * (1 - rng.uniform(0, 0.003, n)) + high = close * (1 + rng.uniform(0.001, 0.005, n)) + low = close * (1 - rng.uniform(0.001, 0.005, n)) + signals = np.where(np.arange(n) % 10 < 5, 1.0, -1.0).astype(np.float64) + + # Vectorized Rust engine + _, _, _, _, eq_rust = backtest_ohlcv_core(open_, high, low, close, signals) + + # PaperTrader bar-by-bar + trader = PaperTrader(initial_capital=100_000) + for i in range(n): + trader.on_bar(open_[i], high[i], low[i], close[i], signals[i]) + + eq_paper = np.array(trader.equity_curve) + assert eq_paper.shape == eq_rust.shape + npt.assert_allclose( + eq_paper, + eq_rust, + rtol=1e-6, + atol=1e-9, + err_msg="PaperTrader equity curve does not match backtest_ohlcv_core", + ) diff --git a/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_v2.py b/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_v2.py new file mode 100644 index 0000000..440da41 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/analysis/test_backtest_v2.py @@ -0,0 +1,546 @@ +""" +v1.1.0 backtest feature tests. + +Covers: +- CommissionModel: total_cost, presets, round-trip JSON, save/load +- Currency: INR/USD formatting, from_code lookup +- BacktestEngine: initial_capital, commission_model, trailing_stop, benchmark +- AdvancedBacktestResult: equity_abs, pnl_abs in trade log, summary fields +- Volatility-target position sizing +- Benchmark comparison metrics +""" + +from __future__ import annotations + +import os +import tempfile + +import numpy as np +import pytest +from ferro_ta._ferro_ta import CommissionModel + +from ferro_ta.analysis.backtest import ( + EUR, + GBP, + INR, + JPY, + USD, + USDT, + BacktestEngine, + Currency, + format_currency, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def close_500(): + """500-bar synthetic close price series.""" + rng = np.random.default_rng(12345) + return np.cumprod(1.0 + rng.standard_normal(500) * 0.01) * 100.0 + + +@pytest.fixture +def ohlcv_500(close_500): + close = close_500 + high = close * 1.005 + low = close * 0.995 + open_ = close * 0.999 + volume = np.full(len(close), 1_000_000.0) + return open_, high, low, close, volume + + +# =========================================================================== +# TestCommissionModel +# =========================================================================== + + +class TestCommissionModel: + def test_zero_model_costs_nothing(self): + m = CommissionModel.zero() + assert m.total_cost(100_000, 1, True) == 0.0 + assert m.total_cost(100_000, 1, False) == 0.0 + + def test_flat_per_order(self): + m = CommissionModel() + m.flat_per_order = 20.0 + assert m.total_cost(100_000, 1, True) == pytest.approx(20.0) + assert m.total_cost(100_000, 1, False) == pytest.approx(20.0) + + def test_max_brokerage_cap(self): + m = CommissionModel() + m.flat_per_order = 0.0 + m.rate_of_value = 0.001 # 0.1% + m.max_brokerage = 20.0 + # 0.1% of 50_000 = 50, capped at 20 + assert m.total_cost(50_000, 1, True) == pytest.approx(20.0) + # 0.1% of 5_000 = 5, not capped + assert m.total_cost(5_000, 1, True) == pytest.approx(5.0) + + def test_stt_buy_side_only(self): + m = CommissionModel() + m.stt_rate = 0.001 + m.stt_on_buy = True + m.stt_on_sell = False + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(100.0) + assert sell_cost == pytest.approx(0.0) + + def test_stt_sell_side_only(self): + m = CommissionModel() + m.stt_rate = 0.00025 + m.stt_on_buy = False + m.stt_on_sell = True + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(0.0) + assert sell_cost == pytest.approx(25.0) + + def test_gst_on_brokerage_exchange_not_stt(self): + m = CommissionModel() + m.flat_per_order = 20.0 + m.exchange_charges_rate = 0.0001 + m.gst_rate = 0.18 + m.stt_rate = 0.001 + m.stt_on_sell = True + # GST = 0.18 * (20 + 0.0001 * 100_000) = 0.18 * 30 = 5.4 + # STT = 100 (sell side) + total = m.total_cost(100_000, 1, False) + expected_gst = 0.18 * (20.0 + 0.0001 * 100_000) + assert total == pytest.approx(20.0 + 100.0 + 0.0001 * 100_000 + expected_gst) + + def test_stamp_duty_buy_only(self): + m = CommissionModel() + m.stamp_duty_rate = 0.00015 + buy_cost = m.total_cost(100_000, 1, True) + sell_cost = m.total_cost(100_000, 1, False) + assert buy_cost == pytest.approx(15.0) + assert sell_cost == pytest.approx(0.0) + + def test_per_lot_charge(self): + m = CommissionModel() + m.per_lot = 2.0 + # 5 lots + assert m.total_cost(50_000, 5, True) == pytest.approx(10.0) + + def test_cost_fraction(self): + m = CommissionModel() + m.flat_per_order = 20.0 + frac = m.cost_fraction(100_000, 1, True, 100_000.0) + assert frac == pytest.approx(20.0 / 100_000.0) + + def test_cost_fraction_zero_capital(self): + m = CommissionModel() + m.flat_per_order = 20.0 + assert m.cost_fraction(100_000, 1, True, 0.0) == 0.0 + + def test_proportional_preset(self): + m = CommissionModel.proportional(0.001) + assert m.total_cost(100_000, 1, True) == pytest.approx(100.0) + assert m.gst_rate == 0.0 + + def test_repr_contains_key_fields(self): + m = CommissionModel.equity_delivery_india() + r = repr(m) + assert "CommissionModel" in r + assert "lot_size" in r + + +class TestCommissionPresets: + def test_equity_delivery_india_smoke(self): + m = CommissionModel.equity_delivery_india() + # Buy ₹1L trade: brokerage cap ₹20, STT ₹100 (both sides) + cost = m.total_cost(100_000, 1, True) + assert cost > 0.0 + assert cost < 500.0 # sanity upper bound + # Brokerage should be capped at ₹20 + assert m.flat_per_order == 0.0 + assert m.max_brokerage == pytest.approx(20.0) + assert m.stt_on_buy is True + assert m.stt_on_sell is True + + def test_equity_intraday_india_smoke(self): + m = CommissionModel.equity_intraday_india() + cost_buy = m.total_cost(100_000, 1, True) + cost_sell = m.total_cost(100_000, 1, False) + # STT only on sell side for intraday + assert m.stt_on_buy is False + assert m.stt_on_sell is True + assert cost_sell > cost_buy # sell has more cost (STT) + + def test_futures_india_smoke(self): + m = CommissionModel.futures_india() + assert m.flat_per_order == pytest.approx(20.0) + assert m.stt_on_buy is False + assert m.stt_on_sell is True + assert m.lot_size == pytest.approx(25.0) + + def test_options_india_smoke(self): + m = CommissionModel.options_india() + assert m.flat_per_order == pytest.approx(20.0) + assert m.stt_rate == pytest.approx(0.0015) + assert m.lot_size == pytest.approx(25.0) + + +class TestCommissionFix: + """The old 'commission_per_trade=20.0' bug would subtract ₹20 from 1.0-normalized + equity — a 2000% error. The new model correctly computes 0.02% fraction.""" + + def test_flat_20_on_1L_capital_is_tiny_fraction(self): + m = CommissionModel() + m.flat_per_order = 20.0 + frac = m.cost_fraction(100_000, 1, True, 100_000.0) + # ₹20 / ₹100_000 = 0.02% + assert frac == pytest.approx(20.0 / 100_000.0, rel=1e-6) + assert frac < 0.01 # definitely not 2000% + + def test_commission_reduces_equity_vs_no_commission(self): + rng = np.random.default_rng(99) + close = np.cumprod(1.0 + rng.standard_normal(200) * 0.01) * 100.0 + m = CommissionModel.equity_intraday_india() + r_comm = ( + BacktestEngine() + .with_commission_model(m) + .with_initial_capital(100_000) + .run(close, "sma_crossover") + ) + r_none = ( + BacktestEngine().with_initial_capital(100_000).run(close, "sma_crossover") + ) + # Commission should reduce final equity (or keep equal if zero trades) + assert r_comm.final_equity <= r_none.final_equity + + +class TestCommissionSaveLoad: + def test_to_json_from_json_round_trip(self): + m = CommissionModel.equity_delivery_india() + j = m.to_json() + m2 = CommissionModel.from_json(j) + assert m == m2 + assert m2.stt_rate == pytest.approx(m.stt_rate) + assert m2.lot_size == pytest.approx(m.lot_size) + assert m2.gst_rate == pytest.approx(m.gst_rate) + + def test_save_load_round_trip(self): + m = CommissionModel.futures_india() + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + path = f.name + try: + m.save(path) + assert os.path.exists(path) + m2 = CommissionModel.load(path) + assert m == m2 + finally: + os.unlink(path) + + def test_from_json_invalid_raises(self): + with pytest.raises(Exception): + CommissionModel.from_json("{invalid json") + + def test_load_missing_file_raises(self): + with pytest.raises(Exception): + CommissionModel.load("/nonexistent/path/commission.json") + + +# =========================================================================== +# TestCurrency +# =========================================================================== + + +class TestCurrency: + def test_inr_lakh_grouping(self): + assert INR.format(123456.78) == "₹1,23,456.78" + assert INR.format(1000000.0) == "₹10,00,000.00" + assert INR.format(10000000.0) == "₹1,00,00,000.00" + assert INR.format(100.0) == "₹100.00" + assert INR.format(1234.5) == "₹1,234.50" + + def test_inr_negative(self): + result = INR.format(-5000.0) + assert result.startswith("-₹") + assert "5,000.00" in result + + def test_usd_standard_grouping(self): + assert USD.format(1234567.89) == "$1,234,567.89" + assert USD.format(0.5) == "$0.50" + assert USD.format(1000.0) == "$1,000.00" + + def test_jpy_no_decimals(self): + result = JPY.format(1000000.0) + assert result == "¥1,000,000" + + def test_eur_format(self): + assert "€" in EUR.format(100.0) + + def test_gbp_format(self): + assert "£" in GBP.format(100.0) + + def test_usdt_format(self): + assert "₮" in USDT.format(100.0) + + def test_format_currency_helper(self): + assert format_currency(123456.78) == "₹1,23,456.78" + assert format_currency(1000.0, USD) == "$1,000.00" + + def test_currency_immutable(self): + with pytest.raises(AttributeError): + INR.code = "USD" # type: ignore[misc] + + def test_currency_equality(self): + c1 = Currency.from_code("INR") + assert c1 == INR + assert INR != USD + + def test_currency_hash_usable_in_dict(self): + d = {INR: 100_000, USD: 100} + assert d[INR] == 100_000 + + +# =========================================================================== +# TestInitialCapital +# =========================================================================== + + +class TestInitialCapital: + def test_equity_abs_shape(self, close_500): + result = ( + BacktestEngine() + .with_initial_capital(200_000) + .run(close_500, "sma_crossover") + ) + assert result.equity_abs.shape == result.equity.shape + + def test_equity_abs_is_equity_times_capital(self, close_500): + capital = 150_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .run(close_500, "sma_crossover") + ) + np.testing.assert_allclose(result.equity_abs, result.equity * capital) + + def test_summary_contains_capital_fields(self, close_500): + capital = 100_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .run(close_500, "sma_crossover") + ) + s = result.summary() + assert "initial_capital" in s + assert "final_capital" in s + assert "absolute_pnl" in s + assert s["initial_capital"] == pytest.approx(capital) + assert s["final_capital"] == pytest.approx(result.equity_abs[-1]) + assert s["absolute_pnl"] == pytest.approx(s["final_capital"] - capital) + + def test_pnl_abs_in_trade_log(self, close_500, ohlcv_500): + open_, high, low, close, _ = ohlcv_500 + capital = 100_000.0 + result = ( + BacktestEngine() + .with_initial_capital(capital) + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, "sma_crossover") + ) + if result.trades is not None and len(result.trades) > 0: + assert "pnl_abs" in result.trades.columns + np.testing.assert_allclose( + result.trades["pnl_abs"].values, + result.trades["pnl_pct"].values * capital, + ) + + +class TestINRRepr: + def test_repr_shows_inr_symbol(self, close_500): + result = ( + BacktestEngine() + .with_currency(INR) + .with_initial_capital(100_000) + .run(close_500, "sma_crossover") + ) + r = repr(result) + assert "₹" in r + + def test_currency_code_in_summary(self, close_500): + result = ( + BacktestEngine() + .with_currency("USD") + .with_initial_capital(10_000) + .run(close_500, "sma_crossover") + ) + s = result.summary() + assert s["currency"] == "USD" + + def test_unknown_currency_raises(self): + with pytest.raises(Exception, match="Unknown currency"): + BacktestEngine().with_currency("XYZ") + + +# =========================================================================== +# TestVolatilityTargetSizing +# =========================================================================== + + +class TestVolatilityTargetSizing: + def test_vol_target_runs_without_error(self, close_500): + result = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=0.10) + .run(close_500, "sma_crossover") + ) + assert len(result.equity) == len(close_500) + assert np.isfinite(result.final_equity) + + def test_vol_target_signals_are_scaled(self, close_500): + # With very low target vol the strategy should have fewer active positions + result_low = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=0.01) + .run(close_500, "sma_crossover") + ) + result_high = ( + BacktestEngine() + .with_position_sizing("volatility_target", target_vol=1.0) + .run(close_500, "sma_crossover") + ) + # Lower vol target → lower absolute position sizes → lower annualised vol + low_std = float(np.nanstd(result_low.strategy_returns)) + high_std = float(np.nanstd(result_high.strategy_returns)) + assert low_std <= high_std or np.isclose(low_std, high_std, rtol=0.5) + + +# =========================================================================== +# TestBenchmark +# =========================================================================== + + +class TestBenchmark: + def test_benchmark_metrics_present(self, close_500): + rng = np.random.default_rng(77) + benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0 + result = ( + BacktestEngine().with_benchmark(benchmark).run(close_500, "sma_crossover") + ) + s = result.summary() + assert "alpha" in s + assert "beta" in s + assert "tracking_error" in s + assert "information_ratio" in s + assert "benchmark_cagr" in s + + def test_identical_strategy_benchmark_has_low_tracking_error(self, close_500): + # When strategy returns = benchmark returns, tracking error ≈ 0 + # Use the equity as its own benchmark + result = ( + BacktestEngine().with_benchmark(close_500).run(close_500, "sma_crossover") + ) + m = result.metrics + # Beta should be finite + assert np.isfinite(m.get("beta", float("nan"))) + + def test_benchmark_wrong_length_ignored(self, close_500): + short_bench = close_500[:100] + # Should not raise — benchmark mismatch is silently ignored + result = ( + BacktestEngine().with_benchmark(short_bench).run(close_500, "sma_crossover") + ) + # alpha should NOT appear (length mismatch) + assert "alpha" not in result.metrics + + +# =========================================================================== +# TestTrailingStop +# =========================================================================== + + +class TestTrailingStop: + def test_trailing_stop_runs(self, ohlcv_500, close_500): + open_, high, low, close, _ = ohlcv_500 + result = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .with_trailing_stop(0.02) + .run(close, "sma_crossover") + ) + assert len(result.equity) == len(close) + assert np.isfinite(result.final_equity) + + def test_trailing_stop_reduces_losses_on_downtrend(self): + """Trailing stop should exit longs earlier on a falling market.""" + # Construct a clear downtrend after initial rise + prices = np.concatenate( + [ + np.linspace(100, 120, 50), # rise (signal stays long) + np.linspace(120, 60, 150), # sharp fall + ] + ) + high = prices * 1.002 + low = prices * 0.998 + open_ = prices * 0.999 + + result_trail = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .with_trailing_stop(0.03) + .run(prices, "sma_crossover") + ) + result_no_trail = ( + BacktestEngine() + .with_ohlcv(high=high, low=low, open_=open_) + .run(prices, "sma_crossover") + ) + # Trailing stop should yield better (or equal) max drawdown + dd_trail = result_trail.metrics.get("max_drawdown", 0.0) + dd_no_trail = result_no_trail.metrics.get("max_drawdown", 0.0) + # max_drawdown is negative; higher value = smaller drawdown + assert dd_trail >= dd_no_trail - 0.05 # allow 5% tolerance + + +# =========================================================================== +# TestBacktestEngineChaining +# =========================================================================== + + +class TestBacktestEngineChaining: + def test_full_chain_runs(self, close_500, ohlcv_500): + open_, high, low, close, _ = ohlcv_500 + rng = np.random.default_rng(42) + benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0 + + result = ( + BacktestEngine() + .with_currency("INR") + .with_initial_capital(100_000) + .with_commission_model(CommissionModel.equity_intraday_india()) + .with_trailing_stop(0.02) + .with_benchmark(benchmark) + .with_ohlcv(high=high, low=low, open_=open_) + .run(close, "sma_crossover") + ) + assert len(result.equity) == len(close) + assert result.currency == INR + assert result.initial_capital == pytest.approx(100_000.0) + assert np.isfinite(result.final_equity) + + s = result.summary() + assert s["currency"] == "INR" + assert "alpha" in s # benchmark was set + + def test_to_equity_dataframe(self, close_500): + result = ( + BacktestEngine() + .with_initial_capital(50_000) + .run(close_500, "sma_crossover") + ) + df = result.to_equity_dataframe() + assert "equity" in df.columns + assert "equity_abs" in df.columns + assert "strategy_returns" in df.columns + assert "drawdown" in df.columns + assert len(df) == len(close_500) + np.testing.assert_allclose(df["equity_abs"].values, result.equity_abs) diff --git a/vendor/ferro-ta-main/tests/unit/conftest.py b/vendor/ferro-ta-main/tests/unit/conftest.py new file mode 100644 index 0000000..a0de85d --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/conftest.py @@ -0,0 +1,7 @@ +""" +Unit test conftest — inherits shared fixtures from tests/conftest.py. + +pytest automatically loads parent conftest.py files, so all fixtures +defined in tests/conftest.py (ohlcv_500, ohlcv_100, ohlcv_real) are +available here without any explicit import. +""" diff --git a/vendor/ferro-ta-main/tests/unit/helpers.py b/vendor/ferro-ta-main/tests/unit/helpers.py new file mode 100644 index 0000000..10b06e6 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/helpers.py @@ -0,0 +1,159 @@ +"""Shared test helpers for ferro_ta unit tests. + +This module consolidates common assertion patterns and data-generation +utilities that are duplicated across multiple test files. Importing +from here keeps individual test modules DRY and makes it easier to +update assertion logic in one place. + +Usage +----- + from tests.unit.helpers import ( + nan_count, finite, assert_nan_warmup, assert_output_length, + assert_finite_values, assert_range, make_ohlcv, + ) + +Note: Each test file that already has inline helpers continues to work +unchanged. These helpers are provided for *new* tests and for gradual +consolidation of existing ones. +""" + +from __future__ import annotations + +import numpy as np + +# --------------------------------------------------------------------------- +# Array inspection helpers +# --------------------------------------------------------------------------- + + +def nan_count(arr: np.ndarray) -> int: + """Return the number of NaN entries in *arr*. + + Equivalent to the ``_nan_count`` functions duplicated in: + - tests/unit/test_ferro_ta.py + - tests/integration/test_vs_talib.py + - tests/integration/test_vs_pandas_ta.py + """ + return int(np.sum(np.isnan(arr))) + + +def finite(arr: np.ndarray) -> np.ndarray: + """Return only the finite (non-NaN) elements of *arr*. + + Equivalent to the ``_finite`` helpers in: + - tests/unit/test_ferro_ta.py + - tests/unit/streaming/test_streaming.py + """ + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# Common assertion helpers +# --------------------------------------------------------------------------- + + +def assert_output_length(result: np.ndarray, expected_length: int) -> None: + """Assert the indicator output has the expected length. + + This pattern (``assert len(result) == len(PRICES)``) appears 82+ times + across the test suite. + """ + assert len(result) == expected_length, ( + f"Expected output length {expected_length}, got {len(result)}" + ) + + +def assert_nan_warmup(result: np.ndarray, warmup: int) -> None: + """Assert that the first *warmup* values are NaN and that at least + one value after the warmup period is finite. + + This pattern (``assert np.all(np.isnan(result[:N]))``) appears 36+ + times in indicator tests. + """ + assert np.all(np.isnan(result[:warmup])), ( + f"Expected first {warmup} values to be NaN" + ) + if len(result) > warmup: + assert np.any(np.isfinite(result[warmup:])), ( + f"Expected at least one finite value after warmup index {warmup}" + ) + + +def assert_finite_values(arr: np.ndarray) -> None: + """Assert that *all* non-NaN values are finite (not +/-inf). + + The pattern ``np.all(np.isfinite(arr[~np.isnan(arr)]))`` appears + 60+ times across the test suite. + """ + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)), "Found non-finite (inf) values in output" + + +def assert_range( + arr: np.ndarray, + lo: float = 0.0, + hi: float = 100.0, + *, + ignore_nan: bool = True, +) -> None: + """Assert every (non-NaN) value in *arr* falls within [lo, hi]. + + The ``valid >= 0 and valid <= 100`` pattern appears 10+ times for + oscillator-type indicators (RSI, WILLR, CMO, etc.). + """ + values = arr[~np.isnan(arr)] if ignore_nan else arr + assert np.all(values >= lo), f"Found value below {lo}: {values.min()}" + assert np.all(values <= hi), f"Found value above {hi}: {values.max()}" + + +def assert_close( + actual: np.ndarray, + expected: np.ndarray, + *, + rtol: float = 1e-6, + atol: float = 0.0, + ignore_nan: bool = True, +) -> None: + """Assert element-wise closeness, optionally skipping NaN positions. + + Thin wrapper around ``np.testing.assert_allclose`` that mirrors the + NaN-stripping pattern seen in integration tests. + """ + if ignore_nan: + mask = ~(np.isnan(actual) | np.isnan(expected)) + actual = actual[mask] + expected = expected[mask] + np.testing.assert_allclose(actual, expected, rtol=rtol, atol=atol) + + +# --------------------------------------------------------------------------- +# Data generation helpers +# --------------------------------------------------------------------------- + + +def make_ohlcv( + n: int = 100, + seed: int = 42, + base_price: float = 100.0, +) -> dict[str, np.ndarray]: + """Generate reproducible synthetic OHLCV data. + + This pattern is duplicated across many test files with slight + variations (different seeds, base prices, spread logic). Using + this helper ensures consistent generation logic. + + Returns a dict with keys: close, high, low, open, volume. + """ + rng = np.random.default_rng(seed) + close = base_price + np.cumsum(rng.normal(0, 0.5, n)) + high = close + np.abs(rng.normal(0, 0.3, n)) + low = close - np.abs(rng.normal(0, 0.3, n)) + open_ = close + rng.normal(0, 0.1, n) + volume = rng.uniform(1000, 5000, n) + return { + "close": close, + "high": high, + "low": low, + "open": open_, + "volume": volume, + } diff --git a/vendor/ferro-ta-main/tests/unit/indicators/__init__.py b/vendor/ferro-ta-main/tests/unit/indicators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_cycle.py b/vendor/ferro-ta-main/tests/unit/indicators/test_cycle.py new file mode 100644 index 0000000..bea645d --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_cycle.py @@ -0,0 +1,183 @@ +"""Unit tests for ferro_ta.indicators.cycle""" + +import numpy as np + +from ferro_ta.indicators.cycle import ( + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + HT_TRENDLINE, + HT_TRENDMODE, +) + +# --------------------------------------------------------------------------- +# Shared fixtures — cycle indicators need at least ~64 bars for valid output +# --------------------------------------------------------------------------- + +N = 200 +t = np.linspace(0, 10 * np.pi, N) +SINE_CLOSE = 100 + 10 * np.sin(t) # clean sine wave + + +def _warmup_end(arr): + """Return index of first non-NaN value (or N if all NaN).""" + valid = np.where(~np.isnan(arr.astype(float)))[0] + return valid[0] if len(valid) else N + + +# --------------------------------------------------------------------------- +# HT_DCPERIOD +# --------------------------------------------------------------------------- + + +class TestHT_DCPERIOD: + def test_length(self): + result = HT_DCPERIOD(SINE_CLOSE) + assert len(result) == N + + def test_nan_warmup(self): + result = HT_DCPERIOD(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + assert np.all(np.isnan(result[:w])) + + def test_valid_finite(self): + result = HT_DCPERIOD(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + def test_sine_period_reasonable(self): + # Our sine has period = 2*pi in t; with N=200 and t in [0,10*pi] + # the true period in samples = 200 / (10*pi / (2*pi)) = 200/5 = 40 + result = HT_DCPERIOD(SINE_CLOSE) + valid = result[~np.isnan(result)] + # HT_DCPERIOD should detect a period in a reasonable range [6, 100] + assert np.any((valid > 6) & (valid < 100)) + + +# --------------------------------------------------------------------------- +# HT_DCPHASE +# --------------------------------------------------------------------------- + + +class TestHT_DCPHASE: + def test_length(self): + assert len(HT_DCPHASE(SINE_CLOSE)) == N + + def test_nan_warmup(self): + result = HT_DCPHASE(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + + def test_valid_finite(self): + result = HT_DCPHASE(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + +# --------------------------------------------------------------------------- +# HT_PHASOR +# --------------------------------------------------------------------------- + + +class TestHT_PHASOR: + def test_returns_two_arrays(self): + result = HT_PHASOR(SINE_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + assert len(inphase) == len(quadrature) == N + + def test_nan_warmup(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + w = _warmup_end(inphase) + assert w > 0 + + def test_valid_finite(self): + inphase, quadrature = HT_PHASOR(SINE_CLOSE) + wi = _warmup_end(inphase) + wq = _warmup_end(quadrature) + assert np.all(np.isfinite(inphase[wi:])) + assert np.all(np.isfinite(quadrature[wq:])) + + +# --------------------------------------------------------------------------- +# HT_SINE +# --------------------------------------------------------------------------- + + +class TestHT_SINE: + def test_returns_two_arrays(self): + result = HT_SINE(SINE_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + assert len(sine) == len(leadsine) == N + + def test_nan_warmup(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + w = _warmup_end(sine) + assert w > 0 + + def test_valid_finite(self): + sine, leadsine = HT_SINE(SINE_CLOSE) + ws = _warmup_end(sine) + wl = _warmup_end(leadsine) + assert np.all(np.isfinite(sine[ws:])) + assert np.all(np.isfinite(leadsine[wl:])) + + def test_values_in_sine_range(self): + # Sine values should be in [-1, 1] roughly + sine, leadsine = HT_SINE(SINE_CLOSE) + valid = sine[~np.isnan(sine)] + assert np.all(valid >= -1.5) and np.all(valid <= 1.5) + + +# --------------------------------------------------------------------------- +# HT_TRENDLINE +# --------------------------------------------------------------------------- + + +class TestHT_TRENDLINE: + def test_length(self): + assert len(HT_TRENDLINE(SINE_CLOSE)) == N + + def test_nan_warmup(self): + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + assert w > 0 + + def test_valid_finite(self): + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + assert np.all(np.isfinite(result[w:])) + + def test_smooth_trendline(self): + # Trendline should be smoother than raw close + result = HT_TRENDLINE(SINE_CLOSE) + w = _warmup_end(result) + raw_std = np.std(np.diff(SINE_CLOSE[w:])) + trend_std = np.std(np.diff(result[w:])) + assert trend_std < raw_std + + +# --------------------------------------------------------------------------- +# HT_TRENDMODE +# --------------------------------------------------------------------------- + + +class TestHT_TRENDMODE: + def test_length(self): + assert len(HT_TRENDMODE(SINE_CLOSE)) == N + + def test_values_binary(self): + result = HT_TRENDMODE(SINE_CLOSE) + assert np.all(np.isin(result, [0, 1])) + + def test_nan_warmup_as_zero(self): + # HT_TRENDMODE returns integers (no NaN); warmup bars should be 0 + result = HT_TRENDMODE(SINE_CLOSE) + assert np.all(np.isfinite(result.astype(float))) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_extended.py b/vendor/ferro-ta-main/tests/unit/indicators/test_extended.py new file mode 100644 index 0000000..f849a01 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_extended.py @@ -0,0 +1,292 @@ +"""Unit tests for ferro_ta.indicators.extended""" + +import numpy as np + +from ferro_ta.indicators.extended import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + DONCHIAN, + HULL_MA, + ICHIMOKU, + KELTNER_CHANNELS, + PIVOT_POINTS, + SUPERTREND, + VWAP, + VWMA, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(99) +N = 200 +_C = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_H = _C + np.abs(RNG.normal(0, 0.3, N)) +_L = _C - np.abs(RNG.normal(0, 0.3, N)) +_O = _C + RNG.normal(0, 0.1, N) +_VOL = RNG.uniform(1000, 5000, N) + + +# --------------------------------------------------------------------------- +# VWAP +# --------------------------------------------------------------------------- + + +class TestVWAP: + def test_length(self): + result = VWAP(_H, _L, _C, _VOL) + assert len(result) == N + + def test_no_nan(self): + result = VWAP(_H, _L, _C, _VOL) + assert np.all(np.isfinite(result)) + + def test_positive(self): + result = VWAP(_H, _L, _C, _VOL) + assert np.all(result > 0) + + def test_windowed(self): + result = VWAP(_H, _L, _C, _VOL, timeperiod=20) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# SUPERTREND +# --------------------------------------------------------------------------- + + +class TestSUPERTREND: + def test_returns_two_arrays(self): + result = SUPERTREND(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + trend, direction = SUPERTREND(_H, _L, _C) + assert len(trend) == len(direction) == N + + def test_direction_binary(self): + trend, direction = SUPERTREND(_H, _L, _C) + valid = direction[~np.isnan(direction.astype(float))] + assert np.all(np.isin(valid, [-1, 0, 1])) + + def test_nan_warmup(self): + trend, direction = SUPERTREND(_H, _L, _C, timeperiod=7) + assert np.any(np.isnan(trend)) + + +# --------------------------------------------------------------------------- +# ICHIMOKU +# --------------------------------------------------------------------------- + + +class TestICHIMOKU: + def test_returns_five_arrays(self): + result = ICHIMOKU(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 5 + + def test_length(self): + result = ICHIMOKU(_H, _L, _C) + for arr in result: + assert len(arr) == N + + def test_tenkan_warmup(self): + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU( + _H, _L, _C, tenkan_period=9 + ) + assert np.all(np.isnan(tenkan[:8])) + + def test_finite_after_warmup(self): + tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(_H, _L, _C) + for arr in [tenkan, kijun]: + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# DONCHIAN +# --------------------------------------------------------------------------- + + +class TestDONCHIAN: + def test_returns_three_arrays(self): + result = DONCHIAN(_H, _L) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + upper, middle, lower = DONCHIAN(_H, _L) + assert len(upper) == len(middle) == len(lower) == N + + def test_upper_ge_lower(self): + upper, middle, lower = DONCHIAN(_H, _L) + valid = ~np.isnan(upper) & ~np.isnan(lower) + assert np.all(upper[valid] >= lower[valid]) + + def test_middle_is_average(self): + upper, middle, lower = DONCHIAN(_H, _L) + valid = ~np.isnan(upper) & ~np.isnan(lower) & ~np.isnan(middle) + np.testing.assert_allclose( + middle[valid], + (upper[valid] + lower[valid]) / 2.0, + rtol=1e-10, + ) + + def test_nan_warmup(self): + upper, middle, lower = DONCHIAN(_H, _L, timeperiod=20) + assert np.all(np.isnan(upper[:19])) + + +# --------------------------------------------------------------------------- +# PIVOT_POINTS +# --------------------------------------------------------------------------- + + +class TestPIVOT_POINTS: + def test_returns_five_arrays(self): + result = PIVOT_POINTS(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 5 + + def test_length(self): + result = PIVOT_POINTS(_H, _L, _C) + for arr in result: + assert len(arr) == N + + def test_classic_pivot_formula(self): + # PP = (H + L + C) / 3 + pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C, method="classic") + valid = ~np.isnan(pp) + expected_pp = (_H[:-1] + _L[:-1] + _C[:-1]) / 3.0 + np.testing.assert_allclose(pp[valid], expected_pp[valid[1:]], rtol=1e-6) + + def test_first_is_nan(self): + pp, r1, s1, r2, s2 = PIVOT_POINTS(_H, _L, _C) + assert np.isnan(pp[0]) + + +# --------------------------------------------------------------------------- +# KELTNER_CHANNELS +# --------------------------------------------------------------------------- + + +class TestKELTNER_CHANNELS: + def test_returns_three_arrays(self): + result = KELTNER_CHANNELS(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C) + assert len(upper) == len(middle) == len(lower) == N + + def test_upper_gt_lower(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C) + valid = ~np.isnan(upper) & ~np.isnan(lower) + assert np.all(upper[valid] > lower[valid]) + + def test_nan_warmup(self): + upper, middle, lower = KELTNER_CHANNELS(_H, _L, _C, timeperiod=20) + assert np.all(np.isnan(upper[:19])) + + +# --------------------------------------------------------------------------- +# HULL_MA +# --------------------------------------------------------------------------- + + +class TestHULL_MA: + def test_length(self): + assert len(HULL_MA(_C, timeperiod=16)) == N + + def test_nan_warmup(self): + result = HULL_MA(_C, timeperiod=16) + assert np.all(np.isnan(result[:18])) + + def test_finite_after_warmup(self): + result = HULL_MA(_C, timeperiod=16) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_tracks_trend(self): + rising = np.linspace(10.0, 200.0, 200) + result = HULL_MA(rising, timeperiod=16) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# CHANDELIER_EXIT +# --------------------------------------------------------------------------- + + +class TestCHANDELIER_EXIT: + def test_returns_two_arrays(self): + result = CHANDELIER_EXIT(_H, _L, _C) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C) + assert len(long_stop) == len(short_stop) == N + + def test_nan_warmup(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22) + assert np.all(np.isnan(long_stop[:21])) + + def test_finite_after_warmup(self): + long_stop, short_stop = CHANDELIER_EXIT(_H, _L, _C, timeperiod=22) + for arr in [long_stop, short_stop]: + valid = arr[~np.isnan(arr)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# VWMA +# --------------------------------------------------------------------------- + + +class TestVWMA: + def test_length(self): + assert len(VWMA(_C, _VOL, timeperiod=20)) == N + + def test_nan_warmup(self): + result = VWMA(_C, _VOL, timeperiod=20) + assert np.all(np.isnan(result[:19])) + + def test_finite_after_warmup(self): + result = VWMA(_C, _VOL, timeperiod=20) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_constant_volume_equals_sma(self): + # When all volumes are equal, VWMA = SMA + vol = np.ones(N) * 1000.0 + vwma = VWMA(_C, vol, timeperiod=20) + from ferro_ta.indicators.overlap import SMA + + sma = SMA(_C, timeperiod=20) + valid = ~np.isnan(vwma) & ~np.isnan(sma) + np.testing.assert_allclose(vwma[valid], sma[valid], rtol=1e-8) + + +# --------------------------------------------------------------------------- +# CHOPPINESS_INDEX +# --------------------------------------------------------------------------- + + +class TestCHOPPINESS_INDEX: + def test_length(self): + assert len(CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14)) == N + + def test_nan_warmup(self): + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_range(self): + # Choppiness index is bounded between 0 and 100 + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) and np.all(valid < 200) + + def test_finite_after_warmup(self): + result = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_math_ops.py b/vendor/ferro-ta-main/tests/unit/indicators/test_math_ops.py new file mode 100644 index 0000000..a65f56c --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_math_ops.py @@ -0,0 +1,313 @@ +"""Unit tests for ferro_ta.indicators.math_ops""" + +import numpy as np + +from ferro_ta.indicators.math_ops import ( + ACOS, + ADD, + ASIN, + ATAN, + CEIL, + COS, + COSH, + DIV, + EXP, + FLOOR, + LN, + LOG10, + MAX, + MAXINDEX, + MIN, + MININDEX, + MULT, + SIN, + SINH, + SQRT, + SUB, + SUM, + TAN, + TANH, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +A3 = np.array([1.0, 2.0, 3.0]) +B3 = np.array([4.0, 5.0, 6.0]) +TRIG = np.array([0.0, np.pi / 6, np.pi / 4, np.pi / 3, np.pi / 2]) +UNIT = np.array([0.0, 0.25, 0.5, 0.75, 1.0]) # values in [0,1] for ASIN/ACOS + +RNG = np.random.default_rng(17) +N = 100 +_ARR = 1.0 + RNG.random(N) * 9.0 # positive values in (1, 10] + + +# --------------------------------------------------------------------------- +# ADD +# --------------------------------------------------------------------------- + + +class TestADD: + def test_known_values(self): + result = ADD(A3, B3) + np.testing.assert_allclose(result, [5.0, 7.0, 9.0], rtol=1e-10) + + def test_commutative(self): + np.testing.assert_allclose(ADD(A3, B3), ADD(B3, A3), rtol=1e-10) + + def test_length(self): + assert len(ADD(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# SUB +# --------------------------------------------------------------------------- + + +class TestSUB: + def test_known_values(self): + result = SUB(B3, A3) + np.testing.assert_allclose(result, [3.0, 3.0, 3.0], rtol=1e-10) + + def test_length(self): + assert len(SUB(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# MULT +# --------------------------------------------------------------------------- + + +class TestMULT: + def test_known_values(self): + result = MULT(A3, B3) + np.testing.assert_allclose(result, [4.0, 10.0, 18.0], rtol=1e-10) + + def test_commutative(self): + np.testing.assert_allclose(MULT(A3, B3), MULT(B3, A3), rtol=1e-10) + + def test_length(self): + assert len(MULT(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# DIV +# --------------------------------------------------------------------------- + + +class TestDIV: + def test_known_values(self): + result = DIV(B3, A3) + np.testing.assert_allclose(result, [4.0, 2.5, 2.0], rtol=1e-10) + + def test_self_division_is_one(self): + np.testing.assert_allclose(DIV(_ARR, _ARR), np.ones(N), rtol=1e-10) + + def test_length(self): + assert len(DIV(_ARR, _ARR)) == N + + +# --------------------------------------------------------------------------- +# SUM +# --------------------------------------------------------------------------- + + +class TestSUM: + def test_known_values(self): + arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = SUM(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 6.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 12.0, rtol=1e-10) + + def test_nan_warmup(self): + result = SUM(_ARR, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(SUM(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MAX +# --------------------------------------------------------------------------- + + +class TestMAX: + def test_known_values(self): + arr = np.array([1.0, 3.0, 2.0, 5.0, 4.0]) + result = MAX(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 5.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MAX(_ARR, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(MAX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MIN +# --------------------------------------------------------------------------- + + +class TestMIN: + def test_known_values(self): + arr = np.array([5.0, 3.0, 4.0, 1.0, 2.0]) + result = MIN(arr, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 1.0, rtol=1e-10) + + def test_length(self): + assert len(MIN(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MAXINDEX +# --------------------------------------------------------------------------- + + +class TestMAXINDEX: + def test_known_values(self): + arr = np.array([1.0, 5.0, 3.0, 2.0, 4.0]) + result = MAXINDEX(arr, timeperiod=3) + # warmup entries are -1 (sentinel for "no data") + assert result[0] < 0 and result[1] < 0 + # window[0:3] = [1,5,3] → max at local index 1 → absolute index 1 + np.testing.assert_allclose(result[2], 1.0, rtol=1e-10) + # window[2:5] = [3,2,4] → max at local index 2 → absolute index 4 + np.testing.assert_allclose(result[4], 4.0, rtol=1e-10) + + def test_length(self): + assert len(MAXINDEX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# MININDEX +# --------------------------------------------------------------------------- + + +class TestMININDEX: + def test_known_values(self): + arr = np.array([5.0, 1.0, 3.0, 2.0, 4.0]) + result = MININDEX(arr, timeperiod=3) + # warmup entries are -1 (sentinel for "no data") + assert result[0] < 0 and result[1] < 0 + # window[0:3] = [5,1,3] → min at local index 1 → absolute index 1 + np.testing.assert_allclose(result[2], 1.0, rtol=1e-10) + # window[2:5] = [3,2,4] → min at local index 1 → absolute index 3 + np.testing.assert_allclose(result[4], 3.0, rtol=1e-10) + + def test_length(self): + assert len(MININDEX(_ARR, 5)) == N + + +# --------------------------------------------------------------------------- +# Trig functions +# --------------------------------------------------------------------------- + + +class TestSIN: + def test_known_values(self): + angles = np.array([0.0, np.pi / 2, np.pi]) + result = SIN(angles) + np.testing.assert_allclose(result, np.sin(angles), atol=1e-10) + + def test_matches_numpy(self): + np.testing.assert_allclose(SIN(TRIG), np.sin(TRIG), rtol=1e-10) + + +class TestCOS: + def test_matches_numpy(self): + np.testing.assert_allclose(COS(TRIG), np.cos(TRIG), rtol=1e-10) + + +class TestTAN: + def test_matches_numpy(self): + safe = np.array([0.0, 0.5, 1.0]) + np.testing.assert_allclose(TAN(safe), np.tan(safe), rtol=1e-10) + + +class TestASIN: + def test_matches_numpy(self): + np.testing.assert_allclose(ASIN(UNIT), np.arcsin(UNIT), rtol=1e-10) + + +class TestACOS: + def test_matches_numpy(self): + np.testing.assert_allclose(ACOS(UNIT), np.arccos(UNIT), rtol=1e-10) + + +class TestATAN: + def test_matches_numpy(self): + np.testing.assert_allclose(ATAN(TRIG), np.arctan(TRIG), rtol=1e-10) + + +class TestSINH: + def test_matches_numpy(self): + np.testing.assert_allclose(SINH(A3), np.sinh(A3), rtol=1e-10) + + +class TestCOSH: + def test_matches_numpy(self): + np.testing.assert_allclose(COSH(A3), np.cosh(A3), rtol=1e-10) + + +class TestTANH: + def test_matches_numpy(self): + np.testing.assert_allclose(TANH(UNIT), np.tanh(UNIT), rtol=1e-10) + + +# --------------------------------------------------------------------------- +# Rounding/exponential +# --------------------------------------------------------------------------- + + +class TestCEIL: + def test_known_values(self): + arr = np.array([1.1, 2.5, 3.9, -0.5]) + np.testing.assert_allclose(CEIL(arr), np.ceil(arr), rtol=1e-10) + + +class TestFLOOR: + def test_known_values(self): + arr = np.array([1.1, 2.5, 3.9, -0.5]) + np.testing.assert_allclose(FLOOR(arr), np.floor(arr), rtol=1e-10) + + +class TestEXP: + def test_matches_numpy(self): + np.testing.assert_allclose(EXP(A3), np.exp(A3), rtol=1e-10) + + def test_exp_zero_is_one(self): + np.testing.assert_allclose(EXP(np.array([0.0])), [1.0], rtol=1e-10) + + +class TestLN: + def test_matches_numpy(self): + np.testing.assert_allclose(LN(_ARR), np.log(_ARR), rtol=1e-10) + + def test_ln_exp_inverse(self): + np.testing.assert_allclose(LN(EXP(A3)), A3, rtol=1e-10) + + +class TestLOG10: + def test_matches_numpy(self): + np.testing.assert_allclose(LOG10(_ARR), np.log10(_ARR), rtol=1e-10) + + def test_log10_of_100_is_2(self): + np.testing.assert_allclose(LOG10(np.array([100.0])), [2.0], rtol=1e-10) + + +class TestSQRT: + def test_matches_numpy(self): + np.testing.assert_allclose(SQRT(_ARR), np.sqrt(_ARR), rtol=1e-10) + + def test_sqrt_of_4_is_2(self): + np.testing.assert_allclose(SQRT(np.array([4.0])), [2.0], rtol=1e-10) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_momentum.py b/vendor/ferro-ta-main/tests/unit/indicators/test_momentum.py new file mode 100644 index 0000000..377c300 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_momentum.py @@ -0,0 +1,588 @@ +"""Unit tests for ferro_ta.indicators.momentum""" + +import numpy as np + +from ferro_ta.indicators.momentum import ( + ADX, + ADXR, + APO, + AROON, + AROONOSC, + BOP, + CCI, + CMO, + DX, + MFI, + MINUS_DI, + MINUS_DM, + MOM, + PLUS_DI, + PLUS_DM, + PPO, + ROC, + ROCP, + ROCR, + ROCR100, + RSI, + STOCH, + STOCHF, + STOCHRSI, + TRIX, + ULTOSC, + WILLR, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(7) +N = 100 +_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N)) +_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N)) +_OPEN = _CLOSE + RNG.normal(0, 0.1, N) +_VOL = RNG.uniform(1000, 5000, N) + +SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL5_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL5_O = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0]) + + +# --------------------------------------------------------------------------- +# RSI +# --------------------------------------------------------------------------- + + +class TestRSI: + def test_nan_warmup(self): + result = RSI(_CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_range(self): + result = RSI(_CLOSE, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(RSI(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# STOCH +# --------------------------------------------------------------------------- + + +class TestSTOCH: + def test_returns_two_arrays(self): + result = STOCH(_HIGH, _LOW, _CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE) + for arr in [slowk, slowd]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE) + assert len(slowk) == len(slowd) == N + + +# --------------------------------------------------------------------------- +# STOCHF +# --------------------------------------------------------------------------- + + +class TestSTOCHF: + def test_returns_two_arrays(self): + result = STOCHF(_HIGH, _LOW, _CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_fastk_range(self): + fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE, fastk_period=5, fastd_period=3) + valid = fastk[~np.isnan(fastk)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_known_values(self): + # With identical OHLC, fast %K = 100 * (C - min_low) / (max_high - min_low) + # On our SMALL5 data the range is constant so all = 2/6 * 100 ≈ 66.67 + h5 = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l5 = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + c5 = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + fastk, fastd = STOCHF(h5, l5, c5, fastk_period=3, fastd_period=2) + valid_k = fastk[~np.isnan(fastk)] + assert np.all(valid_k >= 0) and np.all(valid_k <= 100) + + def test_length(self): + fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE) + assert len(fastk) == len(fastd) == N + + +# --------------------------------------------------------------------------- +# STOCHRSI +# --------------------------------------------------------------------------- + + +class TestSTOCHRSI: + def test_returns_two_arrays(self): + result = STOCHRSI(_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + fastk, fastd = STOCHRSI(_CLOSE, timeperiod=14) + for arr in [fastk, fastd]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= -1e-10) and np.all(valid <= 100 + 1e-10) + + def test_length(self): + fastk, fastd = STOCHRSI(_CLOSE) + assert len(fastk) == N + + +# --------------------------------------------------------------------------- +# ADX +# --------------------------------------------------------------------------- + + +class TestADX: + def test_nan_warmup(self): + result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:27])) + + def test_range(self): + result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(ADX(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# ADXR +# --------------------------------------------------------------------------- + + +class TestADXR: + def test_length(self): + assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_range(self): + result = ADXR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + +# --------------------------------------------------------------------------- +# CCI +# --------------------------------------------------------------------------- + + +class TestCCI: + def test_known_constant_mean_dev(self): + # Constant typical price → CCI = 0 after warmup + c5 = np.full(10, 12.0) + h5 = np.full(10, 13.0) + l5 = np.full(10, 11.0) + result = CCI(h5, l5, c5, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_length(self): + assert len(CCI(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_nan_warmup(self): + result = CCI(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_simple_rising(self): + h = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + c = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + result = CCI(h, l, c, 3) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 100.0, atol=1e-8) + + +# --------------------------------------------------------------------------- +# WILLR +# --------------------------------------------------------------------------- + + +class TestWILLR: + def test_range(self): + result = WILLR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= -100) and np.all(valid <= 0) + + def test_length(self): + assert len(WILLR(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# AROON +# --------------------------------------------------------------------------- + + +class TestAROON: + def test_returns_two_arrays(self): + result = AROON(_HIGH, _LOW, 14) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + for arr in [aroon_down, aroon_up]: + valid = arr[~np.isnan(arr)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + assert len(aroon_down) == N + + +# --------------------------------------------------------------------------- +# AROONOSC +# --------------------------------------------------------------------------- + + +class TestAROONOSC: + def test_known_values(self): + h = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) + l = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + result = AROONOSC(h, l, timeperiod=2) + valid = result[~np.isnan(result)] + # Monotone rising high/low → aroon_up = 100, aroon_down = 0 → osc = 100 + np.testing.assert_allclose(valid, 100.0, atol=1e-10) + + def test_equals_aroon_diff(self): + aroon_down, aroon_up = AROON(_HIGH, _LOW, 14) + aroonosc = AROONOSC(_HIGH, _LOW, 14) + valid = ~np.isnan(aroon_up) & ~np.isnan(aroon_down) & ~np.isnan(aroonosc) + np.testing.assert_allclose( + aroonosc[valid], + aroon_up[valid] - aroon_down[valid], + atol=1e-10, + ) + + def test_length(self): + assert len(AROONOSC(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# MFI +# --------------------------------------------------------------------------- + + +class TestMFI: + def test_range(self): + result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)) == N + + def test_nan_warmup(self): + result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14) + assert np.all(np.isnan(result[:14])) + + def test_constant_price_is_50(self): + # When money flow is neither positive nor negative → MFI should be near 50 + # Use alternating tiny moves around constant so no clear direction + c = np.full(20, 100.0) + h = np.full(20, 101.0) + l = np.full(20, 99.0) + v = np.full(20, 1000.0) + result = MFI(h, l, c, v, 5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 # just ensure it runs + + +# --------------------------------------------------------------------------- +# MOM +# --------------------------------------------------------------------------- + + +class TestMOM: + def test_known_values(self): + result = MOM(SMALL5, timeperiod=2) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 2.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 2.0, rtol=1e-10) + + def test_length(self): + assert len(MOM(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROC +# --------------------------------------------------------------------------- + + +class TestROC: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROC(arr, 2) + # ROC = ((close - close[n]) / close[n]) * 100 + np.testing.assert_allclose(result[2], (12 - 10) / 10 * 100, rtol=1e-10) + + def test_length(self): + assert len(ROC(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCP +# --------------------------------------------------------------------------- + + +class TestROCP: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCP(arr, 2) + # ROCP = (close - close[n]) / close[n] + np.testing.assert_allclose(result[2], (12 - 10) / 10, rtol=1e-10) + + def test_length(self): + assert len(ROCP(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCR +# --------------------------------------------------------------------------- + + +class TestROCR: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCR(arr, 2) + # ROCR = close / close[n] + np.testing.assert_allclose(result[2], 12 / 10, rtol=1e-10) + np.testing.assert_allclose(result[4], 14 / 12, rtol=1e-10) + + def test_nan_warmup(self): + result = ROCR(_CLOSE, 10) + assert np.all(np.isnan(result[:10])) + + def test_length(self): + assert len(ROCR(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# ROCR100 +# --------------------------------------------------------------------------- + + +class TestROCR100: + def test_known_values(self): + arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + result = ROCR100(arr, 2) + # ROCR100 = (close / close[n]) * 100 + np.testing.assert_allclose(result[2], 12 / 10 * 100, rtol=1e-10) + + def test_relation_to_rocr(self): + rocr = ROCR(_CLOSE, 5) + rocr100 = ROCR100(_CLOSE, 5) + valid = ~np.isnan(rocr) + np.testing.assert_allclose(rocr100[valid], rocr[valid] * 100, rtol=1e-10) + + def test_length(self): + assert len(ROCR100(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# CMO +# --------------------------------------------------------------------------- + + +class TestCMO: + def test_range(self): + result = CMO(_CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= -100) and np.all(valid <= 100) + + def test_length(self): + assert len(CMO(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# DX +# --------------------------------------------------------------------------- + + +class TestDX: + def test_range(self): + result = DX(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(DX(_HIGH, _LOW, _CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# MINUS_DI / MINUS_DM +# --------------------------------------------------------------------------- + + +class TestMINUS: + def test_minus_di_range(self): + result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_minus_dm_range(self): + result = MINUS_DM(_HIGH, _LOW, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_lengths(self): + assert len(MINUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N + assert len(MINUS_DM(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# PLUS_DI / PLUS_DM +# --------------------------------------------------------------------------- + + +class TestPLUS: + def test_plus_di_range(self): + result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_plus_dm_range(self): + result = PLUS_DM(_HIGH, _LOW, 14) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + def test_lengths(self): + assert len(PLUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N + assert len(PLUS_DM(_HIGH, _LOW, 14)) == N + + +# --------------------------------------------------------------------------- +# PPO +# --------------------------------------------------------------------------- + + +class TestPPO: + def test_returns_three_arrays(self): + result = PPO(_CLOSE, fastperiod=12, slowperiod=26) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26) + valid = ~np.isnan(ppo) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], ppo[valid] - signal[valid], atol=1e-10) + + def test_length(self): + ppo, signal, hist = PPO(_CLOSE) + assert len(ppo) == len(signal) == len(hist) == N + + def test_nan_warmup(self): + ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26) + assert np.any(np.isnan(ppo)) + + +# --------------------------------------------------------------------------- +# APO +# --------------------------------------------------------------------------- + + +class TestAPO: + def test_known_direction(self): + # Rising close → fast EMA > slow EMA → APO > 0 after warmup + rising = np.linspace(1.0, 100.0, 60) + result = APO(rising, fastperiod=5, slowperiod=10) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_length(self): + assert len(APO(_CLOSE, 12, 26)) == N + + def test_nan_warmup(self): + result = APO(_CLOSE, 12, 26) + assert np.any(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# TRIX +# --------------------------------------------------------------------------- + + +class TestTRIX: + def test_length(self): + assert len(TRIX(_CLOSE, 10)) == N + + def test_nan_warmup(self): + result = TRIX(_CLOSE, timeperiod=5) + # TRIX warmup = 3*(tp-1) for triple EMA + 1 for diff + assert np.all(np.isnan(result[:12])) + + def test_finite_after_warmup(self): + result = TRIX(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_rising_series_positive(self): + rising = np.linspace(1.0, 200.0, 100) + result = TRIX(rising, timeperiod=5) + valid = result[~np.isnan(result)] + # On monotone rise, rate of change of triple EMA is positive + assert np.all(valid > 0) + + +# --------------------------------------------------------------------------- +# BOP +# --------------------------------------------------------------------------- + + +class TestBOP: + def test_known_values(self): + o = np.array([10.0, 11.0]) + h = np.array([14.0, 15.0]) + l = np.array([8.0, 9.0]) + c = np.array([12.0, 13.0]) + # BOP = (close - open) / (high - low) + result = BOP(o, h, l, c) + np.testing.assert_allclose(result[0], (12 - 10) / (14 - 8), rtol=1e-10) + np.testing.assert_allclose(result[1], (13 - 11) / (15 - 9), rtol=1e-10) + + def test_bearish_is_negative(self): + o = np.array([14.0, 14.0]) + h = np.array([15.0, 15.0]) + l = np.array([8.0, 8.0]) + c = np.array([10.0, 10.0]) + result = BOP(o, h, l, c) + assert np.all(result < 0) + + def test_range(self): + # BOP = (close - open) / (high - low); can exceed [-1,1] with noisy data + result = BOP(_OPEN, _HIGH, _LOW, _CLOSE) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(BOP(_OPEN, _HIGH, _LOW, _CLOSE)) == N + + +# --------------------------------------------------------------------------- +# ULTOSC +# --------------------------------------------------------------------------- + + +class TestULTOSC: + def test_range(self): + result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) and np.all(valid <= 100) + + def test_length(self): + assert len(ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)) == N + + def test_nan_warmup(self): + result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28) + assert np.any(np.isnan(result)) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_overlap.py b/vendor/ferro-ta-main/tests/unit/indicators/test_overlap.py new file mode 100644 index 0000000..fa471af --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_overlap.py @@ -0,0 +1,484 @@ +"""Unit tests for ferro_ta.indicators.overlap""" + +import numpy as np + +from ferro_ta.indicators.overlap import ( + BBANDS, + DEMA, + EMA, + KAMA, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MIDPOINT, + MIDPRICE, + SAR, + SAREXT, + SMA, + T3, + TEMA, + TRIMA, + WMA, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 +_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N)) +_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N)) + +SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) +SMALL5_HIGH = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +SMALL5_LOW = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) + + +# --------------------------------------------------------------------------- +# SMA +# --------------------------------------------------------------------------- + + +class TestSMA: + def test_known_values(self): + result = SMA(SMALL5, timeperiod=3) + expected = np.array([np.nan, np.nan, 11.0, 12.0, 13.0]) + np.testing.assert_allclose(result[2:], expected[2:], rtol=1e-10) + + def test_nan_warmup(self): + result = SMA(SMALL5, timeperiod=3) + assert np.all(np.isnan(result[:2])) + + def test_length(self): + result = SMA(_CLOSE, timeperiod=20) + assert len(result) == N + + def test_nan_warmup_long(self): + result = SMA(_CLOSE, timeperiod=20) + assert np.all(np.isnan(result[:19])) + assert np.all(np.isfinite(result[19:])) + + +# --------------------------------------------------------------------------- +# EMA +# --------------------------------------------------------------------------- + + +class TestEMA: + def test_known_values(self): + # k = 2/(3+1) = 0.5; seed = SMA(3) = 11.0 + # EMA[2] = SMA([10,11,12]) = 11.0 + # EMA[3] = close[3]*k + EMA[2]*(1-k) = 13*0.5 + 11.0*0.5 = 12.0 + # EMA[4] = close[4]*k + EMA[3]*(1-k) = 14*0.5 + 12.0*0.5 = 13.0 + result = EMA(SMALL5, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], 11.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 12.0, rtol=1e-10) + np.testing.assert_allclose(result[4], 13.0, rtol=1e-10) + + def test_nan_warmup(self): + result = EMA(SMALL5, timeperiod=3) + assert np.all(np.isnan(result[:2])) + + def test_length(self): + assert len(EMA(_CLOSE, 20)) == N + + def test_monotone_on_rising(self): + rising = np.arange(1.0, 51.0) + result = EMA(rising, 5) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# WMA +# --------------------------------------------------------------------------- + + +class TestWMA: + def test_known_values(self): + arr = np.arange(1.0, 6.0) + result = WMA(arr, timeperiod=3) + # weights 1,2,3 / 6 + expected_2 = (1 * 1 + 2 * 2 + 3 * 3) / 6.0 # 14/6 + expected_3 = (1 * 2 + 2 * 3 + 3 * 4) / 6.0 # 20/6 + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], expected_2, rtol=1e-10) + np.testing.assert_allclose(result[3], expected_3, rtol=1e-10) + + def test_nan_warmup(self): + result = WMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(WMA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# DEMA +# --------------------------------------------------------------------------- + + +class TestDEMA: + def test_nan_warmup(self): + result = DEMA(_CLOSE, timeperiod=5) + assert np.all(np.isnan(result[:8])) # DEMA needs 2*(tp-1) bars + + def test_length(self): + assert len(DEMA(_CLOSE, 5)) == N + + def test_values_finite_after_warmup(self): + result = DEMA(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + def test_tracks_close(self): + # DEMA is more responsive than EMA; on trending data it should lead EMA + rising = np.linspace(10.0, 100.0, 100) + dema = DEMA(rising, 5) + ema = EMA(rising, 5) + valid = ~np.isnan(dema) & ~np.isnan(ema) + # DEMA > EMA on a rising series (lower lag) + assert np.all(dema[valid] >= ema[valid] - 1e-9) + + +# --------------------------------------------------------------------------- +# TEMA +# --------------------------------------------------------------------------- + + +class TestTEMA: + def test_nan_warmup(self): + result = TEMA(_CLOSE, timeperiod=5) + assert np.all(np.isnan(result[:12])) + + def test_length(self): + assert len(TEMA(_CLOSE, 5)) == N + + def test_values_finite_after_warmup(self): + result = TEMA(_CLOSE, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# TRIMA +# --------------------------------------------------------------------------- + + +class TestTRIMA: + def test_known_values(self): + arr = np.arange(1.0, 11.0) + result = TRIMA(arr, timeperiod=5) + # TRIMA(5) is SMA of SMA(3) on a 5-window + assert np.all(np.isnan(result[:4])) + np.testing.assert_allclose(result[4], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[5], 4.0, rtol=1e-10) + + def test_nan_warmup(self): + result = TRIMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(TRIMA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# KAMA +# --------------------------------------------------------------------------- + + +class TestKAMA: + def test_nan_warmup(self): + result = KAMA(_CLOSE, timeperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(KAMA(_CLOSE, 10)) == N + + def test_seed_equals_close(self): + arr = np.arange(1.0, 21.0) + result = KAMA(arr, timeperiod=10) + # First valid KAMA value equals close at warmup index + np.testing.assert_allclose(result[9], arr[9], rtol=1e-10) + + def test_finite_after_warmup(self): + result = KAMA(_CLOSE, timeperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# T3 +# --------------------------------------------------------------------------- + + +class TestT3: + def test_nan_warmup(self): + arr = np.linspace(10.0, 30.0, 100) + result = T3(arr, timeperiod=5) + # warmup for T3(tp) = 6*(tp-1) + assert np.all(np.isnan(result[:24])) + + def test_length(self): + assert len(T3(_CLOSE, timeperiod=5)) == N + + def test_finite_after_warmup(self): + arr = np.linspace(10.0, 30.0, 100) + result = T3(arr, timeperiod=5) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) + + def test_trending(self): + rising = np.linspace(10.0, 200.0, 150) + result = T3(rising, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.diff(valid) > 0) + + +# --------------------------------------------------------------------------- +# MA +# --------------------------------------------------------------------------- + + +class TestMA: + def test_default_is_sma(self): + result_ma = MA(_CLOSE, timeperiod=10, matype=0) + result_sma = SMA(_CLOSE, timeperiod=10) + np.testing.assert_allclose(result_ma, result_sma, rtol=1e-10, equal_nan=True) + + def test_ema_matype(self): + result_ma = MA(_CLOSE, timeperiod=10, matype=1) + result_ema = EMA(_CLOSE, timeperiod=10) + np.testing.assert_allclose(result_ma, result_ema, rtol=1e-10, equal_nan=True) + + def test_length(self): + assert len(MA(_CLOSE, 10)) == N + + +# --------------------------------------------------------------------------- +# MACD +# --------------------------------------------------------------------------- + + +class TestMACD: + def test_returns_three_arrays(self): + result = MACD(_CLOSE, 12, 26, 9) + assert isinstance(result, tuple) and len(result) == 3 + + def test_length(self): + macd, signal, hist = MACD(_CLOSE, 12, 26, 9) + assert len(macd) == len(signal) == len(hist) == N + + def test_histogram_is_diff(self): + macd, signal, hist = MACD(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_nan_warmup(self): + macd, signal, hist = MACD(_CLOSE, 12, 26, 9) + # MACD line: warmup = slowperiod - 1 = 25 + assert np.all(np.isnan(macd[:25])) + + +# --------------------------------------------------------------------------- +# MACDFIX +# --------------------------------------------------------------------------- + + +class TestMACDFIX: + def test_returns_three_arrays(self): + result = MACDFIX(_CLOSE) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + macd, signal, hist = MACDFIX(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_length(self): + macd, signal, hist = MACDFIX(_CLOSE) + assert len(macd) == N + + +# --------------------------------------------------------------------------- +# MACDEXT +# --------------------------------------------------------------------------- + + +class TestMACDEXT: + def test_returns_three_arrays(self): + result = MACDEXT(_CLOSE) + assert isinstance(result, tuple) and len(result) == 3 + + def test_histogram_is_diff(self): + macd, signal, hist = MACDEXT(_CLOSE) + valid = ~np.isnan(macd) & ~np.isnan(signal) + np.testing.assert_allclose(hist[valid], macd[valid] - signal[valid], atol=1e-10) + + def test_length(self): + assert len(MACDEXT(_CLOSE)[0]) == N + + +# --------------------------------------------------------------------------- +# BBANDS +# --------------------------------------------------------------------------- + + +class TestBBANDS: + def test_returns_three_arrays(self): + result = BBANDS(_CLOSE, 20) + assert isinstance(result, tuple) and len(result) == 3 + + def test_middle_is_sma(self): + upper, middle, lower = BBANDS(_CLOSE, timeperiod=20) + sma = SMA(_CLOSE, timeperiod=20) + np.testing.assert_allclose(middle, sma, rtol=1e-10, equal_nan=True) + + def test_bands_symmetric(self): + upper, middle, lower = BBANDS(_CLOSE, 20, nbdevup=2.0, nbdevdn=2.0) + valid = ~np.isnan(upper) + np.testing.assert_allclose( + upper[valid] - middle[valid], + middle[valid] - lower[valid], + rtol=1e-10, + ) + + def test_nan_warmup(self): + upper, middle, lower = BBANDS(_CLOSE, 20) + assert np.all(np.isnan(middle[:19])) + + +# --------------------------------------------------------------------------- +# SAR +# --------------------------------------------------------------------------- + + +class TestSAR: + def test_length(self): + result = SAR(_HIGH, _LOW) + assert len(result) == N + + def test_first_is_nan(self): + result = SAR(_HIGH, _LOW) + assert np.isnan(result[0]) + + def test_finite_after_warmup(self): + result = SAR(_HIGH, _LOW) + assert np.all(np.isfinite(result[1:])) + + +# --------------------------------------------------------------------------- +# SAREXT +# --------------------------------------------------------------------------- + + +class TestSAREXT: + def test_length(self): + result = SAREXT(_HIGH, _LOW) + assert len(result) == N + + def test_first_is_nan(self): + result = SAREXT(_HIGH, _LOW) + assert np.isnan(result[0]) + + def test_finite_after_warmup(self): + result = SAREXT(_HIGH, _LOW) + assert np.all(np.isfinite(result[1:])) + + +# --------------------------------------------------------------------------- +# MAMA +# --------------------------------------------------------------------------- + + +class TestMAMA: + def test_returns_two_arrays(self): + result = MAMA(_CLOSE) + assert isinstance(result, tuple) and len(result) == 2 + + def test_length(self): + mama, fama = MAMA(_CLOSE) + assert len(mama) == len(fama) == N + + def test_nan_warmup(self): + mama, fama = MAMA(_CLOSE) + assert np.all(np.isnan(mama[:32])) + + def test_mama_ge_fama(self): + # MAMA is adaptive; on average MAMA >= FAMA on a trending up series + rising = np.linspace(10.0, 200.0, 200) + mama, fama = MAMA(rising) + valid = ~np.isnan(mama) & ~np.isnan(fama) + # not strictly guaranteed, just check output is finite + assert np.all(np.isfinite(mama[valid])) + + +# --------------------------------------------------------------------------- +# MAVP +# --------------------------------------------------------------------------- + + +class TestMAVP: + def test_length(self): + arr = np.linspace(10.0, 30.0, 50) + periods = np.full(50, 5.0) + result = MAVP(arr, periods, minperiod=2, maxperiod=10) + assert len(result) == 50 + + def test_finite_for_large_enough_data(self): + arr = np.linspace(10.0, 30.0, 50) + periods = np.full(50, 3.0) + result = MAVP(arr, periods, minperiod=2, maxperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + +# --------------------------------------------------------------------------- +# MIDPOINT +# --------------------------------------------------------------------------- + + +class TestMIDPOINT: + def test_known_values(self): + arr = np.array([10.0, 12.0, 14.0, 16.0, 18.0]) + result = MIDPOINT(arr, timeperiod=3) + # MIDPOINT(n) = (max + min) / 2 over window + assert np.isnan(result[0]) and np.isnan(result[1]) + np.testing.assert_allclose(result[2], (10.0 + 14.0) / 2.0, rtol=1e-10) + np.testing.assert_allclose(result[4], (14.0 + 18.0) / 2.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MIDPOINT(_CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(MIDPOINT(_CLOSE, 14)) == N + + +# --------------------------------------------------------------------------- +# MIDPRICE +# --------------------------------------------------------------------------- + + +class TestMIDPRICE: + def test_known_values(self): + result = MIDPRICE(SMALL5_HIGH, SMALL5_LOW, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + # window [0..2]: max_high=13, min_low=9 → (13+9)/2 = 11 + np.testing.assert_allclose(result[2], 11.0, rtol=1e-10) + + def test_nan_warmup(self): + result = MIDPRICE(_HIGH, _LOW, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(MIDPRICE(_HIGH, _LOW, 14)) == N diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_pattern.py b/vendor/ferro-ta-main/tests/unit/indicators/test_pattern.py new file mode 100644 index 0000000..cce26c1 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_pattern.py @@ -0,0 +1,260 @@ +"""Unit tests for ferro_ta.indicators.pattern (CDL* functions)""" + +import numpy as np +import pytest + +from ferro_ta.indicators.pattern import ( + CDL2CROWS, + CDL3BLACKCROWS, + CDL3INSIDE, + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLEVENINGSTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLMORNINGSTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSPINNINGTOP, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, +) + +# --------------------------------------------------------------------------- +# Shared random OHLCV data (realistic OHLCV, proper H >= O,C >= L) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 200 +_C = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_O = _C + RNG.normal(0, 0.2, N) +_H = np.maximum(np.maximum(_O, _C) + np.abs(RNG.normal(0, 0.3, N)), np.maximum(_O, _C)) +_L = np.minimum(np.minimum(_O, _C) - np.abs(RNG.normal(0, 0.3, N)), np.minimum(_O, _C)) + +# All CDL* functions to test systematically +ALL_CDL = [ + ("CDL2CROWS", CDL2CROWS), + ("CDL3BLACKCROWS", CDL3BLACKCROWS), + ("CDL3INSIDE", CDL3INSIDE), + ("CDL3LINESTRIKE", CDL3LINESTRIKE), + ("CDL3OUTSIDE", CDL3OUTSIDE), + ("CDL3STARSINSOUTH", CDL3STARSINSOUTH), + ("CDL3WHITESOLDIERS", CDL3WHITESOLDIERS), + ("CDLABANDONEDBABY", CDLABANDONEDBABY), + ("CDLADVANCEBLOCK", CDLADVANCEBLOCK), + ("CDLBELTHOLD", CDLBELTHOLD), + ("CDLBREAKAWAY", CDLBREAKAWAY), + ("CDLCLOSINGMARUBOZU", CDLCLOSINGMARUBOZU), + ("CDLCONCEALBABYSWALL", CDLCONCEALBABYSWALL), + ("CDLCOUNTERATTACK", CDLCOUNTERATTACK), + ("CDLDARKCLOUDCOVER", CDLDARKCLOUDCOVER), + ("CDLDOJI", CDLDOJI), + ("CDLDOJISTAR", CDLDOJISTAR), + ("CDLDRAGONFLYDOJI", CDLDRAGONFLYDOJI), + ("CDLENGULFING", CDLENGULFING), + ("CDLEVENINGDOJISTAR", CDLEVENINGDOJISTAR), + ("CDLEVENINGSTAR", CDLEVENINGSTAR), + ("CDLGAPSIDESIDEWHITE", CDLGAPSIDESIDEWHITE), + ("CDLGRAVESTONEDOJI", CDLGRAVESTONEDOJI), + ("CDLHAMMER", CDLHAMMER), + ("CDLHANGINGMAN", CDLHANGINGMAN), + ("CDLHARAMI", CDLHARAMI), + ("CDLHARAMICROSS", CDLHARAMICROSS), + ("CDLHIGHWAVE", CDLHIGHWAVE), + ("CDLHIKKAKE", CDLHIKKAKE), + ("CDLHIKKAKEMOD", CDLHIKKAKEMOD), + ("CDLHOMINGPIGEON", CDLHOMINGPIGEON), + ("CDLIDENTICAL3CROWS", CDLIDENTICAL3CROWS), + ("CDLINNECK", CDLINNECK), + ("CDLINVERTEDHAMMER", CDLINVERTEDHAMMER), + ("CDLKICKING", CDLKICKING), + ("CDLKICKINGBYLENGTH", CDLKICKINGBYLENGTH), + ("CDLLADDERBOTTOM", CDLLADDERBOTTOM), + ("CDLLONGLEGGEDDOJI", CDLLONGLEGGEDDOJI), + ("CDLLONGLINE", CDLLONGLINE), + ("CDLMARUBOZU", CDLMARUBOZU), + ("CDLMATCHINGLOW", CDLMATCHINGLOW), + ("CDLMATHOLD", CDLMATHOLD), + ("CDLMORNINGDOJISTAR", CDLMORNINGDOJISTAR), + ("CDLMORNINGSTAR", CDLMORNINGSTAR), + ("CDLONNECK", CDLONNECK), + ("CDLPIERCING", CDLPIERCING), + ("CDLRICKSHAWMAN", CDLRICKSHAWMAN), + ("CDLRISEFALL3METHODS", CDLRISEFALL3METHODS), + ("CDLSEPARATINGLINES", CDLSEPARATINGLINES), + ("CDLSHOOTINGSTAR", CDLSHOOTINGSTAR), + ("CDLSHORTLINE", CDLSHORTLINE), + ("CDLSPINNINGTOP", CDLSPINNINGTOP), + ("CDLSTALLEDPATTERN", CDLSTALLEDPATTERN), + ("CDLSTICKSANDWICH", CDLSTICKSANDWICH), + ("CDLTAKURI", CDLTAKURI), + ("CDLTASUKIGAP", CDLTASUKIGAP), + ("CDLTHRUSTING", CDLTHRUSTING), + ("CDLTRISTAR", CDLTRISTAR), + ("CDLUNIQUE3RIVER", CDLUNIQUE3RIVER), + ("CDLUPSIDEGAP2CROWS", CDLUPSIDEGAP2CROWS), + ("CDLXSIDEGAP3METHODS", CDLXSIDEGAP3METHODS), +] + + +# --------------------------------------------------------------------------- +# Parametrised tests: all CDL patterns +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_output_length(name, fn): + result = fn(_O, _H, _L, _C) + assert len(result) == N, f"{name}: expected length {N}, got {len(result)}" + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_values_in_valid_set(name, fn): + result = fn(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])), ( + f"{name}: unexpected values {np.unique(result)}" + ) + + +@pytest.mark.parametrize("name,fn", ALL_CDL) +def test_cdl_no_nan(name, fn): + result = fn(_O, _H, _L, _C) + assert np.all(np.isfinite(result.astype(float))), f"{name}: contains NaN/Inf" + + +# --------------------------------------------------------------------------- +# Specific tests for previously untested patterns +# --------------------------------------------------------------------------- + + +class TestCDLSPINNINGTOP: + def test_detects_pattern(self): + # Spinning top: small body, long upper and lower shadows + # open ≈ close (small body), high much higher, low much lower + o = np.array([10.0, 10.1, 10.0]) + h = np.array([15.0, 15.1, 15.0]) + l = np.array([5.0, 5.1, 5.0]) + c = np.array([10.0, 10.0, 10.05]) + result = CDLSPINNINGTOP(o, h, l, c) + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_output_values_random(self): + result = CDLSPINNINGTOP(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])) + + +class TestCDLEVENINGSTAR: + def test_basic_run(self): + result = CDLEVENINGSTAR(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_large_dataset_has_valid_output(self): + # On 200 bars of random data, result should be all in {-100,0,100} + result = CDLEVENINGSTAR(_O, _H, _L, _C) + assert np.all(np.isin(result, [-100, 0, 100])) + + +class TestCDLMORNINGSTAR: + def test_basic_run(self): + result = CDLMORNINGSTAR(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_bullish_signal_is_100(self): + # Any detected signal must be 100 (bullish) + result = CDLMORNINGSTAR(_O, _H, _L, _C) + assert np.all(result[result != 0] == 100) + + +class TestCDL2CROWS: + def test_basic_run(self): + result = CDL2CROWS(_O, _H, _L, _C) + assert len(result) == N + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_bearish_signal_is_minus_100(self): + # Any detected signal must be -100 (bearish) + result = CDL2CROWS(_O, _H, _L, _C) + assert np.all(result[result != 0] == -100) + + +class TestCDLDOJI: + def test_detects_doji(self): + # Exact doji: open == close + o = np.array([10.0, 10.0, 10.0]) + h = np.array([12.0, 12.0, 12.0]) + l = np.array([8.0, 8.0, 8.0]) + c = np.array([10.0, 10.0, 10.0]) + result = CDLDOJI(o, h, l, c) + assert np.all(result == 100) + + def test_non_doji_returns_zero(self): + o = np.array([10.0, 11.0, 12.0]) + h = np.array([15.0, 16.0, 17.0]) + l = np.array([9.0, 10.0, 11.0]) + c = np.array([14.0, 15.0, 16.0]) # large body, not doji + result = CDLDOJI(o, h, l, c) + assert np.all(result == 0) + + +class TestCDLMARUBOZU: + def test_detects_bullish_marubozu(self): + # Bullish marubozu: open == low, close == high, close > open + o = np.array([10.0, 10.0]) + h = np.array([15.0, 15.0]) + l = np.array([10.0, 10.0]) + c = np.array([15.0, 15.0]) + result = CDLMARUBOZU(o, h, l, c) + assert np.all(np.isin(result, [-100, 0, 100])) + + def test_length(self): + result = CDLMARUBOZU(_O, _H, _L, _C) + assert len(result) == N diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_price_transform.py b/vendor/ferro-ta-main/tests/unit/indicators/test_price_transform.py new file mode 100644 index 0000000..6c27adf --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_price_transform.py @@ -0,0 +1,113 @@ +"""Unit tests for ferro_ta.indicators.price_transform""" + +import numpy as np + +from ferro_ta.indicators.price_transform import AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +O = np.array([10.0, 11.0, 12.0, 13.0]) +H = np.array([12.0, 13.0, 14.0, 15.0]) +L = np.array([9.0, 10.0, 11.0, 12.0]) +C = np.array([11.0, 12.0, 13.0, 14.0]) + + +# --------------------------------------------------------------------------- +# AVGPRICE +# --------------------------------------------------------------------------- + + +class TestAVGPRICE: + def test_known_formula(self): + result = AVGPRICE(O, H, L, C) + expected = (O + H + L + C) / 4.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = AVGPRICE(O, H, L, C) + np.testing.assert_allclose(result[0], (10 + 12 + 9 + 11) / 4.0, rtol=1e-10) + + def test_no_nan(self): + result = AVGPRICE(O, H, L, C) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(AVGPRICE(O, H, L, C)) == len(O) + + +# --------------------------------------------------------------------------- +# MEDPRICE +# --------------------------------------------------------------------------- + + +class TestMEDPRICE: + def test_known_formula(self): + result = MEDPRICE(H, L) + expected = (H + L) / 2.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = MEDPRICE(H, L) + np.testing.assert_allclose(result[0], (12 + 9) / 2.0, rtol=1e-10) + + def test_no_nan(self): + result = MEDPRICE(H, L) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(MEDPRICE(H, L)) == len(H) + + +# --------------------------------------------------------------------------- +# TYPPRICE +# --------------------------------------------------------------------------- + + +class TestTYPPRICE: + def test_known_formula(self): + result = TYPPRICE(H, L, C) + expected = (H + L + C) / 3.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = TYPPRICE(H, L, C) + np.testing.assert_allclose(result[0], (12 + 9 + 11) / 3.0, rtol=1e-10) + + def test_no_nan(self): + result = TYPPRICE(H, L, C) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(TYPPRICE(H, L, C)) == len(H) + + +# --------------------------------------------------------------------------- +# WCLPRICE +# --------------------------------------------------------------------------- + + +class TestWCLPRICE: + def test_known_formula(self): + result = WCLPRICE(H, L, C) + expected = (H + L + 2.0 * C) / 4.0 + np.testing.assert_allclose(result, expected, rtol=1e-10) + + def test_first_bar(self): + result = WCLPRICE(H, L, C) + np.testing.assert_allclose(result[0], (12 + 9 + 2 * 11) / 4.0, rtol=1e-10) + + def test_no_nan(self): + result = WCLPRICE(H, L, C) + assert np.all(np.isfinite(result)) + + def test_close_weight_double(self): + # WCLPRICE weights close twice vs TYPPRICE + wcl = WCLPRICE(H, L, C) + # On a rising series (H > L > 0), WCLPRICE > TYPPRICE when C > (H+L)/2 + # Just verify formula correctness already done above + assert np.all(np.isfinite(wcl)) + + def test_length(self): + assert len(WCLPRICE(H, L, C)) == len(H) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_statistic.py b/vendor/ferro-ta-main/tests/unit/indicators/test_statistic.py new file mode 100644 index 0000000..8487785 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_statistic.py @@ -0,0 +1,488 @@ +"""Unit tests for ferro_ta.indicators.statistic""" + +import numpy as np +import pytest + +from ferro_ta.indicators.statistic import ( + BATCH_DTW, + BETA, + CORREL, + DTW, + DTW_DISTANCE, + LINEARREG, + LINEARREG_ANGLE, + LINEARREG_INTERCEPT, + LINEARREG_SLOPE, + STDDEV, + TSF, + VAR, +) + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(11) +N = 100 +_A = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_B = 100 + np.cumsum(RNG.normal(0, 0.5, N)) + +LINDATA = np.arange(1.0, 6.0) # [1,2,3,4,5] +CONSTDATA = np.ones(10) # all 1.0 + + +def _naive_linreg_window(window: np.ndarray) -> tuple[float, float]: + x = np.arange(len(window), dtype=np.float64) + sum_x = float(np.sum(x)) + sum_y = float(np.sum(window)) + sum_xy = float(np.sum(x * window)) + sum_x2 = float(np.sum(x * x)) + n = float(len(window)) + denom = n * sum_x2 - sum_x * sum_x + slope = (n * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0 + intercept = (sum_y - slope * sum_x) / n + return slope, intercept + + +def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray: + out = np.full(len(series), np.nan, dtype=np.float64) + for end in range(timeperiod - 1, len(series)): + slope, intercept = _naive_linreg_window(series[end + 1 - timeperiod : end + 1]) + out[end] = intercept + slope * x_value + return out + + +def _naive_correl(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(timeperiod - 1, len(x)): + x_window = x[end + 1 - timeperiod : end + 1] + y_window = y[end + 1 - timeperiod : end + 1] + mean_x = float(np.sum(x_window)) / timeperiod + mean_y = float(np.sum(y_window)) / timeperiod + cov = float(np.sum((x_window - mean_x) * (y_window - mean_y))) + std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2))) + std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2))) + denom = std_x * std_y + out[end] = cov / denom if denom != 0.0 else np.nan + return out + + +def _naive_beta(x: np.ndarray, y: np.ndarray, timeperiod: int) -> np.ndarray: + out = np.full(len(x), np.nan, dtype=np.float64) + for end in range(timeperiod, len(x)): + start = end - timeperiod + rx = np.array( + [ + x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + ry = np.array( + [ + y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan + for idx in range(start, end) + ], + dtype=np.float64, + ) + mean_x = float(np.sum(rx)) / timeperiod + mean_y = float(np.sum(ry)) / timeperiod + cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / timeperiod + var_x = float(np.sum((rx - mean_x) ** 2)) / timeperiod + out[end] = cov / var_x if var_x != 0.0 else np.nan + return out + + +# --------------------------------------------------------------------------- +# STDDEV +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + def test_constant_is_zero(self): + result = STDDEV(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_known_values(self): + # std([1,2,3,4,5], ddof=0) = sqrt(2) + result = STDDEV(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], np.sqrt(2.0), rtol=1e-6) + + def test_nan_warmup(self): + result = STDDEV(_A, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(STDDEV(_A, 5)) == N + + def test_positive(self): + result = STDDEV(_A, 5) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + +# --------------------------------------------------------------------------- +# VAR +# --------------------------------------------------------------------------- + + +class TestVAR: + def test_constant_is_zero(self): + result = VAR(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_known_values(self): + # var([1,2,3,4,5], ddof=0) = 2.0 + result = VAR(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 2.0, rtol=1e-6) + + def test_equals_stddev_squared(self): + std = STDDEV(_A, timeperiod=10) + var = VAR(_A, timeperiod=10) + valid = ~np.isnan(std) & ~np.isnan(var) + np.testing.assert_allclose(var[valid], std[valid] ** 2, rtol=1e-6) + + def test_length(self): + assert len(VAR(_A, 5)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG +# --------------------------------------------------------------------------- + + +class TestLINEARREG: + def test_perfect_line(self): + # For [1,2,3,4,5] over window 5, forecast = 5.0 + result = LINEARREG(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = LINEARREG(_A, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(LINEARREG(_A, 14)) == N + + def test_matches_naive_regression(self): + expected = _naive_linearreg(_A, timeperiod=14, x_value=13.0) + result = LINEARREG(_A, timeperiod=14) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# LINEARREG_SLOPE +# --------------------------------------------------------------------------- + + +class TestLINEARREG_SLOPE: + def test_perfect_line_slope_one(self): + result = LINEARREG_SLOPE(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 1.0, rtol=1e-10) + + def test_constant_slope_zero(self): + result = LINEARREG_SLOPE(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-10) + + def test_length(self): + assert len(LINEARREG_SLOPE(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG_INTERCEPT +# --------------------------------------------------------------------------- + + +class TestLINEARREG_INTERCEPT: + def test_perfect_line_intercept_one(self): + # y = [1,2,3,4,5] with x=[0,1,2,3,4] → y = 1 + 1*x → intercept = 1.0 + result = LINEARREG_INTERCEPT(LINDATA, timeperiod=5) + np.testing.assert_allclose(result[4], 1.0, atol=1e-10) + + def test_length(self): + assert len(LINEARREG_INTERCEPT(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# LINEARREG_ANGLE +# --------------------------------------------------------------------------- + + +class TestLINEARREG_ANGLE: + def test_slope_one_gives_45_degrees(self): + result = LINEARREG_ANGLE(LINDATA, timeperiod=5) + # arctan(1) * 180/pi = 45 + np.testing.assert_allclose(result[4], 45.0, rtol=1e-6) + + def test_constant_gives_zero_degrees(self): + result = LINEARREG_ANGLE(CONSTDATA, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 0.0, atol=1e-8) + + def test_length(self): + assert len(LINEARREG_ANGLE(_A, 14)) == N + + +# --------------------------------------------------------------------------- +# BETA +# --------------------------------------------------------------------------- + + +class TestBETA: + def test_nan_warmup(self): + result = BETA(_A, _B, timeperiod=5) + assert np.all(np.isnan(result[:4])) + + def test_length(self): + assert len(BETA(_A, _B, 5)) == N + + def test_same_series(self): + # Beta of x vs x = 1.0 (regression of itself) + result = BETA(_A, _A, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_finite_after_warmup(self): + result = BETA(_A, _B, timeperiod=5) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_matches_naive_beta(self): + expected = _naive_beta(_A, _B, timeperiod=5) + result = BETA(_A, _B, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# CORREL +# --------------------------------------------------------------------------- + + +class TestCOREL: + def test_self_correlation_is_one(self): + result = CORREL(_A, _A, timeperiod=10) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, 1.0, atol=1e-10) + + def test_opposite_correlation_is_minus_one(self): + arr = np.arange(1.0, 11.0) + result = CORREL(arr, arr[::-1], timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid, -1.0, atol=1e-10) + + def test_range(self): + result = CORREL(_A, _B, timeperiod=10) + valid = result[~np.isnan(result)] + assert np.all(valid >= -1 - 1e-10) and np.all(valid <= 1 + 1e-10) + + def test_length(self): + assert len(CORREL(_A, _B, 10)) == N + + def test_matches_naive_correlation(self): + expected = _naive_correl(_A, _B, timeperiod=10) + result = CORREL(_A, _B, timeperiod=10) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# TSF +# --------------------------------------------------------------------------- + + +class TestTSF: + def test_perfect_line(self): + arr = np.arange(1.0, 10.0) + result = TSF(arr, timeperiod=3) + # TSF(3) on [1,2,...] = linear forecast one period ahead + # Over window [1,2,3]: slope=1, intercept=0 → forecast at bar 2+1=3 → TSF[2]=4 + np.testing.assert_allclose(result[2], 4.0, rtol=1e-10) + np.testing.assert_allclose(result[3], 5.0, rtol=1e-10) + + def test_nan_warmup(self): + result = TSF(_A, timeperiod=14) + assert np.all(np.isnan(result[:13])) + + def test_length(self): + assert len(TSF(_A, 14)) == N + + def test_matches_naive_tsf(self): + expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0) + result = TSF(_A, timeperiod=14) + np.testing.assert_allclose(result, expected, equal_nan=True) + + +# --------------------------------------------------------------------------- +# DTW — Dynamic Time Warping +# --------------------------------------------------------------------------- + +dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed") + +_DTW_RNG = np.random.default_rng(42) + + +class TestDTW: + # --- Validation against dtaidistance (SOTA reference) --- + + def test_distance_matches_dtaidistance_random(self): + """Core correctness: our distance == dtaidistance on 20 random pairs.""" + for _ in range(20): + n = int(_DTW_RNG.integers(5, 50)) + a = _DTW_RNG.random(n) + b = _DTW_RNG.random(n) + expected = dtai.dtw.distance(a, b) + actual = DTW_DISTANCE(a, b) + np.testing.assert_allclose( + actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}" + ) + + def test_distance_matches_dtaidistance_unequal_length(self): + """Handles unequal-length series correctly.""" + for _ in range(10): + a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30))) + b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30))) + expected = dtai.dtw.distance(a, b) + actual = DTW_DISTANCE(a, b) + np.testing.assert_allclose(actual, expected, rtol=1e-9) + + def test_path_distance_matches_dtaidistance(self): + """DTW() path variant: returned distance matches dtaidistance.""" + a = _DTW_RNG.random(20) + b = _DTW_RNG.random(25) + expected = dtai.dtw.distance(a, b) + dist, _ = DTW(a, b) + np.testing.assert_allclose(dist, expected, rtol=1e-9) + + def test_path_matches_dtaidistance_warping_path(self): + """Warping path matches dtaidistance.dtw.warping_path() on same-length series.""" + for _ in range(10): + n = int(_DTW_RNG.integers(5, 20)) + a = _DTW_RNG.random(n) + b = _DTW_RNG.random(n) + expected_path = dtai.dtw.warping_path(a, b) + _, actual_path = DTW(a, b) + actual_pairs = [tuple(int(x) for x in row) for row in actual_path] + assert actual_pairs == expected_path, ( + f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}" + ) + + def test_window_constrained_matches_dtaidistance(self): + """Sakoe-Chiba window matches dtaidistance window parameter.""" + a = _DTW_RNG.random(30) + b = _DTW_RNG.random(30) + for w in [3, 8, 15]: + expected = dtai.dtw.distance(a, b, window=w) + actual = DTW_DISTANCE(a, b, window=w) + np.testing.assert_allclose( + actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}" + ) + + def test_batch_matches_dtaidistance(self): + """BATCH_DTW matches calling dtaidistance per-row.""" + ref = _DTW_RNG.random(20) + matrix = _DTW_RNG.random((8, 20)) + batch_result = BATCH_DTW(matrix, ref) + for i in range(8): + expected = dtai.dtw.distance(matrix[i], ref) + np.testing.assert_allclose( + batch_result[i], + expected, + rtol=1e-9, + err_msg=f"Batch mismatch at row {i}", + ) + + # --- Mathematical properties --- + + def test_identical_distance_is_zero(self): + a = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + dist, _ = DTW(a, a) + assert dist == pytest.approx(0.0, abs=1e-10) + + def test_symmetry(self): + a, b = _DTW_RNG.random(20), _DTW_RNG.random(20) + assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10) + + def test_triangle_inequality(self): + a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15) + assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9 + + # --- Known hardcoded values --- + + def test_known_shifted_series(self): + # [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2) + # Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance. + a = np.array([0.0, 1.0, 2.0]) + b = np.array([1.0, 2.0, 3.0]) + np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9) + + def test_known_single_element(self): + # sqrt((3-7)^2) = sqrt(16) = 4.0 + np.testing.assert_allclose( + DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9 + ) + + def test_known_constant_series(self): + assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx( + 0.0, abs=1e-12 + ) + + # --- Path structural guarantees --- + + def test_path_starts_at_origin(self): + _, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10)) + assert tuple(int(x) for x in path[0]) == (0, 0) + + def test_path_ends_at_corner(self): + _, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9)) + assert tuple(int(x) for x in path[-1]) == (6, 8) + + def test_path_is_monotone(self): + _, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20)) + for k in range(1, len(path)): + assert path[k][0] >= path[k - 1][0] + assert path[k][1] >= path[k - 1][1] + + def test_path_steps_unit_size(self): + _, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12)) + for k in range(1, len(path)): + di = int(path[k][0]) - int(path[k - 1][0]) + dj = int(path[k][1]) - int(path[k - 1][1]) + assert di in (0, 1) and dj in (0, 1) + assert not (di == 0 and dj == 0) + + # --- DTW_DISTANCE == DTW distance --- + + def test_distance_only_matches_full(self): + a, b = _DTW_RNG.random(25), _DTW_RNG.random(25) + d_full, _ = DTW(a, b) + np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10) + + # --- Batch --- + + def test_batch_single_row(self): + ref = np.array([1.0, 2.0, 3.0]) + result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref) + assert result[0] == pytest.approx(0.0, abs=1e-10) + + def test_batch_matches_single_calls(self): + ref = _DTW_RNG.random(20) + matrix = _DTW_RNG.random((8, 20)) + batch = BATCH_DTW(matrix, ref) + for i in range(8): + np.testing.assert_allclose( + batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10 + ) + + # --- Edge cases --- + + def test_empty_series_raises(self): + with pytest.raises((ValueError, Exception)): + DTW(np.array([]), np.array([1.0, 2.0])) + + def test_window_constrained_ge_unconstrained(self): + a, b = _DTW_RNG.random(20), _DTW_RNG.random(20) + d_full = DTW_DISTANCE(a, b) + d_narrow = DTW_DISTANCE(a, b, window=2) + assert d_narrow >= d_full - 1e-9 diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_volatility.py b/vendor/ferro-ta-main/tests/unit/indicators/test_volatility.py new file mode 100644 index 0000000..5ff81c2 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_volatility.py @@ -0,0 +1,125 @@ +"""Unit tests for ferro_ta.indicators.volatility""" + +import numpy as np + +from ferro_ta.indicators.volatility import ATR, NATR, TRANGE + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(3) +N = 100 +_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N)) +_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N)) + +# Simple 5-bar data with constant range +SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) + + +# --------------------------------------------------------------------------- +# TRANGE +# --------------------------------------------------------------------------- + + +class TestTRANGE: + def test_known_values_constant_range(self): + result = TRANGE(SMALL_H, SMALL_L, SMALL_C) + # First bar: only high-low = 3 (no prior close) + np.testing.assert_allclose(result[0], 3.0, rtol=1e-10) + np.testing.assert_allclose(result[1], 3.0, rtol=1e-10) + + def test_no_nan(self): + result = TRANGE(SMALL_H, SMALL_L, SMALL_C) + assert np.all(np.isfinite(result)) + + def test_always_positive(self): + result = TRANGE(_HIGH, _LOW, _CLOSE) + assert np.all(result > 0) + + def test_length(self): + assert len(TRANGE(_HIGH, _LOW, _CLOSE)) == N + + def test_formula_first_bar(self): + h = np.array([15.0, 16.0, 17.0]) + l = np.array([10.0, 11.0, 12.0]) + c = np.array([13.0, 14.0, 15.0]) + result = TRANGE(h, l, c) + # bar 0: TRANGE = h[0] - l[0] = 5 + np.testing.assert_allclose(result[0], 5.0, rtol=1e-10) + # bar 1: max(h[1]-l[1], |h[1]-c[0]|, |l[1]-c[0]|) + # = max(5, |16-13|, |11-13|) = max(5, 3, 2) = 5 + np.testing.assert_allclose(result[1], 5.0, rtol=1e-10) + + def test_with_gap(self): + # Gap up: prev close=10, curr high=20, curr low=15 + h = np.array([10.0, 20.0]) + l = np.array([8.0, 15.0]) + c = np.array([10.0, 18.0]) + result = TRANGE(h, l, c) + # bar 1: max(20-15, |20-10|, |15-10|) = max(5, 10, 5) = 10 + np.testing.assert_allclose(result[1], 10.0, rtol=1e-10) + + +# --------------------------------------------------------------------------- +# ATR +# --------------------------------------------------------------------------- + + +class TestATR: + def test_timeperiod_1_equals_trange(self): + atr = ATR(SMALL_H, SMALL_L, SMALL_C, timeperiod=1) + trange = TRANGE(SMALL_H, SMALL_L, SMALL_C) + # ATR(1) first bar is NaN, subsequent equal TRANGE + np.testing.assert_allclose(atr[1:], trange[1:], rtol=1e-10) + + def test_nan_warmup(self): + result = ATR(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_length(self): + assert len(ATR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_always_positive(self): + result = ATR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_constant_range_converges(self): + # Constant TRANGE=3 → ATR should converge to 3 + h = np.full(100, 12.0) + np.arange(100) * 0.0 + l = np.full(100, 9.0) + np.arange(100) * 0.0 + c = np.full(100, 11.0) + np.arange(100) * 0.0 + result = ATR(h, l, c, timeperiod=5) + valid = result[~np.isnan(result)] + np.testing.assert_allclose(valid[-1], 3.0, atol=0.01) + + +# --------------------------------------------------------------------------- +# NATR +# --------------------------------------------------------------------------- + + +class TestNATR: + def test_nan_warmup(self): + result = NATR(_HIGH, _LOW, _CLOSE, timeperiod=14) + assert np.all(np.isnan(result[:14])) + + def test_length(self): + assert len(NATR(_HIGH, _LOW, _CLOSE, 14)) == N + + def test_positive(self): + result = NATR(_HIGH, _LOW, _CLOSE, 14) + valid = result[~np.isnan(result)] + assert np.all(valid > 0) + + def test_relation_to_atr(self): + # NATR = ATR / close * 100 + atr = ATR(_HIGH, _LOW, _CLOSE, 14) + natr = NATR(_HIGH, _LOW, _CLOSE, 14) + valid = ~np.isnan(atr) & ~np.isnan(natr) + expected = atr[valid] / _CLOSE[valid] * 100 + np.testing.assert_allclose(natr[valid], expected, rtol=1e-5) diff --git a/vendor/ferro-ta-main/tests/unit/indicators/test_volume.py b/vendor/ferro-ta-main/tests/unit/indicators/test_volume.py new file mode 100644 index 0000000..12b18c5 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/indicators/test_volume.py @@ -0,0 +1,118 @@ +"""Unit tests for ferro_ta.indicators.volume""" + +import numpy as np + +from ferro_ta.indicators.volume import AD, ADOSC, OBV + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(5) +N = 100 +_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N)) +_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N)) +_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N)) +_VOL = RNG.uniform(1000, 5000, N) + +SMALL_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0]) +SMALL_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0]) +SMALL_C = np.array([11.0, 12.0, 13.0, 14.0, 15.0]) +SMALL_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0]) + + +# --------------------------------------------------------------------------- +# OBV +# --------------------------------------------------------------------------- + + +class TestOBV: + def test_known_values_rising(self): + # Rising close: OBV accumulates all volume + c = np.array([10.0, 11.0, 12.0, 13.0, 14.0]) + v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0]) + result = OBV(c, v) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + np.testing.assert_allclose(result[1], 1000.0, atol=1e-10) + np.testing.assert_allclose(result[4], 4000.0, atol=1e-10) + + def test_known_values_falling(self): + c = np.array([14.0, 13.0, 12.0, 11.0, 10.0]) + v = np.array([1000.0, 1000.0, 1000.0, 1000.0, 1000.0]) + result = OBV(c, v) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + np.testing.assert_allclose(result[1], -1000.0, atol=1e-10) + np.testing.assert_allclose(result[4], -4000.0, atol=1e-10) + + def test_unchanged_price_no_change(self): + c = np.array([10.0, 10.0, 10.0]) + v = np.array([500.0, 500.0, 500.0]) + result = OBV(c, v) + np.testing.assert_allclose(result, [0.0, 0.0, 0.0], atol=1e-10) + + def test_no_nan(self): + result = OBV(SMALL_C, SMALL_V) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(OBV(_CLOSE, _VOL)) == N + + def test_starts_zero(self): + result = OBV(_CLOSE, _VOL) + np.testing.assert_allclose(result[0], 0.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# AD +# --------------------------------------------------------------------------- + + +class TestAD: + def test_known_formula(self): + # AD = cumsum(CLV * volume) + # CLV = ((close - low) - (high - close)) / (high - low) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([12.0]) + v = np.array([1000.0]) + clv = ((12 - 10) - (15 - 12)) / (15 - 10) # (2 - 3) / 5 = -0.2 + expected = clv * 1000.0 + result = AD(h, l, c, v) + np.testing.assert_allclose(result[0], expected, rtol=1e-10) + + def test_monotone_rising_positive(self): + # High CLV on rising data → AD should be non-negative cumulatively + result = AD(SMALL_H, SMALL_L, SMALL_C, SMALL_V) + assert np.all(np.isfinite(result)) + + def test_no_nan(self): + result = AD(_HIGH, _LOW, _CLOSE, _VOL) + assert np.all(np.isfinite(result)) + + def test_length(self): + assert len(AD(_HIGH, _LOW, _CLOSE, _VOL)) == N + + +# --------------------------------------------------------------------------- +# ADOSC +# --------------------------------------------------------------------------- + + +class TestADOSC: + def test_nan_warmup(self): + result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10) + assert np.all(np.isnan(result[:9])) + + def test_length(self): + assert len(ADOSC(_HIGH, _LOW, _CLOSE, _VOL, 3, 10)) == N + + def test_finite_after_warmup(self): + result = ADOSC(_HIGH, _LOW, _CLOSE, _VOL, fastperiod=3, slowperiod=10) + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + def test_known_values(self): + result = ADOSC(SMALL_H, SMALL_L, SMALL_C, SMALL_V, fastperiod=2, slowperiod=3) + valid = result[~np.isnan(result)] + assert len(valid) > 0 + assert np.all(np.isfinite(valid)) diff --git a/vendor/ferro-ta-main/tests/unit/streaming/__init__.py b/vendor/ferro-ta-main/tests/unit/streaming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/tests/unit/streaming/test_streaming.py b/vendor/ferro-ta-main/tests/unit/streaming/test_streaming.py new file mode 100644 index 0000000..841ef38 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/streaming/test_streaming.py @@ -0,0 +1,387 @@ +"""Tests for ferro_ta streaming / incremental indicators.""" + +import math + +import numpy as np +import pytest + +from ferro_ta import EMA, RSI, SMA +from ferro_ta.data.streaming import StreamingEMA, StreamingRSI, StreamingSMA + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +PRICES = 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, + ], + dtype=np.float64, +) + + +def _finite(arr: np.ndarray) -> np.ndarray: + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# StreamingSMA +# --------------------------------------------------------------------------- + + +class TestStreamingSMA: + def test_basic_values(self): + """Feed known values, verify manually computed SMA.""" + sma = StreamingSMA(period=3) + assert math.isnan(sma.update(1.0)) + assert math.isnan(sma.update(2.0)) + assert math.isclose(sma.update(3.0), 2.0) + assert math.isclose(sma.update(4.0), 3.0) + assert math.isclose(sma.update(5.0), 4.0) + + def test_matches_batch_sma(self): + """Streaming SMA final values must match batch SMA on the same data.""" + period = 5 + batch = SMA(PRICES, timeperiod=period) + sma = StreamingSMA(period=period) + for i, price in enumerate(PRICES): + val = sma.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-10), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_period_property(self): + sma = StreamingSMA(period=7) + assert sma.period == 7 + + def test_warmup_returns_nan(self): + """First period-1 updates must return NaN.""" + period = 4 + sma = StreamingSMA(period=period) + for i in range(period - 1): + assert math.isnan(sma.update(float(i + 1))) + # The period-th update should NOT be NaN + assert not math.isnan(sma.update(float(period))) + + def test_single_value_period_1(self): + """Period=1 means every value is immediately returned.""" + sma = StreamingSMA(period=1) + assert math.isclose(sma.update(42.0), 42.0) + assert math.isclose(sma.update(99.0), 99.0) + + def test_reset(self): + """After reset, the indicator should behave as freshly constructed.""" + sma = StreamingSMA(period=3) + sma.update(10.0) + sma.update(20.0) + result_before_reset = sma.update(30.0) + assert math.isclose(result_before_reset, 20.0) + + sma.reset() + # After reset, warmup restarts + assert math.isnan(sma.update(100.0)) + assert math.isnan(sma.update(200.0)) + assert math.isclose(sma.update(300.0), 200.0) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingSMA(period=0) + + def test_repr(self): + sma = StreamingSMA(period=5) + assert "StreamingSMA" in repr(sma) + assert "5" in repr(sma) + + +# --------------------------------------------------------------------------- +# StreamingEMA +# --------------------------------------------------------------------------- + + +class TestStreamingEMA: + def test_basic_seeding(self): + """EMA seeds from the first `period` values using their SMA.""" + ema = StreamingEMA(period=3) + assert math.isnan(ema.update(1.0)) + assert math.isnan(ema.update(2.0)) + # Seed = SMA(1,2,3) = 2.0 + seed = ema.update(3.0) + assert math.isclose(seed, 2.0) + + def test_matches_batch_ema(self): + """Streaming EMA must match batch EMA on the same data.""" + period = 5 + batch = EMA(PRICES, timeperiod=period) + ema = StreamingEMA(period=period) + for i, price in enumerate(PRICES): + val = ema.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-10), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_warmup_returns_nan(self): + period = 5 + ema = StreamingEMA(period=period) + for i in range(period - 1): + assert math.isnan(ema.update(float(i + 1))) + assert not math.isnan(ema.update(float(period))) + + def test_ema_differs_from_sma_after_warmup(self): + """After warmup, EMA and SMA should diverge for non-constant data.""" + period = 3 + prices = [1.0, 2.0, 3.0, 10.0, 11.0] + sma = StreamingSMA(period=period) + ema = StreamingEMA(period=period) + sma_vals = [sma.update(p) for p in prices] + ema_vals = [ema.update(p) for p in prices] + # At the seed point they should match (both are SMA of first 3) + assert math.isclose(sma_vals[2], ema_vals[2]) + # After the seed they should diverge + assert not math.isclose(sma_vals[-1], ema_vals[-1], rel_tol=1e-9) + + def test_reset(self): + ema = StreamingEMA(period=3) + for p in [10.0, 20.0, 30.0, 40.0]: + ema.update(p) + ema.reset() + # After reset, warmup restarts + assert math.isnan(ema.update(1.0)) + assert math.isnan(ema.update(2.0)) + assert math.isclose(ema.update(3.0), 2.0) + + def test_period_property(self): + ema = StreamingEMA(period=10) + assert ema.period == 10 + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingEMA(period=0) + + def test_single_value_period_1(self): + ema = StreamingEMA(period=1) + assert math.isclose(ema.update(42.0), 42.0) + assert math.isclose(ema.update(50.0), 50.0) + + def test_repr(self): + ema = StreamingEMA(period=12) + assert "StreamingEMA" in repr(ema) + assert "12" in repr(ema) + + +# --------------------------------------------------------------------------- +# StreamingRSI +# --------------------------------------------------------------------------- + + +class TestStreamingRSI: + def test_matches_batch_rsi(self): + """Streaming RSI must match batch RSI on the same data.""" + period = 5 + batch = RSI(PRICES, timeperiod=period) + rsi = StreamingRSI(period=period) + for i, price in enumerate(PRICES): + val = rsi.update(price) + if math.isnan(batch[i]): + assert math.isnan(val), f"Expected NaN at index {i}" + else: + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Mismatch at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_warmup_returns_nan(self): + """RSI needs period+1 bars (1 for first prev, then period deltas).""" + period = 5 + rsi = StreamingRSI(period=period) + # First bar: sets prev, returns NaN + assert math.isnan(rsi.update(50.0)) + # Next period-1 bars: accumulating deltas, returns NaN + for i in range(period - 1): + assert math.isnan(rsi.update(50.0 + i)) + # The (period+1)-th bar should produce a value + assert not math.isnan(rsi.update(55.0)) + + def test_rsi_range(self): + """All finite RSI values must be in [0, 100].""" + rsi = StreamingRSI(period=5) + for price in PRICES: + val = rsi.update(price) + if not math.isnan(val): + assert 0.0 <= val <= 100.0, f"RSI out of range: {val}" + + def test_constant_prices(self): + """Constant prices produce no gains or losses -- RSI should be 100 + (avg_loss == 0 leads to RS = infinity -> RSI = 100).""" + rsi = StreamingRSI(period=5) + results = [rsi.update(50.0) for _ in range(20)] + finite = [v for v in results if not math.isnan(v)] + assert len(finite) > 0 + for v in finite: + assert math.isclose(v, 100.0) or math.isclose(v, 0.0) or (0.0 <= v <= 100.0) + + def test_monotone_increasing(self): + """Monotonically increasing prices should yield RSI = 100.""" + rsi = StreamingRSI(period=3) + results = [rsi.update(float(i)) for i in range(1, 20)] + finite = [v for v in results if not math.isnan(v)] + for v in finite: + assert math.isclose(v, 100.0), ( + f"Expected RSI=100 for monotone increase, got {v}" + ) + + def test_monotone_decreasing(self): + """Monotonically decreasing prices should yield RSI = 0.""" + rsi = StreamingRSI(period=3) + results = [rsi.update(float(100 - i)) for i in range(20)] + finite = [v for v in results if not math.isnan(v)] + for v in finite: + assert math.isclose(v, 0.0, abs_tol=1e-10), ( + f"Expected RSI=0 for monotone decrease, got {v}" + ) + + def test_default_period_14(self): + rsi = StreamingRSI() + assert rsi.period == 14 + + def test_reset(self): + rsi = StreamingRSI(period=3) + for price in PRICES: + rsi.update(price) + rsi.reset() + # After reset, warmup restarts -- first update should be NaN + assert math.isnan(rsi.update(50.0)) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + StreamingRSI(period=0) + + def test_repr(self): + rsi = StreamingRSI(period=14) + assert "StreamingRSI" in repr(rsi) + assert "14" in repr(rsi) + + +# --------------------------------------------------------------------------- +# Edge cases (shared across indicators) +# --------------------------------------------------------------------------- + + +class TestStreamingEdgeCases: + def test_nan_input_sma(self): + """Feeding NaN into SMA should propagate NaN through the window.""" + sma = StreamingSMA(period=3) + sma.update(1.0) + sma.update(2.0) + # Third value is NaN -- the sum will include NaN, producing NaN + val = sma.update(float("nan")) + assert math.isnan(val) + + def test_nan_input_ema(self): + """Feeding NaN into EMA should produce NaN output.""" + ema = StreamingEMA(period=3) + ema.update(1.0) + ema.update(2.0) + val = ema.update(float("nan")) + assert math.isnan(val) + + def test_nan_input_rsi(self): + """Feeding NaN into RSI should produce NaN output.""" + rsi = StreamingRSI(period=3) + rsi.update(1.0) + rsi.update(2.0) + val = rsi.update(float("nan")) + assert math.isnan(val) + + def test_single_value_sma(self): + """Feeding exactly one value to SMA with period > 1 yields NaN.""" + sma = StreamingSMA(period=5) + assert math.isnan(sma.update(42.0)) + + def test_single_value_ema(self): + ema = StreamingEMA(period=5) + assert math.isnan(ema.update(42.0)) + + def test_single_value_rsi(self): + rsi = StreamingRSI(period=5) + assert math.isnan(rsi.update(42.0)) + + def test_large_dataset_sma(self): + """Ensure streaming SMA is stable over many updates.""" + period = 20 + sma = StreamingSMA(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = SMA(data, timeperiod=period) + for i, price in enumerate(data): + val = sma.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_large_dataset_ema(self): + """Ensure streaming EMA is stable over many updates.""" + period = 20 + ema = StreamingEMA(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = EMA(data, timeperiod=period) + for i, price in enumerate(data): + val = ema.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-8), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_large_dataset_rsi(self): + """Ensure streaming RSI is stable over many updates.""" + period = 14 + rsi = StreamingRSI(period=period) + np.random.seed(42) + data = np.random.randn(10_000).cumsum() + 100.0 + batch = RSI(data, timeperiod=period) + for i, price in enumerate(data): + val = rsi.update(price) + if not math.isnan(batch[i]): + assert math.isclose(val, batch[i], rel_tol=1e-6), ( + f"Drift at index {i}: streaming={val}, batch={batch[i]}" + ) + + def test_reset_then_reuse_matches_fresh_instance(self): + """A reset indicator should produce identical output to a new one.""" + period = 5 + data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0] + + sma_reused = StreamingSMA(period=period) + for p in [99.0, 98.0, 97.0, 96.0, 95.0]: + sma_reused.update(p) + sma_reused.reset() + + sma_fresh = StreamingSMA(period=period) + + for p in data: + v1 = sma_reused.update(p) + v2 = sma_fresh.update(p) + if math.isnan(v1): + assert math.isnan(v2) + else: + assert math.isclose(v1, v2, rel_tol=1e-12) diff --git a/vendor/ferro-ta-main/tests/unit/test_coverage.py b/vendor/ferro-ta-main/tests/unit/test_coverage.py new file mode 100644 index 0000000..bb95bd5 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_coverage.py @@ -0,0 +1,2513 @@ +"""Additional tests to improve code coverage across all ferro_ta modules. + +These tests target previously uncovered code paths including: +- Error-handling branches in indicator wrappers (except ValueError blocks) +- Utility helpers with untested code paths +- Module-level imports and edge cases +""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +CLOSE = np.cumprod(1 + RNG.normal(0, 0.01, 200)) * 100.0 +HIGH = CLOSE * RNG.uniform(1.001, 1.01, 200) +LOW = CLOSE * RNG.uniform(0.99, 0.999, 200) +OPEN = CLOSE * RNG.uniform(0.999, 1.001, 200) +VOLUME = RNG.uniform(500, 5000, 200) + +# 2D array — triggers ValueError("Input must be a 1-D array") in _to_f64 +_2D = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]]) + + +# =========================================================================== +# _utils.py — uncovered paths +# =========================================================================== + + +class TestUtilsUncovered: + """Cover uncovered paths in _utils.py.""" + + def test_to_f64_pandas_series(self): + """Line 43: pandas Series path in _to_f64.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import _to_f64 + + s = pd.Series([1.0, 2.0, 3.0]) + result = _to_f64(s) + assert result.dtype == np.float64 + np.testing.assert_array_equal(result, [1.0, 2.0, 3.0]) + + def test_to_f64_polars_series(self): + """Lines 47-50: polars Series path in _to_f64.""" + pl = pytest.importorskip("polars") + from ferro_ta._utils import _to_f64 + + s = pl.Series("close", [1.0, 2.0, 3.0]) + result = _to_f64(s) + assert result.dtype == np.float64 + assert len(result) == 3 + + def test_get_ohlcv_non_dataframe_raises(self): + """Lines 100-101: get_ohlcv raises TypeError for non-DataFrame.""" + pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + with pytest.raises(TypeError, match="pandas.DataFrame"): + get_ohlcv({"not": "a dataframe"}) + + def test_get_ohlcv_missing_column_raises(self): + """Line 104: get_ohlcv raises KeyError for missing column.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + df = pd.DataFrame({"open": [1.0], "high": [1.1], "low": [0.9], "close": [1.0]}) + # Pass a custom close_col that doesn't exist → raises KeyError + with pytest.raises(KeyError): + get_ohlcv(df, close_col="nonexistent_col") + + def test_get_ohlcv_none_volume_col(self): + """Lines 108-110: volume_col=None returns NaN array.""" + pd = pytest.importorskip("pandas") + from ferro_ta._utils import get_ohlcv + + df = pd.DataFrame( + { + "open": [1.0, 2.0], + "high": [1.1, 2.1], + "low": [0.9, 1.9], + "close": [1.0, 2.0], + } + ) + o, h, l, c, v = get_ohlcv( + df, + open_col="open", + high_col="high", + low_col="low", + close_col="close", + volume_col=None, + ) + assert np.all(np.isnan(v)) + + def test_pandas_wrap_dataframe_single_col(self): + """Lines 160-161: pandas_wrap handles single-column DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta import SMA + + df = pd.DataFrame({"close": CLOSE[:50]}) + result = SMA(df, timeperiod=5) + assert isinstance(result, (pd.Series, np.ndarray)) + + def test_pandas_wrap_tuple_output(self): + """Lines 172-178: pandas_wrap wraps tuple output in Series.""" + pd = pytest.importorskip("pandas") + from ferro_ta import BBANDS + + s = pd.Series(CLOSE[:50]) + upper, mid, lower = BBANDS(s, timeperiod=5) + assert isinstance(upper, pd.Series) + assert isinstance(mid, pd.Series) + assert isinstance(lower, pd.Series) + + def test_polars_wrap_tuple_output(self): + """Lines 233-234: polars_wrap wraps tuple output.""" + pl = pytest.importorskip("polars") + from ferro_ta import BBANDS + + s = pl.Series("close", CLOSE[:50].tolist()) + upper, mid, lower = BBANDS(s, timeperiod=5) + assert isinstance(upper, pl.Series) + + def test_polars_wrap_single_output(self): + """Line 246: polars_wrap wraps single ndarray output.""" + pl = pytest.importorskip("polars") + from ferro_ta import SMA + + s = pl.Series("close", CLOSE[:50].tolist()) + result = SMA(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_to_f64_polars_cast_exception_fallback(self): + """Lines 49-50: polars Series with cast exception falls back to to_list.""" + from ferro_ta._utils import _to_f64 + + # Create a mock that simulates a polars-like Series: + # - no 'to_numpy' attribute (so the pandas path is skipped) + # - has 'to_list()' method + # - type name is 'Series' (polars Series match condition) + # - has 'cast()' that raises an exception + class _FakePolars: + def to_list(self): + return [1.0, 2.0, 3.0] + + def cast(self, *args, **kwargs): + raise Exception("cast failed") + + _FakePolars.__name__ = "Series" + + result = _to_f64(_FakePolars()) + assert result.dtype == np.float64 + np.testing.assert_array_equal(result, [1.0, 2.0, 3.0]) + + +# =========================================================================== +# _binding.py — full coverage +# =========================================================================== + + +class TestBindingCall: + """Cover binding_call in _binding.py.""" + + def test_basic_call_success(self): + """Basic binding_call with timeperiod validation.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + + result = binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=CLOSE, + timeperiod=5, + ) + assert len(result) == len(CLOSE) + + def test_timeperiod_validation_raises(self): + """binding_call raises FerroTAValueError for invalid timeperiod.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + binding_call( + _sma, + array_params=["close"], + timeperiod_param="timeperiod", + close=CLOSE, + timeperiod=0, + ) + + def test_equal_length_validation_raises(self): + """binding_call raises FerroTAInputError for mismatched lengths.""" + from ferro_ta._ferro_ta import atr as _atr + + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + binding_call( + _atr, + array_params=["high", "low", "close"], + equal_length_groups=[["high", "low", "close"]], + timeperiod_param="timeperiod", + high=HIGH, + low=LOW[:10], # mismatched + close=CLOSE, + timeperiod=14, + ) + + def test_rust_error_normalization(self): + """binding_call normalizes Rust ValueError via _normalize_rust_error.""" + from ferro_ta._binding import binding_call + from ferro_ta.core.exceptions import FerroTAValueError + + # Use a function that will raise ValueError from Rust (invalid timeperiod) + def bad_fn(*args, **kwargs): + raise ValueError("timeperiod must be >= 1") + + with pytest.raises(FerroTAValueError): + binding_call(bad_fn, array_params=["close"], close=CLOSE) + + def test_no_timeperiod_param(self): + """binding_call without timeperiod_param skips timeperiod check.""" + from ferro_ta._ferro_ta import sma as _sma + + from ferro_ta._binding import binding_call + + result = binding_call(_sma, array_params=["close"], close=CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + +# =========================================================================== +# raw.py — import coverage +# =========================================================================== + + +class TestRawImport: + """Import ferro_ta.raw to cover the re-export statements.""" + + def test_raw_import(self): + """Importing ferro_ta.raw covers the re-export lines.""" + import ferro_ta.core.raw as raw + + assert hasattr(raw, "sma") + assert hasattr(raw, "ema") + assert hasattr(raw, "rsi") + assert hasattr(raw, "batch_sma") + + def test_raw_sma(self): + """ferro_ta.raw.sma works directly.""" + from ferro_ta.core.raw import sma + + result = sma(CLOSE, 5) + assert len(result) == len(CLOSE) + + +# =========================================================================== +# mcp/__main__.py — import coverage +# =========================================================================== + + +class TestMCPMain: + """Import ferro_ta.mcp.__main__ to cover module-level lines.""" + + def test_main_import(self): + """Importing mcp.__main__ covers lines 3-4.""" + import importlib + + mod = importlib.import_module("ferro_ta.mcp.__main__") + assert hasattr(mod, "run_server") + + +# =========================================================================== +# cycle.py — error-path coverage +# =========================================================================== + + +class TestCycleErrorPaths: + """Cover except ValueError branches in cycle.py.""" + + def test_ht_trendline_2d_raises(self): + from ferro_ta import HT_TRENDLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_TRENDLINE(_2D) + + def test_ht_dcperiod_2d_raises(self): + from ferro_ta import HT_DCPERIOD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_DCPERIOD(_2D) + + def test_ht_dcphase_2d_raises(self): + from ferro_ta import HT_DCPHASE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_DCPHASE(_2D) + + def test_ht_phasor_2d_raises(self): + from ferro_ta import HT_PHASOR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_PHASOR(_2D) + + def test_ht_sine_2d_raises(self): + from ferro_ta import HT_SINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_SINE(_2D) + + def test_ht_trendmode_2d_raises(self): + from ferro_ta import HT_TRENDMODE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + HT_TRENDMODE(_2D) + + +# =========================================================================== +# statistic.py — error-path coverage +# =========================================================================== + + +class TestStatisticErrorPaths: + """Cover except ValueError branches in statistic.py.""" + + def test_stddev_2d_raises(self): + from ferro_ta import STDDEV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STDDEV(_2D) + + def test_var_2d_raises(self): + from ferro_ta import VAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + VAR(_2D) + + def test_linearreg_2d_raises(self): + from ferro_ta import LINEARREG + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG(_2D) + + def test_linearreg_slope_2d_raises(self): + from ferro_ta import LINEARREG_SLOPE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_SLOPE(_2D) + + def test_linearreg_intercept_2d_raises(self): + from ferro_ta import LINEARREG_INTERCEPT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_INTERCEPT(_2D) + + def test_linearreg_angle_2d_raises(self): + from ferro_ta import LINEARREG_ANGLE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + LINEARREG_ANGLE(_2D) + + def test_tsf_2d_raises(self): + from ferro_ta import TSF + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TSF(_2D) + + def test_beta_2d_raises(self): + from ferro_ta import BETA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BETA(_2D, CLOSE) + + def test_correl_2d_raises(self): + from ferro_ta import CORREL + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CORREL(_2D, CLOSE) + + +# =========================================================================== +# overlap.py — error-path coverage +# =========================================================================== + + +class TestOverlapErrorPaths: + """Cover except ValueError branches in overlap.py.""" + + def test_sma_2d_raises(self): + from ferro_ta import SMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SMA(_2D) + + def test_ema_2d_raises(self): + from ferro_ta import EMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + EMA(_2D) + + def test_wma_2d_raises(self): + from ferro_ta import WMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WMA(_2D) + + def test_trima_2d_raises(self): + from ferro_ta import TRIMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TRIMA(_2D) + + def test_kama_2d_raises(self): + from ferro_ta import KAMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + KAMA(_2D) + + def test_t3_2d_raises(self): + from ferro_ta import T3 + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + T3(_2D) + + def test_bbands_2d_raises(self): + from ferro_ta import BBANDS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BBANDS(_2D) + + def test_macd_2d_raises(self): + from ferro_ta import MACD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACD(_2D) + + def test_macdfix_2d_raises(self): + from ferro_ta import MACDFIX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACDFIX(_2D) + + def test_sar_2d_raises(self): + from ferro_ta import SAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SAR(_2D, LOW) + + def test_midpoint_2d_raises(self): + from ferro_ta import MIDPOINT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIDPOINT(_2D) + + def test_midprice_2d_raises(self): + from ferro_ta import MIDPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIDPRICE(_2D, LOW) + + def test_mama_2d_raises(self): + from ferro_ta import MAMA + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAMA(_2D) + + def test_sarext_2d_raises(self): + from ferro_ta import SAREXT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SAREXT(_2D, LOW) + + def test_macdext_2d_raises(self): + from ferro_ta import MACDEXT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MACDEXT(_2D) + + +# =========================================================================== +# momentum.py — error-path coverage +# =========================================================================== + + +class TestMomentumErrorPaths: + """Cover except ValueError branches in momentum.py.""" + + def test_rsi_2d_raises(self): + from ferro_ta import RSI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + RSI(_2D) + + def test_mom_2d_raises(self): + from ferro_ta import MOM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MOM(_2D) + + def test_roc_2d_raises(self): + from ferro_ta import ROC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROC(_2D) + + def test_rocp_2d_raises(self): + from ferro_ta import ROCP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCP(_2D) + + def test_rocr_2d_raises(self): + from ferro_ta import ROCR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCR(_2D) + + def test_rocr100_2d_raises(self): + from ferro_ta import ROCR100 + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ROCR100(_2D) + + def test_willr_2d_raises(self): + from ferro_ta import WILLR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WILLR(_2D, LOW, CLOSE) + + def test_adx_2d_raises(self): + from ferro_ta import ADX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADX(_2D, LOW, CLOSE) + + def test_adxr_2d_raises(self): + from ferro_ta import ADXR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADXR(_2D, LOW, CLOSE) + + def test_apo_2d_raises(self): + from ferro_ta import APO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + APO(_2D) + + def test_ppo_2d_raises(self): + from ferro_ta import PPO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PPO(_2D) + + def test_cci_2d_raises(self): + from ferro_ta import CCI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CCI(_2D, LOW, CLOSE) + + def test_mfi_2d_raises(self): + from ferro_ta import MFI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MFI(_2D, LOW, CLOSE, VOLUME) + + def test_bop_2d_raises(self): + from ferro_ta import BOP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + BOP(_2D, HIGH, LOW, CLOSE) + + def test_stochf_2d_raises(self): + from ferro_ta import STOCHF + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCHF(_2D, LOW, CLOSE) + + def test_stoch_2d_raises(self): + from ferro_ta import STOCH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCH(_2D, LOW, CLOSE) + + def test_stochrsi_2d_raises(self): + from ferro_ta import STOCHRSI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + STOCHRSI(_2D) + + def test_ultosc_2d_raises(self): + from ferro_ta import ULTOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ULTOSC(_2D, LOW, CLOSE) + + def test_dx_2d_raises(self): + from ferro_ta import DX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + DX(_2D, LOW, CLOSE) + + def test_plus_di_2d_raises(self): + from ferro_ta import PLUS_DI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PLUS_DI(_2D, LOW, CLOSE) + + def test_minus_di_2d_raises(self): + from ferro_ta import MINUS_DI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MINUS_DI(_2D, LOW, CLOSE) + + def test_plus_dm_2d_raises(self): + from ferro_ta import PLUS_DM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + PLUS_DM(_2D, LOW) + + def test_minus_dm_2d_raises(self): + from ferro_ta import MINUS_DM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MINUS_DM(_2D, LOW) + + def test_cmo_2d_raises(self): + from ferro_ta import CMO + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CMO(_2D) + + def test_aroon_2d_raises(self): + from ferro_ta import AROON + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AROON(_2D, LOW) + + def test_aroonosc_2d_raises(self): + from ferro_ta import AROONOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AROONOSC(_2D, LOW) + + def test_trix_2d_raises(self): + from ferro_ta import TRIX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TRIX(_2D) + + +# =========================================================================== +# price_transform.py — error-path coverage +# =========================================================================== + + +class TestPriceTransformErrorPaths: + """Cover except ValueError branches in price_transform.py.""" + + def test_avgprice_2d_raises(self): + from ferro_ta import AVGPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AVGPRICE(_2D, HIGH, LOW, CLOSE) + + def test_medprice_2d_raises(self): + from ferro_ta import MEDPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MEDPRICE(_2D, LOW) + + def test_typprice_2d_raises(self): + from ferro_ta import TYPPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + TYPPRICE(_2D, LOW, CLOSE) + + def test_wclprice_2d_raises(self): + from ferro_ta import WCLPRICE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + WCLPRICE(_2D, LOW, CLOSE) + + +# =========================================================================== +# volume.py — error-path coverage +# =========================================================================== + + +class TestVolumeErrorPaths: + """Cover except ValueError branches in volume.py.""" + + def test_ad_2d_raises(self): + from ferro_ta import AD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + AD(_2D, LOW, CLOSE, VOLUME) + + def test_adosc_2d_raises(self): + from ferro_ta import ADOSC + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADOSC(_2D, LOW, CLOSE, VOLUME) + + def test_obv_2d_raises(self): + from ferro_ta import OBV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + OBV(_2D, VOLUME) + + +# =========================================================================== +# volatility.py — error-path coverage +# =========================================================================== + + +class TestVolatilityErrorPaths: + """Cover except ValueError branches in volatility.py.""" + + def test_atr_2d_raises(self): + from ferro_ta import ATR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ATR(_2D, LOW, CLOSE) + + def test_natr_2d_raises(self): + from ferro_ta import NATR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + NATR(_2D, LOW, CLOSE) + + +# =========================================================================== +# math_ops.py — error-path coverage +# =========================================================================== + + +class TestMathOpsErrorPaths: + """Cover except ValueError branches in math_ops.py.""" + + def test_add_2d_raises(self): + from ferro_ta import ADD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + ADD(_2D, CLOSE) + + def test_sub_2d_raises(self): + from ferro_ta import SUB + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SUB(_2D, CLOSE) + + def test_mult_2d_raises(self): + from ferro_ta import MULT + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MULT(_2D, CLOSE) + + def test_div_2d_raises(self): + from ferro_ta import DIV + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + DIV(_2D, CLOSE) + + def test_sum_2d_raises(self): + from ferro_ta import SUM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + SUM(_2D) + + def test_max_2d_raises(self): + from ferro_ta import MAX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAX(_2D) + + def test_min_2d_raises(self): + from ferro_ta import MIN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MIN(_2D) + + def test_maxindex_2d_raises(self): + from ferro_ta import MAXINDEX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MAXINDEX(_2D) + + def test_minindex_2d_raises(self): + from ferro_ta import MININDEX + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + MININDEX(_2D) + + +# =========================================================================== +# pattern.py — error-path coverage (sample of CDL functions) +# =========================================================================== + + +class TestPatternErrorPaths: + """Cover except ValueError branches in pattern.py for CDL functions.""" + + def _ohlc(self): + return OPEN, HIGH, LOW, CLOSE + + def test_cdl2crows_2d_raises(self): + from ferro_ta import CDL2CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL2CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdldoji_2d_raises(self): + from ferro_ta import CDLDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdl3blackcrows_2d_raises(self): + from ferro_ta import CDL3BLACKCROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3BLACKCROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3inside_2d_raises(self): + from ferro_ta import CDL3INSIDE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3INSIDE(_2D, HIGH, LOW, CLOSE) + + def test_cdlengulfing_2d_raises(self): + from ferro_ta import CDLENGULFING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLENGULFING(_2D, HIGH, LOW, CLOSE) + + def test_cdlhammer_2d_raises(self): + from ferro_ta import CDLHAMMER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHAMMER(_2D, HIGH, LOW, CLOSE) + + def test_cdlmarubozu_2d_raises(self): + from ferro_ta import CDLMARUBOZU + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMARUBOZU(_2D, HIGH, LOW, CLOSE) + + def test_cdlmorningstar_2d_raises(self): + from ferro_ta import CDLMORNINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMORNINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdleveningstar_2d_raises(self): + from ferro_ta import CDLEVENINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLEVENINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlshootingstar_2d_raises(self): + from ferro_ta import CDLSHOOTINGSTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSHOOTINGSTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlharami_2d_raises(self): + from ferro_ta import CDLHARAMI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHARAMI(_2D, HIGH, LOW, CLOSE) + + def test_cdldojistar_2d_raises(self): + from ferro_ta import CDLDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlspinningtop_2d_raises(self): + from ferro_ta import CDLSPINNINGTOP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSPINNINGTOP(_2D, HIGH, LOW, CLOSE) + + def test_cdlkicking_2d_raises(self): + from ferro_ta import CDLKICKING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLKICKING(_2D, HIGH, LOW, CLOSE) + + def test_cdlpiercing_2d_raises(self): + from ferro_ta import CDLPIERCING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLPIERCING(_2D, HIGH, LOW, CLOSE) + + def test_cdl3whitesoldiers_2d_raises(self): + from ferro_ta import CDL3WHITESOLDIERS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3WHITESOLDIERS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3outside_2d_raises(self): + from ferro_ta import CDL3OUTSIDE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3OUTSIDE(_2D, HIGH, LOW, CLOSE) + + def test_cdlmorningdojistar_2d_raises(self): + from ferro_ta import CDLMORNINGDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMORNINGDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdleveningdojistar_2d_raises(self): + from ferro_ta import CDLEVENINGDOJISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLEVENINGDOJISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlharamicross_2d_raises(self): + from ferro_ta import CDLHARAMICROSS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHARAMICROSS(_2D, HIGH, LOW, CLOSE) + + def test_cdl3linestrike_2d_raises(self): + from ferro_ta import CDL3LINESTRIKE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3LINESTRIKE(_2D, HIGH, LOW, CLOSE) + + def test_cdl3starsinsouth_2d_raises(self): + from ferro_ta import CDL3STARSINSOUTH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDL3STARSINSOUTH(_2D, HIGH, LOW, CLOSE) + + def test_cdlabandonedbaby_2d_raises(self): + from ferro_ta import CDLABANDONEDBABY + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLABANDONEDBABY(_2D, HIGH, LOW, CLOSE) + + def test_cdladvanceblock_2d_raises(self): + from ferro_ta import CDLADVANCEBLOCK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLADVANCEBLOCK(_2D, HIGH, LOW, CLOSE) + + def test_cdlbelthold_2d_raises(self): + from ferro_ta import CDLBELTHOLD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLBELTHOLD(_2D, HIGH, LOW, CLOSE) + + def test_cdlbreakaway_2d_raises(self): + from ferro_ta import CDLBREAKAWAY + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLBREAKAWAY(_2D, HIGH, LOW, CLOSE) + + def test_cdlclosingmarubozu_2d_raises(self): + from ferro_ta import CDLCLOSINGMARUBOZU + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCLOSINGMARUBOZU(_2D, HIGH, LOW, CLOSE) + + def test_cdlconcealbabyswall_2d_raises(self): + from ferro_ta import CDLCONCEALBABYSWALL + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCONCEALBABYSWALL(_2D, HIGH, LOW, CLOSE) + + def test_cdlcounterattack_2d_raises(self): + from ferro_ta import CDLCOUNTERATTACK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLCOUNTERATTACK(_2D, HIGH, LOW, CLOSE) + + def test_cdldarkcloudcover_2d_raises(self): + from ferro_ta import CDLDARKCLOUDCOVER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDARKCLOUDCOVER(_2D, HIGH, LOW, CLOSE) + + def test_cdldragonflydoji_2d_raises(self): + from ferro_ta import CDLDRAGONFLYDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLDRAGONFLYDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdlgapsidesidewhite_2d_raises(self): + from ferro_ta import CDLGAPSIDESIDEWHITE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLGAPSIDESIDEWHITE(_2D, HIGH, LOW, CLOSE) + + def test_cdlgravestonedoji_2d_raises(self): + from ferro_ta import CDLGRAVESTONEDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLGRAVESTONEDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdlhangingman_2d_raises(self): + from ferro_ta import CDLHANGINGMAN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHANGINGMAN(_2D, HIGH, LOW, CLOSE) + + def test_cdlhighwave_2d_raises(self): + from ferro_ta import CDLHIGHWAVE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIGHWAVE(_2D, HIGH, LOW, CLOSE) + + def test_cdlhikkake_2d_raises(self): + from ferro_ta import CDLHIKKAKE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIKKAKE(_2D, HIGH, LOW, CLOSE) + + def test_cdlhikkakemod_2d_raises(self): + from ferro_ta import CDLHIKKAKEMOD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHIKKAKEMOD(_2D, HIGH, LOW, CLOSE) + + def test_cdlhomingpigeon_2d_raises(self): + from ferro_ta import CDLHOMINGPIGEON + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLHOMINGPIGEON(_2D, HIGH, LOW, CLOSE) + + def test_cdlidentical3crows_2d_raises(self): + from ferro_ta import CDLIDENTICAL3CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLIDENTICAL3CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdlinneck_2d_raises(self): + from ferro_ta import CDLINNECK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLINNECK(_2D, HIGH, LOW, CLOSE) + + def test_cdlinvertedhammer_2d_raises(self): + from ferro_ta import CDLINVERTEDHAMMER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLINVERTEDHAMMER(_2D, HIGH, LOW, CLOSE) + + def test_cdlkickingbylength_2d_raises(self): + from ferro_ta import CDLKICKINGBYLENGTH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLKICKINGBYLENGTH(_2D, HIGH, LOW, CLOSE) + + def test_cdlladderbottom_2d_raises(self): + from ferro_ta import CDLLADDERBOTTOM + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLADDERBOTTOM(_2D, HIGH, LOW, CLOSE) + + def test_cdllongleggeddoji_2d_raises(self): + from ferro_ta import CDLLONGLEGGEDDOJI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLONGLEGGEDDOJI(_2D, HIGH, LOW, CLOSE) + + def test_cdllongline_2d_raises(self): + from ferro_ta import CDLLONGLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLLONGLINE(_2D, HIGH, LOW, CLOSE) + + def test_cdlmatchinglow_2d_raises(self): + from ferro_ta import CDLMATCHINGLOW + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMATCHINGLOW(_2D, HIGH, LOW, CLOSE) + + def test_cdlmathold_2d_raises(self): + from ferro_ta import CDLMATHOLD + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLMATHOLD(_2D, HIGH, LOW, CLOSE) + + def test_cdlonneck_2d_raises(self): + from ferro_ta import CDLONNECK + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLONNECK(_2D, HIGH, LOW, CLOSE) + + def test_cdlrickshawman_2d_raises(self): + from ferro_ta import CDLRICKSHAWMAN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLRICKSHAWMAN(_2D, HIGH, LOW, CLOSE) + + def test_cdlrisefall3methods_2d_raises(self): + from ferro_ta import CDLRISEFALL3METHODS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLRISEFALL3METHODS(_2D, HIGH, LOW, CLOSE) + + def test_cdlseparatinglines_2d_raises(self): + from ferro_ta import CDLSEPARATINGLINES + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSEPARATINGLINES(_2D, HIGH, LOW, CLOSE) + + def test_cdlshortline_2d_raises(self): + from ferro_ta import CDLSHORTLINE + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSHORTLINE(_2D, HIGH, LOW, CLOSE) + + def test_cdlstalledpattern_2d_raises(self): + from ferro_ta import CDLSTALLEDPATTERN + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSTALLEDPATTERN(_2D, HIGH, LOW, CLOSE) + + def test_cdlsticksandwich_2d_raises(self): + from ferro_ta import CDLSTICKSANDWICH + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLSTICKSANDWICH(_2D, HIGH, LOW, CLOSE) + + def test_cdltakuri_2d_raises(self): + from ferro_ta import CDLTAKURI + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTAKURI(_2D, HIGH, LOW, CLOSE) + + def test_cdltasukigap_2d_raises(self): + from ferro_ta import CDLTASUKIGAP + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTASUKIGAP(_2D, HIGH, LOW, CLOSE) + + def test_cdlthrusting_2d_raises(self): + from ferro_ta import CDLTHRUSTING + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTHRUSTING(_2D, HIGH, LOW, CLOSE) + + def test_cdltristar_2d_raises(self): + from ferro_ta import CDLTRISTAR + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLTRISTAR(_2D, HIGH, LOW, CLOSE) + + def test_cdlunique3river_2d_raises(self): + from ferro_ta import CDLUNIQUE3RIVER + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLUNIQUE3RIVER(_2D, HIGH, LOW, CLOSE) + + def test_cdlupsidegap2crows_2d_raises(self): + from ferro_ta import CDLUPSIDEGAP2CROWS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLUPSIDEGAP2CROWS(_2D, HIGH, LOW, CLOSE) + + def test_cdlxsidegap3methods_2d_raises(self): + from ferro_ta import CDLXSIDEGAP3METHODS + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + CDLXSIDEGAP3METHODS(_2D, HIGH, LOW, CLOSE) + + +# =========================================================================== +# exceptions.py — uncovered paths +# =========================================================================== + + +class TestExceptionsUncovered: + """Cover uncovered lines in exceptions.py.""" + + def test_check_equal_length_with_shape_attr(self): + """Line 107-108: check_equal_length with object having .shape attr.""" + from ferro_ta.core.exceptions import check_equal_length + + class FakeArr: + shape = (3,) + + arr = FakeArr() + # Should not raise when both have same length via shape attribute + check_equal_length(a=arr, b=arr) + + def test_check_equal_length_mismatched_shapes(self): + """Lines 110-114: check_equal_length raises with mismatched .shape.""" + from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length + + class FakeArr: + def __init__(self, n): + self.shape = (n,) + + with pytest.raises(FerroTAInputError, match="same length"): + check_equal_length(a=FakeArr(3), b=FakeArr(5)) + + def test_check_min_length_with_shape_attr(self): + """Lines 167-168: check_min_length with .shape attribute.""" + from ferro_ta.core.exceptions import FerroTAInputError, check_min_length + + class FakeArr: + shape = (2,) + + arr = FakeArr() + # Should raise since len = 2 < min_len = 5 + with pytest.raises(FerroTAInputError, match="at least 5 elements"): + check_min_length(arr, 5, name="input") + + +# =========================================================================== +# extended.py — uncovered path +# =========================================================================== + + +class TestExtendedUncovered: + """Cover uncovered lines in extended.py.""" + + def test_vwap_negative_timeperiod_raises(self): + """Lines 107-109: VWAP raises FerroTAValueError for negative timeperiod.""" + from ferro_ta import VWAP + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 0"): + VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=-1) + + +# =========================================================================== +# options.py — uncovered path +# =========================================================================== + + +class TestOptionsUncovered: + """Cover uncovered line in options.py.""" + + def test_validate_iv_2d_raises(self): + """Line 63: _validate_iv raises for 2D input.""" + from ferro_ta.analysis.options import iv_rank + from ferro_ta.core.exceptions import FerroTAInputError + + with pytest.raises(FerroTAInputError): + iv_rank(np.array([[0.2, 0.3]]), window=5) + + +# =========================================================================== +# adapters.py — uncovered paths +# =========================================================================== + + +class TestAdaptersUncovered: + """Cover uncovered lines in adapters.py.""" + + def test_register_non_subclass_raises(self): + """Line 82: register_adapter raises TypeError for non-subclass.""" + from ferro_ta.data.adapters import register_adapter + + with pytest.raises(TypeError): + register_adapter("bad", int) + + def test_dataadapter_repr(self): + """Line 134: DataAdapter __repr__.""" + from ferro_ta.data.adapters import InMemoryAdapter + + adapter = InMemoryAdapter({"close": CLOSE}) + assert "InMemoryAdapter" in repr(adapter) + + def test_inmemory_adapter_fetch(self): + """Lines 263: InMemoryAdapter.fetch returns wrapped data.""" + from ferro_ta.data.adapters import InMemoryAdapter + + data = {"close": CLOSE, "high": HIGH} + adapter = InMemoryAdapter(data) + result = adapter.fetch() + assert result is data + + def test_csvadapter_repr(self): + """Line 225: CsvAdapter __repr__.""" + from ferro_ta.data.adapters import CsvAdapter + + adapter = CsvAdapter("/tmp/test.csv") + assert "/tmp/test.csv" in repr(adapter) + + def test_csvadapter_fetch_with_rename(self): + """Lines 200-221: CsvAdapter.fetch with column rename.""" + import csv + import os + import tempfile + + pytest.importorskip("pandas") + from ferro_ta.data.adapters import CsvAdapter + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".csv", delete=False, newline="" + ) as f: + writer = csv.writer(f) + writer.writerow(["Open", "High", "Low", "Close", "Volume"]) + for i in range(5): + writer.writerow([1.0, 1.1, 0.9, 1.0, 1000]) + fname = f.name + + try: + adapter = CsvAdapter( + fname, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col="Volume", + ) + df = adapter.fetch() + assert "close" in df.columns or "Close" in df.columns + finally: + os.unlink(fname) + + +# =========================================================================== +# aggregation.py — uncovered paths +# =========================================================================== + + +class TestAggregationUncovered: + """Cover uncovered lines in aggregation.py.""" + + def test_aggregate_ticks_no_pandas_fallback(self): + """Lines 194-195: aggregate_ticks without pandas returns dict.""" + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + } + bars = aggregate_ticks(ticks, rule="tick:50") + assert "close" in bars + + def test_tick_aggregator_repr(self): + """Line 233: TickAggregator __repr__.""" + from ferro_ta.data.aggregation import TickAggregator + + agg = TickAggregator(rule="tick:50") + assert "TickAggregator" in repr(agg) + assert "tick:50" in repr(agg) + + def test_aggregate_ticks_with_extra_timestamp(self): + """Lines 75-76, 80: aggregate_ticks with extra (timestamp) parameter.""" + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + "timestamp": np.arange(n, dtype=np.int64), + } + bars = aggregate_ticks(ticks, rule="tick:50") + assert "close" in bars + + def test_time_resample_missing_columns_raises(self): + """Lines 156-163: aggregate_ticks with time rule requires timestamp.""" + from ferro_ta.data.aggregation import aggregate_ticks + + # Time bars without timestamp should raise ValueError + rng = np.random.default_rng(0) + n = 200 + ticks = { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + # no timestamp — time bars would require one + } + with pytest.raises(ValueError, match="timestamp"): + aggregate_ticks(ticks, rule="time:60") + + def test_time_resample_non_datetime_index_raises(self): + """Lines 155-162: aggregate_ticks with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.aggregation import aggregate_ticks + + rng = np.random.default_rng(0) + n = 200 + df = pd.DataFrame( + { + "price": rng.uniform(99, 101, n), + "size": rng.uniform(1, 10, n), + } + ) + bars = aggregate_ticks(df, rule="tick:50") + assert "close" in bars + + +# =========================================================================== +# alerts.py — uncovered paths +# =========================================================================== + + +class TestAlertsUncovered: + """Cover uncovered lines in alerts.py.""" + + def test_alert_event_repr(self): + """Line 166: AlertEvent __repr__.""" + from ferro_ta.tools.alerts import AlertEvent + + ev = AlertEvent("test_cond", bar_index=5, value=75.0, payload={"key": "val"}) + r = repr(ev) + assert "test_cond" in r + assert "5" in r + + def test_dispatch_with_callback(self): + """Lines 407-408: _dispatch invokes callback.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + events_received = [] + + def cb(ev): + events_received.append(ev) + + ev = AlertEvent("cond", bar_index=1, value=50.0) + AlertManager._dispatch(ev, cb, None) + assert len(events_received) == 1 + + def test_dispatch_callback_exception_logged(self): + """Lines 407-408: _dispatch swallows callback exceptions.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + def bad_cb(ev): + raise RuntimeError("callback error") + + ev = AlertEvent("cond", bar_index=1, value=50.0) + # Should not raise — exception is logged/swallowed + AlertManager._dispatch(ev, bad_cb, None) + + def test_dispatch_webhook_post(self): + """Lines 410-411: _dispatch calls _post_webhook when url is set.""" + from ferro_ta.tools.alerts import AlertEvent, AlertManager + + ev = AlertEvent("cond", bar_index=1, value=50.0) + # Use an invalid URL to trigger the except branch in _post_webhook + AlertManager._dispatch(ev, None, "http://localhost:99999/webhook") + + def test_post_webhook_failure_logged(self): + """Lines 416-430: _post_webhook logs failure on connection error.""" + from ferro_ta.tools.alerts import AlertManager + + # Should not raise — failure is logged + AlertManager._post_webhook("http://localhost:99999/x", {"key": "val"}) + + def test_alert_manager_force_live_with_callback(self): + """Lines 388: AlertManager.run_backtest with force_live dispatches events.""" + from ferro_ta.tools.alerts import AlertManager + + received = [] + + def cb(ev): + received.append(ev) + + series = np.array([20.0, 25.0, 30.0, 35.0, 28.0]) + mgr = AlertManager(symbol="TEST") + mgr.add_threshold_condition( + "rsi_ob", series, level=29.0, direction=1, callback=cb + ) + events = mgr.run_backtest(force_live=True) + # Events should have been dispatched + assert len(received) > 0 or len(events) >= 0 + + +# =========================================================================== +# attribution.py — uncovered paths +# =========================================================================== + + +class TestAttributionUncovered: + """Cover uncovered lines in attribution.py.""" + + def test_trade_stats_repr(self): + """Line 105: TradeStats __repr__.""" + from ferro_ta.analysis.attribution import TradeStats + + ts = TradeStats( + win_rate=0.55, + avg_win=120.0, + avg_loss=-80.0, + profit_factor=1.8, + avg_hold_bars=5.0, + n_trades=20, + ) + r = repr(ts) + assert "n_trades=20" in r + assert "win_rate" in r + + def test_monthly_contribution_without_timestamps(self): + """Lines 282-307: attribution_by_month without timestamps.""" + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 100) + result = attribution_by_month(ret) + assert isinstance(result, dict) + assert len(result) > 0 + + def test_monthly_contribution_with_timestamps(self): + """Lines 267-281: attribution_by_month with timestamps.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.attribution import attribution_by_month + + n = 60 + ret = RNG.normal(0, 0.01, n) + ts = pd.date_range("2023-01-01", periods=n, freq="D") + timestamps = ts.view("int64") + result = attribution_by_month(ret, timestamps=timestamps) + assert isinstance(result, dict) + + def test_factor_attribution_basic(self): + """Lines 290-305: attribution_by_signal returns factor exposures.""" + from ferro_ta.analysis.attribution import attribution_by_signal + + n = 100 + portfolio_ret = RNG.normal(0, 0.01, n) + signal = (RNG.normal(0, 1, n) > 0).astype(np.float64) + result = attribution_by_signal(portfolio_ret, signal) + assert isinstance(result, dict) + + +# =========================================================================== +# backtest.py — uncovered paths +# =========================================================================== + + +class TestBacktestUncovered: + """Cover uncovered lines in backtest.py.""" + + def test_sma_cross_fast_gt_slow_raises(self): + """Lines 188-190: sma_crossover_strategy raises when fast >= slow.""" + from ferro_ta.analysis.backtest import sma_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(CLOSE, fast=26, slow=12) + + def test_sma_cross_fast_zero_raises(self): + """Line 188: sma_crossover_strategy raises for fast < 1.""" + from ferro_ta.analysis.backtest import sma_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(CLOSE, fast=0, slow=20) + + def test_macd_cross_fast_ge_slow_raises(self): + """Line 232: macd_crossover_strategy raises when fast >= slow.""" + from ferro_ta.analysis.backtest import macd_crossover_strategy + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError): + macd_crossover_strategy(CLOSE, fastperiod=26, slowperiod=12) + + def test_backtest_unknown_string_strategy_raises(self): + """Line 338: backtest raises for unknown strategy string.""" + from ferro_ta.analysis.backtest import backtest + from ferro_ta.core.exceptions import FerroTAValueError + + with pytest.raises(FerroTAValueError, match="strategy must be"): + backtest(CLOSE, strategy=123) + + +# =========================================================================== +# batch.py — uncovered paths +# =========================================================================== + + +class TestBatchUncovered: + """Cover uncovered lines in batch.py.""" + + def test_batch_sma_1d_fallback(self): + """Line 95: batch_sma with 1D input calls single-series SMA.""" + from ferro_ta.data.batch import batch_sma + + result = batch_sma(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_ema_1d_fallback(self): + """Line 137: batch_ema with 1D input.""" + from ferro_ta.data.batch import batch_ema + + result = batch_ema(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_rsi_1d_fallback(self): + """Line 160: batch_rsi with 1D input.""" + from ferro_ta.data.batch import batch_rsi + + result = batch_rsi(CLOSE, timeperiod=14) + assert len(result) == len(CLOSE) + + def test_batch_apply_1d(self): + """Line 185: batch_apply with 1D input.""" + from ferro_ta import SMA + from ferro_ta.data.batch import batch_apply + + result = batch_apply(CLOSE, SMA, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_batch_apply_3d_raises(self): + """Line 162, 187: batch_apply with 3D input raises ValueError.""" + from ferro_ta import SMA + from ferro_ta.data.batch import batch_apply + + arr_3d = np.ones((10, 3, 2)) + with pytest.raises(ValueError, match="1-D or 2-D"): + batch_apply(arr_3d, SMA, timeperiod=5) + + +# =========================================================================== +# chunked.py — uncovered paths +# =========================================================================== + + +class TestChunkedUncovered: + """Cover uncovered lines in chunked.py.""" + + def test_chunk_apply_empty_series(self): + """Line 190: chunk_apply returns empty for zero-length input.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + result = chunk_apply(SMA, np.array([]), timeperiod=5) + assert len(result) == 0 + + def test_chunk_apply_small_series_no_chunks(self): + """Lines 194-195: chunk_apply falls back when ranges is empty.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + # chunk_size larger than series → make_chunk_ranges returns [] → fallback + result = chunk_apply( + SMA, CLOSE[:10], chunk_size=5000, overlap=100, timeperiod=3 + ) + assert len(result) == 10 + + +# =========================================================================== +# crypto.py — uncovered paths +# =========================================================================== + + +class TestCryptoUncovered: + """Cover uncovered lines in crypto.py.""" + + def test_funding_rate_pnl_with_tuple_ohlcv(self): + """Lines 67-107: funding_pnl basic usage.""" + from ferro_ta.analysis.crypto import funding_pnl + + n = 96 + position_size = np.ones(n) + funding = np.full(n, 0.0001) + + result = funding_pnl(position_size, funding) + assert len(result) == n + + +# =========================================================================== +# dsl.py — uncovered paths +# =========================================================================== + + +class TestDSLUncovered: + """Cover uncovered lines in dsl.py.""" + + def test_expr_not_implemented(self): + """Line 71: _Expr.eval raises NotImplementedError.""" + from ferro_ta.tools.dsl import _Expr + + expr = _Expr() + with pytest.raises(NotImplementedError): + expr.eval({}) + + def test_price_ref_missing_raises(self): + """Line 80: _PriceRef raises ValueError for missing series.""" + from ferro_ta.tools.dsl import _PriceRef + + ref = _PriceRef("volume") + with pytest.raises(ValueError, match="not found"): + ref.eval({"close": CLOSE}) + + def test_indicator_call_missing_close_raises(self): + """Line 101: _IndicatorCall raises ValueError when close missing.""" + from ferro_ta.tools.dsl import _IndicatorCall + + call = _IndicatorCall("SMA", [14]) + with pytest.raises(ValueError, match="'close' series is required"): + call.eval({}) + + def test_indicator_call_unknown_indicator_raises(self): + """Lines 125-128: _IndicatorCall raises for unknown indicator name.""" + from ferro_ta.tools.dsl import _IndicatorCall + + call = _IndicatorCall("NONEXISTENT_INDICATOR_XYZ", [14]) + with pytest.raises((ValueError, Exception)): + call.eval({"close": CLOSE}) + + def test_crossover_below_expr(self): + """Lines 191-203: _CrossFunc evaluates 'below' direction.""" + from ferro_ta.tools.dsl import _CrossFunc, _Expr + + class ConstArr(_Expr): + def __init__(self, arr): + self._arr = arr + + def eval(self, ctx): + return self._arr + + fast_arr = np.array([15.0, 15.0, 12.0, 11.0]) + slow_arr = np.array([13.0, 13.0, 13.0, 13.0]) + + crossover = _CrossFunc("below", ConstArr(fast_arr), ConstArr(slow_arr)) + result = crossover.eval({}) + assert result[2] == 1 + + def test_dsl_parse_and_run(self): + """Lines 119, 123-124, 126: DSL parse and run with indicators.""" + from ferro_ta.tools.dsl import evaluate + + ctx = { + "close": CLOSE, + "high": HIGH, + "low": LOW, + } + result = evaluate("SMA(14)", ctx) + assert isinstance(result, np.ndarray) + + def test_dsl_comparison_operators(self): + """Lines 270, 279: DSL comparison operators.""" + from ferro_ta.tools.dsl import evaluate + + ctx = {"close": CLOSE} + # Expression with comparison + result = evaluate("RSI(14) < 40", ctx) + assert isinstance(result, np.ndarray) + + +# =========================================================================== +# features.py — uncovered paths +# =========================================================================== + + +class TestFeaturesUncovered: + """Cover uncovered lines in features.py.""" + + def test_feature_matrix_missing_close_raises(self): + """Line 115: feature_matrix raises when close col missing.""" + from ferro_ta.analysis.features import feature_matrix + + with pytest.raises(ValueError, match="close column"): + feature_matrix({"high": HIGH}, [("SMA", {"timeperiod": 5})]) + + def test_feature_matrix_multi_output_with_index(self): + """Lines 152-165: feature_matrix with tuple output and out_key.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE, "high": HIGH, "low": LOW, "volume": VOLUME} + fm = feature_matrix( + ohlcv, + [("BBANDS", {"timeperiod": 5}, 0)], # out_key=0 → BBANDS_0 + ) + assert isinstance(fm, dict) or hasattr(fm, "columns") + + def test_feature_matrix_nan_policy_drop(self): + """Lines 183-194: feature_matrix with nan_policy='drop'.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="drop") + # Result should have fewer rows than input (NaNs dropped) + if hasattr(fm, "__len__"): + assert len(fm) <= 30 + + def test_feature_matrix_nan_policy_fill(self): + """Lines 204-222: feature_matrix with nan_policy='fill'.""" + from ferro_ta.analysis.features import feature_matrix + + ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="fill") + assert fm is not None + + def test_feature_matrix_pandas_dataframe_input(self): + """Lines 100-103: feature_matrix with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.features import feature_matrix + + df = pd.DataFrame({"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}) + fm = feature_matrix(df, [("SMA", {"timeperiod": 5})]) + assert isinstance(fm, pd.DataFrame) + + +# =========================================================================== +# gpu.py — uncovered paths (mock CuPy) +# =========================================================================== + + +class TestGPUUncovered: + """Cover uncovered lines in gpu.py using mocked CuPy.""" + + def test_sma_gpu_no_cupy_falls_back(self): + """Line 42: sma falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import sma as gpu_sma + + result = gpu_sma(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_ema_gpu_no_cupy_falls_back(self): + """Lines 55-57: ema falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import ema as gpu_ema + + result = gpu_ema(CLOSE, timeperiod=5) + assert len(result) == len(CLOSE) + + def test_rsi_gpu_no_cupy_falls_back(self): + """Line 62: rsi falls back to CPU when CuPy not available.""" + from ferro_ta.tools.gpu import rsi as gpu_rsi + + result = gpu_rsi(CLOSE, timeperiod=14) + assert len(result) == len(CLOSE) + + def test_sma_gpu_with_mock_cupy(self): + """_is_torch and _to_cpu helpers: NumPy arrays are not torch, _to_cpu passes through.""" + import ferro_ta.tools.gpu as gpu_module + + arr = np.asarray(CLOSE[:30], dtype=np.float64) + assert not gpu_module._is_torch(arr) + result = gpu_module._to_cpu(arr) + np.testing.assert_array_equal(result, arr) + + def test_ema_gpu_with_mock_cupy(self): + """Lines 138-178: public sma/ema/rsi fallback produces correct shapes.""" + import ferro_ta.tools.gpu as gpu_module + + arr = np.asarray(CLOSE[:50], dtype=np.float64) + # All three public functions should fall back to CPU + sma_result = gpu_module.sma(arr, timeperiod=5) + ema_result = gpu_module.ema(arr, timeperiod=5) + rsi_result = gpu_module.rsi(arr, timeperiod=14) + assert len(sma_result) == len(arr) + assert len(ema_result) == len(arr) + assert len(rsi_result) == len(arr) + + def test_rsi_gpu_with_mock_cupy(self): + """gpu.py __all__ list and module attributes (_TORCH_AVAILABLE).""" + import ferro_ta.tools.gpu as gpu_module + + assert hasattr(gpu_module, "sma") + assert hasattr(gpu_module, "ema") + assert hasattr(gpu_module, "rsi") + assert hasattr(gpu_module, "_TORCH_AVAILABLE") + + +# =========================================================================== +# viz.py — uncovered paths (mock matplotlib/plotly) +# =========================================================================== + + +class TestVizUncovered: + """Cover uncovered lines in viz.py using mocked backends.""" + + def test_plot_dict_input(self): + """Lines 146-154: _extract_close_volume with dict input.""" + from ferro_ta.tools.viz import _extract_close_volume + + ohlcv = {"close": CLOSE, "volume": VOLUME} + close, vol = _extract_close_volume(ohlcv, "close", "volume") + np.testing.assert_array_equal(close, CLOSE) + + def test_plot_dict_no_volume(self): + """_extract_close_volume returns None for missing volume key.""" + from ferro_ta.tools.viz import _extract_close_volume + + ohlcv = {"close": CLOSE} + close, vol = _extract_close_volume(ohlcv, "close", "volume") + assert vol is None + + def test_plot_array_input(self): + """_extract_close_volume with plain array input.""" + from ferro_ta.tools.viz import _extract_close_volume + + close, vol = _extract_close_volume(CLOSE, "close", "volume") + np.testing.assert_array_equal(close, CLOSE) + assert vol is None + + def test_n_subplots_calculation(self): + """Lines 167-176: _n_subplots helper.""" + from ferro_ta.tools.viz import _n_subplots + + assert _n_subplots(None, None) == 1 + assert _n_subplots(None, VOLUME) == 2 + assert _n_subplots({"RSI": CLOSE}, None) == 2 + assert _n_subplots({"RSI": CLOSE}, VOLUME) == 3 + + def test_plot_matplotlib_no_matplotlib_raises(self): + """Lines 194-200: _plot_matplotlib raises ImportError without matplotlib.""" + from ferro_ta.tools.viz import _plot_matplotlib + + with patch.dict( + sys.modules, + { + "matplotlib": None, + "matplotlib.pyplot": None, + "matplotlib.gridspec": None, + }, + ): + with pytest.raises((ImportError, Exception)): + _plot_matplotlib( + CLOSE, + None, + None, + title=None, + figsize=None, + savefig=None, + show=False, + ) + + def test_plot_plotly_no_plotly_raises(self): + """Lines 262-268: _plot_plotly raises ImportError without plotly.""" + from ferro_ta.tools.viz import _plot_plotly + + with patch.dict( + sys.modules, + {"plotly": None, "plotly.graph_objects": None, "plotly.subplots": None}, + ): + with pytest.raises((ImportError, Exception)): + _plot_plotly( + CLOSE, + None, + None, + title=None, + figsize=None, + savefig=None, + show=False, + ) + + def test_plot_unknown_backend_raises(self): + """Line 116-119: plot raises ValueError for unknown backend.""" + from ferro_ta.tools.viz import plot + + with pytest.raises(ValueError, match="Unknown backend"): + plot({"close": CLOSE}, backend="unknown_backend") + + def test_plot_matplotlib_backend(self): + """Lines 106, 194-244: plot with matplotlib backend.""" + matplotlib = pytest.importorskip("matplotlib") + matplotlib.use("Agg") # non-interactive backend + + from ferro_ta import RSI, SMA + from ferro_ta.tools.viz import plot + + ohlcv = {"close": CLOSE[:50], "volume": VOLUME[:50]} + fig = plot( + ohlcv, + indicators={ + "SMA(10)": SMA(CLOSE[:50], timeperiod=10), + "RSI(14)": RSI(CLOSE[:50], timeperiod=14), + }, + backend="matplotlib", + show=False, + volume=True, + title="Test", + ) + assert fig is not None + + def test_plot_pandas_dataframe_input(self): + """Lines 146-154: _extract_close_volume with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.tools.viz import _extract_close_volume + + df = pd.DataFrame({"close": CLOSE[:10], "volume": VOLUME[:10]}) + close, vol = _extract_close_volume(df, "close", "volume") + assert len(close) == 10 + + +# =========================================================================== +# dashboard.py — uncovered paths +# =========================================================================== + + +class TestDashboardUncovered: + """Cover uncovered lines in dashboard.py.""" + + def test_indicator_widget_no_ipywidgets_raises(self): + """Lines 96-132: indicator_widget raises ImportError without ipywidgets.""" + from ferro_ta.tools.dashboard import indicator_widget + + with patch.dict( + sys.modules, + {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None}, + ): + with pytest.raises((ImportError, Exception)): + indicator_widget(CLOSE, lambda c, **kw: c, "timeperiod", range(5, 15)) + + def test_backtest_widget_no_ipywidgets_raises(self): + """Lines 160-203: backtest_widget raises ImportError without ipywidgets.""" + from ferro_ta.tools.dashboard import backtest_widget + + with patch.dict( + sys.modules, + {"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None}, + ): + with pytest.raises((ImportError, Exception)): + backtest_widget(CLOSE) + + def test_streamlit_app_no_streamlit_raises(self): + """Lines 240-336: streamlit_app raises ImportError without streamlit.""" + from ferro_ta.tools.dashboard import streamlit_app + + with patch.dict(sys.modules, {"streamlit": None}): + with pytest.raises((ImportError, Exception)): + streamlit_app() + + +# =========================================================================== +# regime.py — uncovered paths +# =========================================================================== + + +class TestRegimeUncovered: + """Cover uncovered lines in regime.py.""" + + def test_detect_regime_with_dataframe_ohlcv(self): + """Lines 249-256: regime accepts pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.regime import regime + + df = pd.DataFrame( + { + "open": OPEN, + "high": HIGH, + "low": LOW, + "close": CLOSE, + "volume": VOLUME, + } + ) + result = regime(df) + assert isinstance(result, np.ndarray) + + def test_detect_regime_with_tuple_ohlcv(self): + """Lines 254-256: regime with tuple ohlcv.""" + from ferro_ta.analysis.regime import regime + + ohlcv = (OPEN, HIGH, LOW, CLOSE, VOLUME) + result = regime(ohlcv) + assert isinstance(result, np.ndarray) + + +# =========================================================================== +# resampling.py — uncovered paths +# =========================================================================== + + +class TestResamplingUncovered: + """Cover uncovered lines in resampling.py.""" + + def test_resample_ohlcv_missing_columns_raises(self): + """Lines 114-115: resample raises for missing columns.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.resampling import resample + + df = pd.DataFrame( + {"close": [1.0, 2.0]}, + index=pd.to_datetime(["2024-01-01", "2024-01-02"]), + ) + with pytest.raises((ValueError, KeyError)): + resample(df, rule="1D") + + def test_resample_ohlcv_non_datetime_index_raises(self): + """Lines 123-128: resample raises for non-DatetimeIndex.""" + pd = pytest.importorskip("pandas") + from ferro_ta.data.resampling import resample + + df = pd.DataFrame( + { + "open": [1.0, 2.0], + "high": [1.1, 2.1], + "low": [0.9, 1.9], + "close": [1.0, 2.0], + "volume": [1000.0, 2000.0], + } + ) + with pytest.raises(ValueError, match="DatetimeIndex"): + resample(df, rule="1D") + + def test_multi_timeframe_no_pandas_fallback(self): + """Line 198-199: multi_timeframe with pandas DataFrame.""" + from ferro_ta.data.resampling import multi_timeframe + + pd_module = pytest.importorskip("pandas") + # With pandas available, test basic usage + n = 100 + df = pd_module.DataFrame( + { + "open": OPEN[:n], + "high": HIGH[:n], + "low": LOW[:n], + "close": CLOSE[:n], + "volume": VOLUME[:n], + }, + index=pd_module.date_range("2024-01-01", periods=n, freq="1h"), + ) + result = multi_timeframe(df, rules=["4h"]) + assert "4h" in result + + def test_ohlcv_resampler_repr(self): + """Line 276: volume_bars and resample function.""" + from ferro_ta.data.resampling import resample + + pd_module = pytest.importorskip("pandas") + n = 60 + df = pd_module.DataFrame( + { + "open": OPEN[:n], + "high": HIGH[:n], + "low": LOW[:n], + "close": CLOSE[:n], + "volume": VOLUME[:n], + }, + index=pd_module.date_range("2024-01-01", periods=n, freq="5min"), + ) + result = resample(df, rule="15min") + assert len(result) < n + + +# =========================================================================== +# signals.py — uncovered paths +# =========================================================================== + + +class TestSignalsUncovered: + """Cover uncovered lines in signals.py.""" + + def test_compose_signals_pandas_dataframe(self): + """Lines 115-123: compose with pandas DataFrame.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.signals import compose + + n = 50 + signals_df = pd.DataFrame( + { + "sig1": RNG.choice([-1, 0, 1], n).astype(np.float64), + "sig2": RNG.choice([-1, 0, 1], n).astype(np.float64), + } + ) + result = compose(signals_df, method="mean") + assert len(result) == n + + def test_screen_symbols_pandas_series(self): + """Lines 196-197: screen with pandas Series.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.signals import screen + + scores = pd.Series({"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}) + result = screen(scores, top_n=2) + assert len(result) == 2 + + def test_screen_symbols_above_filter(self): + """Lines 223-224: screen with above filter.""" + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9} + result = screen(scores, above=0.7) + assert "GOOG" not in result + assert "AAPL" in result + + def test_screen_symbols_below_filter(self): + """Lines 225-227: screen with below filter.""" + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9} + result = screen(scores, below=0.7) + assert "GOOG" in result + assert "MSFT" not in result + + def test_screen_symbols_list_input(self): + """Lines 202-210: screen with list input.""" + from ferro_ta.analysis.signals import screen + + scores = [0.8, 0.5, 0.9] + result = screen(scores, top_n=2) + assert len(result) == 2 + + def test_compose_signals_rank_method(self): + """Line 126: compose with rank method.""" + from ferro_ta.analysis.signals import compose + + n = 50 + signals = np.column_stack( + [ + RNG.choice([-1, 0, 1], n).astype(np.float64), + RNG.choice([-1, 0, 1], n).astype(np.float64), + ] + ) + result = compose(signals, method="rank") + assert len(result) == n + + +# =========================================================================== +# pipeline.py — uncovered paths +# =========================================================================== + + +class TestPipelineUncovered: + """Cover uncovered lines in pipeline.py.""" + + def test_add_non_callable_raises(self): + """Line 170: Pipeline.add raises TypeError for non-callable.""" + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + with pytest.raises(TypeError, match="func must be callable"): + p.add("bad_step", "not_a_function") + + def test_add_duplicate_name_raises(self): + """Lines 179-183: Pipeline.add raises ValueError for duplicate name.""" + from ferro_ta import SMA + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("sma", SMA, timeperiod=5) + with pytest.raises(ValueError, match="already exists"): + p.add("sma", SMA, timeperiod=10) + + def test_add_duplicate_output_key_raises(self): + """Lines 176-177: Pipeline.add raises ValueError for duplicate output key.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"]) + with pytest.raises(ValueError, match="Duplicate output key"): + p.add("bands2", BBANDS, timeperiod=10, output_keys=["upper", "x", "y"]) + + def test_remove_missing_step_raises(self): + """Line 210: Pipeline.remove raises KeyError for missing step.""" + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + with pytest.raises(KeyError, match="No step named"): + p.remove("nonexistent") + + def test_pipeline_run_with_tuple_output_and_output_keys(self): + """Lines 267-277: Pipeline.run handles tuple output with output_keys.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"]) + result = p.run(CLOSE) + assert "upper" in result + assert "mid" in result + assert "lower" in result + + def test_pipeline_run_output_keys_length_mismatch_raises(self): + """Lines 269-272: Pipeline.run raises ValueError for output_keys length mismatch.""" + from ferro_ta import BBANDS + from ferro_ta.tools.pipeline import Pipeline + + p = Pipeline() + p.add("bands", BBANDS, timeperiod=5, output_keys=["upper"]) # BBANDS returns 3 + with pytest.raises(ValueError, match="output_keys has"): + p.run(CLOSE) + + def test_make_pipeline(self): + """Lines 291, 300-301: make_pipeline convenience factory.""" + from ferro_ta import RSI, SMA + from ferro_ta.tools.pipeline import make_pipeline + + p = make_pipeline( + sma=(SMA, {"timeperiod": 10}), + rsi=(RSI, {"timeperiod": 14}), + ) + result = p.run(CLOSE) + assert "sma" in result + assert "rsi" in result + + +# =========================================================================== +# portfolio.py — uncovered paths +# =========================================================================== + + +class TestPortfolioUncovered: + """Cover uncovered lines in portfolio.py.""" + + def test_correlation_matrix_pandas_returns(self): + """Lines 88-95: correlation_matrix with pandas DataFrame returns.""" + pd = pytest.importorskip("pandas") + from ferro_ta.analysis.portfolio import correlation_matrix + + n = 100 + df = pd.DataFrame( + { + "AAPL": RNG.normal(0, 0.01, n), + "GOOG": RNG.normal(0, 0.01, n), + } + ) + result = correlation_matrix(df) + assert isinstance(result, pd.DataFrame) + assert result.shape == (2, 2) + + def test_portfolio_beta_basic(self): + """Lines 164-195: portfolio.beta returns scalar or array.""" + from ferro_ta.analysis.portfolio import beta + + n = 100 + asset_ret = RNG.normal(0, 0.01, n) + bench_ret = RNG.normal(0, 0.01, n) + result = beta(asset_ret, bench_ret) + assert isinstance(result, (float, np.floating)) + + +# =========================================================================== +# tools.py — uncovered paths +# =========================================================================== + + +class TestToolsUncovered: + """Cover uncovered lines in tools.py.""" + + def test_call_indicator_multi_output_returns_dict(self): + """Line 129: compute_indicator returns dict for multi-output indicators.""" + from ferro_ta.tools import compute_indicator + + result = compute_indicator("BBANDS", CLOSE, timeperiod=5) + assert isinstance(result, dict) + assert "upper" in result + + def test_describe_indicator_unknown_raises(self): + """Line 273: describe_indicator raises for unknown name.""" + from ferro_ta.tools import describe_indicator + + with pytest.raises(Exception): + describe_indicator("NONEXISTENT_INDICATOR_XYZ") + + def test_describe_indicator_known(self): + """describe_indicator returns description for known indicator.""" + from ferro_ta.tools import describe_indicator + + result = describe_indicator("SMA") + assert isinstance(result, str) + assert len(result) > 0 + + +# =========================================================================== +# workflow.py — uncovered paths +# =========================================================================== + + +class TestWorkflowUncovered: + """Cover uncovered lines in workflow.py.""" + + def test_workflow_with_multi_output_indicator_alert(self): + """Lines 230, 233: workflow.run with alert on dict output (skip silently).""" + from ferro_ta.tools.workflow import Workflow + + wf = Workflow() + wf.add_indicator("bb", "BBANDS", timeperiod=5) + # Add an alert on a multi-output indicator key (should be skipped silently) + wf.add_alert("bb", level=CLOSE.mean(), direction=1) + result = wf.run(CLOSE) + assert "bb" in result diff --git a/vendor/ferro-ta-main/tests/unit/test_data_pipeline.py b/vendor/ferro-ta-main/tests/unit/test_data_pipeline.py new file mode 100644 index 0000000..cbc8f7c --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_data_pipeline.py @@ -0,0 +1,777 @@ +"""Tests for resampling, tick aggregation, DSL, signals, +portfolio analytics, cross-asset analytics, feature matrix, viz, and adapters. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Synthetic helpers +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(2024) + + +def _make_ohlcv(n: int = 100): + """Return (open, high, low, close, volume) as numpy arrays.""" + close = np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0 + open_ = close * RNG.uniform(0.995, 1.005, n) + high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n) + low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n) + volume = RNG.uniform(500, 5000, n) + return open_, high, low, close, volume + + +def _make_ticks(n: int = 500): + price = 100.0 + np.cumsum(RNG.normal(0, 0.05, n)) + size = RNG.uniform(10, 100, n) + return price, size + + +# --------------------------------------------------------------------------- +# Resampling +# --------------------------------------------------------------------------- + + +class TestVolumeBarResampling: + """Rust-backed volume_bars function.""" + + def test_returns_five_arrays(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + bars = volume_bars((o, h, l, c, v), volume_threshold=2000) + assert len(bars) == 5 + assert all(isinstance(b, np.ndarray) for b in bars) + + def test_volume_bars_reduce_length(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(200) + bars = volume_bars((o, h, l, c, v), volume_threshold=5000) + # Output should have fewer bars than input + assert len(bars[0]) < 200 + + def test_each_bar_high_ge_low(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + ro, rh, rl, rc, rv = volume_bars((o, h, l, c, v), volume_threshold=2000) + assert np.all(rh >= rl) + + def test_output_volume_ge_threshold(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(100) + threshold = 1500.0 + _, _, _, _, rv = volume_bars((o, h, l, c, v), volume_threshold=threshold) + # All but the last bar should satisfy the threshold + if len(rv) > 1: + assert np.all(rv[:-1] >= threshold) + + def test_invalid_threshold_raises(self): + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(Exception): + volume_bars((o, h, l, c, v), volume_threshold=-1) + + def test_ohlcv_agg_rust_function(self): + from ferro_ta._ferro_ta import ohlcv_agg + + o, h, l, c, v = _make_ohlcv(10) + labels = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2, 2], dtype=np.int64) + ro, rh, rl, rc, rv = ohlcv_agg(o, h, l, c, v, labels) + assert len(ro) == 3 + + def test_resample_with_pandas(self): + """Time-based resampling using pandas DatetimeIndex.""" + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.resampling import resample + + idx = pd.date_range("2024-01-01", periods=60, freq="1min") + o, h, l, c, v = _make_ohlcv(60) + df = pd.DataFrame( + {"open": o, "high": h, "low": l, "close": c, "volume": v}, + index=idx, + ) + df5 = resample(df, "5min") + # 60 1-minute bars → 12 or 13 5-minute bars depending on pandas version/label + assert 11 <= len(df5) <= 13 + assert set(df5.columns) == {"open", "high", "low", "close", "volume"} + + def test_volume_bars_dataframe_return(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.resampling import volume_bars + + o, h, l, c, v = _make_ohlcv(60) + df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v}) + result = volume_bars(df, volume_threshold=3000) + assert isinstance(result, pd.DataFrame) + assert "close" in result.columns + + def test_multi_timeframe_returns_dict(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta import RSI + from ferro_ta.data.resampling import multi_timeframe + + idx = pd.date_range("2024-01-01", periods=200, freq="1min") + o, h, l, c, v = _make_ohlcv(200) + df = pd.DataFrame( + {"open": o, "high": h, "low": l, "close": c, "volume": v}, + index=idx, + ) + result = multi_timeframe( + df, ["5min", "15min"], indicator=RSI, indicator_kwargs={"timeperiod": 14} + ) + assert sorted(result.keys()) == ["15min", "5min"] + for key, arr in result.items(): + assert isinstance(arr, np.ndarray) + + +# --------------------------------------------------------------------------- +# Tick aggregation +# --------------------------------------------------------------------------- + + +class TestTickAggregation: + """aggregate_ticks and TickAggregator.""" + + def test_tick_bars_dict_input(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(500) + result = aggregate_ticks({"price": price, "size": size}, rule="tick:50") + assert "open" in result + # 500 / 50 = 10 bars + assert len(result["open"]) == 10 + + def test_volume_bars_ticks(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(200) + result = aggregate_ticks({"price": price, "size": size}, rule="volume:500") + assert len(result["open"]) > 0 + + def test_time_bars_ticks(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(300) + ts = np.arange(300, dtype=np.float64) # 1 second intervals + result = aggregate_ticks( + {"timestamp": ts, "price": price, "size": size}, rule="time:60" + ) + # 300 seconds / 60 = 5 bars + assert len(result["open"]) == 5 + + def test_tick_aggregator_class(self): + from ferro_ta.data.aggregation import TickAggregator + + agg = TickAggregator(rule="tick:50") + price, size = _make_ticks(200) + result = agg.aggregate({"price": price, "size": size}) + assert len(result["open"]) == 4 # 200 / 50 = 4 + + def test_invalid_rule_raises(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(100) + with pytest.raises(ValueError, match="Invalid rule"): + aggregate_ticks({"price": price, "size": size}, rule="bad_rule") + + def test_unknown_bar_type_raises(self): + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(100) + with pytest.raises(ValueError, match="Unknown bar type"): + aggregate_ticks({"price": price, "size": size}, rule="unknown:50") + + def test_tick_bars_indicator_pipeline(self): + """Full pipeline: ticks → bars → RSI.""" + from ferro_ta import RSI + from ferro_ta.data.aggregation import aggregate_ticks + + price, size = _make_ticks(1000) + bars = aggregate_ticks({"price": price, "size": size}, rule="tick:20") + close = np.asarray(bars["close"], dtype=np.float64) + rsi = RSI(close, timeperiod=14) + assert rsi.shape == close.shape + + def test_list_input(self): + from ferro_ta.data.aggregation import aggregate_ticks + + ticks = [(float(i), 100.0 + i * 0.01, 10.0) for i in range(100)] + result = aggregate_ticks(ticks, rule="tick:10") + assert len(result["open"]) == 10 + + +# --------------------------------------------------------------------------- +# Strategy DSL +# --------------------------------------------------------------------------- + + +class TestStrategyDSL: + def test_parse_simple_expression(self): + from ferro_ta.tools.dsl import parse_expression + + ast = parse_expression("RSI(14) < 30") + assert ast is not None + + def test_evaluate_returns_int_array(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 30", {"close": close}) + assert sig.dtype == np.int32 + assert sig.shape == (100,) + assert set(sig.tolist()).issubset({0, 1}) + + def test_evaluate_and_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 70 and RSI(14) > 30", {"close": close}) + assert sig.shape == (100,) + + def test_evaluate_or_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("RSI(14) < 30 or RSI(14) > 70", {"close": close}) + assert sig.shape == (100,) + + def test_evaluate_not_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + sig = evaluate("not RSI(14) < 30", {"close": close}) + assert set(sig.tolist()).issubset({0, 1}) + + def test_strategy_class(self): + from ferro_ta.tools.dsl import Strategy + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + strat = Strategy("RSI(14) < 30") + sig = strat.evaluate({"close": close}) + assert sig.shape == (100,) + + def test_combined_close_sma_expression(self): + from ferro_ta.tools.dsl import evaluate + + close = np.cumprod(1 + RNG.normal(0, 0.01, 60)) * 100 + sig = evaluate("close > SMA(20)", {"close": close}) + assert sig.shape == (60,) + + def test_invalid_expression_raises(self): + from ferro_ta.tools.dsl import parse_expression + + with pytest.raises(ValueError): + parse_expression("") + + def test_parse_expression_with_cross_above_placeholder(self): + """cross_above tokens parse without error.""" + from ferro_ta.tools.dsl import parse_expression + + ast = parse_expression("cross_above(close, SMA(20))") + assert ast is not None + + def test_backtest_with_dsl_signal(self): + """Combine DSL signal with the existing backtest module.""" + from ferro_ta.analysis.backtest import backtest + from ferro_ta.tools.dsl import Strategy + + close = np.cumprod(1 + RNG.normal(0, 0.01, 100)) * 100 + strat = Strategy("RSI(14) < 30") + strat.evaluate({"close": close}) # signal not fed to backtest in this test + # Manually feed signal to backtest + result = backtest(close, strategy="rsi_30_70") + assert result is not None + + +# --------------------------------------------------------------------------- +# Signal composition and screening +# --------------------------------------------------------------------------- + + +class TestSignalComposition: + def test_compose_weighted(self): + from ferro_ta.analysis.signals import compose + + sigs = RNG.standard_normal((50, 3)) + score = compose(sigs, weights=[0.5, 0.3, 0.2]) + assert score.shape == (50,) + + def test_compose_mean(self): + from ferro_ta.analysis.signals import compose + + sigs = np.ones((10, 4)) * 2.0 + score = compose(sigs, method="mean") + np.testing.assert_allclose(score, 2.0) + + def test_compose_rank(self): + from ferro_ta.analysis.signals import compose + + sigs = RNG.standard_normal((30, 3)) + score = compose(sigs, method="rank") + assert score.shape == (30,) + + def test_compose_rank_matches_manual_column_ranks(self): + from ferro_ta.analysis.signals import compose + + sigs = np.array( + [ + [3.0, 1.0], + [1.0, 2.0], + [2.0, 2.0], + ], + dtype=np.float64, + ) + score = compose(sigs, method="rank") + expected = np.array([4.0, 3.5, 4.5], dtype=np.float64) + np.testing.assert_allclose(score, expected) + + def test_compose_equal_weights_default(self): + from ferro_ta.analysis.signals import compose + + sigs = np.ones((5, 3)) + score = compose(sigs) # equal weight by default + np.testing.assert_allclose(score, 1.0) + + def test_screen_top_n(self): + from ferro_ta.analysis.signals import screen + + scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3} + result = screen(scores, top_n=2) + assert list(result.keys()) == ["MSFT", "AAPL"] + + def test_screen_bottom_n(self): + from ferro_ta.analysis.signals import screen + + scores = {"A": 3, "B": 1, "C": 2} + result = screen(scores, bottom_n=2) + assert list(result.keys()) == ["B", "C"] + + def test_screen_above_threshold(self): + from ferro_ta.analysis.signals import screen + + scores = {"A": 0.7, "B": 0.3, "C": 0.9} + result = screen(scores, above=0.5) + assert set(result.keys()) == {"A", "C"} + + def test_rank_signals(self): + from ferro_ta.analysis.signals import rank_signals + + x = np.array([3.0, 1.0, 2.0]) + r = rank_signals(x) + np.testing.assert_allclose(r, [3.0, 1.0, 2.0]) + + def test_rank_signals_ties(self): + from ferro_ta.analysis.signals import rank_signals + + x = np.array([1.0, 1.0, 3.0]) + r = rank_signals(x) + np.testing.assert_allclose(r[0], 1.5) + np.testing.assert_allclose(r[1], 1.5) + np.testing.assert_allclose(r[2], 3.0) + + def test_top_n_indices_rust(self): + from ferro_ta._ferro_ta import top_n_indices + + x = np.array([1.0, 5.0, 3.0, 7.0, 2.0]) + idx = top_n_indices(x, 2) + vals = sorted(x[i] for i in idx) + assert vals == [5.0, 7.0] + + +# --------------------------------------------------------------------------- +# Portfolio analytics +# --------------------------------------------------------------------------- + + +class TestPortfolioAnalytics: + def test_correlation_matrix_shape(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (100, 4)) + corr = correlation_matrix(r) + assert corr.shape == (4, 4) + + def test_correlation_matrix_diagonal_ones(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (100, 3)) + corr = correlation_matrix(r) + np.testing.assert_allclose(np.diag(corr), 1.0, atol=1e-10) + + def test_correlation_matrix_symmetric(self): + from ferro_ta.analysis.portfolio import correlation_matrix + + r = RNG.normal(0, 0.01, (80, 3)) + corr = correlation_matrix(r) + np.testing.assert_allclose(corr, corr.T, atol=1e-12) + + def test_portfolio_volatility_positive(self): + from ferro_ta.analysis.portfolio import portfolio_volatility + + r = RNG.normal(0, 0.01, (100, 3)) + vol = portfolio_volatility(r, weights=[1 / 3, 1 / 3, 1 / 3]) + assert vol > 0 + + def test_portfolio_volatility_annualise(self): + from ferro_ta.analysis.portfolio import portfolio_volatility + + r = RNG.normal(0, 0.01, (252, 1)) + vol_raw = portfolio_volatility(r, weights=[1.0]) + vol_ann = portfolio_volatility(r, weights=[1.0], annualise=252) + np.testing.assert_allclose(vol_ann, vol_raw * 252**0.5, rtol=1e-6) + + def test_beta_scalar(self): + from ferro_ta.analysis.portfolio import beta + + bench = RNG.normal(0, 0.01, 100) + asset = 1.5 * bench + RNG.normal(0, 0.001, 100) + b = beta(asset, bench) + assert abs(b - 1.5) < 0.05 + + def test_beta_rolling(self): + from ferro_ta.analysis.portfolio import beta + + bench = RNG.normal(0, 0.01, 100) + asset = bench + RNG.normal(0, 0.001, 100) + rb = beta(asset, bench, window=20) + assert rb.shape == (100,) + assert np.isnan(rb[0]) + assert not np.isnan(rb[-1]) + + def test_drawdown_series(self): + from ferro_ta.analysis.portfolio import drawdown + + eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + dd, max_dd = drawdown(eq) + assert dd.shape == (5,) + assert dd[0] == 0.0 # no drawdown at start + assert max_dd < 0 + + def test_drawdown_max_only(self): + from ferro_ta.analysis.portfolio import drawdown + + eq = np.array([100.0, 110.0, 105.0, 90.0, 95.0]) + max_dd = drawdown(eq, as_series=False) + assert isinstance(max_dd, float) + assert max_dd < 0 + + +# --------------------------------------------------------------------------- +# Cross-asset analytics +# --------------------------------------------------------------------------- + + +class TestCrossAsset: + def test_relative_strength_shape(self): + from ferro_ta.analysis.cross_asset import relative_strength + + ra = RNG.normal(0, 0.01, 50) + rb = RNG.normal(0, 0.01, 50) + rs = relative_strength(ra, rb) + assert rs.shape == (50,) + + def test_spread_values(self): + from ferro_ta.analysis.cross_asset import spread + + a = np.array([10.0, 11.0, 12.0]) + b = np.array([9.0, 10.0, 11.0]) + sp = spread(a, b) + np.testing.assert_allclose(sp, [1.0, 1.0, 1.0]) + + def test_spread_custom_hedge(self): + from ferro_ta.analysis.cross_asset import spread + + a = np.array([10.0, 10.0]) + b = np.array([5.0, 5.0]) + sp = spread(a, b, hedge=2.0) + np.testing.assert_allclose(sp, [0.0, 0.0]) + + def test_ratio_basic(self): + from ferro_ta.analysis.cross_asset import ratio + + a = np.array([10.0, 12.0, 15.0]) + b = np.array([5.0, 4.0, 5.0]) + r = ratio(a, b) + np.testing.assert_allclose(r, [2.0, 3.0, 3.0]) + + def test_ratio_zero_denominator(self): + from ferro_ta.analysis.cross_asset import ratio + + a = np.array([1.0, 2.0]) + b = np.array([0.0, 1.0]) + r = ratio(a, b) + assert np.isnan(r[0]) + assert r[1] == 2.0 + + def test_zscore_nan_warmup(self): + from ferro_ta.analysis.cross_asset import zscore + + x = np.array([1.0, 2.0, 3.0, 2.0, 1.0]) + z = zscore(x, window=3) + assert np.isnan(z[0]) and np.isnan(z[1]) + assert not np.isnan(z[2]) + + def test_rolling_beta_warmup(self): + from ferro_ta.analysis.cross_asset import rolling_beta + + b = RNG.normal(0, 1, 50) + a = 0.8 * b + RNG.normal(0, 0.1, 50) + rb = rolling_beta(a, b, window=20) + assert np.isnan(rb[18]) + assert not np.isnan(rb[19]) + + +# --------------------------------------------------------------------------- +# Feature matrix +# --------------------------------------------------------------------------- + + +class TestFeatureMatrix: + def test_basic_feature_matrix(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10})]) + assert "SMA" in fm + arr = np.asarray(fm["SMA"] if isinstance(fm, dict) else fm["SMA"].values) + assert arr.shape == (50,) + + def test_multiple_indicators_feature_matrix(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("RSI", {"timeperiod": 14}), + ], + ) + assert "SMA" in fm + assert "RSI" in fm + + def test_nan_policy_drop(self): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [("SMA", {"timeperiod": 10}), ("RSI", {"timeperiod": 14})], + nan_policy="drop", + ) + assert isinstance(fm, pd.DataFrame) + assert not fm.isnull().any().any() + + def test_feature_matrix_string_indicator(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(50) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix(ohlcv, ["SMA"]) + assert "SMA" in fm + + def test_feature_matrix_mixed_fastpath_and_multi_output(self): + from ferro_ta.analysis.features import feature_matrix + + o, h, l, c, v = _make_ohlcv(80) + ohlcv = {"close": c, "high": h, "low": l, "open": o, "volume": v} + fm = feature_matrix( + ohlcv, + [ + ("SMA", {"timeperiod": 10}), + ("ATR", {"timeperiod": 14}), + ("BBANDS", {"timeperiod": 10}, 1), + ], + ) + assert "SMA" in fm + assert "ATR" in fm + assert "BBANDS_1" in fm + + +class TestComputeMany: + def test_close_indicators_match_public_api(self): + from ferro_ta import EMA, RSI, SMA + from ferro_ta.data.batch import compute_many + + _, _, _, close, _ = _make_ohlcv(80) + results = compute_many( + [ + ("SMA", {"timeperiod": 10}), + ("EMA", {"timeperiod": 12}), + ("RSI", {"timeperiod": 14}), + ], + close=close, + ) + + np.testing.assert_allclose( + results[0], SMA(close, timeperiod=10), equal_nan=True + ) + np.testing.assert_allclose( + results[1], EMA(close, timeperiod=12), equal_nan=True + ) + np.testing.assert_allclose( + results[2], RSI(close, timeperiod=14), equal_nan=True + ) + + def test_hlc_indicators_match_public_api(self): + from ferro_ta import ADX, ATR + from ferro_ta.data.batch import compute_many + + _, high, low, close, _ = _make_ohlcv(80) + results = compute_many( + [ + ("ATR", {"timeperiod": 14}), + ("ADX", {"timeperiod": 14}), + ], + close=close, + high=high, + low=low, + ) + + np.testing.assert_allclose( + results[0], ATR(high, low, close, timeperiod=14), equal_nan=True + ) + np.testing.assert_allclose( + results[1], ADX(high, low, close, timeperiod=14), equal_nan=True + ) + + def test_unsupported_kwargs_fall_back_cleanly(self): + from ferro_ta import STDDEV + from ferro_ta.data.batch import compute_many + + _, _, _, close, _ = _make_ohlcv(80) + result = compute_many( + [("STDDEV", {"timeperiod": 10, "nbdev": 2.0})], close=close + ) + np.testing.assert_allclose( + result[0], STDDEV(close, timeperiod=10, nbdev=2.0), equal_nan=True + ) + + +# --------------------------------------------------------------------------- +# Viz (smoke tests) +# --------------------------------------------------------------------------- + + +class TestViz: + def test_plot_matplotlib_no_show(self): + pytest.importorskip("matplotlib") + from ferro_ta import RSI + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(60) + ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v} + rsi = RSI(c, timeperiod=14) + fig = plot( + ohlcv, + indicators={"RSI(14)": rsi}, + backend="matplotlib", + show=False, + ) + assert fig is not None + import matplotlib.pyplot as plt + + plt.close("all") + + def test_plot_unknown_backend_raises(self): + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(ValueError, match="Unknown backend"): + plot({"close": c}, backend="bogus") + + def test_plot_savefig(self, tmp_path): + pytest.importorskip("matplotlib") + from ferro_ta.tools.viz import plot + + o, h, l, c, v = _make_ohlcv(30) + ohlcv = {"close": c, "open": o, "high": h, "low": l, "volume": v} + out = str(tmp_path / "chart.png") + plot(ohlcv, backend="matplotlib", savefig=out, show=False) + import os + + assert os.path.exists(out) + import matplotlib.pyplot as plt + + plt.close("all") + + +# --------------------------------------------------------------------------- +# Data adapters +# --------------------------------------------------------------------------- + + +class TestDataAdapters: + def test_in_memory_adapter(self): + from ferro_ta.data.adapters import InMemoryAdapter + + o, h, l, c, v = _make_ohlcv(20) + adapter = InMemoryAdapter( + {"open": o, "high": h, "low": l, "close": c, "volume": v} + ) + ohlcv = adapter.fetch() + assert "close" in ohlcv + + def test_register_and_get_adapter(self): + from ferro_ta.data.adapters import DataAdapter, get_adapter, register_adapter + + class MyAdapter(DataAdapter): + def fetch(self, **kwargs): + return {} + + register_adapter("_test_my", MyAdapter) + cls = get_adapter("_test_my") + assert cls is MyAdapter + + def test_get_unknown_adapter_raises(self): + from ferro_ta.data.adapters import get_adapter + + with pytest.raises(KeyError): + get_adapter("_nonexistent_adapter_xyz") + + def test_csv_adapter_requires_pandas(self, tmp_path): + """CsvAdapter can be instantiated without pandas; fetch raises ImportError.""" + from ferro_ta.data.adapters import CsvAdapter + + adapter = CsvAdapter(str(tmp_path / "fake.csv")) + assert adapter is not None + + def test_csv_adapter_fetch(self, tmp_path): + pytest.importorskip("pandas") + import pandas as pd + + from ferro_ta.data.adapters import CsvAdapter + + o, h, l, c, v = _make_ohlcv(10) + csv_path = str(tmp_path / "ohlcv.csv") + df = pd.DataFrame({"open": o, "high": h, "low": l, "close": c, "volume": v}) + df.to_csv(csv_path, index=False) + adapter = CsvAdapter(csv_path) + result = adapter.fetch() + assert "close" in result.columns + assert len(result) == 10 + + def test_builtin_adapters_registered(self): + from ferro_ta.data.adapters import CsvAdapter, InMemoryAdapter, get_adapter + + assert get_adapter("csv") is CsvAdapter + assert get_adapter("memory") is InMemoryAdapter diff --git a/vendor/ferro-ta-main/tests/unit/test_dataframe_integration.py b/vendor/ferro-ta-main/tests/unit/test_dataframe_integration.py new file mode 100644 index 0000000..015f509 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_dataframe_integration.py @@ -0,0 +1,224 @@ +"""Integration tests for pandas and polars DataFrame/Series support. + +Verifies that ferro_ta indicators transparently accept pandas Series and +polars Series inputs, returning correctly shaped results with preserved +index/name metadata. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from ferro_ta import BBANDS, EMA, MACD, RSI, SMA + +# --------------------------------------------------------------------------- +# Pandas Series tests +# --------------------------------------------------------------------------- + + +class TestPandasSeries: + """Indicators accept pd.Series and return pd.Series with index.""" + + def test_sma_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_ema_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = EMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_rsi_returns_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + result = RSI(s, timeperiod=14) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_bbands_returns_tuple_of_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + assert isinstance(band, pd.Series) + assert len(band) == len(s) + + def test_macd_returns_tuple_of_series(self, ohlcv_500): + s = pd.Series(ohlcv_500["close"]) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + assert isinstance(arr, pd.Series) + assert len(arr) == len(s) + + def test_index_preserved(self, ohlcv_500): + """Resulting Series should carry the same index as the input.""" + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + result = SMA(s, timeperiod=14) + assert isinstance(result, pd.Series) + pd.testing.assert_index_equal(result.index, idx) + + def test_named_series(self, ohlcv_500): + """Named Series should still work (name is not necessarily preserved, + but the call should not error).""" + s = pd.Series(ohlcv_500["close"], name="close_price") + result = EMA(s, timeperiod=10) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_series_with_nan_values(self): + """NaN values in the input should not crash the indicator.""" + data = np.array([1.0, 2.0, np.nan, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]) + s = pd.Series(data) + result = SMA(s, timeperiod=3) + assert isinstance(result, pd.Series) + assert len(result) == len(s) + + def test_bbands_index_preserved(self, ohlcv_500): + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + pd.testing.assert_index_equal(band.index, idx) + + def test_macd_index_preserved(self, ohlcv_500): + idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D") + s = pd.Series(ohlcv_500["close"], index=idx) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + pd.testing.assert_index_equal(arr.index, idx) + + +# --------------------------------------------------------------------------- +# Polars Series tests +# --------------------------------------------------------------------------- + + +class TestPolarsSeries: + """Indicators accept polars.Series and return polars.Series.""" + + @pytest.fixture(autouse=True) + def _require_polars(self): + self.pl = pytest.importorskip("polars") + + def test_sma_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_ema_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = EMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_rsi_returns_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + result = RSI(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert len(result) == len(s) + + def test_bbands_returns_tuple_of_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + upper, middle, lower = BBANDS(s, timeperiod=5) + for band in (upper, middle, lower): + assert isinstance(band, self.pl.Series) + assert len(band) == len(s) + + def test_macd_returns_tuple_of_polars_series(self, ohlcv_500): + s = self.pl.Series("close", ohlcv_500["close"]) + macd, signal, hist = MACD(s) + for arr in (macd, signal, hist): + assert isinstance(arr, self.pl.Series) + assert len(arr) == len(s) + + def test_series_name_preserved(self, ohlcv_500): + """The polars Series name from the first input should be carried through.""" + s = self.pl.Series("my_close", ohlcv_500["close"]) + result = SMA(s, timeperiod=14) + assert isinstance(result, self.pl.Series) + assert result.name == "my_close" + + +# --------------------------------------------------------------------------- +# DataFrame workflow tests +# --------------------------------------------------------------------------- + + +class TestDataFrameWorkflow: + """End-to-end workflow: build a DataFrame, compute indicators, add columns.""" + + def test_pandas_dataframe_workflow(self, ohlcv_500): + df = pd.DataFrame(ohlcv_500) + + # Compute indicators from DataFrame columns + df["sma_14"] = SMA(df["close"], timeperiod=14) + df["ema_14"] = EMA(df["close"], timeperiod=14) + df["rsi_14"] = RSI(df["close"], timeperiod=14) + + upper, middle, lower = BBANDS(df["close"], timeperiod=5) + df["bb_upper"] = upper + df["bb_middle"] = middle + df["bb_lower"] = lower + + macd, signal, hist = MACD(df["close"]) + df["macd"] = macd + df["macd_signal"] = signal + df["macd_hist"] = hist + + # All new columns should exist and have correct length + new_cols = [ + "sma_14", + "ema_14", + "rsi_14", + "bb_upper", + "bb_middle", + "bb_lower", + "macd", + "macd_signal", + "macd_hist", + ] + for col in new_cols: + assert col in df.columns + assert len(df[col]) == 500 + + # SMA leading values should be NaN + assert np.isnan(df["sma_14"].iloc[0]) + # Non-NaN values should exist after warmup + assert not np.isnan(df["sma_14"].iloc[-1]) + + def test_pandas_dataframe_index_consistency(self, ohlcv_500): + """Indicator columns should align with the original DataFrame index.""" + idx = pd.date_range("2020-01-01", periods=500, freq="D") + df = pd.DataFrame(ohlcv_500, index=idx) + + df["sma_14"] = SMA(df["close"], timeperiod=14) + pd.testing.assert_index_equal(df["sma_14"].dropna().index, idx[13:]) + + def test_polars_dataframe_workflow(self, ohlcv_500): + pl = pytest.importorskip("polars") + df = pl.DataFrame(ohlcv_500) + + sma_result = SMA(df["close"], timeperiod=14) + ema_result = EMA(df["close"], timeperiod=14) + rsi_result = RSI(df["close"], timeperiod=14) + + # Results are polars Series of correct length + for result in (sma_result, ema_result, rsi_result): + assert isinstance(result, pl.Series) + assert len(result) == 500 + + # Can add back to a polars DataFrame via with_columns + df2 = df.with_columns( + sma_result.alias("sma_14"), + ema_result.alias("ema_14"), + rsi_result.alias("rsi_14"), + ) + assert "sma_14" in df2.columns + assert "ema_14" in df2.columns + assert "rsi_14" in df2.columns + assert df2.shape[0] == 500 diff --git a/vendor/ferro-ta-main/tests/unit/test_derivatives.py b/vendor/ferro-ta-main/tests/unit/test_derivatives.py new file mode 100644 index 0000000..d6dceef --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_derivatives.py @@ -0,0 +1,651 @@ +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + + +class TestOptionsAnalytics: + def test_black_scholes_price_scalar(self): + from ferro_ta.analysis.options import black_scholes_price + + price = black_scholes_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + ) + assert price == pytest.approx(10.4506, rel=1e-4) + + def test_black_76_price_vectorized(self): + from ferro_ta.analysis.options import black_76_price + + price = black_76_price( + np.array([100.0, 105.0]), + np.array([100.0, 100.0]), + 0.03, + 1.0, + np.array([0.2, 0.25]), + option_type="call", + ) + assert isinstance(price, np.ndarray) + assert price.shape == (2,) + assert np.all(price > 0.0) + + def test_greeks_and_iv_recovery(self): + from ferro_ta.analysis.options import greeks, implied_volatility, option_price + + price = option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + model="bsm", + ) + iv = implied_volatility( + price, + 100.0, + 100.0, + 0.05, + 1.0, + option_type="call", + model="bsm", + ) + result = greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + model="bsm", + ) + assert iv == pytest.approx(0.2, rel=1e-6) + assert result.delta == pytest.approx(0.6368, rel=1e-3) + assert result.gamma > 0.0 + assert result.vega > 0.0 + + def test_smile_and_chain_helpers(self): + from ferro_ta.analysis.options import ( + label_moneyness, + select_strike, + smile_metrics, + term_structure_slope, + ) + + strikes = np.array([80.0, 90.0, 100.0, 110.0, 120.0]) + vols = np.array([0.30, 0.25, 0.20, 0.22, 0.27]) + + metrics = smile_metrics(strikes, vols, 100.0, 0.5) + labels = label_moneyness(strikes, 100.0, option_type="call") + + assert metrics.atm_iv == pytest.approx(0.20, rel=1e-6) + assert metrics.skew_slope < 0.0 + assert labels.tolist() == ["ITM", "ITM", "ATM", "OTM", "OTM"] + assert select_strike(strikes, 101.0, selector="ATM") == 100.0 + assert ( + select_strike(strikes, 101.0, option_type="call", selector="OTM2") == 120.0 + ) + assert select_strike( + strikes, + 100.0, + selector="DELTA0.25", + option_type="call", + volatilities=vols, + time_to_expiry=0.5, + ) in set(strikes.tolist()) + assert term_structure_slope([0.1, 0.5, 1.0], [0.18, 0.20, 0.22]) > 0.0 + + +class TestFuturesAnalytics: + def test_basis_and_curve_helpers(self): + from ferro_ta.analysis.futures import ( + annualized_basis, + basis, + calendar_spreads, + carry_spread, + curve_summary, + implied_carry_rate, + synthetic_forward, + ) + + assert basis(100.0, 103.0) == pytest.approx(3.0) + assert annualized_basis(100.0, 103.0, 0.25) > 0.0 + assert implied_carry_rate(100.0, 103.0, 0.25) > 0.0 + assert carry_spread(100.0, 103.0, 0.02, 0.25) > -1.0 + assert synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5) > 100.0 + assert np.allclose(calendar_spreads([100.0, 101.0, 103.0]), [1.0, 2.0]) + + summary = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]) + assert summary.is_contango is True + assert summary.slope > 0.0 + + def test_roll_helpers(self): + from ferro_ta.analysis.futures import ( + back_adjusted_continuous_contract, + ratio_adjusted_continuous_contract, + roll_yield, + weighted_continuous_contract, + ) + + front = np.array([100.0, 101.0, 102.0, 103.0]) + nxt = np.array([101.0, 102.0, 103.0, 104.0]) + weights = np.array([0.0, 0.25, 0.75, 1.0]) + + weighted = weighted_continuous_contract(front, nxt, weights) + back_adjusted = back_adjusted_continuous_contract(front, nxt, weights) + ratio_adjusted = ratio_adjusted_continuous_contract(front, nxt, weights) + + assert weighted.shape == front.shape + assert back_adjusted.shape == front.shape + assert ratio_adjusted.shape == front.shape + assert roll_yield(100.0, 102.0, 30.0 / 365.0) > 0.0 + + +class TestStrategyAndPayoff: + def test_strategy_schema_and_preset(self): + from ferro_ta.analysis.options_strategy import ( + DerivativesStrategy, + ExpirySelector, + ExpirySelectorKind, + LegPreset, + StrategyLeg, + StrikeSelector, + StrikeSelectorKind, + build_strategy_preset, + ) + + preset = build_strategy_preset( + LegPreset.STRADDLE, + name="ATM Straddle", + underlying="NIFTY", + expiry_selector=ExpirySelector(ExpirySelectorKind.CURRENT_WEEK), + ) + custom = DerivativesStrategy( + name="Custom Single", + legs=( + StrategyLeg( + "NIFTY", + ExpirySelector(ExpirySelectorKind.CURRENT_WEEK), + StrikeSelector( + StrikeSelectorKind.EXPLICIT, explicit_strike=22000.0 + ), + "call", + ), + ), + ) + + assert len(preset.legs) == 2 + assert custom.to_dict()["name"] == "Custom Single" + + def test_payoff_and_aggregate_greeks(self): + from ferro_ta.analysis.derivatives_payoff import ( + PayoffLeg, + aggregate_greeks, + strategy_payoff, + ) + + spot_grid = np.array([90.0, 100.0, 110.0]) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.2, + time_to_expiry=0.5, + ), + PayoffLeg( + instrument="option", + side="short", + option_type="call", + strike=110.0, + premium=2.0, + volatility=0.22, + time_to_expiry=0.5, + ), + PayoffLeg(instrument="future", side="long", entry_price=100.0), + ] + + payoff = strategy_payoff(spot_grid, legs=legs) + greeks = aggregate_greeks(100.0, legs=legs) + + assert payoff.shape == spot_grid.shape + assert payoff[1] == pytest.approx(-3.0) + assert greeks.delta > 0.0 + assert greeks.gamma > 0.0 + + +class TestStockInstrument: + def test_stock_leg_payoff_linear(self): + from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff + + spot_grid = np.array([90.0, 100.0, 110.0]) + payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="long") + assert payoff == pytest.approx([-10.0, 0.0, 10.0]) + + def test_stock_leg_short_side(self): + from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff + + spot_grid = np.array([90.0, 100.0, 110.0]) + payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="short") + assert payoff == pytest.approx([10.0, 0.0, -10.0]) + + def test_strategy_payoff_with_stock_leg(self): + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff + + # Covered call: long stock + short call + spot_grid = np.array([90.0, 100.0, 110.0, 120.0]) + legs = [ + PayoffLeg(instrument="stock", side="long", entry_price=100.0), + PayoffLeg( + instrument="option", + side="short", + option_type="call", + strike=110.0, + premium=3.0, + ), + ] + payoff = strategy_payoff(spot_grid, legs=legs) + assert payoff.shape == spot_grid.shape + # At 90: stock P&L = -10, short call = +3 (OTM) → total = -7 + assert payoff[0] == pytest.approx(-7.0) + # At 110: stock P&L = +10, short call = +3 (ATM, intrinsic=0) → total = +13 + assert payoff[2] == pytest.approx(13.0) + + def test_strategy_leg_accepts_stock_instrument(self): + from ferro_ta.analysis.options_strategy import StrategyLeg + + leg = StrategyLeg( + underlying="NIFTY", + expiry_selector=None, + strike_selector=None, + option_type=None, + instrument="stock", + side="long", + ) + assert leg.instrument == "stock" + + +class TestExtendedGreeks: + def test_extended_greeks_returns_five_values(self): + from ferro_ta.analysis.options import ExtendedGreeks, extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call") + assert isinstance(eg, ExtendedGreeks) + assert eg.vanna is not None + assert eg.volga is not None + assert eg.charm is not None + assert eg.speed is not None + assert eg.color is not None + + def test_vanna_sign_otm_call(self): + # OTM call vanna > 0 (delta increases as vol rises) + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 110.0, 0.05, 1.0, 0.2, option_type="call") + assert eg.vanna > 0.0 + + def test_extended_greeks_finite_for_valid_inputs(self): + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.25, option_type="put") + assert np.isfinite(eg.vanna) + assert np.isfinite(eg.volga) + assert np.isfinite(eg.charm) + assert np.isfinite(eg.speed) + assert np.isfinite(eg.color) + + def test_volga_positive_atm(self): + # Volga is always non-negative for standard BSM inputs + from ferro_ta.analysis.options import extended_greeks + + eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call") + assert eg.volga >= 0.0 + + +class TestDigitalOptions: + def test_cash_or_nothing_call_atm(self): + from ferro_ta.analysis.options import digital_option_price + + # ATM cash-or-nothing call ≈ e^{-rT} * N(d2) ≈ 0.532 + price = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert 0.0 < price < 1.0 + assert price == pytest.approx(0.532, rel=0.02) + + def test_asset_or_nothing_call_atm(self): + from ferro_ta.analysis.options import digital_option_price + + price = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="asset_or_nothing", + ) + # asset-or-nothing call ≈ S * N(d1) < S + assert 0.0 < price < 100.0 + + def test_put_call_parity_cash_or_nothing(self): + from ferro_ta.analysis.options import digital_option_price + + call = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.25, + option_type="call", + digital_type="cash_or_nothing", + ) + put = digital_option_price( + 100.0, + 100.0, + 0.05, + 1.0, + 0.25, + option_type="put", + digital_type="cash_or_nothing", + ) + discount = np.exp(-0.05) + assert call + put == pytest.approx(discount, rel=1e-6) + + def test_digital_greeks_finite(self): + from ferro_ta.analysis.options import digital_option_greeks + + g = digital_option_greeks( + 100.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert np.isfinite(g.delta) + assert np.isfinite(g.gamma) + assert np.isfinite(g.vega) + + def test_digital_invalid_returns_nan(self): + from ferro_ta.analysis.options import digital_option_price + + price = digital_option_price( + -1.0, + 100.0, + 0.05, + 1.0, + 0.2, + option_type="call", + digital_type="cash_or_nothing", + ) + assert np.isnan(price) + + +class TestAmericanOptions: + def test_american_price_gte_european(self): + from ferro_ta.analysis.options import american_option_price, option_price + + spot, strike, rate, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2 + american = american_option_price( + spot, strike, rate, tte, vol, option_type="call" + ) + european = option_price(spot, strike, rate, tte, vol, option_type="call") + assert american >= european - 1e-8 + + def test_early_exercise_premium_nonnegative(self): + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium( + 100.0, 100.0, 0.05, 1.0, 0.2, option_type="put" + ) + assert premium >= 0.0 + + def test_american_put_early_exercise_positive(self): + # Deep ITM put with high rate should have meaningful early exercise premium + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium(80.0, 100.0, 0.1, 0.5, 0.25, option_type="put") + assert premium > 0.0 + + def test_american_call_no_dividends_no_premium(self): + # With zero carry (no dividends), American call = European call + from ferro_ta.analysis.options import early_exercise_premium + + premium = early_exercise_premium( + 100.0, 100.0, 0.05, 1.0, 0.2, option_type="call", carry=0.0 + ) + assert premium == pytest.approx(0.0, abs=1e-4) + + +class TestVolEstimators: + @pytest.fixture + def sample_ohlc(self): + rng = np.random.default_rng(42) + n = 100 + log_ret = rng.normal(0.0, 0.01, n) + close = 100.0 * np.cumprod(np.exp(log_ret)) + high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n))) + low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n))) + open_ = np.roll(close, 1) + open_[0] = close[0] + return open_, high, low, close + + def test_close_to_close_vol_length(self, sample_ohlc): + from ferro_ta.analysis.options import close_to_close_vol + + _, _, _, close = sample_ohlc + out = close_to_close_vol(close, window=20) + assert len(out) == len(close) + + def test_close_to_close_vol_warmup_nan(self, sample_ohlc): + from ferro_ta.analysis.options import close_to_close_vol + + _, _, _, close = sample_ohlc + out = close_to_close_vol(close, window=20) + # First `window` values are NaN; index `window` is the first valid value + assert all(np.isnan(out[:20])) + assert np.isfinite(out[20]) + + def test_parkinson_vol_finite_and_positive(self, sample_ohlc): + from ferro_ta.analysis.options import parkinson_vol + + _, high, low, _ = sample_ohlc + out = parkinson_vol(high, low, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_garman_klass_vol(self, sample_ohlc): + from ferro_ta.analysis.options import garman_klass_vol + + open_, high, low, close = sample_ohlc + out = garman_klass_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_rogers_satchell_vol(self, sample_ohlc): + from ferro_ta.analysis.options import rogers_satchell_vol + + open_, high, low, close = sample_ohlc + out = rogers_satchell_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + + def test_yang_zhang_vol(self, sample_ohlc): + from ferro_ta.analysis.options import yang_zhang_vol + + open_, high, low, close = sample_ohlc + out = yang_zhang_vol(open_, high, low, close, window=20) + finite = out[~np.isnan(out)] + assert len(finite) > 0 + assert np.all(finite > 0.0) + + def test_yang_zhang_lower_variance_than_close_to_close(self, sample_ohlc): + # YZ is more efficient than close-to-close + from ferro_ta.analysis.options import close_to_close_vol, yang_zhang_vol + + open_, high, low, close = sample_ohlc + c2c = close_to_close_vol(close, window=20) + yz = yang_zhang_vol(open_, high, low, close, window=20) + valid = ~np.isnan(c2c) & ~np.isnan(yz) + # YZ variance < C2C variance (efficiency test) + assert np.var(yz[valid]) <= np.var(c2c[valid]) * 2.0 # lenient bound + + +class TestVolCone: + def test_vol_cone_shape(self): + from ferro_ta.analysis.options import VolCone, vol_cone + + rng = np.random.default_rng(0) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 300))) + cone = vol_cone(close, windows=(21, 42, 63)) + assert isinstance(cone, VolCone) + assert len(cone.windows) == 3 + assert len(cone.min) == 3 + + def test_vol_cone_monotonic_percentiles(self): + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(1) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + cone = vol_cone(close, windows=(21, 42, 63, 126, 252)) + for i in range(len(cone.windows)): + assert ( + cone.min[i] + <= cone.p25[i] + <= cone.median[i] + <= cone.p75[i] + <= cone.max[i] + ) + + def test_vol_cone_positive_values(self): + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(2) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 400))) + cone = vol_cone(close) + assert np.all(cone.min > 0.0) + + +class TestStrategyAnalytics: + def test_put_call_parity_deviation_zero(self): + from ferro_ta.analysis.options import option_price, put_call_parity_deviation + + s, k, r, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2 + call = option_price(s, k, r, tte, vol, option_type="call") + put = option_price(s, k, r, tte, vol, option_type="put") + dev = put_call_parity_deviation(call, put, s, k, r, tte) + assert dev == pytest.approx(0.0, abs=1e-6) + + def test_put_call_parity_deviation_nonzero_for_stale_quote(self): + from ferro_ta.analysis.options import put_call_parity_deviation + + dev = put_call_parity_deviation(15.0, 5.0, 100.0, 100.0, 0.05, 1.0) + assert abs(dev) > 0.01 + + def test_expected_move_positive(self): + from ferro_ta.analysis.options import expected_move + + lower, upper = expected_move(100.0, 0.2, 30.0) + assert upper > 0.0 + assert lower < 0.0 + + def test_expected_move_log_normal_asymmetry(self): + # Log-normal expected move: upper > |lower| (right-skew) + from ferro_ta.analysis.options import expected_move + + lower, upper = expected_move(100.0, 0.2, 30.0) + # Both magnitudes are similar (within 10%) but upper > |lower| + assert upper > abs(lower) * 0.95 + assert upper < abs(lower) * 2.0 + + def test_strategy_value_near_expiry_approx_payoff(self): + from ferro_ta.analysis.derivatives_payoff import ( + PayoffLeg, + strategy_payoff, + strategy_value, + ) + + # Near expiry, BSM value ≈ intrinsic payoff + spot_grid = np.array([90.0, 100.0, 110.0]) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=0.0, + volatility=0.2, + time_to_expiry=0.001, + ) + ] + val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.001, volatility=0.2) + payoff = strategy_payoff(spot_grid, legs=legs) + # Near expiry, value ≈ payoff (within a few cents) + assert np.allclose(val, payoff, atol=0.5) + + def test_strategy_value_shape(self): + from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_value + + spot_grid = np.linspace(80.0, 120.0, 20) + legs = [ + PayoffLeg( + instrument="option", + side="long", + option_type="call", + strike=100.0, + premium=5.0, + volatility=0.2, + time_to_expiry=0.5, + ) + ] + val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.5, volatility=0.2) + assert val.shape == spot_grid.shape + + +class TestDerivativesBenchmarking: + def test_derivatives_benchmark_smoke(self, tmp_path): + root = Path(__file__).resolve().parents[2] + script = root / "benchmarks" / "bench_derivatives_compare.py" + output_path = tmp_path / "derivatives_benchmark.json" + + completed = subprocess.run( + [ + sys.executable, + str(script), + "--sizes", + "32", + "--accuracy-size", + "16", + "--json", + str(output_path), + ], + cwd=root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert output_path.is_file() + payload = output_path.read_text(encoding="utf-8") + assert '"accuracy"' in payload + assert '"speed"' in payload + assert '"provider": "ferro_ta"' in payload diff --git a/vendor/ferro-ta-main/tests/unit/test_derivatives_accuracy.py b/vendor/ferro-ta-main/tests/unit/test_derivatives_accuracy.py new file mode 100644 index 0000000..89d4765 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_derivatives_accuracy.py @@ -0,0 +1,608 @@ +""" +Accuracy/correctness tests for ferro-ta derivatives analytics. + +Each test class validates the ferro-ta implementation against reference +formulas implemented using scipy and numpy. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Reference formulas (pure numpy / scipy) +# --------------------------------------------------------------------------- + + +def _norm_cdf(x): + """Standard normal CDF via scipy.""" + from scipy.stats import norm as _norm + + return _norm.cdf(x) + + +def _norm_pdf(x): + from scipy.stats import norm as _norm + + return _norm.pdf(x) + + +def bsm_call(S, K, r, q, T, sigma): # noqa: N803 + """Reference BSM call price.""" + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return S * np.exp(-q * T) * _norm_cdf(d1) - K * np.exp(-r * T) * _norm_cdf(d2) + + +def bsm_put(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return K * np.exp(-r * T) * _norm_cdf(-d2) - S * np.exp(-q * T) * _norm_cdf(-d1) + + +def bsm_delta_call(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-q * T) * _norm_cdf(d1) + + +def digital_cash_call(S, K, r, q, T, sigma): # noqa: N803 + d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-r * T) * _norm_cdf(d2) + + +def digital_asset_call(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_cdf(d1) + + +def digital_cash_put(S, K, r, q, T, sigma): # noqa: N803 + d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return np.exp(-r * T) * _norm_cdf(-d2) + + +def digital_asset_put(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_cdf(-d1) + + +def vanna_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803 + """∂Δ/∂σ via central differences.""" + delta_up = bsm_delta_call(S, K, r, q, T, sigma + eps) + delta_dn = bsm_delta_call(S, K, r, q, T, sigma - eps) + return (delta_up - delta_dn) / (2 * eps) + + +def vega_bsm(S, K, r, q, T, sigma): # noqa: N803 + d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T)) + return S * np.exp(-q * T) * _norm_pdf(d1) * np.sqrt(T) + + +def volga_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803 + """∂²V/∂σ² via central differences.""" + v_up = vega_bsm(S, K, r, q, T, sigma + eps) + v_dn = vega_bsm(S, K, r, q, T, sigma - eps) + return (v_up - v_dn) / (2 * eps) + + +def ctc_vol_reference(close, window, trading_days=252.0): + """Close-to-close vol: rolling std of log returns × sqrt(trading_days).""" + log_ret = np.log(close[1:] / close[:-1]) + n = len(close) + out = np.full(n, np.nan) + for i in range(window, n): + returns_window = log_ret[i - window : i] + out[i] = np.sqrt(np.sum(returns_window**2) / window * trading_days) + return out + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +# Six parameter sets: ATM, 10% OTM, 10% ITM, low vol, high vol, non-zero carry +_DIGITAL_CASES = [ + # (S, K, r, q, T, sigma, label) + (100.0, 100.0, 0.05, 0.00, 1.0, 0.20, "ATM"), + (100.0, 110.0, 0.05, 0.00, 1.0, 0.20, "10% OTM"), + (100.0, 90.0, 0.05, 0.00, 1.0, 0.20, "10% ITM"), + (100.0, 100.0, 0.05, 0.00, 1.0, 0.05, "low vol"), + (100.0, 100.0, 0.05, 0.00, 1.0, 0.50, "high vol"), + (100.0, 100.0, 0.05, 0.03, 1.0, 0.20, "non-zero carry"), +] + + +class TestDigitalOptionsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_cash_or_nothing_call_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_cash_call(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="call", + digital_type="cash_or_nothing", + carry=q, + ) + assert actual == pytest.approx(expected, abs=1e-6), ( + f"cash_or_nothing call mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_cash_or_nothing_put_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_cash_put(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="put", + digital_type="cash_or_nothing", + carry=q, + ) + assert actual == pytest.approx(expected, abs=1e-6), ( + f"cash_or_nothing put mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_asset_or_nothing_call_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_asset_call(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="call", + digital_type="asset_or_nothing", + carry=q, + ) + # Tolerance 1e-4: asset-or-nothing involves S * N(d1), small numerical diff expected + assert actual == pytest.approx(expected, abs=1e-4), ( + f"asset_or_nothing call mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_asset_or_nothing_put_vs_reference(self): + from ferro_ta.analysis.options import digital_option_price + + for S, K, r, q, T, sigma, label in _DIGITAL_CASES: + expected = digital_asset_put(S, K, r, q, T, sigma) + actual = digital_option_price( + S, + K, + r, + T, + sigma, + option_type="put", + digital_type="asset_or_nothing", + carry=q, + ) + # Tolerance 1e-4: asset-or-nothing involves S * N(-d1), small numerical diff expected + assert actual == pytest.approx(expected, abs=1e-4), ( + f"asset_or_nothing put mismatch for case '{label}': " + f"got {actual}, expected {expected}" + ) + + def test_batch_digital_price_matches_scalar(self): + """Vectorized call must match scalar loop for 10 random points.""" + from ferro_ta.analysis.options import digital_option_price + + rng = np.random.default_rng(7) + n = 10 + S_arr = rng.uniform(80.0, 120.0, n) + K_arr = rng.uniform(80.0, 120.0, n) + r_arr = rng.uniform(0.01, 0.10, n) + T_arr = rng.uniform(0.1, 2.0, n) + sigma_arr = rng.uniform(0.10, 0.50, n) + + batch = digital_option_price( + S_arr, + K_arr, + r_arr, + T_arr, + sigma_arr, + option_type="call", + digital_type="cash_or_nothing", + ) + + scalar_results = np.array( + [ + digital_option_price( + float(S_arr[i]), + float(K_arr[i]), + float(r_arr[i]), + float(T_arr[i]), + float(sigma_arr[i]), + option_type="call", + digital_type="cash_or_nothing", + ) + for i in range(n) + ] + ) + + assert batch == pytest.approx(scalar_results, abs=1e-10), ( + "Batch digital_option_price does not match scalar loop" + ) + + +# Four cases for extended Greeks: ITM call, ATM call, OTM call, ATM put +_GREEK_CASES = [ + # (S, K, r, q, T, sigma, option_type, label) + (110.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ITM call"), + (100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ATM call"), + (90.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "OTM call"), + (100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "put", "ATM put"), +] + + +class TestExtendedGreeksAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_vanna_vs_numerical_fd(self): + """extended_greeks().vanna matches ∂Δ/∂σ from central differences (tol=1e-3).""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + # Reference is defined only for calls; for put use numerical FD directly + if opt_type == "call": + expected = vanna_num(S, K, r, q, T, sigma) + else: + # Vanna for put: ∂(put delta)/∂σ = ∂(call delta - e^{-qT})/∂σ = vanna_call + expected = vanna_num(S, K, r, q, T, sigma) + assert float(eg.vanna) == pytest.approx(expected, abs=1e-3), ( + f"Vanna mismatch for '{label}': got {eg.vanna}, expected {expected}" + ) + + def test_volga_vs_numerical_fd(self): + """extended_greeks().volga matches ∂²V/∂σ² from central differences (tol=1e-2).""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + expected = volga_num(S, K, r, q, T, sigma) + assert float(eg.volga) == pytest.approx(expected, abs=1e-2), ( + f"Volga mismatch for '{label}': got {eg.volga}, expected {expected}" + ) + + def test_speed_negative_for_calls(self): + """Speed (∂Γ/∂S) should be negative for OTM calls — Gamma decreases as S moves away.""" + from ferro_ta.analysis.options import extended_greeks + + # OTM call: S < K + eg = extended_greeks(90.0, 100.0, 0.05, 1.0, 0.20, option_type="call") + assert float(eg.speed) < 0.0, ( + f"Speed should be negative for OTM call, got {eg.speed}" + ) + + def test_charm_finite_for_valid_inputs(self): + """Charm should be finite and non-zero for non-degenerate inputs.""" + from ferro_ta.analysis.options import extended_greeks + + for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES: + eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q) + assert np.isfinite(float(eg.charm)), ( + f"Charm is not finite for '{label}': {eg.charm}" + ) + assert eg.charm != 0.0, ( + f"Charm is zero for '{label}' — unexpected for non-degenerate inputs" + ) + + +class TestAmericanOptionsAccuracy: + """Property-based tests for American options (no scipy required).""" + + def test_baw_vs_published_values(self): + """BAW American put satisfies the lower bound: price ≥ max(K - S, European BSM put). + + The Haug (2007) table uses b = r - q (cost of carry convention). Rather + than replicate the exact table — which requires matching the BAW carry + convention precisely — we verify two model-agnostic inequalities that any + correct American-put implementation must satisfy: + + 1. American put ≥ intrinsic value (K - S) + 2. American put ≥ European BSM put (early exercise has non-negative value) + """ + from ferro_ta.analysis.options import american_option_price, option_price + + S, K, r, T, sigma = 100.0, 100.0, 0.10, 0.25, 0.20 + american = american_option_price(S, K, r, T, sigma, option_type="put") + european = option_price(S, K, r, T, sigma, option_type="put") + + assert american >= max(K - S, 0.0) - 1e-8, ( + f"American put below intrinsic: {american:.4f} < {max(K - S, 0.0)}" + ) + assert american >= european - 1e-8, ( + f"American put below European put: {american:.4f} < {european:.4f}" + ) + # Sanity-check: American ATM put should be in a reasonable range + assert 0.0 < american < K, ( + f"American put price {american:.4f} is outside (0, K={K})" + ) + + def test_american_put_increases_with_strike(self): + """Deeper ITM (higher strike for put) ⇒ higher American put price. + + Uses moderately spaced strikes to avoid the intrinsic-value floor + where K - S becomes the binding constraint and the increments are + exactly 1-for-1, which can mask ordering issues near the floor. + """ + from ferro_ta.analysis.options import american_option_price + + # S = 100, K in {85, 100, 115}; rate and carry both 0.05 to avoid b=0 issues + S, r, T, sigma = 100.0, 0.05, 0.5, 0.25 + strikes = [85.0, 100.0, 115.0] + prices = [ + american_option_price(S, K, r, T, sigma, option_type="put", carry=r) + for K in strikes + ] + assert prices[0] < prices[1] < prices[2], ( + f"American put prices not monotone in strike: " + f"K={strikes} → prices={[round(p, 4) for p in prices]}" + ) + + def test_american_call_increases_with_spot(self): + """Higher spot ⇒ higher American call price.""" + from ferro_ta.analysis.options import american_option_price + + spots = [90.0, 100.0, 110.0] + prices = [ + american_option_price(S, 100.0, 0.05, 1.0, 0.20, option_type="call") + for S in spots + ] + assert prices[0] < prices[1] < prices[2], ( + f"American call prices not monotone in spot: {prices}" + ) + + def test_american_call_equals_european_no_dividends_no_early_exercise(self): + """American call with no early-exercise incentive (carry=0) ≈ European call. + + When the cost-of-carry parameter is zero, there is no dividend/carry + benefit to holding the underlying. In this regime, it is never + optimal to early-exercise an American call, so the American call price + equals the European call price computed with the same carry=0 convention. + The `early_exercise_premium` function exposes this directly and should + return ~0 for calls with carry=0. + """ + from ferro_ta.analysis.options import early_exercise_premium + + S, K, r, T, sigma = 100.0, 100.0, 0.05, 1.0, 0.20 + premium = early_exercise_premium( + S, K, r, T, sigma, option_type="call", carry=0.0 + ) + assert premium == pytest.approx(0.0, abs=1e-4), ( + f"Early exercise premium for call with carry=0 should be ~0, got {premium:.6f}" + ) + + def test_early_exercise_premium_positive_for_deep_itm_put(self): + """Deep ITM American put should have a meaningful early exercise premium. + + When S is well below K (deep ITM put), the time value is low and the + interest gained from early exercise of the put dominates — leading to a + positive early-exercise premium. + """ + from ferro_ta.analysis.options import early_exercise_premium + + # Deep ITM: S=70, K=100 — strong incentive to exercise early + premium = early_exercise_premium( + 70.0, 100.0, 0.10, 1.0, 0.20, option_type="put" + ) + assert premium > 0.0, ( + f"Deep ITM American put early exercise premium should be > 0, got {premium}" + ) + + +class TestVolEstimatorsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_close_to_close_vs_reference_impl(self): + """C2C vol matches reference formula exactly (tol=1e-10), 100 samples.""" + from ferro_ta.analysis.options import close_to_close_vol + + rng = np.random.default_rng(42) + log_ret = rng.normal(0.0, 0.01, 100) + close = 100.0 * np.cumprod(np.exp(log_ret)) + + window = 20 + actual = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + expected = ctc_vol_reference(close, window=window, trading_days=252.0) + + valid = ~np.isnan(expected) + assert np.allclose(actual[valid], expected[valid], atol=1e-10), ( + "close_to_close_vol does not match reference formula" + ) + + def test_constant_returns_known_vol(self): + """Constant daily log-return of 0.01 → C2C vol = 0.01 * sqrt(252) ≈ 0.1587.""" + from ferro_ta.analysis.options import close_to_close_vol + + # Build a price series with constant daily log-return of 0.01 + n = 100 + constant_log_ret = 0.01 + close = 100.0 * np.exp(np.arange(n) * constant_log_ret) + + window = 21 + out = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + + # Expected: sqrt(0.01^2 * 252) = 0.01 * sqrt(252) + expected_vol = constant_log_ret * np.sqrt(252.0) + valid = ~np.isnan(out) + assert np.all(valid[window:]), "Expected valid values after warmup" + assert out[window] == pytest.approx(expected_vol, rel=1e-10), ( + f"Constant-return vol: got {out[window]}, expected {expected_vol}" + ) + + def test_parkinson_lognormal_unbiased(self): + """Parkinson estimator within 50% of true vol=0.20 for simulated OHLC data. + + Parkinson uses the log(high/low) range as a proxy for daily realized + vol. The estimator is unbiased for a Brownian-motion diffusion where + the daily range follows a known distribution, but a simplified + simulation (single end-of-day price + independent range draw) will + underestimate the range. We therefore build a proper multi-step + intraday path so the high/low reflects the true diffusion range, + and use a lenient 50% tolerance to accommodate finite-sample noise. + """ + from ferro_ta.analysis.options import parkinson_vol + + rng = np.random.default_rng(123) + true_vol = 0.20 + n_days = 500 + steps_per_day = 50 # intraday steps to get a realistic H-L range + daily_sigma = true_vol / np.sqrt(252.0) + step_sigma = daily_sigma / np.sqrt(steps_per_day) + + # Simulate intraday paths, extract open/high/low/close each day + highs = np.empty(n_days) + lows = np.empty(n_days) + price = 100.0 + for i in range(n_days): + intraday = price * np.exp( + np.cumsum(rng.normal(0.0, step_sigma, steps_per_day)) + ) + path = np.concatenate([[price], intraday]) + highs[i] = path.max() + lows[i] = path.min() + price = intraday[-1] + + window = 21 + out = parkinson_vol(highs, lows, window=window, trading_days_per_year=252.0) + valid = out[~np.isnan(out)] + + assert len(valid) > 0, "No valid Parkinson estimates" + median_est = float(np.median(valid)) + assert abs(median_est - true_vol) < 0.50 * true_vol, ( + f"Parkinson estimate {median_est:.4f} is more than 50% from true vol {true_vol}" + ) + + def test_vol_estimators_all_positive_finite(self): + """All 5 estimators produce finite and positive non-NaN values on random OHLC.""" + from ferro_ta.analysis.options import ( + close_to_close_vol, + garman_klass_vol, + parkinson_vol, + rogers_satchell_vol, + yang_zhang_vol, + ) + + rng = np.random.default_rng(99) + n = 200 + log_ret = rng.normal(0.0, 0.01, n) + close = 100.0 * np.cumprod(np.exp(log_ret)) + high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n))) + low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n))) + open_ = np.roll(close, 1) + open_[0] = close[0] + + window = 20 + estimators = { + "close_to_close": close_to_close_vol(close, window=window), + "parkinson": parkinson_vol(high, low, window=window), + "garman_klass": garman_klass_vol(open_, high, low, close, window=window), + "rogers_satchell": rogers_satchell_vol( + open_, high, low, close, window=window + ), + "yang_zhang": yang_zhang_vol(open_, high, low, close, window=window), + } + + for name, out in estimators.items(): + valid = out[~np.isnan(out)] + assert len(valid) > 0, f"{name}: no valid (non-NaN) estimates" + assert np.all(np.isfinite(valid)), f"{name}: non-finite values present" + assert np.all(valid > 0.0), f"{name}: non-positive values present" + + +class TestVolConeAccuracy: + """Tests for vol_cone — no scipy required.""" + + def test_cone_windows_match_requested(self): + """Output windows should match the input list exactly.""" + from ferro_ta.analysis.options import vol_cone + + rng = np.random.default_rng(0) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + requested = (10, 21, 42) + cone = vol_cone(close, windows=requested) + + assert list(cone.windows.astype(int)) == list(requested), ( + f"Cone windows {list(cone.windows)} do not match requested {list(requested)}" + ) + + def test_cone_median_matches_rolling_median(self): + """Manually computed rolling C2C vol median for window=21 should match cone.median[0].""" + from ferro_ta.analysis.options import close_to_close_vol, vol_cone + + rng = np.random.default_rng(5) + close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500))) + window = 21 + + cone = vol_cone(close, windows=(window,)) + + rolling = close_to_close_vol(close, window=window, trading_days_per_year=252.0) + valid = rolling[~np.isnan(rolling)] + manual_median = float(np.median(valid)) + + assert cone.median[0] == pytest.approx(manual_median, rel=1e-6), ( + f"vol_cone median {cone.median[0]:.6f} does not match manual median {manual_median:.6f}" + ) + + +class TestStrategyAnalyticsAccuracy: + @pytest.fixture(autouse=True) + def require_scipy(self): + pytest.importorskip("scipy") + + def test_put_call_parity_deviation_analytical(self): + """BSM call/put from scipy formulas fed into put_call_parity_deviation → < 1e-8.""" + from ferro_ta.analysis.options import put_call_parity_deviation + + S, K, r, q, T, sigma = 100.0, 100.0, 0.05, 0.02, 1.0, 0.20 + call = bsm_call(S, K, r, q, T, sigma) + put = bsm_put(S, K, r, q, T, sigma) + + dev = put_call_parity_deviation(call, put, S, K, r, T, carry=q) + assert abs(dev) < 1e-8, ( + f"put_call_parity_deviation for BSM-consistent prices: got {dev}, expected ~0" + ) + + def test_expected_move_known_value(self): + """S=100, iv=0.20, days=30, trading_days=252 → upper move ≈ 7.14.""" + from ferro_ta.analysis.options import expected_move + + S, iv, days, td = 100.0, 0.20, 30.0, 252.0 + lower, upper = expected_move(S, iv, days, td) + + # log-normal formula: S * (exp(sigma * sqrt(days/trading_days)) - 1) + expected_upper = S * (np.exp(iv * np.sqrt(days / td)) - 1.0) + expected_lower = S * (np.exp(-iv * np.sqrt(days / td)) - 1.0) + + assert upper == pytest.approx(expected_upper, rel=1e-6), ( + f"expected_move upper: got {upper:.4f}, expected {expected_upper:.4f}" + ) + assert lower == pytest.approx(expected_lower, rel=1e-6), ( + f"expected_move lower: got {lower:.4f}, expected {expected_lower:.4f}" + ) + # Numeric check: upper ≈ 7.14 + assert upper == pytest.approx(7.14, abs=0.05), ( + f"expected_move upper should be ~7.14, got {upper:.4f}" + ) diff --git a/vendor/ferro-ta-main/tests/unit/test_edge_cases.py b/vendor/ferro-ta-main/tests/unit/test_edge_cases.py new file mode 100644 index 0000000..238f392 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_edge_cases.py @@ -0,0 +1,296 @@ +"""Edge-case tests for ferro_ta indicators. + +Covers NaN handling, empty arrays, single-element inputs, extreme values, +constant series, and dtype robustness. +""" + +import numpy as np +import pytest + +from ferro_ta import ( + ATR, + BBANDS, + EMA, + MACD, + MFI, + OBV, + RSI, + SMA, + STOCH, + WMA, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _all_nan(arr): + """True if every element is NaN.""" + return np.all(np.isnan(arr)) + + +# --------------------------------------------------------------------------- +# Empty arrays +# --------------------------------------------------------------------------- + + +class TestEmptyInput: + """All indicators should return an empty array (not crash) for len-0 input.""" + + def test_sma_empty(self): + result = SMA(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_ema_empty(self): + result = EMA(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_rsi_empty(self): + result = RSI(np.array([], dtype=np.float64), timeperiod=14) + assert len(result) == 0 + + def test_bbands_empty(self): + upper, mid, lower = BBANDS(np.array([], dtype=np.float64), timeperiod=5) + assert len(upper) == 0 + assert len(mid) == 0 + assert len(lower) == 0 + + def test_macd_empty(self): + macd, sig, hist = MACD(np.array([], dtype=np.float64)) + assert len(macd) == 0 + + def test_wma_empty(self): + result = WMA(np.array([], dtype=np.float64), timeperiod=10) + assert len(result) == 0 + + +# --------------------------------------------------------------------------- +# Single-element arrays +# --------------------------------------------------------------------------- + + +class TestSingleElement: + """Single-element inputs should produce NaN (insufficient data) without panic.""" + + def test_sma_single(self): + result = SMA(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_ema_single(self): + result = EMA(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_rsi_single(self): + result = RSI(np.array([42.0]), timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_sma_period_1_single(self): + """SMA(period=1) on a single element should return that element.""" + result = SMA(np.array([42.0]), timeperiod=1) + assert len(result) == 1 + np.testing.assert_allclose(result[0], 42.0) + + +# --------------------------------------------------------------------------- +# All-NaN input +# --------------------------------------------------------------------------- + + +class TestAllNaN: + """Indicators fed entirely NaN input should not crash and return all NaN.""" + + @pytest.fixture() + def nan_50(self): + return np.full(50, np.nan) + + def test_sma_all_nan(self, nan_50): + result = SMA(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + def test_ema_all_nan(self, nan_50): + result = EMA(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + def test_rsi_all_nan(self, nan_50): + result = RSI(nan_50, timeperiod=14) + assert len(result) == 50 + assert _all_nan(result) + + +# --------------------------------------------------------------------------- +# NaN in the middle +# --------------------------------------------------------------------------- + + +class TestNaNInMiddle: + """A single NaN in a valid series should propagate but not crash.""" + + def test_sma_nan_mid(self): + data = np.arange(1.0, 21.0) + data[10] = np.nan + result = SMA(data, timeperiod=5) + assert len(result) == 20 + # Values around the NaN should be NaN + for i in range(10, min(15, 20)): + assert np.isnan(result[i]) + + def test_rsi_nan_mid(self): + data = np.arange(1.0, 31.0) + data[15] = np.nan + result = RSI(data, timeperiod=14) + assert len(result) == 30 + + +# --------------------------------------------------------------------------- +# Extreme values +# --------------------------------------------------------------------------- + + +class TestExtremeValues: + """Indicators should not crash on very large or very small values.""" + + def test_sma_large_values(self): + data = np.full(50, 1e300) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + # Non-NaN values should be ~1e300 + valid = result[~np.isnan(result)] + if len(valid) > 0: + np.testing.assert_allclose(valid, 1e300, rtol=1e-10) + + def test_sma_tiny_values(self): + data = np.full(50, 1e-300) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + valid = result[~np.isnan(result)] + if len(valid) > 0: + np.testing.assert_allclose(valid, 1e-300, rtol=1e-10) + + def test_rsi_large_monotone(self): + """Monotonically increasing large values -> RSI should approach 100.""" + data = np.linspace(1e10, 2e10, 100) + result = RSI(data, timeperiod=14) + valid = result[~np.isnan(result)] + if len(valid) > 0: + assert valid[-1] > 90.0 # strongly bullish + + def test_rsi_zero_change(self): + """Constant series -> RSI should be 50 (or NaN in some implementations).""" + data = np.full(100, 50.0) + result = RSI(data, timeperiod=14) + valid = result[~np.isnan(result)] + # Constant series: no gains, no losses -> typically NaN or 50 + # Just verify no crash and valid range + for v in valid: + assert 0.0 <= v <= 100.0 or np.isnan(v) + + def test_bbands_constant_series(self): + """Constant series -> upper == middle == lower (zero std dev).""" + data = np.full(50, 100.0) + upper, mid, lower = BBANDS(data, timeperiod=10) + valid_mask = ~np.isnan(mid) + np.testing.assert_allclose(upper[valid_mask], mid[valid_mask]) + np.testing.assert_allclose(lower[valid_mask], mid[valid_mask]) + + +# --------------------------------------------------------------------------- +# Timeperiod edge cases +# --------------------------------------------------------------------------- + + +class TestTimePeriodEdge: + """Boundary conditions for the timeperiod parameter.""" + + def test_sma_period_equals_length(self): + data = np.arange(1.0, 11.0) # 10 elements + result = SMA(data, timeperiod=10) + assert len(result) == 10 + # Only last element should be valid + assert not np.isnan(result[-1]) + np.testing.assert_allclose(result[-1], 5.5) + + def test_sma_period_exceeds_length(self): + data = np.arange(1.0, 6.0) # 5 elements + result = SMA(data, timeperiod=10) + assert len(result) == 5 + assert _all_nan(result) + + def test_ema_period_1(self): + """EMA with period=1 should return the input itself.""" + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = EMA(data, timeperiod=1) + np.testing.assert_allclose(result, data) + + +# --------------------------------------------------------------------------- +# Multi-input indicator edge cases (OHLCV) +# --------------------------------------------------------------------------- + + +class TestOHLCVEdgeCases: + """Edge cases for indicators requiring multiple price series.""" + + def test_atr_empty(self): + empty = np.array([], dtype=np.float64) + result = ATR(empty, empty, empty, timeperiod=14) + assert len(result) == 0 + + def test_stoch_empty(self): + empty = np.array([], dtype=np.float64) + slowk, slowd = STOCH(empty, empty, empty) + assert len(slowk) == 0 + assert len(slowd) == 0 + + def test_obv_empty(self): + empty = np.array([], dtype=np.float64) + result = OBV(empty, empty) + assert len(result) == 0 + + def test_atr_single_bar(self): + h = np.array([10.0]) + l = np.array([9.0]) + c = np.array([9.5]) + result = ATR(h, l, c, timeperiod=14) + assert len(result) == 1 + assert np.isnan(result[0]) + + def test_mfi_constant_price(self): + """Constant price -> no money flow direction -> MFI should be well-defined.""" + n = 50 + h = np.full(n, 100.0) + l = np.full(n, 100.0) + c = np.full(n, 100.0) + v = np.full(n, 1000.0) + result = MFI(h, l, c, v, timeperiod=14) + assert len(result) == n + # Should not crash; values may be NaN or 50 + + +# --------------------------------------------------------------------------- +# Dtype robustness +# --------------------------------------------------------------------------- + + +class TestDtypeRobustness: + """Indicators should accept float32/int inputs and coerce to float64.""" + + def test_sma_float32(self): + data = np.arange(1.0, 51.0, dtype=np.float32) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + + def test_sma_int64(self): + data = np.arange(1, 51, dtype=np.int64) + result = SMA(data, timeperiod=14) + assert len(result) == 50 + + def test_rsi_float32(self): + data = np.arange(1.0, 51.0, dtype=np.float32) + result = RSI(data, timeperiod=14) + assert len(result) == 50 diff --git a/vendor/ferro-ta-main/tests/unit/test_ferro_ta.py b/vendor/ferro-ta-main/tests/unit/test_ferro_ta.py new file mode 100644 index 0000000..0ca39c8 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_ferro_ta.py @@ -0,0 +1,2986 @@ +"""Tests for ferro_ta technical analysis indicators.""" + +import math + +import numpy as np +import pytest + +from ferro_ta import ( + ACOS, + # Volume + AD, + # Math Operators + ADD, + ADOSC, + ADX, + ADXR, + AROON, + ASIN, + ATAN, + # Volatility + ATR, + # Price transforms + AVGPRICE, + BBANDS, + CDL3BLACKCROWS, + CDL3INSIDE, + # Candlestick patterns + CDL3LINESTRIKE, + CDL3OUTSIDE, + CDL3STARSINSOUTH, + CDL3WHITESOLDIERS, + CDLABANDONEDBABY, + CDLADVANCEBLOCK, + CDLBELTHOLD, + CDLBREAKAWAY, + CDLCLOSINGMARUBOZU, + CDLCONCEALBABYSWALL, + CDLCOUNTERATTACK, + CDLDARKCLOUDCOVER, + # Patterns + CDLDOJI, + CDLDOJISTAR, + CDLDRAGONFLYDOJI, + CDLENGULFING, + CDLEVENINGDOJISTAR, + CDLGAPSIDESIDEWHITE, + CDLGRAVESTONEDOJI, + CDLHAMMER, + CDLHANGINGMAN, + CDLHARAMI, + CDLHARAMICROSS, + CDLHIGHWAVE, + CDLHIKKAKE, + CDLHIKKAKEMOD, + CDLHOMINGPIGEON, + CDLIDENTICAL3CROWS, + CDLINNECK, + CDLINVERTEDHAMMER, + CDLKICKING, + CDLKICKINGBYLENGTH, + CDLLADDERBOTTOM, + CDLLONGLEGGEDDOJI, + CDLLONGLINE, + CDLMARUBOZU, + CDLMATCHINGLOW, + CDLMATHOLD, + CDLMORNINGDOJISTAR, + CDLONNECK, + CDLPIERCING, + CDLRICKSHAWMAN, + CDLRISEFALL3METHODS, + CDLSEPARATINGLINES, + CDLSHOOTINGSTAR, + CDLSHORTLINE, + CDLSTALLEDPATTERN, + CDLSTICKSANDWICH, + CDLTAKURI, + CDLTASUKIGAP, + CDLTHRUSTING, + CDLTRISTAR, + CDLUNIQUE3RIVER, + CDLUPSIDEGAP2CROWS, + CDLXSIDEGAP3METHODS, + CEIL, + CMO, + CORREL, + COS, + COSH, + DEMA, + DIV, + DX, + EMA, + EXP, + FLOOR, + HT_DCPERIOD, + HT_DCPHASE, + HT_PHASOR, + HT_SINE, + # Cycle + HT_TRENDLINE, + HT_TRENDMODE, + LINEARREG, + LN, + LOG10, + MA, + MACD, + MACDEXT, + MACDFIX, + MAMA, + MAVP, + MAX, + MAXINDEX, + MEDPRICE, + MIDPOINT, + MIDPRICE, + MIN, + MININDEX, + MINUS_DI, + MINUS_DM, + # Momentum + MOM, + MULT, + NATR, + OBV, + PLUS_DI, + PLUS_DM, + ROC, + ROCP, + RSI, + SAR, + SAREXT, + SIN, + SINH, + SMA, + SQRT, + # Statistics + STDDEV, + STOCH, + STOCHRSI, + SUB, + SUM, + TAN, + TANH, + TEMA, + TRANGE, + TYPPRICE, + WCLPRICE, + WILLR, + # Overlap + WMA, +) + +# --------------------------------------------------------------------------- +# Shared fixture +# --------------------------------------------------------------------------- + +PRICES = 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, + ], + dtype=np.float64, +) + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _nan_count(arr: np.ndarray) -> int: + return int(np.sum(np.isnan(arr))) + + +def _finite(arr: np.ndarray) -> np.ndarray: + return arr[~np.isnan(arr)] + + +# --------------------------------------------------------------------------- +# SMA +# --------------------------------------------------------------------------- + + +class TestSMA: + def test_output_length(self): + result = SMA(PRICES, timeperiod=3) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = SMA(PRICES, timeperiod=period) + assert _nan_count(result) == period - 1 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = SMA(prices, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + assert math.isclose(result[2], 2.0) + assert math.isclose(result[3], 3.0) + assert math.isclose(result[4], 4.0) + + def test_accepts_python_list(self): + result = SMA([1.0, 2.0, 3.0, 4.0], timeperiod=2) + assert len(result) == 4 + + def test_default_period(self): + long_prices = np.arange(1.0, 51.0) + result = SMA(long_prices) # default period = 30 + assert _nan_count(result) == 29 + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + SMA(PRICES, timeperiod=0) + + def test_period_equals_length(self): + prices = np.array([1.0, 2.0, 3.0]) + result = SMA(prices, timeperiod=3) + assert np.isnan(result[0]) and np.isnan(result[1]) + assert math.isclose(result[2], 2.0) + + +# --------------------------------------------------------------------------- +# EMA +# --------------------------------------------------------------------------- + + +class TestEMA: + def test_output_length(self): + result = EMA(PRICES, timeperiod=3) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = EMA(PRICES, timeperiod=period) + assert _nan_count(result) == period - 1 + + def test_values_reasonable(self): + prices = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0]) + result = EMA(prices, timeperiod=3) + finite = _finite(result) + assert len(finite) == len(prices) - 2 + # EMA should be a reasonable average-like value + assert all(8.0 <= v <= 14.0 for v in finite) + + def test_ema_differs_from_sma(self): + """EMA weights recent prices more — it must differ from SMA.""" + prices = np.array([1.0, 2.0, 3.0, 10.0, 11.0]) + ema_result = EMA(prices, timeperiod=3) + sma_result = SMA(prices, timeperiod=3) + # Both should be finite for the last value + assert not math.isclose(ema_result[-1], sma_result[-1], rel_tol=1e-9) + + +# --------------------------------------------------------------------------- +# RSI +# --------------------------------------------------------------------------- + + +class TestRSI: + def test_output_length(self): + result = RSI(PRICES, timeperiod=5) + assert len(result) == len(PRICES) + + def test_leading_nans(self): + period = 5 + result = RSI(PRICES, timeperiod=period) + assert _nan_count(result) == period + + def test_rsi_range(self): + result = RSI(PRICES, timeperiod=5) + finite = _finite(result) + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_constant_prices_rsi_50(self): + """For constant prices, RSI should be around 50 (no gains or losses).""" + prices = np.full(20, 50.0) + result = RSI(prices, timeperiod=5) + finite = _finite(result) + # With constant prices there are no changes, RSI is typically 50 or 100 + assert all(0.0 <= v <= 100.0 for v in finite) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + RSI(PRICES, timeperiod=0) + + +# --------------------------------------------------------------------------- +# MACD +# --------------------------------------------------------------------------- + + +class TestMACD: + def test_output_tuple_of_three(self): + result = MACD(PRICES) + assert isinstance(result, tuple) + assert len(result) == 3 + + def test_output_lengths_equal(self): + macd_line, signal, hist = MACD(PRICES) + assert len(macd_line) == len(PRICES) + assert len(signal) == len(PRICES) + assert len(hist) == len(PRICES) + + def test_histogram_is_macd_minus_signal(self): + """Histogram must equal MACD line minus signal line for valid indices.""" + prices = np.arange(1.0, 60.0) + macd_line, signal, hist = MACD( + prices, fastperiod=3, slowperiod=6, signalperiod=2 + ) + mask = ~(np.isnan(macd_line) | np.isnan(signal) | np.isnan(hist)) + assert np.allclose(hist[mask], macd_line[mask] - signal[mask], atol=1e-10) + + def test_fast_must_be_less_than_slow(self): + with pytest.raises(Exception): + MACD(PRICES, fastperiod=26, slowperiod=12) + + def test_all_nan_when_not_enough_data(self): + prices = np.arange(1.0, 6.0) # only 5 points + macd_line, signal, hist = MACD( + prices, fastperiod=3, slowperiod=4, signalperiod=2 + ) + # warmup = 4 + 2 - 2 = 4, so only index 4 might be valid + assert np.isnan(macd_line[0]) + + def test_default_periods(self): + prices = np.arange(1.0, 100.0) + macd_line, signal, hist = MACD(prices) + # MACD line is valid from slowperiod-1=25; signal from slowperiod+signalperiod-2=33 + assert all(np.isnan(macd_line[:25])) + assert any(~np.isnan(macd_line[25:])) + # Signal line starts at index 33 + assert all(np.isnan(signal[:33])) + assert any(~np.isnan(signal[33:])) + + +# --------------------------------------------------------------------------- +# Bollinger Bands +# --------------------------------------------------------------------------- + + +class TestBBANDS: + def test_output_tuple_of_three(self): + result = BBANDS(PRICES, timeperiod=5) + assert isinstance(result, tuple) + assert len(result) == 3 + + def test_output_lengths_equal(self): + upper, middle, lower = BBANDS(PRICES, timeperiod=5) + assert len(upper) == len(PRICES) + assert len(middle) == len(PRICES) + assert len(lower) == len(PRICES) + + def test_leading_nans(self): + period = 5 + upper, middle, lower = BBANDS(PRICES, timeperiod=period) + assert _nan_count(upper) == period - 1 + assert _nan_count(middle) == period - 1 + assert _nan_count(lower) == period - 1 + + def test_band_ordering(self): + """Upper >= middle >= lower for all valid values.""" + upper, middle, lower = BBANDS(PRICES, timeperiod=5) + mask = ~(np.isnan(upper) | np.isnan(middle) | np.isnan(lower)) + assert np.all(upper[mask] >= middle[mask]) + assert np.all(middle[mask] >= lower[mask]) + + def test_symmetric_bands(self): + """With equal nbdevup/nbdevdn, bands are symmetric around middle.""" + prices = np.array([10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0]) + upper, middle, lower = BBANDS(prices, timeperiod=3, nbdevup=2.0, nbdevdn=2.0) + mask = ~(np.isnan(upper) | np.isnan(lower)) + assert np.allclose( + upper[mask] - middle[mask], + middle[mask] - lower[mask], + atol=1e-10, + ) + + def test_invalid_period_zero(self): + with pytest.raises(Exception): + BBANDS(PRICES, timeperiod=0) + + def test_accepts_python_list(self): + prices = [10.0, 11.0, 12.0, 11.0, 10.0, 11.0, 12.0] + upper, middle, lower = BBANDS(prices, timeperiod=3) + assert len(upper) == len(prices) + + +# --------------------------------------------------------------------------- +# Input validation shared tests +# --------------------------------------------------------------------------- + + +class TestInputValidation: + def test_2d_array_raises(self): + arr = np.array([[1.0, 2.0], [3.0, 4.0]]) + with pytest.raises(ValueError): + SMA(arr, timeperiod=2) + + def test_int_array_is_coerced(self): + """Integer arrays should be automatically cast to float64.""" + prices = np.array([10, 11, 12, 13, 14], dtype=np.int64) + result = SMA(prices, timeperiod=3) + assert result.dtype == np.float64 + + +# --------------------------------------------------------------------------- +# Shared fixtures for OHLCV tests +# --------------------------------------------------------------------------- + +OHLCV_PRICES = np.arange(1.0, 51.0) +OHLCV_HIGH = OHLCV_PRICES + 0.5 +OHLCV_LOW = OHLCV_PRICES - 0.5 +OHLCV_CLOSE = OHLCV_PRICES +OHLCV_OPEN = OHLCV_PRICES - 0.2 +OHLCV_VOLUME = np.ones(50) * 1000.0 + + +# --------------------------------------------------------------------------- +# Overlap Studies — new indicators +# --------------------------------------------------------------------------- + + +class TestWMA: + def test_output_length(self): + result = WMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = WMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = WMA(prices, 3) + # WMA(3) at i=2: (1*1 + 2*2 + 3*3) / (1+2+3) = 14/6 + assert math.isclose(result[2], 14.0 / 6.0, rel_tol=1e-9) + + +class TestDEMA: + def test_output_length(self): + result = DEMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = DEMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 2 * (5 - 1) + + +class TestTEMA: + def test_output_length(self): + result = TEMA(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = TEMA(OHLCV_PRICES, 5) + assert _nan_count(result) == 3 * (5 - 1) + + +class TestMACDFIX: + def test_output_tuple_of_three(self): + result = MACDFIX(OHLCV_PRICES) + assert isinstance(result, tuple) and len(result) == 3 + + def test_all_same_length(self): + m, s, h = MACDFIX(OHLCV_PRICES) + assert len(m) == len(OHLCV_PRICES) + assert len(s) == len(OHLCV_PRICES) + assert len(h) == len(OHLCV_PRICES) + + +class TestSAR: + def test_output_length(self): + result = SAR(OHLCV_HIGH, OHLCV_LOW) + assert len(result) == len(OHLCV_HIGH) + + def test_values_reasonable(self): + result = SAR(OHLCV_HIGH, OHLCV_LOW) + finite = _finite(result) + assert len(finite) > 0 + assert all(v > 0 for v in finite) + + +class TestMIDPOINT: + def test_output_length(self): + result = MIDPOINT(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = MIDPOINT(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + +class TestMIDPRICE: + def test_output_length(self): + result = MIDPRICE(OHLCV_HIGH, OHLCV_LOW, 5) + assert len(result) == len(OHLCV_HIGH) + + +# --------------------------------------------------------------------------- +# Momentum Indicators — new indicators +# --------------------------------------------------------------------------- + + +class TestMOM: + def test_output_length(self): + result = MOM(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = MOM(OHLCV_PRICES, 5) + assert _nan_count(result) == 5 + + def test_values_correct(self): + prices = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = MOM(prices, 2) + assert math.isclose(result[2], 2.0) + assert math.isclose(result[3], 2.0) + + +class TestROC: + def test_output_length(self): + result = ROC(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = ROC(OHLCV_PRICES, 5) + assert _nan_count(result) == 5 + + def test_values_formula(self): + prices = np.array([10.0, 11.0, 12.0, 10.0, 11.0]) + result = ROC(prices, 2) + # ROC[4] = (11 - 12) / 12 * 100 + assert math.isclose(result[4], (11.0 - 12.0) / 12.0 * 100.0, rel_tol=1e-9) + + +class TestROCP: + def test_output_length(self): + result = ROCP(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_relation_to_roc(self): + """ROCP * 100 should equal ROC.""" + roc_result = ROC(OHLCV_PRICES, 5) + rocp_result = ROCP(OHLCV_PRICES, 5) + mask = ~(np.isnan(roc_result) | np.isnan(rocp_result)) + assert np.allclose(rocp_result[mask] * 100.0, roc_result[mask], atol=1e-10) + + +class TestWILLR: + def test_output_length(self): + result = WILLR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = WILLR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 5) + finite = _finite(result) + assert all(-100.0 <= v <= 0.0 for v in finite) + + +class TestAROON: + def test_output_tuple(self): + result = AROON(OHLCV_HIGH, OHLCV_LOW, 14) + assert isinstance(result, tuple) and len(result) == 2 + + def test_range_correct(self): + down, up = AROON(OHLCV_HIGH, OHLCV_LOW, 14) + down_finite = _finite(down) + up_finite = _finite(up) + assert all(0.0 <= v <= 100.0 for v in down_finite) + assert all(0.0 <= v <= 100.0 for v in up_finite) + + +class TestADX: + def test_output_length(self): + result = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(0.0 <= v <= 100.0 for v in finite) + + +class TestCMO: + def test_output_length(self): + result = CMO(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_range_correct(self): + result = CMO(OHLCV_PRICES, 5) + finite = _finite(result) + assert all(-100.0 <= v <= 100.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Volume Indicators +# --------------------------------------------------------------------------- + + +class TestOBV: + def test_output_length(self): + result = OBV(OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + def test_monotone_increasing(self): + """With always-rising prices, OBV should be non-decreasing.""" + result = OBV(OHLCV_CLOSE, OHLCV_VOLUME) + assert all(result[i] <= result[i + 1] for i in range(1, len(result) - 1)) + + +class TestAD: + def test_output_length(self): + result = AD(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + +class TestADOSC: + def test_output_length(self): + result = ADOSC(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, OHLCV_VOLUME) + assert len(result) == len(OHLCV_PRICES) + + +# --------------------------------------------------------------------------- +# Volatility Indicators +# --------------------------------------------------------------------------- + + +class TestATR: + def test_output_length(self): + result = ATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = ATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(v > 0 for v in finite) + + +class TestNATR: + def test_output_length(self): + result = NATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = NATR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, 14) + finite = _finite(result) + assert all(v > 0 for v in finite) + + +class TestTRANGE: + def test_output_length(self): + result = TRANGE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + + def test_values_positive(self): + result = TRANGE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert all(v > 0 for v in result) + + +# --------------------------------------------------------------------------- +# Statistic Functions +# --------------------------------------------------------------------------- + + +class TestSTDDEV: + def test_output_length(self): + result = STDDEV(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_leading_nans(self): + result = STDDEV(OHLCV_PRICES, 5) + assert _nan_count(result) == 4 + + def test_constant_prices_zero_stddev(self): + prices = np.full(20, 100.0) + result = STDDEV(prices, 5) + finite = _finite(result) + assert all(math.isclose(v, 0.0, abs_tol=1e-10) for v in finite) + + +class TestLINEARREG: + def test_output_length(self): + result = LINEARREG(OHLCV_PRICES, 5) + assert len(result) == len(OHLCV_PRICES) + + def test_linear_data_matches_values(self): + """For perfectly linear data, LINEARREG endpoint should match the actual value.""" + result = LINEARREG(OHLCV_PRICES, 5) + finite = _finite(result) + expected = OHLCV_PRICES[len(OHLCV_PRICES) - len(finite) :] + assert np.allclose(finite, expected, atol=1e-10) + + +class TestCORREL: + def test_perfect_correlation(self): + result = CORREL(OHLCV_PRICES, OHLCV_PRICES, 10) + finite = _finite(result) + assert all(math.isclose(v, 1.0, abs_tol=1e-10) for v in finite) + + def test_range(self): + result = CORREL(OHLCV_PRICES, OHLCV_HIGH, 10) + finite = _finite(result) + assert all(-1.0 <= v <= 1.0 for v in finite) + + +# --------------------------------------------------------------------------- +# Price Transformations +# --------------------------------------------------------------------------- + + +class TestPriceTransforms: + def test_avgprice(self): + result = AVGPRICE(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_OPEN + OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE) / 4.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_medprice(self): + result = MEDPRICE(OHLCV_HIGH, OHLCV_LOW) + expected = (OHLCV_HIGH + OHLCV_LOW) / 2.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_typprice(self): + result = TYPPRICE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE) / 3.0 + assert np.allclose(result, expected, atol=1e-10) + + def test_wclprice(self): + result = WCLPRICE(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + expected = (OHLCV_HIGH + OHLCV_LOW + OHLCV_CLOSE * 2.0) / 4.0 + assert np.allclose(result, expected, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Recognition +# --------------------------------------------------------------------------- + + +class TestPatternRecognition: + def test_cdldoji_output_values(self): + result = CDLDOJI(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdlengulfing_output_values(self): + result = CDLENGULFING(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_cdlmarubozu_detects_full_body(self): + """A full-body candle with no shadows should be detected as marubozu.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([15.0]) + result = CDLMARUBOZU(o, h, l, c) + assert result[0] == 100 + + def test_cdldoji_detects_doji(self): + """A candle where open == close should be detected.""" + o = np.array([10.0]) + h = np.array([12.0]) + l = np.array([8.0]) + c = np.array([10.0]) + result = CDLDOJI(o, h, l, c) + assert result[0] == 100 + + def test_cdlhammer_detects_hammer(self): + """Long lower shadow, small body at top, tiny upper shadow.""" + # body = 0.5, range = 2.0, lower = 1.0 >= 2*0.5, upper = 0.5 <= 0.5 + o = np.array([8.0]) + h = np.array([9.0]) + l = np.array([7.0]) + c = np.array([8.5]) + result = CDLHAMMER(o, h, l, c) + assert result[0] == 100 + + def test_cdlshootingstar_detects_pattern(self): + """Long upper shadow, small body at bottom, tiny lower shadow.""" + o = np.array([8.5]) + h = np.array([11.0]) + l = np.array([8.0]) + c = np.array([8.0]) + result = CDLSHOOTINGSTAR(o, h, l, c) + assert result[0] == -100 + + +# --------------------------------------------------------------------------- +# New Overlap Indicators +# --------------------------------------------------------------------------- + +# Larger price series for indicators that need more data (MAMA, HT need 32+/63+ bars) +N_LONG = 200 +RNG_LONG = np.random.default_rng(123) +LONG_CLOSE = 50.0 + np.cumsum(RNG_LONG.standard_normal(N_LONG) * 0.5) +LONG_HIGH = LONG_CLOSE + RNG_LONG.uniform(0.1, 1.0, N_LONG) +LONG_LOW = LONG_CLOSE - RNG_LONG.uniform(0.1, 1.0, N_LONG) + + +class TestMA: + def test_ma_sma_matches_sma(self): + result_ma = MA(PRICES, timeperiod=5, matype=0) + result_sma = SMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_sma, equal_nan=True) + + def test_ma_ema_matches_ema(self): + result_ma = MA(PRICES, timeperiod=5, matype=1) + result_ema = EMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_ema, equal_nan=True) + + def test_ma_wma_matches_wma(self): + result_ma = MA(PRICES, timeperiod=5, matype=2) + result_wma = WMA(PRICES, timeperiod=5) + assert np.allclose(result_ma, result_wma, equal_nan=True) + + def test_ma_invalid_matype_raises(self): + with pytest.raises(Exception): + MA(PRICES, timeperiod=5, matype=99) + + def test_ma_output_length(self): + result = MA(PRICES, timeperiod=5, matype=0) + assert len(result) == len(PRICES) + + def test_ma_leading_nans(self): + """MA(matype=0, period=5) should have 4 leading NaNs.""" + result = MA(PRICES, timeperiod=5, matype=0) + assert _nan_count(result) == 4 # timeperiod - 1 leading NaNs + + +class TestMAVP: + def test_output_length(self): + periods = np.full(len(PRICES), 5.0) + result = MAVP(PRICES, periods) + assert len(result) == len(PRICES) + + def test_constant_period_matches_sma(self): + """MAVP with constant period should equal SMA with that period.""" + periods = np.full(len(PRICES), 5.0) + result = MAVP(PRICES, periods, minperiod=5, maxperiod=5) + expected = SMA(PRICES, timeperiod=5) + valid = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[valid], expected[valid], atol=1e-10) + + def test_mismatched_lengths_raises(self): + with pytest.raises(Exception): + MAVP(PRICES, np.array([5.0, 5.0])) + + +class TestMAMA: + def test_output_length(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + assert len(mama_arr) == N_LONG + assert len(fama_arr) == N_LONG + + def test_leading_nans(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + # First 32 values should be NaN + assert all(np.isnan(mama_arr[:32])) + assert all(np.isnan(fama_arr[:32])) + + def test_valid_values_finite(self): + mama_arr, fama_arr = MAMA(LONG_CLOSE) + valid = ~np.isnan(mama_arr) + assert np.all(np.isfinite(mama_arr[valid])) + assert np.all(np.isfinite(fama_arr[valid])) + + +class TestSAREXT: + def test_output_length(self): + result = SAREXT(LONG_HIGH, LONG_LOW) + assert len(result) == N_LONG + + def test_first_value_nan(self): + result = SAREXT(LONG_HIGH, LONG_LOW) + assert np.isnan(result[0]) + + def test_default_matches_sar(self): + """SAREXT with default params should be close to SAR.""" + sar_result = SAR(LONG_HIGH, LONG_LOW) + sarext_result = SAREXT(LONG_HIGH, LONG_LOW) + valid = ~np.isnan(sar_result) & ~np.isnan(sarext_result) + assert np.allclose(sar_result[valid], sarext_result[valid], atol=1e-10) + + +class TestMACDEXT: + def test_output_length(self): + m, s, h = MACDEXT(LONG_CLOSE) + assert len(m) == len(s) == len(h) == N_LONG + + def test_ema_matches_standard_macd(self): + """MACDEXT with EMA (matype=1) should produce valid output of correct shape. + + Note: MACDEXT uses a different EMA seeding strategy than the `ta` crate's + EMA (price at index period-1 vs. accumulated from index 0), so exact value + equivalence with MACD is not expected in the warmup period. + """ + m_ext, s_ext, h_ext = MACDEXT( + LONG_CLOSE, fastmatype=1, slowmatype=1, signalmatype=1 + ) + m_std, s_std, h_std = MACD(LONG_CLOSE) + # Both should have same length + assert len(m_ext) == len(m_std) + # Both should have valid (non-NaN) values at the same trailing region + valid_ext = ~np.isnan(m_ext) + valid_std = ~np.isnan(m_std) + # At least 50% of values should be valid for 200-bar series + assert valid_ext.sum() >= N_LONG // 2 + assert valid_std.sum() >= N_LONG // 2 + + def test_invalid_periods_raise(self): + with pytest.raises(Exception): + MACDEXT(LONG_CLOSE, fastperiod=26, slowperiod=12) # fast >= slow + + +# --------------------------------------------------------------------------- +# New Candlestick Patterns +# --------------------------------------------------------------------------- + + +class TestNewPatterns: + def test_cdl3blackcrows_output_values(self): + result = CDL3BLACKCROWS(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0) for v in result) + + def test_cdl3whitesoldiers_output_values(self): + result = CDL3WHITESOLDIERS(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdl3inside_output_values(self): + result = CDL3INSIDE(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_cdl3outside_output_values(self): + result = CDL3OUTSIDE(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_cdlharami_detects_bearish(self): + """Prior large bullish, small bearish inside.""" + # Candle 1: bullish, large body (o=10, c=15) + # Candle 2: bearish (o > c), body inside candle 1 body [10, 15] + o = np.array([10.0, 12.5]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 11.5]) + c = np.array([15.0, 12.0]) # bearish: c=12.0 < o=12.5, body inside [10, 15] + result = CDLHARAMI(o, h, l, c) + assert result[1] == -100 + + def test_cdlharami_detects_bullish(self): + """Prior large bearish, small bullish inside.""" + o = np.array([15.0, 12.0]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 11.5]) + c = np.array([10.0, 12.5]) # bullish inside + result = CDLHARAMI(o, h, l, c) + assert result[1] == 100 + + def test_cdlharamicross_output_values(self): + result = CDLHARAMICROSS(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_cdldojistar_output_values(self): + result = CDLDOJISTAR(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_cdlmorningdojistar_output_values(self): + result = CDLMORNINGDOJISTAR(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (0, 100) for v in result) + + def test_cdleveningdojistar_output_values(self): + result = CDLEVENINGDOJISTAR(OHLCV_OPEN, OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(result) == len(OHLCV_PRICES) + assert all(v in (-100, 0) for v in result) + + def test_cdl3blackcrows_detects_pattern(self): + """Three consecutive bearish candles, each opening in previous body.""" + # Three strong bearish candles + o = np.array([100.0, 95.0, 90.0]) + h = np.array([101.0, 97.0, 92.0]) + l = np.array([90.0, 85.0, 80.0]) + c = np.array([91.0, 86.0, 81.0]) # bearish, long body, closes near low + result = CDL3BLACKCROWS(o, h, l, c) + assert result[2] == -100 + + def test_cdl3whitesoldiers_detects_pattern(self): + """Three consecutive bullish candles, each opening in previous body.""" + o = np.array([80.0, 86.0, 92.0]) + h = np.array([92.0, 98.0, 104.0]) + l = np.array([79.0, 85.0, 91.0]) + c = np.array([91.0, 97.0, 103.0]) # bullish, long body, closes near high + result = CDL3WHITESOLDIERS(o, h, l, c) + assert result[2] == 100 + + +# --------------------------------------------------------------------------- +# Cycle Indicators +# --------------------------------------------------------------------------- + + +class TestHilbertTransform: + def test_ht_trendline_output_length(self): + result = HT_TRENDLINE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_trendline_leading_nans(self): + result = HT_TRENDLINE(LONG_CLOSE) + assert all(np.isnan(result[:63])) + + def test_ht_trendline_valid_values(self): + result = HT_TRENDLINE(LONG_CLOSE) + valid = ~np.isnan(result) + assert valid.any() + assert np.all(np.isfinite(result[valid])) + + def test_ht_dcperiod_output_length(self): + result = HT_DCPERIOD(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_dcperiod_values_in_range(self): + """Dominant cycle period should be between 6 and 50.""" + result = HT_DCPERIOD(LONG_CLOSE) + valid = ~np.isnan(result) + assert valid.any() + assert np.all(result[valid] >= 6.0) + assert np.all(result[valid] <= 50.0) + + def test_ht_dcphase_output_length(self): + result = HT_DCPHASE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_phasor_returns_two_arrays(self): + inphase, quad = HT_PHASOR(LONG_CLOSE) + assert len(inphase) == N_LONG + assert len(quad) == N_LONG + + def test_ht_phasor_leading_nans(self): + inphase, quad = HT_PHASOR(LONG_CLOSE) + assert all(np.isnan(inphase[:63])) + assert all(np.isnan(quad[:63])) + + def test_ht_sine_returns_two_arrays(self): + sine, lead = HT_SINE(LONG_CLOSE) + assert len(sine) == N_LONG + assert len(lead) == N_LONG + + def test_ht_sine_values_in_range(self): + """Sine values must be in [-1, 1].""" + sine, lead = HT_SINE(LONG_CLOSE) + valid = ~np.isnan(sine) + assert valid.any() + assert np.all(np.abs(sine[valid]) <= 1.0 + 1e-9) + assert np.all(np.abs(lead[valid]) <= 1.0 + 1e-9) + + def test_ht_trendmode_output_length(self): + result = HT_TRENDMODE(LONG_CLOSE) + assert len(result) == N_LONG + + def test_ht_trendmode_values_binary(self): + """Trend mode must be 0 or 1.""" + result = HT_TRENDMODE(LONG_CLOSE) + assert all(v in (0, 1) for v in result) + + def test_short_series_returns_all_nans(self): + """Series shorter than lookback should return all NaN.""" + short = np.arange(1.0, 10.0) + result = HT_TRENDLINE(short) + assert all(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# New Pattern Recognition Tests (43 patterns) +# --------------------------------------------------------------------------- + + +class TestNewPatterns: + """Basic output-length and value-set checks for 43 new patterns.""" + + O = OHLCV_OPEN + H = OHLCV_HIGH + L = OHLCV_LOW + C = OHLCV_CLOSE + N = len(OHLCV_PRICES) + + # -- CDL3LINESTRIKE ------------------------------------------------------- + def test_cdl3linestrike_length(self): + r = CDL3LINESTRIKE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdl3linestrike_values(self): + r = CDL3LINESTRIKE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdl3linestrike_detects_bearish(self): + """3 bullish candles then a bearish engulfing all three.""" + o = np.array([10.0, 11.0, 12.0, 16.0]) + h = np.array([11.5, 12.5, 13.5, 16.5]) + l = np.array([9.5, 10.5, 11.5, 9.0]) + c = np.array([11.0, 12.0, 13.0, 9.5]) # bearish closes below first open + r = CDL3LINESTRIKE(o, h, l, c) + assert r[3] == -100 + + # -- CDL3STARSINSOUTH ----------------------------------------------------- + def test_cdl3starsinsouth_length(self): + r = CDL3STARSINSOUTH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdl3starsinsouth_values(self): + r = CDL3STARSINSOUTH(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLABANDONEDBABY ----------------------------------------------------- + def test_cdlabandonedbaby_length(self): + r = CDLABANDONEDBABY(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlabandonedbaby_values(self): + r = CDLABANDONEDBABY(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlabandonedbaby_detects_bullish(self): + """Large bearish, doji gaps down (h_doji < l_prior), large bullish gaps up.""" + o = np.array([20.0, 9.0, 12.0]) + h = np.array([21.0, 9.1, 20.0]) + l = np.array([11.0, 8.9, 11.5]) + c = np.array( + [12.0, 9.0, 19.0] + ) # doji gaps below l[0]=11, bullish gaps above h[1]=9.1 + r = CDLABANDONEDBABY(o, h, l, c) + assert r[2] == 100 + + # -- CDLADVANCEBLOCK ------------------------------------------------------ + def test_cdladvanceblock_length(self): + r = CDLADVANCEBLOCK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdladvanceblock_values(self): + r = CDLADVANCEBLOCK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLBELTHOLD ---------------------------------------------------------- + def test_cdlbelthold_length(self): + r = CDLBELTHOLD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlbelthold_values(self): + r = CDLBELTHOLD(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlbelthold_detects_bullish(self): + """Bullish candle opening at its low.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([10.0]) # open == low + c = np.array([14.5]) + r = CDLBELTHOLD(o, h, l, c) + assert r[0] == 100 + + def test_cdlbelthold_detects_bearish(self): + """Bearish candle opening at its high.""" + o = np.array([15.0]) + h = np.array([15.0]) # open == high + l = np.array([10.0]) + c = np.array([10.5]) + r = CDLBELTHOLD(o, h, l, c) + assert r[0] == -100 + + # -- CDLBREAKAWAY --------------------------------------------------------- + def test_cdlbreakaway_length(self): + r = CDLBREAKAWAY(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlbreakaway_values(self): + r = CDLBREAKAWAY(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLCLOSINGMARUBOZU --------------------------------------------------- + def test_cdlclosingmarubozu_length(self): + r = CDLCLOSINGMARUBOZU(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlclosingmarubozu_values(self): + r = CDLCLOSINGMARUBOZU(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlclosingmarubozu_detects_bullish(self): + """Bullish closing marubozu: close == high.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.0]) + c = np.array([15.0]) # close == high, no upper shadow + r = CDLCLOSINGMARUBOZU(o, h, l, c) + assert r[0] == 100 + + def test_cdlclosingmarubozu_detects_bearish(self): + """Bearish closing marubozu: close == low.""" + o = np.array([15.0]) + h = np.array([16.0]) + l = np.array([10.0]) + c = np.array([10.0]) # close == low, no lower shadow + r = CDLCLOSINGMARUBOZU(o, h, l, c) + assert r[0] == -100 + + # -- CDLCONCEALBABYSWALL -------------------------------------------------- + def test_cdlconcealbabyswall_length(self): + r = CDLCONCEALBABYSWALL(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlconcealbabyswall_values(self): + r = CDLCONCEALBABYSWALL(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLCOUNTERATTACK ----------------------------------------------------- + def test_cdlcounterattack_length(self): + r = CDLCOUNTERATTACK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlcounterattack_values(self): + r = CDLCOUNTERATTACK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLDARKCLOUDCOVER ---------------------------------------------------- + def test_cdldarkcloudcover_length(self): + r = CDLDARKCLOUDCOVER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdldarkcloudcover_values(self): + r = CDLDARKCLOUDCOVER(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdldarkcloudcover_detects_pattern(self): + """Bearish candle opening above prior high and closing below midpoint.""" + o = np.array([10.0, 16.0]) + h = np.array([15.0, 17.0]) + l = np.array([9.5, 11.0]) + c = np.array([14.0, 11.5]) # bearish, closes below midpoint of (10,14) + r = CDLDARKCLOUDCOVER(o, h, l, c) + assert r[1] == -100 + + # -- CDLDRAGONFLYDOJI ----------------------------------------------------- + def test_cdldragonflydoji_length(self): + r = CDLDRAGONFLYDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdldragonflydoji_values(self): + r = CDLDRAGONFLYDOJI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdldragonflydoji_detects_pattern(self): + """Open ≈ close ≈ high with long lower shadow.""" + o = np.array([15.0]) + h = np.array([15.1]) + l = np.array([10.0]) + c = np.array([15.0]) + r = CDLDRAGONFLYDOJI(o, h, l, c) + assert r[0] == 100 + + # -- CDLGAPSIDESIDEWHITE -------------------------------------------------- + def test_cdlgapsidesidewhite_length(self): + r = CDLGAPSIDESIDEWHITE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlgapsidesidewhite_values(self): + r = CDLGAPSIDESIDEWHITE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLGRAVESTONEDOJI ---------------------------------------------------- + def test_cdlgravestonedoji_length(self): + r = CDLGRAVESTONEDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlgravestonedoji_values(self): + r = CDLGRAVESTONEDOJI(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdlgravestonedoji_detects_pattern(self): + """Open ≈ close ≈ low with long upper shadow.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.9]) + c = np.array([10.0]) + r = CDLGRAVESTONEDOJI(o, h, l, c) + assert r[0] == -100 + + # -- CDLHANGINGMAN -------------------------------------------------------- + def test_cdlhangingman_length(self): + r = CDLHANGINGMAN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhangingman_values(self): + r = CDLHANGINGMAN(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + def test_cdlhangingman_detects_pattern(self): + """Same shape as hammer but returns -100.""" + o = np.array([14.0]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([14.5]) + r = CDLHANGINGMAN(o, h, l, c) + assert r[0] == -100 + + # -- CDLHIGHWAVE ---------------------------------------------------------- + def test_cdlhighwave_length(self): + r = CDLHIGHWAVE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhighwave_values(self): + r = CDLHIGHWAVE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlhighwave_detects_pattern(self): + """Small body with very long shadows.""" + o = np.array([12.4]) + h = np.array([20.0]) + l = np.array([5.0]) + c = np.array([12.6]) # body=0.2, range=15, upper=7.6, lower=7.4 + r = CDLHIGHWAVE(o, h, l, c) + assert r[0] != 0 + + # -- CDLHIKKAKE ----------------------------------------------------------- + def test_cdlhikkake_length(self): + r = CDLHIKKAKE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhikkake_values(self): + r = CDLHIKKAKE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLHIKKAKEMOD -------------------------------------------------------- + def test_cdlhikkakemod_length(self): + r = CDLHIKKAKEMOD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhikkakemod_values(self): + r = CDLHIKKAKEMOD(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLHOMINGPIGEON ------------------------------------------------------ + def test_cdlhomingpigeon_length(self): + r = CDLHOMINGPIGEON(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlhomingpigeon_values(self): + r = CDLHOMINGPIGEON(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlhomingpigeon_detects_pattern(self): + """2 bearish candles, second entirely within first body.""" + o = np.array([20.0, 17.0]) + h = np.array([20.5, 17.5]) + l = np.array([10.0, 13.0]) + c = np.array([11.0, 14.0]) # both bearish, second within first body + r = CDLHOMINGPIGEON(o, h, l, c) + assert r[1] == 100 + + # -- CDLIDENTICAL3CROWS --------------------------------------------------- + def test_cdlidentical3crows_length(self): + r = CDLIDENTICAL3CROWS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlidentical3crows_values(self): + r = CDLIDENTICAL3CROWS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLINNECK ------------------------------------------------------------ + def test_cdlinneck_length(self): + r = CDLINNECK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlinneck_values(self): + r = CDLINNECK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLINVERTEDHAMMER ---------------------------------------------------- + def test_cdlinvertedhammer_length(self): + r = CDLINVERTEDHAMMER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlinvertedhammer_values(self): + r = CDLINVERTEDHAMMER(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlinvertedhammer_detects_pattern(self): + """Small body at bottom, long upper shadow.""" + o = np.array([10.5]) + h = np.array([15.0]) + l = np.array([10.0]) + c = np.array([11.0]) # body=0.5, upper=4.0, lower=0.5 + r = CDLINVERTEDHAMMER(o, h, l, c) + assert r[0] == 100 + + # -- CDLKICKING ----------------------------------------------------------- + def test_cdlkicking_length(self): + r = CDLKICKING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlkicking_values(self): + r = CDLKICKING(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlkicking_detects_bullish(self): + """Bearish marubozu then bullish marubozu with gap up.""" + o = np.array([15.0, 18.0]) + h = np.array([15.0, 23.0]) # bearish: open==high; bullish: close==high + l = np.array([10.0, 18.0]) # bearish: close==low; bullish: open==low + c = np.array([10.0, 23.0]) + r = CDLKICKING(o, h, l, c) + assert r[1] == 100 + + # -- CDLKICKINGBYLENGTH --------------------------------------------------- + def test_cdlkickingbylength_length(self): + r = CDLKICKINGBYLENGTH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlkickingbylength_values(self): + r = CDLKICKINGBYLENGTH(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLLADDERBOTTOM ------------------------------------------------------ + def test_cdlladderbottom_length(self): + r = CDLLADDERBOTTOM(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlladderbottom_values(self): + r = CDLLADDERBOTTOM(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLLONGLEGGEDDOJI ---------------------------------------------------- + def test_cdllongleggeddoji_length(self): + r = CDLLONGLEGGEDDOJI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdllongleggeddoji_values(self): + r = CDLLONGLEGGEDDOJI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdllongleggeddoji_detects_pattern(self): + """Doji with long upper and lower shadows.""" + o = np.array([12.5]) + h = np.array([20.0]) + l = np.array([5.0]) + c = np.array([12.5]) # body=0, range=15, doji with both long shadows + r = CDLLONGLEGGEDDOJI(o, h, l, c) + assert r[0] == 100 + + # -- CDLLONGLINE ---------------------------------------------------------- + def test_cdllongline_length(self): + r = CDLLONGLINE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdllongline_values(self): + r = CDLLONGLINE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdllongline_detects_bullish(self): + """Long body >= 70% of range.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.5]) + c = np.array([15.0]) # body=5, range=5.5 => body/range=0.91 + r = CDLLONGLINE(o, h, l, c) + assert r[0] == 100 + + # -- CDLMATCHINGLOW ------------------------------------------------------- + def test_cdlmatchinglow_length(self): + r = CDLMATCHINGLOW(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlmatchinglow_values(self): + r = CDLMATCHINGLOW(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlmatchinglow_detects_pattern(self): + """Two bearish candles with equal closes.""" + o = np.array([15.0, 14.0]) + h = np.array([15.5, 14.5]) + l = np.array([10.0, 10.0]) + c = np.array([10.0, 10.0]) # equal closes, both bearish + r = CDLMATCHINGLOW(o, h, l, c) + assert r[1] == 100 + + # -- CDLMATHOLD ----------------------------------------------------------- + def test_cdlmathold_length(self): + r = CDLMATHOLD(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlmathold_values(self): + r = CDLMATHOLD(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLONNECK ------------------------------------------------------------ + def test_cdlonneck_length(self): + r = CDLONNECK(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlonneck_values(self): + r = CDLONNECK(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLPIERCING ---------------------------------------------------------- + def test_cdlpiercing_length(self): + r = CDLPIERCING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlpiercing_values(self): + r = CDLPIERCING(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlpiercing_detects_pattern(self): + """Bearish then bullish that opens below prior low and closes above midpoint.""" + o = np.array([14.0, 9.0]) + h = np.array([15.0, 13.0]) + l = np.array([10.0, 8.5]) + c = np.array([10.5, 12.5]) # closes above midpoint of (14,10.5)=12.25 + r = CDLPIERCING(o, h, l, c) + assert r[1] == 100 + + # -- CDLRICKSHAWMAN ------------------------------------------------------- + def test_cdlrickshawman_length(self): + r = CDLRICKSHAWMAN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlrickshawman_values(self): + r = CDLRICKSHAWMAN(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLRISEFALL3METHODS -------------------------------------------------- + def test_cdlrisefall3methods_length(self): + r = CDLRISEFALL3METHODS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlrisefall3methods_values(self): + r = CDLRISEFALL3METHODS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLSEPARATINGLINES --------------------------------------------------- + def test_cdlseparatinglines_length(self): + r = CDLSEPARATINGLINES(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlseparatinglines_values(self): + r = CDLSEPARATINGLINES(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLSHORTLINE --------------------------------------------------------- + def test_cdlshortline_length(self): + r = CDLSHORTLINE(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlshortline_values(self): + r = CDLSHORTLINE(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlshortline_detects_bullish(self): + """Short bullish body <= 30% of range.""" + o = np.array([10.0]) + h = np.array([15.0]) + l = np.array([9.0]) + c = np.array([11.0]) # body=1, range=6 => body/range=0.17 + r = CDLSHORTLINE(o, h, l, c) + assert r[0] == 100 + + # -- CDLSTALLEDPATTERN ---------------------------------------------------- + def test_cdlstalledpattern_length(self): + r = CDLSTALLEDPATTERN(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlstalledpattern_values(self): + r = CDLSTALLEDPATTERN(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLSTICKSANDWICH ----------------------------------------------------- + def test_cdlsticksandwich_length(self): + r = CDLSTICKSANDWICH(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlsticksandwich_values(self): + r = CDLSTICKSANDWICH(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdlsticksandwich_detects_pattern(self): + """Bearish, bullish in middle, bearish with same close as first.""" + o = np.array([15.0, 10.5, 14.0]) + h = np.array([15.5, 14.5, 14.5]) + l = np.array([10.0, 10.0, 10.0]) + c = np.array([10.0, 14.0, 10.0]) # first and third close at 10.0 + r = CDLSTICKSANDWICH(o, h, l, c) + assert r[2] == 100 + + # -- CDLTAKURI ------------------------------------------------------------ + def test_cdltakuri_length(self): + r = CDLTAKURI(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltakuri_values(self): + r = CDLTAKURI(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + def test_cdltakuri_detects_pattern(self): + """Very long lower shadow >= 3x body, open near high.""" + o = np.array([15.0]) + h = np.array([15.2]) + l = np.array([10.0]) + c = np.array([15.1]) # body=0.1, lower=5.0, lower>=3*body + r = CDLTAKURI(o, h, l, c) + assert r[0] == 100 + + # -- CDLTASUKIGAP --------------------------------------------------------- + def test_cdltasukigap_length(self): + r = CDLTASUKIGAP(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltasukigap_values(self): + r = CDLTASUKIGAP(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLTHRUSTING --------------------------------------------------------- + def test_cdlthrusting_length(self): + r = CDLTHRUSTING(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlthrusting_values(self): + r = CDLTHRUSTING(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLTRISTAR ----------------------------------------------------------- + def test_cdltristar_length(self): + r = CDLTRISTAR(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdltristar_values(self): + r = CDLTRISTAR(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + # -- CDLUNIQUE3RIVER ------------------------------------------------------ + def test_cdlunique3river_length(self): + r = CDLUNIQUE3RIVER(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlunique3river_values(self): + r = CDLUNIQUE3RIVER(self.O, self.H, self.L, self.C) + assert all(v in (0, 100) for v in r) + + # -- CDLUPSIDEGAP2CROWS --------------------------------------------------- + def test_cdlupsidegap2crows_length(self): + r = CDLUPSIDEGAP2CROWS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlupsidegap2crows_values(self): + r = CDLUPSIDEGAP2CROWS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0) for v in r) + + # -- CDLXSIDEGAP3METHODS -------------------------------------------------- + def test_cdlxsidegap3methods_length(self): + r = CDLXSIDEGAP3METHODS(self.O, self.H, self.L, self.C) + assert len(r) == self.N + + def test_cdlxsidegap3methods_values(self): + r = CDLXSIDEGAP3METHODS(self.O, self.H, self.L, self.C) + assert all(v in (-100, 0, 100) for v in r) + + def test_cdlxsidegap3methods_detects_bullish(self): + """Upside gap three methods: gap up bullish, bearish fills gap.""" + o = np.array([10.0, 12.0, 11.5]) + h = np.array([10.5, 13.0, 12.0]) + l = np.array([9.5, 11.5, 10.0]) + c = np.array([10.0, 12.5, 10.5]) # gap up then partial fill + r = CDLXSIDEGAP3METHODS(o, h, l, c) + assert r[2] in (0, 100) # may or may not detect depending on threshold + + +# --------------------------------------------------------------------------- +# Math Operators & Math Transforms +# --------------------------------------------------------------------------- + + +class TestMathOperators: + A = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + B = np.array([2.0, 2.0, 2.0, 2.0, 2.0]) + + def test_add(self): + r = ADD(self.A, self.B) + assert np.allclose(r, [3, 4, 5, 6, 7]) + + def test_sub(self): + r = SUB(self.A, self.B) + assert np.allclose(r, [-1, 0, 1, 2, 3]) + + def test_mult(self): + r = MULT(self.A, self.B) + assert np.allclose(r, [2, 4, 6, 8, 10]) + + def test_div(self): + r = DIV(self.A, self.B) + assert np.allclose(r, [0.5, 1, 1.5, 2, 2.5]) + + def test_sum_rolling(self): + r = SUM(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 6.0) + assert math.isclose(r[3], 9.0) + assert math.isclose(r[4], 12.0) + + def test_max_rolling(self): + r = MAX(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 3.0) + assert math.isclose(r[4], 5.0) + + def test_min_rolling(self): + r = MIN(self.A, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert math.isclose(r[2], 1.0) + assert math.isclose(r[4], 3.0) + + def test_maxindex(self): + r = MAXINDEX(self.A, timeperiod=3) + assert r[0] == -1 and r[1] == -1 + assert r[2] == 2 # max at index 2 (value 3) + assert r[4] == 4 # max at index 4 (value 5) + + def test_minindex(self): + r = MININDEX(self.A, timeperiod=3) + assert r[0] == -1 and r[1] == -1 + assert r[2] == 0 # min at index 0 (value 1) + assert r[4] == 2 # min at index 2 (value 3) + + def test_sum_output_length(self): + r = SUM(self.A, timeperiod=2) + assert len(r) == len(self.A) + + def test_max_output_length(self): + r = MAX(self.A, timeperiod=2) + assert len(r) == len(self.A) + + +class TestMathTransforms: + X = np.array([0.0, 0.5, 1.0]) + POS = np.array([1.0, 2.0, 4.0]) + + def test_acos(self): + r = ACOS(self.X) + assert np.allclose(r, np.arccos(self.X)) + + def test_asin(self): + r = ASIN(self.X) + assert np.allclose(r, np.arcsin(self.X)) + + def test_atan(self): + r = ATAN(self.X) + assert np.allclose(r, np.arctan(self.X)) + + def test_ceil(self): + r = CEIL(np.array([1.1, 2.5, 3.9])) + assert np.allclose(r, [2.0, 3.0, 4.0]) + + def test_floor(self): + r = FLOOR(np.array([1.1, 2.5, 3.9])) + assert np.allclose(r, [1.0, 2.0, 3.0]) + + def test_cos(self): + r = COS(self.X) + assert np.allclose(r, np.cos(self.X)) + + def test_sin(self): + r = SIN(self.X) + assert np.allclose(r, np.sin(self.X)) + + def test_tan(self): + r = TAN(self.X) + assert np.allclose(r, np.tan(self.X)) + + def test_exp(self): + r = EXP(self.X) + assert np.allclose(r, np.exp(self.X)) + + def test_ln(self): + r = LN(self.POS) + assert np.allclose(r, np.log(self.POS)) + + def test_log10(self): + r = LOG10(self.POS) + assert np.allclose(r, np.log10(self.POS)) + + def test_sqrt(self): + r = SQRT(self.POS) + assert np.allclose(r, np.sqrt(self.POS)) + + def test_sinh(self): + r = SINH(self.X) + assert np.allclose(r, np.sinh(self.X)) + + def test_cosh(self): + r = COSH(self.X) + assert np.allclose(r, np.cosh(self.X)) + + def test_tanh(self): + r = TANH(self.X) + assert np.allclose(r, np.tanh(self.X)) + + def test_accepts_list_input(self): + r = SQRT([1.0, 4.0, 9.0]) + assert np.allclose(r, [1.0, 2.0, 3.0]) + + +# --------------------------------------------------------------------------- +# Pandas Series / DataFrame API +# --------------------------------------------------------------------------- + + +class TestPandasAPI: + """Verify that pandas.Series inputs are transparently supported.""" + + pd = pytest.importorskip("pandas") + + @pytest.fixture(autouse=True) + def prices_series(self): + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=20) + self.close_s = pd.Series(np.arange(1.0, 21.0), index=idx) + self.open_s = pd.Series(np.arange(1.0, 21.0) - 0.2, index=idx) + self.high_s = pd.Series(np.arange(1.0, 21.0) + 0.5, index=idx) + self.low_s = pd.Series(np.arange(1.0, 21.0) - 0.5, index=idx) + + def test_sma_returns_series(self): + import pandas as pd + + r = SMA(self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + + def test_sma_index_preserved(self): + r = SMA(self.close_s, timeperiod=5) + assert list(r.index) == list(self.close_s.index) + + def test_sma_values_match_numpy(self): + np_result = SMA(self.close_s.to_numpy(), timeperiod=5) + pd_result = SMA(self.close_s, timeperiod=5) + assert np.allclose(np_result, pd_result.to_numpy(), equal_nan=True) + + def test_ema_returns_series_with_index(self): + import pandas as pd + + r = EMA(self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + assert list(r.index) == list(self.close_s.index) + + def test_rsi_returns_series(self): + import pandas as pd + + long_s = pd.concat( + [ + self.close_s, + pd.Series( + np.arange(21.0, 41.0), index=pd.date_range("2024-01-21", periods=20) + ), + ] + ) + r = RSI(long_s, timeperiod=10) + assert isinstance(r, pd.Series) + assert len(r) == len(long_s) + + def test_bbands_returns_tuple_of_series(self): + import pandas as pd + + upper, mid, lower = BBANDS(self.close_s, timeperiod=5) + assert isinstance(upper, pd.Series) + assert isinstance(mid, pd.Series) + assert isinstance(lower, pd.Series) + assert list(upper.index) == list(self.close_s.index) + + def test_macd_returns_tuple_of_series(self): + import pandas as pd + + close_long = self.pd.Series(np.arange(1.0, 101.0)) + m, s, h = MACD(close_long) + assert isinstance(m, pd.Series) + assert isinstance(s, pd.Series) + assert isinstance(h, pd.Series) + + def test_pattern_returns_series(self): + import pandas as pd + + r = CDLDOJI(self.open_s, self.high_s, self.low_s, self.close_s) + assert isinstance(r, pd.Series) + assert list(r.index) == list(self.close_s.index) + + def test_atr_returns_series(self): + import pandas as pd + + r = ATR(self.high_s, self.low_s, self.close_s, timeperiod=5) + assert isinstance(r, pd.Series) + + def test_numpy_input_unaffected(self): + """Passing plain numpy arrays still returns numpy arrays.""" + arr = np.arange(1.0, 21.0) + r = SMA(arr, timeperiod=5) + assert isinstance(r, np.ndarray) + + def test_math_add_with_series(self): + import pandas as pd + + a = pd.Series([1.0, 2.0, 3.0]) + b = pd.Series([4.0, 5.0, 6.0]) + r = ADD(a, b) + assert isinstance(r, pd.Series) + assert np.allclose(r.to_numpy(), [5, 7, 9]) + + +# --------------------------------------------------------------------------- +# Pandas DataFrame OHLCV contract (get_ohlcv + configurable column names) +# --------------------------------------------------------------------------- + + +class TestPandasDataFrameOHLCV: + """DataFrame with OHLCV columns: get_ohlcv, default and custom column names, index preservation.""" + + pd = pytest.importorskip("pandas") + + @pytest.fixture(autouse=True) + def df_default_columns(self): + """DataFrame with default column names open, high, low, close, volume.""" + import pandas as pd + + n = 30 + idx = pd.date_range("2024-01-01", periods=n, freq="D") + close = np.arange(1.0, n + 1.0, dtype=float) + self.df_default = pd.DataFrame( + { + "open": close - 0.2, + "high": close + 0.5, + "low": close - 0.5, + "close": close, + "volume": np.full(n, 1000.0), + }, + index=idx, + ) + return None + + @pytest.fixture + def df_custom_columns(self): + """DataFrame with custom column names (Open, High, Low, Close).""" + import pandas as pd + + n = 30 + idx = pd.date_range("2024-02-01", periods=n, freq="D") + close = np.arange(10.0, n + 10.0, dtype=float) + return pd.DataFrame( + { + "Open": close - 0.2, + "High": close + 0.5, + "Low": close - 0.5, + "Close": close, + }, + index=idx, + ) + + def test_get_ohlcv_default_columns(self): + """get_ohlcv with default column names returns (o, h, l, c, v) with index.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + assert list(o.index) == list(self.df_default.index) + np.testing.assert_array_almost_equal(c, self.df_default["close"].to_numpy()) + + def test_get_ohlcv_custom_columns(self, df_custom_columns): + """get_ohlcv with custom column names.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv( + df_custom_columns, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col=None, + ) + assert len(c) == len(df_custom_columns) + np.testing.assert_array_almost_equal(c, df_custom_columns["Close"].to_numpy()) + + def test_dataframe_ohlcv_overlap_sma(self): + """Overlap (SMA): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = SMA(c, timeperiod=5) + r_numpy = SMA(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_momentum_rsi(self): + """Momentum (RSI): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = RSI(c, timeperiod=5) + r_numpy = RSI(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volatility_atr(self, df_custom_columns): + """Volatility (ATR): custom column names, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv( + df_custom_columns, + open_col="Open", + high_col="High", + low_col="Low", + close_col="Close", + volume_col=None, + ) + r_series = ATR(h, l, c, timeperiod=5) + r_numpy = ATR( + df_custom_columns["High"].to_numpy(), + df_custom_columns["Low"].to_numpy(), + df_custom_columns["Close"].to_numpy(), + timeperiod=5, + ) + assert list(r_series.index) == list(df_custom_columns.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_pattern(self): + """Pattern (CDLDOJI): DataFrame via get_ohlcv, index preserved.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = CDLDOJI(o, h, l, c) + r_numpy = CDLDOJI( + self.df_default["open"].to_numpy(), + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_array_equal(r_series.to_numpy(), r_numpy) + + def test_dataframe_ohlcv_cycle_ht_trendline(self): + """Cycle (HT_TRENDLINE): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + import pandas as pd + + n = 100 + idx = pd.date_range("2024-03-01", periods=n, freq="D") + close_arr = np.arange(1.0, n + 1.0, dtype=float) + df = pd.DataFrame( + { + "open": close_arr - 0.2, + "high": close_arr + 0.5, + "low": close_arr - 0.5, + "close": close_arr, + "volume": np.full(n, 1000.0), + }, + index=idx, + ) + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(df) + r_series = HT_TRENDLINE(c) + r_numpy = HT_TRENDLINE(close_arr) + assert list(r_series.index) == list(idx) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_statistic_stddev(self): + """Statistic (STDDEV): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = STDDEV(c, timeperiod=5) + r_numpy = STDDEV(self.df_default["close"].to_numpy(), timeperiod=5) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_statistic_correl(self): + """Statistic (CORREL): two Series from DataFrame, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = CORREL(c, h, timeperiod=5) + r_numpy = CORREL( + self.df_default["close"].to_numpy(), + self.df_default["high"].to_numpy(), + timeperiod=5, + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volume_ad(self): + """Volume (AD): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = AD(h, l, c, v) + r_numpy = AD( + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + self.df_default["volume"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_volume_obv(self): + """Volume (OBV): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = OBV(c, v) + r_numpy = OBV( + self.df_default["close"].to_numpy(), + self.df_default["volume"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + def test_dataframe_ohlcv_price_transform(self): + """Price transform (AVGPRICE): DataFrame via get_ohlcv, index preserved, values match NumPy.""" + from ferro_ta.utils import get_ohlcv + + o, h, l, c, v = get_ohlcv(self.df_default) + r_series = AVGPRICE(o, h, l, c) + r_numpy = AVGPRICE( + self.df_default["open"].to_numpy(), + self.df_default["high"].to_numpy(), + self.df_default["low"].to_numpy(), + self.df_default["close"].to_numpy(), + ) + assert list(r_series.index) == list(self.df_default.index) + np.testing.assert_allclose(r_series.to_numpy(), r_numpy, equal_nan=True) + + +# --------------------------------------------------------------------------- +# STOCH, STOCHRSI, ADX/DI/DM accuracy +# --------------------------------------------------------------------------- + + +class TestSTOCHAccuracy: + """STOCH SMA smoothing — basic correctness checks.""" + + def test_output_length(self): + k, d = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + assert len(k) == len(OHLCV_PRICES) + assert len(d) == len(OHLCV_PRICES) + + def test_values_in_range(self): + k, d = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(k): + assert 0.0 <= v <= 100.0, f"slowk out of range: {v}" + for v in _finite(d): + assert 0.0 <= v <= 100.0, f"slowd out of range: {v}" + + def test_warmup_nans(self): + """First fastk_period + slowk_period - 2 bars should be NaN.""" + k, _ = STOCH(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE, fastk_period=5, slowk_period=3) + warmup = 5 + 3 - 2 # = 6 + assert all(math.isnan(v) for v in k[:warmup]), "Expected NaN in warmup" + + def test_sma_smoothing(self): + """Verify SMA: slowk values are stable for constant high-close data.""" + # constant prices → fastk = 50% (close at midpoint) + n = 30 + h = np.ones(n) * 10.0 + l = np.zeros(n) + c = np.ones(n) * 5.0 # close at midpoint of range + k, d = STOCH(h, l, c, fastk_period=5, slowk_period=3, slowd_period=3) + finite_k = [v for v in k if not math.isnan(v)] + assert all(math.isclose(v, 50.0, abs_tol=1e-9) for v in finite_k), ( + f"Expected slowk=50 for close at midpoint; got {finite_k[:3]}" + ) + finite_d = [v for v in d if not math.isnan(v)] + assert all(math.isclose(v, 50.0, abs_tol=1e-9) for v in finite_d) + + +class TestSTOCHRSIAccuracy: + """STOCHRSI with SMA fastd.""" + + def test_output_length(self): + k, d = STOCHRSI(OHLCV_PRICES) + assert len(k) == len(OHLCV_PRICES) + assert len(d) == len(OHLCV_PRICES) + + def test_values_in_range(self): + prices = np.arange(1.0, 101.0) + k, d = STOCHRSI(prices, timeperiod=14, fastk_period=5, fastd_period=3) + for v in _finite(k): + assert 0.0 <= v <= 100.0 + for v in _finite(d): + assert 0.0 <= v <= 100.0 + + def test_fastd_is_sma_of_fastk(self): + """fastd[i] == mean(fastk[i-2:i+1]) for period=3.""" + prices = np.arange(1.0, 101.0) + np.sin(np.arange(100)) * 0.5 + k, d = STOCHRSI(prices, timeperiod=14, fastk_period=5, fastd_period=3) + # Find first valid fastd bar + first_d = next(i for i, v in enumerate(d) if not math.isnan(v)) + # Check SMA relationship + for i in range(first_d, len(d) - 1): + if not math.isnan(d[i]) and not any( + math.isnan(k[j]) for j in range(i - 2, i + 1) + ): + expected = (k[i] + k[i - 1] + k[i - 2]) / 3.0 + assert math.isclose(d[i], expected, rel_tol=1e-9), ( + f"SMA mismatch at {i}" + ) + break # one check is sufficient + + +class TestADXAccuracy: + """ADX/DX/+DI/-DI/PLUS_DM/MINUS_DM with TA-Lib sum-seeding.""" + + def test_adx_output_length(self): + assert len(ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + def test_adx_range(self): + r = ADX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_plus_di_range(self): + r = PLUS_DI(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_minus_di_range(self): + r = MINUS_DI(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE) + for v in _finite(r): + assert 0.0 <= v <= 100.0 + + def test_plus_dm_positive(self): + r = PLUS_DM(OHLCV_HIGH, OHLCV_LOW) + for v in _finite(r): + assert v >= 0.0 + + def test_minus_dm_positive(self): + r = MINUS_DM(OHLCV_HIGH, OHLCV_LOW) + for v in _finite(r): + assert v >= 0.0 + + def test_dx_output_length(self): + assert len(DX(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + def test_adxr_output_length(self): + assert len(ADXR(OHLCV_HIGH, OHLCV_LOW, OHLCV_CLOSE)) == len(OHLCV_PRICES) + + +# --------------------------------------------------------------------------- +# Extended Indicators (VWAP, Supertrend) +# --------------------------------------------------------------------------- + +from ferro_ta import SUPERTREND, VWAP + + +class TestVWAP: + """VWAP — Volume Weighted Average Price.""" + + H = np.array([11.0, 12.0, 13.0, 12.0, 11.0, 10.0, 9.0, 10.0, 11.0, 12.0]) + L = H - 1.0 + C = (H + L) / 2.0 + V = np.ones(10) * 1000.0 + + def test_output_length(self): + r = VWAP(self.H, self.L, self.C, self.V) + assert len(r) == len(self.H) + + def test_cumulative_no_nans(self): + """Cumulative VWAP (default) has no NaNs.""" + r = VWAP(self.H, self.L, self.C, self.V) + assert not np.any(np.isnan(r)) + + def test_cumulative_first_bar(self): + """First bar of cumulative VWAP equals its typical price.""" + r = VWAP(self.H, self.L, self.C, self.V) + tp0 = (self.H[0] + self.L[0] + self.C[0]) / 3.0 + assert math.isclose(r[0], tp0, rel_tol=1e-9) + + def test_cumulative_monotone_volume_contribution(self): + """Cumulative VWAP is bounded by min/max typical price.""" + r = VWAP(self.H, self.L, self.C, self.V) + tp = (self.H + self.L + self.C) / 3.0 + assert np.all(r >= tp.min() - 1e-9) + assert np.all(r <= tp.max() + 1e-9) + + def test_rolling_warmup_nans(self): + """Rolling VWAP has timeperiod-1 NaN values at the start.""" + r = VWAP(self.H, self.L, self.C, self.V, timeperiod=3) + assert np.isnan(r[0]) and np.isnan(r[1]) + assert not np.isnan(r[2]) + + def test_rolling_output_length(self): + r = VWAP(self.H, self.L, self.C, self.V, timeperiod=3) + assert len(r) == len(self.H) + + def test_constant_uniform_price(self): + """With uniform price and volume, VWAP == typical price.""" + n = 10 + h = np.full(n, 10.0) + l = np.full(n, 8.0) + c = np.full(n, 9.0) + v = np.full(n, 500.0) + tp = (10.0 + 8.0 + 9.0) / 3.0 + r = VWAP(h, l, c, v) + assert np.allclose(r, tp) + + +class TestSUPERTREND: + """Supertrend ATR-based trend indicator.""" + + N = 20 + H = np.array( + [10.0 + i * 0.5 if i < 10 else 15.0 - (i - 10) * 0.5 for i in range(N)] + ) + L = H - 1.0 + C = (H + L) / 2.0 + + def test_output_shape(self): + st, direction = SUPERTREND(self.H, self.L, self.C) + assert len(st) == len(self.H) + assert len(direction) == len(self.H) + + def test_warmup_nans(self): + """First timeperiod bars in supertrend should be NaN.""" + st, _ = SUPERTREND(self.H, self.L, self.C, timeperiod=7) + assert all(np.isnan(st[i]) for i in range(7)) + + def test_direction_values(self): + """Direction should only be -1, 0, or 1.""" + _, direction = SUPERTREND(self.H, self.L, self.C) + assert all(d in (-1, 0, 1) for d in direction) + + def test_direction_matches_price_vs_supertrend(self): + """When direction=1 (uptrend), close > supertrend.""" + st, direction = SUPERTREND(self.H, self.L, self.C) + for i in range(len(self.H)): + if direction[i] == 1: + assert self.C[i] > st[i] - 1e-9, ( + f"At {i}: close={self.C[i]}, st={st[i]}" + ) + elif direction[i] == -1: + assert self.C[i] < st[i] + 1e-9, ( + f"At {i}: close={self.C[i]}, st={st[i]}" + ) + + def test_supertrend_positive(self): + """Supertrend values should be positive.""" + st, _ = SUPERTREND(self.H, self.L, self.C) + for v in st[~np.isnan(st)]: + assert v > 0.0 + + def test_custom_multiplier(self): + """Higher multiplier widens bands → same trend can persist longer.""" + _, d1 = SUPERTREND(self.H, self.L, self.C, multiplier=1.0) + _, d2 = SUPERTREND(self.H, self.L, self.C, multiplier=5.0) + # Just check they both produce valid outputs + assert all(d in (-1, 0, 1) for d in d1) + assert all(d in (-1, 0, 1) for d in d2) + + def test_pandas_series_input(self): + """Accepts pandas Series and returns Series.""" + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=self.N) + h_s = pd.Series(self.H, index=idx) + l_s = pd.Series(self.L, index=idx) + c_s = pd.Series(self.C, index=idx) + st, direction = SUPERTREND(h_s, l_s, c_s) + assert isinstance(st, pd.Series) + assert isinstance(direction, pd.Series) + assert list(st.index) == list(idx) + + +# --------------------------------------------------------------------------- +# Streaming / Incremental API +# --------------------------------------------------------------------------- + +from ferro_ta.data.streaming import ( + StreamingATR, + StreamingBBands, + StreamingEMA, + StreamingMACD, + StreamingRSI, + StreamingSMA, + StreamingStoch, + StreamingSupertrend, + StreamingVWAP, +) + + +class TestStreamingSMA: + def test_warmup_nans(self): + sma = StreamingSMA(3) + assert math.isnan(sma.update(1.0)) + assert math.isnan(sma.update(2.0)) + + def test_first_valid(self): + sma = StreamingSMA(3) + sma.update(1.0) + sma.update(2.0) + v = sma.update(3.0) + assert math.isclose(v, 2.0) + + def test_rolling(self): + sma = StreamingSMA(3) + [sma.update(x) for x in [1.0, 2.0, 3.0]] + v = sma.update(4.0) + assert math.isclose(v, 3.0) + + def test_matches_batch_sma(self): + import ferro_ta + + data = np.arange(1.0, 21.0) + batch = ferro_ta.SMA(data, timeperiod=5) + stream_sma = StreamingSMA(5) + for i, x in enumerate(data): + sv = stream_sma.update(x) + if not math.isnan(batch[i]): + assert math.isclose(sv, batch[i], rel_tol=1e-9) + + def test_reset(self): + sma = StreamingSMA(3) + [sma.update(x) for x in [1.0, 2.0, 3.0]] + sma.reset() + assert math.isnan(sma.update(1.0)) + + def test_period_1(self): + sma = StreamingSMA(1) + v = sma.update(42.0) + assert math.isclose(v, 42.0) + + +class TestStreamingEMA: + def test_warmup_nans(self): + ema = StreamingEMA(5) + for _ in range(4): + assert math.isnan(ema.update(1.0)) + + def test_first_valid(self): + ema = StreamingEMA(3) + ema.update(1.0) + ema.update(2.0) + v = ema.update(3.0) + assert math.isclose(v, 2.0) + + def test_matches_batch_ema(self): + """StreamingEMA (SMA-seeded) and batch EMA converge after enough bars.""" + import ferro_ta + + # Oscillating data helps convergence independent of seed + data = np.array([50.0 + 10.0 * math.sin(i * 0.3) for i in range(100)]) + period = 5 + batch = ferro_ta.EMA(data, timeperiod=period) + stream_ema = StreamingEMA(period) + converge_bar = period * 6 # allow seed to wash out fully + for i, x in enumerate(data): + sv = stream_ema.update(x) + if i >= converge_bar and not math.isnan(batch[i]): + # Allow 0.1% relative tolerance after convergence + assert math.isclose(sv, batch[i], rel_tol=1e-3), ( + f"i={i}: {sv} != {batch[i]}" + ) + + def test_reset(self): + ema = StreamingEMA(3) + [ema.update(x) for x in [1.0, 2.0, 3.0]] + ema.reset() + assert math.isnan(ema.update(1.0)) + + +class TestStreamingRSI: + def test_warmup(self): + rsi = StreamingRSI(14) + for _ in range(14): + assert math.isnan(rsi.update(50.0)) + + def test_constant_series_not_nan(self): + """Constant prices: RSI is defined (gain=0, loss=0 → special case).""" + rsi = StreamingRSI(5) + last = float("nan") + for _ in range(10): + last = rsi.update(100.0) + # With all gains=0 and losses=0, RSI returns 100 (avg_loss==0 branch) + # This is acceptable behavior for degenerate input. + assert not math.isnan(last) + + def test_always_rising_near_100(self): + rsi = StreamingRSI(5) + last = float("nan") + for i in range(20): + last = rsi.update(float(i)) + assert not math.isnan(last) and last > 90.0 + + def test_range(self): + rsi = StreamingRSI(5) + vals = [ + rsi.update(float(v)) + for v in [1.0, 2.0, 1.0, 3.0, 1.0, 4.0, 1.0, 5.0, 1.0, 6.0] + ] + for v in vals: + if not math.isnan(v): + assert 0.0 <= v <= 100.0 + + def test_reset(self): + rsi = StreamingRSI(3) + [rsi.update(x) for x in [1.0, 2.0, 3.0, 4.0]] + rsi.reset() + assert math.isnan(rsi.update(1.0)) + + +class TestStreamingATR: + def test_warmup(self): + atr = StreamingATR(3) + assert math.isnan(atr.update(11.0, 9.0, 10.0)) + assert math.isnan(atr.update(12.0, 10.0, 11.0)) + assert math.isnan(atr.update(13.0, 11.0, 12.0)) + + def test_positive(self): + atr = StreamingATR(3) + vals = [ + atr.update(h, l, c) + for h, l, c in [ + (11.0, 9.0, 10.0), + (12.0, 10.0, 11.0), + (13.0, 11.0, 12.0), + (14.0, 12.0, 13.0), + (15.0, 13.0, 14.0), + ] + ] + for v in vals: + if not math.isnan(v): + assert v > 0 + + def test_constant_range(self): + """With constant HL spread of 2 and no gaps, ATR converges to 2.""" + atr = StreamingATR(5) + h, l = 11.0, 9.0 + c = 10.0 + last = float("nan") + for _ in range(50): + last = atr.update(h, l, c) + assert math.isclose(last, 2.0, abs_tol=0.01) + + def test_reset(self): + atr = StreamingATR(3) + [ + atr.update(h, l, c) + for h, l, c in [(11.0, 9.0, 10.0), (12.0, 10.0, 11.0), (13.0, 11.0, 12.0)] + ] + atr.reset() + assert math.isnan(atr.update(11.0, 9.0, 10.0)) + + +class TestStreamingBBands: + def test_warmup(self): + bb = StreamingBBands(5) + for _ in range(4): + u, m, l = bb.update(10.0) + assert all(math.isnan(x) for x in [u, m, l]) + + def test_structure(self): + bb = StreamingBBands(3) + for _ in range(3): + u, m, l = bb.update(10.0) + assert u >= m >= l + + def test_constant_price(self): + """Constant price → std=0, all three bands equal to price.""" + bb = StreamingBBands(5) + u = m = l = float("nan") + for _ in range(20): + u, m, l = bb.update(42.0) + assert math.isclose(u, 42.0, abs_tol=1e-9) + assert math.isclose(m, 42.0, abs_tol=1e-9) + assert math.isclose(l, 42.0, abs_tol=1e-9) + + +class TestStreamingMACD: + def test_warmup(self): + macd = StreamingMACD() + for _ in range(25): + ml, s, h = macd.update(100.0) + # slowperiod=26, so at bar 25 (0-indexed) MACD line may not yet be valid + # (seeded after 26 bars). At this point both ml and s could still be NaN. + # Just verify they are floats. + assert isinstance(ml, float) and isinstance(s, float) and isinstance(h, float) + + def test_returns_three(self): + macd = StreamingMACD() + result = macd.update(100.0) + assert len(result) == 3 + + def test_histogram_equals_macd_minus_signal(self): + macd = StreamingMACD(fastperiod=3, slowperiod=6, signalperiod=2) + for _ in range(20): + ml, s, h = macd.update(float(_ + 1)) + if not math.isnan(ml) and not math.isnan(s): + assert math.isclose(h, ml - s, rel_tol=1e-9) + + def test_reset(self): + macd = StreamingMACD(fastperiod=3, slowperiod=6, signalperiod=2) + [macd.update(x) for x in np.arange(1.0, 20.0)] + macd.reset() + ml, s, h = macd.update(1.0) + assert math.isnan(ml) + + +class TestStreamingStoch: + def test_warmup(self): + stoch = StreamingStoch(5, 3, 3) + for _ in range(7): + k, d = stoch.update(10.0, 9.0, 9.5) + # k should be valid, d still might be NaN + assert isinstance(k, float) and isinstance(d, float) + + def test_range(self): + stoch = StreamingStoch(5, 3, 3) + for _ in range(20): + k, d = stoch.update(float(_ + 1), float(_), float(_ + 0.5)) + if not math.isnan(k): + assert 0 <= k <= 100 + + +class TestStreamingVWAP: + def test_cumulative(self): + vwap = StreamingVWAP() + v1 = vwap.update(11.0, 9.0, 10.0, 1000.0) + assert math.isclose(v1, (11.0 + 9.0 + 10.0) / 3.0) + + def test_always_valid(self): + vwap = StreamingVWAP() + for i in range(5): + v = vwap.update(10.0 + i, 9.0 + i, 9.5 + i, 1000.0) + assert not math.isnan(v) + + def test_reset(self): + vwap = StreamingVWAP() + vwap.update(11.0, 9.0, 10.0, 1000.0) + vwap.reset() + v = vwap.update(20.0, 18.0, 19.0, 500.0) + assert math.isclose(v, (20.0 + 18.0 + 19.0) / 3.0) + + +class TestStreamingSupertrend: + def test_warmup_nans(self): + st = StreamingSupertrend(3) + for _ in range(3): + line, d = st.update(10.0, 9.0, 9.5) + # First 3 bars: ATR warming up + # By bar 4 it should be valid + line, d = st.update(11.0, 10.0, 10.5) + assert not math.isnan(line) + assert d in (-1, 0, 1) + + def test_direction_values(self): + st = StreamingSupertrend(3) + for i in range(20): + line, d = st.update(10.0 + i * 0.5, 9.0 + i * 0.5, 9.5 + i * 0.5) + assert d in (-1, 1) + + def test_reset(self): + st = StreamingSupertrend(3) + [st.update(10.0 + i, 9.0 + i, 9.5 + i) for i in range(10)] + st.reset() + line, d = st.update(10.0, 9.0, 9.5) + assert d == 0 # warmup + + +# --------------------------------------------------------------------------- +# Additional Extended Indicators (ICHIMOKU, DONCHIAN, PIVOT_POINTS) +# --------------------------------------------------------------------------- + +from ferro_ta import DONCHIAN, ICHIMOKU, PIVOT_POINTS + + +class TestICHIMOKU: + N = 80 + H = np.arange(10.0, 10.0 + N) + np.sin(np.arange(N)) * 0.5 + L = H - 1.5 + C = (H + L) / 2.0 + + def test_output_shapes(self): + t, k, sa, sb, ch = ICHIMOKU(self.H, self.L, self.C) + for arr in (t, k, sa, sb, ch): + assert len(arr) == self.N + + def test_tenkan_warmup(self): + t, *_ = ICHIMOKU(self.H, self.L, self.C, tenkan_period=9) + assert all(np.isnan(t[:8])) + assert not np.isnan(t[8]) + + def test_kijun_warmup(self): + _, k, *_ = ICHIMOKU(self.H, self.L, self.C, kijun_period=26) + assert all(np.isnan(k[:25])) + assert not np.isnan(k[25]) + + def test_tenkan_is_midpoint(self): + t, *_ = ICHIMOKU(self.H, self.L, self.C, tenkan_period=5) + for i in range(4, self.N): + expected = (self.H[i - 4 : i + 1].max() + self.L[i - 4 : i + 1].min()) / 2.0 + assert math.isclose(t[i], expected, rel_tol=1e-9) + + def test_chikou_is_shifted_close(self): + *_, ch = ICHIMOKU(self.H, self.L, self.C, displacement=26) + # chikou[26:] == close[0 : N-26] + for i in range(26, self.N): + assert math.isclose(ch[i], self.C[i - 26], rel_tol=1e-9) + + def test_pandas_output(self): + import pandas as pd + + idx = pd.date_range("2024-01-01", periods=self.N) + h_s = pd.Series(self.H, index=idx) + l_s = pd.Series(self.L, index=idx) + c_s = pd.Series(self.C, index=idx) + t, k, sa, sb, ch = ICHIMOKU(h_s, l_s, c_s) + for s in (t, k, sa, sb, ch): + assert isinstance(s, pd.Series) + + +class TestDONCHIAN: + N = 30 + H = np.arange(1.0, N + 1.0) + L = np.zeros(N) + + def test_output_shape(self): + u, m, lo = DONCHIAN(self.H, self.L, 10) + for arr in (u, m, lo): + assert len(arr) == self.N + + def test_warmup_nans(self): + u, m, lo = DONCHIAN(self.H, self.L, 10) + for arr in (u, m, lo): + assert all(np.isnan(arr[:9])) + assert not np.isnan(arr[9]) + + def test_upper_is_max_high(self): + u, _, _ = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + assert math.isclose(u[i], self.H[i - 4 : i + 1].max(), rel_tol=1e-9) + + def test_lower_is_min_low(self): + _, _, lo = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + assert math.isclose(lo[i], self.L[i - 4 : i + 1].min(), rel_tol=1e-9) + + def test_middle_is_avg(self): + u, m, lo = DONCHIAN(self.H, self.L, 5) + for i in range(4, self.N): + if not np.isnan(u[i]): + assert math.isclose(m[i], (u[i] + lo[i]) / 2.0, rel_tol=1e-9) + + def test_monotone_upper(self): + """With monotone-increasing H, upper band is non-decreasing.""" + u, _, _ = DONCHIAN(self.H, self.L, 5) + valid = u[~np.isnan(u)] + assert all(valid[i] <= valid[i + 1] for i in range(len(valid) - 1)) + + +class TestPIVOT_POINTS: + N = 10 + H = np.array([12.0, 13.0, 14.0, 13.0, 12.0, 11.0, 12.0, 13.0, 14.0, 15.0]) + L = H - 2.0 + C = H - 1.0 + + def test_output_shape(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C) + for arr in (p, r1, s1, r2, s2): + assert len(arr) == self.N + + def test_first_bar_nan(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C) + for arr in (p, r1, s1, r2, s2): + assert np.isnan(arr[0]) + + def test_classic_pivot_formula(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="classic") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + expected_p = (ph + pl + pc) / 3.0 + assert math.isclose(p[i], expected_p, rel_tol=1e-9) + assert math.isclose(r1[i], 2 * expected_p - pl, rel_tol=1e-9) + assert math.isclose(s1[i], 2 * expected_p - ph, rel_tol=1e-9) + + def test_fibonacci_method(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="fibonacci") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + pp = (ph + pl + pc) / 3.0 + hl = ph - pl + assert math.isclose(r1[i], pp + 0.382 * hl, rel_tol=1e-9) + assert math.isclose(s1[i], pp - 0.382 * hl, rel_tol=1e-9) + + def test_camarilla_method(self): + p, r1, s1, r2, s2 = PIVOT_POINTS(self.H, self.L, self.C, method="camarilla") + for i in range(1, self.N): + ph, pl, pc = self.H[i - 1], self.L[i - 1], self.C[i - 1] + hl = ph - pl + assert math.isclose(r1[i], pc + 1.1 * hl / 12.0, rel_tol=1e-9) + + def test_invalid_method_raises(self): + import pytest + + with pytest.raises(ValueError, match="Unknown pivot method"): + PIVOT_POINTS(self.H, self.L, self.C, method="unknown") + + def test_r1_gt_pivot_gt_s1(self): + p, r1, s1, _, _ = PIVOT_POINTS(self.H, self.L, self.C, method="classic") + for i in range(1, self.N): + if not np.isnan(p[i]): + assert r1[i] > p[i] > s1[i] + + +# --------------------------------------------------------------------------- +# New Extended Indicators (KELTNER_CHANNELS, HULL_MA, +# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +# --------------------------------------------------------------------------- + +from ferro_ta import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + HULL_MA, + KELTNER_CHANNELS, + VWMA, +) + + +class TestKELTNER_CHANNELS: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_shapes(self): + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + assert len(u) == len(m) == len(lo) == self.N + + def test_upper_gt_middle_gt_lower(self): + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + valid = ~np.isnan(u) + assert np.all(u[valid] > m[valid]) + assert np.all(m[valid] > lo[valid]) + + def test_middle_is_ema(self): + from ferro_ta import EMA + + u, m, lo = KELTNER_CHANNELS(self.H, self.L, self.C, timeperiod=5, atr_period=3) + ema = EMA(self.C, timeperiod=5) + valid = ~np.isnan(m) & ~np.isnan(ema) + assert np.allclose(m[valid], ema[valid], rtol=1e-9) + + +class TestHULL_MA: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + + def test_output_length(self): + hull = HULL_MA(self.C, timeperiod=4) + assert len(hull) == self.N + + def test_leading_nans(self): + hull = HULL_MA(self.C, timeperiod=4) + assert int(np.sum(np.isnan(hull))) >= 1 + + def test_finite_after_warmup(self): + hull = HULL_MA(self.C, timeperiod=4) + assert np.all(np.isfinite(hull[~np.isnan(hull)])) + + def test_linear_series_tracks_input(self): + """For a perfectly linear series, HMA should be close to close.""" + c = np.arange(1.0, 31.0) + hull = HULL_MA(c, timeperiod=4) + valid = ~np.isnan(hull) + # Should be within 5% of actual price + assert np.all(np.abs(hull[valid] - c[valid]) < c[valid] * 0.05) + + +class TestCHANDELIER_EXIT: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_shapes(self): + le, se = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + assert len(le) == len(se) == self.N + + def test_long_lt_highest_high(self): + le, _ = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + valid = ~np.isnan(le) + # long exit must be below the local highest high + from ferro_ta import MAX + + hh = MAX(self.H, timeperiod=5) + assert np.all(le[valid] <= hh[valid]) + + def test_short_gt_lowest_low(self): + _, se = CHANDELIER_EXIT(self.H, self.L, self.C, timeperiod=5, multiplier=2.0) + valid = ~np.isnan(se) + from ferro_ta import MIN + + ll = MIN(self.L, timeperiod=5) + assert np.all(se[valid] >= ll[valid]) + + +class TestVWMA: + N = 20 + C = np.full(N, 50.0) + V = np.full(N, 1_000.0) + + def test_output_length(self): + v = VWMA(self.C, self.V, timeperiod=5) + assert len(v) == self.N + + def test_leading_nans(self): + v = VWMA(self.C, self.V, timeperiod=5) + assert int(np.sum(np.isnan(v))) == 4 + + def test_constant_price_equals_price(self): + """When price is constant, VWMA == price regardless of volume.""" + v = VWMA(self.C, self.V, timeperiod=5) + valid = ~np.isnan(v) + assert np.allclose(v[valid], 50.0, rtol=1e-9) + + def test_weighted_by_volume(self): + """Higher volume at a price bar should pull VWMA toward that price.""" + close = np.array([10.0] * 5 + [20.0]) + vol = np.array([1.0] * 5 + [100.0]) + v = VWMA(close, vol, timeperiod=6) + assert v[-1] > 19.0 # strongly weighted toward 20.0 + + +class TestCHOPPINESS_INDEX: + N = 30 + C = np.cumsum(np.ones(N)) + 40.0 + H = C + 0.5 + L = C - 0.5 + + def test_output_length(self): + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + assert len(ci) == self.N + + def test_leading_nans(self): + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + assert np.sum(~np.isnan(ci)) <= self.N - 5 + + def test_range_0_to_100(self): + """Choppiness Index should be in (0, 100].""" + ci = CHOPPINESS_INDEX(self.H, self.L, self.C, timeperiod=5) + valid = ci[~np.isnan(ci)] + if len(valid) > 0: + assert np.all(valid >= 0.0) + assert np.all(valid <= 100.0) + + def test_trending_market_lower_than_choppy(self): + """A strong trend should have lower CI than a sideways market.""" + # Trending: monotone rise + trend_c = np.arange(1.0, 31.0) + trend_h = trend_c + 0.1 + trend_l = trend_c - 0.1 + ci_trend = CHOPPINESS_INDEX(trend_h, trend_l, trend_c, timeperiod=14) + + # Choppy: alternating + chop_c = np.array([50.0 + ((-1) ** i) * 1.0 for i in range(30)]) + chop_h = chop_c + 0.1 + chop_l = chop_c - 0.1 + ci_chop = CHOPPINESS_INDEX(chop_h, chop_l, chop_c, timeperiod=14) + + valid_t = ci_trend[~np.isnan(ci_trend)] + valid_c = ci_chop[~np.isnan(ci_chop)] + if len(valid_t) > 0 and len(valid_c) > 0: + assert np.mean(valid_t) < np.mean(valid_c) diff --git a/vendor/ferro-ta-main/tests/unit/test_infrastructure.py b/vendor/ferro-ta-main/tests/unit/test_infrastructure.py new file mode 100644 index 0000000..c7bf687 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_infrastructure.py @@ -0,0 +1,1060 @@ +"""Tests for exceptions, backtest, registry, release playbook, GPU backend, WASM.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import ferro_ta + +# --------------------------------------------------------------------------- +# Exception model & validation +# --------------------------------------------------------------------------- +from ferro_ta.core.exceptions import ( + FerroTAError, + FerroTAInputError, + FerroTAValueError, + check_equal_length, + check_finite, + check_timeperiod, +) + + +class TestExceptionHierarchy: + """FerroTAError hierarchy and isinstance relationships.""" + + def test_ferro_ta_error_is_exception(self): + assert issubclass(FerroTAError, Exception) + + def test_value_error_is_base_and_value_error(self): + assert issubclass(FerroTAValueError, FerroTAError) + assert issubclass(FerroTAValueError, ValueError) + + def test_input_error_is_base_and_value_error(self): + assert issubclass(FerroTAInputError, FerroTAError) + assert issubclass(FerroTAInputError, ValueError) + + def test_exported_from_ferro_ta(self): + assert ferro_ta.FerroTAError is FerroTAError + assert ferro_ta.FerroTAValueError is FerroTAValueError + assert ferro_ta.FerroTAInputError is FerroTAInputError + + +class TestCheckTimeperiod: + """check_timeperiod raises FerroTAValueError with clear message.""" + + def test_valid_timeperiod_does_not_raise(self): + check_timeperiod(1) + check_timeperiod(14) + check_timeperiod(100) + + def test_zero_raises_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1, got 0"): + check_timeperiod(0) + + def test_negative_raises_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError) as exc_info: + check_timeperiod(-5, name="timeperiod") + assert "timeperiod" in str(exc_info.value) + assert "-5" in str(exc_info.value) + + def test_custom_name_in_message(self): + with pytest.raises(FerroTAValueError, match="fastperiod"): + check_timeperiod(0, name="fastperiod") + + def test_custom_minimum(self): + with pytest.raises(FerroTAValueError, match=">= 2"): + check_timeperiod(1, minimum=2) + + +class TestCheckEqualLength: + """check_equal_length raises FerroTAInputError for mismatched arrays.""" + + def test_equal_lengths_pass(self): + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0, 6.0]) + check_equal_length(open=a, close=b) # no exception + + def test_mismatched_lengths_raise(self): + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0]) + with pytest.raises(FerroTAInputError) as exc_info: + check_equal_length(open=a, close=b) + # message must mention the lengths + msg = str(exc_info.value) + assert "3" in msg + assert "2" in msg + + def test_three_arrays_all_different(self): + with pytest.raises(FerroTAInputError): + check_equal_length( + open=np.array([1.0]), + high=np.array([1.0, 2.0]), + close=np.array([1.0, 2.0, 3.0]), + ) + + +class TestCheckFinite: + """check_finite raises FerroTAInputError for NaN/Inf.""" + + def test_all_finite_passes(self): + check_finite(np.array([1.0, 2.0, 3.0])) + + def test_nan_raises(self): + with pytest.raises(FerroTAInputError, match="NaN or Inf"): + check_finite(np.array([1.0, float("nan"), 3.0])) + + def test_inf_raises(self): + with pytest.raises(FerroTAInputError, match="NaN or Inf"): + check_finite(np.array([1.0, float("inf"), 3.0])) + + def test_name_in_message(self): + with pytest.raises(FerroTAInputError, match="myarray"): + check_finite(np.array([float("nan")]), name="myarray") + + +# --------------------------------------------------------------------------- +# Backtesting utilities +# --------------------------------------------------------------------------- + +from ferro_ta.analysis.backtest import ( + BacktestResult, + backtest, + macd_crossover_strategy, + rsi_strategy, + sma_crossover_strategy, +) + + +def _make_close(n: int = 50, seed: int = 42) -> np.ndarray: + rng = np.random.default_rng(seed) + returns = rng.normal(0.001, 0.01, n) + return np.cumprod(1 + returns) * 100.0 + + +class TestRsiStrategy: + """rsi_strategy returns correct signal arrays.""" + + def test_output_shape(self): + close = _make_close(50) + signals = rsi_strategy(close, timeperiod=5) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(50) + signals = rsi_strategy(close, timeperiod=5) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 0.0, 1.0}) + + def test_nan_during_warmup(self): + close = _make_close(20) + signals = rsi_strategy(close, timeperiod=5) + # First 5 values should be NaN (RSI warm-up) + assert np.all(np.isnan(signals[:5])) + + def test_invalid_timeperiod(self): + with pytest.raises(FerroTAValueError): + rsi_strategy(_make_close(10), timeperiod=0) + + +class TestSmaCrossoverStrategy: + """sma_crossover_strategy returns signals when fast < slow.""" + + def test_output_shape(self): + close = _make_close(60) + signals = sma_crossover_strategy(close, fast=5, slow=20) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(60) + signals = sma_crossover_strategy(close, fast=5, slow=20) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 1.0}) + + def test_fast_must_be_less_than_slow(self): + with pytest.raises(FerroTAValueError): + sma_crossover_strategy(_make_close(60), fast=20, slow=10) + + +class TestMacdCrossoverStrategy: + """macd_crossover_strategy returns signals from MACD line vs signal line.""" + + def test_output_shape(self): + close = _make_close(100) + signals = macd_crossover_strategy( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + assert signals.shape == close.shape + + def test_only_valid_signal_values(self): + close = _make_close(100) + signals = macd_crossover_strategy( + close, fastperiod=12, slowperiod=26, signalperiod=9 + ) + finite = signals[np.isfinite(signals)] + assert set(finite).issubset({-1.0, 1.0}) + + def test_fastperiod_must_be_less_than_slowperiod(self): + with pytest.raises(FerroTAValueError): + macd_crossover_strategy(_make_close(60), fastperiod=26, slowperiod=12) + + +class TestBacktest: + """backtest() produces correct BacktestResult.""" + + def test_rsi_strategy_runs(self): + close = _make_close(100) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + assert isinstance(result, BacktestResult) + + def test_output_lengths_match_input(self): + close = _make_close(80) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + n = len(close) + assert len(result.signals) == n + assert len(result.positions) == n + assert len(result.equity) == n + + def test_equity_starts_near_one(self): + close = _make_close(50) + result = backtest(close, strategy="rsi_30_70", timeperiod=5) + assert abs(result.equity[0] - 1.0) < 0.01 + + def test_sma_crossover_strategy_runs(self): + close = _make_close(80) + result = backtest(close, strategy="sma_crossover", fast=5, slow=20) + assert isinstance(result, BacktestResult) + assert result.n_trades >= 0 + + def test_custom_callable_strategy(self): + def my_strategy(close, **_): + signals = np.zeros(len(close)) + signals[len(close) // 2 :] = 1.0 + return signals + + close = _make_close(40) + result = backtest(close, strategy=my_strategy) + assert isinstance(result, BacktestResult) + assert len(result.signals) == len(close) + + def test_unknown_strategy_raises(self): + with pytest.raises(FerroTAValueError, match="Unknown strategy"): + backtest(_make_close(30), strategy="nonexistent") + + def test_too_short_input_raises(self): + with pytest.raises(FerroTAInputError): + backtest(np.array([1.0])) + + def test_non_1d_input_raises(self): + with pytest.raises(FerroTAInputError): + backtest(np.array([[1.0, 2.0], [3.0, 4.0]])) + + def test_n_trades_is_integer(self): + close = _make_close(60) + result = backtest(close, strategy="sma_crossover", fast=5, slow=15) + assert isinstance(result.n_trades, int) + assert result.n_trades >= 0 + + def test_macd_crossover_strategy_runs(self): + close = _make_close(100) + result = backtest( + close, + strategy="macd_crossover", + fastperiod=12, + slowperiod=26, + signalperiod=9, + ) + assert isinstance(result, BacktestResult) + assert len(result.equity) == len(close) + + def test_commission_reduces_equity(self): + close = _make_close(80) + result_no_comm = backtest(close, strategy="sma_crossover", fast=5, slow=20) + result_with_comm = backtest( + close, + strategy="sma_crossover", + fast=5, + slow=20, + commission_per_trade=0.01, + ) + assert result_with_comm.final_equity <= result_no_comm.final_equity + assert result_with_comm.final_equity < result_no_comm.final_equity or ( + result_no_comm.n_trades == 0 + ) + + def test_slippage_reduces_equity(self): + close = _make_close(80) + result_no_slip = backtest(close, strategy="sma_crossover", fast=5, slow=20) + result_with_slip = backtest( + close, + strategy="sma_crossover", + fast=5, + slow=20, + slippage_bps=10.0, + ) + assert result_with_slip.final_equity <= result_no_slip.final_equity + assert result_with_slip.final_equity < result_no_slip.final_equity or ( + result_no_slip.n_trades == 0 + ) + + def test_commission_matches_reference_loop(self): + from ferro_ta._ferro_ta import CommissionModel + + from ferro_ta.analysis.backtest import BacktestEngine + + close = np.array([100.0, 102.0, 101.0, 104.0, 103.0, 105.0], dtype=np.float64) + raw_signals = np.array([0.0, 1.0, 1.0, -1.0, -1.0, 0.0], dtype=np.float64) + + def strategy(_, **__): + return raw_signals + + initial_capital = 100_000.0 + cm = CommissionModel.proportional(0.001) # 0.1% proportional commission + + result = ( + BacktestEngine() + .with_commission_model(cm) + .with_initial_capital(initial_capital) + .run(close, strategy=strategy) + ) + + expected_positions = np.array( + [0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64 + ) + np.testing.assert_allclose(result.positions, expected_positions) + # With commission, final equity should be less than without + result_no_comm = ( + BacktestEngine() + .with_initial_capital(initial_capital) + .run(close, strategy=strategy) + ) + assert result.final_equity <= result_no_comm.final_equity + + +# --------------------------------------------------------------------------- +# Plugin / Registry +# --------------------------------------------------------------------------- + +from ferro_ta.core.registry import ( + FerroTARegistryError, + get, + list_indicators, + register, + run, + unregister, +) + + +class TestRegistry: + """Registry: register, get, run, unregister, list_indicators.""" + + def test_builtins_registered(self): + names = list_indicators() + assert "SMA" in names + assert "RSI" in names + assert "EMA" in names + assert "ATR" in names + + def test_run_builtin_sma(self): + close = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = run("SMA", close, timeperiod=3) + # SMA(3) of [1,2,3,4,5]: valid at indices 2,3,4 + assert result.shape == (5,) + assert np.isnan(result[0]) + assert abs(float(result[2]) - 2.0) < 1e-8 + + def test_run_builtin_rsi(self): + 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, + ] + ) + result = run("RSI", close, timeperiod=14) + assert result.shape == (15,) + + def test_get_returns_callable(self): + fn = get("EMA") + assert callable(fn) + + def test_register_custom_indicator(self): + def DOUBLE_SMA(close, timeperiod=5): + return close * 2.0 + + register("DOUBLE_SMA", DOUBLE_SMA) + try: + close = np.array([1.0, 2.0, 3.0]) + result = run("DOUBLE_SMA", close, timeperiod=2) + np.testing.assert_array_equal(result, np.array([2.0, 4.0, 6.0])) + finally: + unregister("DOUBLE_SMA") + + def test_unregister_removes_indicator(self): + def TEMP_IND(close): + return close + + register("TEMP_IND", TEMP_IND) + assert "TEMP_IND" in list_indicators() + unregister("TEMP_IND") + assert "TEMP_IND" not in list_indicators() + + def test_unknown_indicator_raises(self): + with pytest.raises(FerroTARegistryError): + get("UNKNOWN_INDICATOR_XYZ") + + def test_run_unknown_indicator_raises(self): + with pytest.raises(FerroTARegistryError): + run("NO_SUCH_IND", np.array([1.0, 2.0])) + + def test_unregister_unknown_raises(self): + with pytest.raises(FerroTARegistryError): + unregister("NEVER_REGISTERED") + + def test_register_non_callable_raises(self): + with pytest.raises(TypeError): + register("BAD", 42) # type: ignore[arg-type] + + def test_list_indicators_is_sorted(self): + names = list_indicators() + assert names == sorted(names) + + def test_all_builtins_are_callable(self): + for name in list_indicators(): + fn = get(name) + assert callable(fn), f"{name} is not callable" + + +# --------------------------------------------------------------------------- +# New Extended Indicators (KELTNER_CHANNELS, HULL_MA, +# CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX) +# --------------------------------------------------------------------------- + +from ferro_ta import ( + CHANDELIER_EXIT, + CHOPPINESS_INDEX, + HULL_MA, + KELTNER_CHANNELS, + VWMA, +) + +_N = 30 +_C = np.cumsum(np.ones(_N)) + 40.0 +_H = _C + 0.5 +_L = _C - 0.5 +_V = np.full(_N, 1_000_000.0) + + +class TestKeltnerChannels: + def test_output_shapes(self): + u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3) + assert len(u) == len(m) == len(lo) == _N + + def test_upper_gt_middle_gt_lower(self): + u, m, lo = KELTNER_CHANNELS(_H, _L, _C, timeperiod=5, atr_period=3) + valid = ~np.isnan(u) + assert np.all(u[valid] > m[valid]) + assert np.all(m[valid] > lo[valid]) + + +class TestHullMA: + def test_output_length(self): + hull = HULL_MA(_C, timeperiod=4) + assert len(hull) == _N + + def test_leading_nans(self): + hull = HULL_MA(_C, timeperiod=4) + assert int(np.sum(np.isnan(hull))) >= 1 + + def test_finite_after_warmup(self): + hull = HULL_MA(_C, timeperiod=4) + assert np.all(np.isfinite(hull[~np.isnan(hull)])) + + +class TestChandelierExit: + def test_output_shapes(self): + le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0) + assert len(le) == len(se) == _N + + def test_long_lt_high_short_gt_low(self): + le, se = CHANDELIER_EXIT(_H, _L, _C, timeperiod=5, multiplier=2.0) + # Both outputs should have valid values after warmup + valid_le = ~np.isnan(le) + valid_se = ~np.isnan(se) + assert valid_le.any() + assert valid_se.any() + # Long exit must be finite and positive + assert np.all(np.isfinite(le[valid_le])) + assert np.all(le[valid_le] > 0.0) + # Short exit must be finite and positive + assert np.all(np.isfinite(se[valid_se])) + assert np.all(se[valid_se] > 0.0) + + +class TestVWMA: + def test_output_length(self): + v = VWMA(_C, _V, timeperiod=5) + assert len(v) == _N + + def test_leading_nans(self): + v = VWMA(_C, _V, timeperiod=5) + assert int(np.sum(np.isnan(v))) == 4 + + def test_uniform_volume_equals_sma(self): + """With uniform volume, VWMA equals SMA.""" + from ferro_ta import SMA + + c = np.arange(1.0, 21.0) + v = np.ones(20) + vwma = VWMA(c, v, timeperiod=5) + sma = SMA(c, timeperiod=5) + valid = ~np.isnan(vwma) & ~np.isnan(sma) + assert np.allclose(vwma[valid], sma[valid], rtol=1e-9) + + +class TestChoppinessIndex: + def test_output_length(self): + ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5) + assert len(ci) == _N + + def test_range_0_to_100(self): + ci = CHOPPINESS_INDEX(_H, _L, _C, timeperiod=5) + valid = ci[~np.isnan(ci)] + if len(valid) > 0: + assert np.all(valid >= 0.0) + assert np.all(valid <= 100.0) + + +# --------------------------------------------------------------------------- +# Batch execution API +# --------------------------------------------------------------------------- + +from ferro_ta import EMA, RSI, SMA +from ferro_ta.data.batch import ( + batch_apply, + batch_atr, + batch_ema, + batch_rsi, + batch_sma, +) + + +class TestBatchSMA: + C2D = np.random.default_rng(7).random((50, 3)) + 50.0 + C1D = C2D[:, 0] + + def test_output_shape_2d(self): + result = batch_sma(self.C2D, timeperiod=10) + assert result.shape == (50, 3) + + def test_output_shape_1d_unchanged(self): + """1-D input should return 1-D (backward compatible).""" + result = batch_sma(self.C1D, timeperiod=10) + assert result.ndim == 1 + assert len(result) == 50 + + def test_column_matches_single_series(self): + """Each column of batch_sma must match single-series SMA.""" + result = batch_sma(self.C2D, timeperiod=10) + for j in range(3): + expected = SMA(self.C2D[:, j], timeperiod=10) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchEMA: + C2D = np.random.default_rng(8).random((50, 4)) + 40.0 + + def test_output_shape(self): + result = batch_ema(self.C2D, timeperiod=5) + assert result.shape == (50, 4) + + def test_column_matches_single_series(self): + result = batch_ema(self.C2D, timeperiod=5) + for j in range(4): + expected = EMA(self.C2D[:, j], timeperiod=5) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchRSI: + C2D = np.random.default_rng(9).random((50, 2)) + 45.0 + + def test_output_shape(self): + result = batch_rsi(self.C2D, timeperiod=14) + assert result.shape == (50, 2) + + def test_values_in_range(self): + result = batch_rsi(self.C2D, timeperiod=14) + valid = result[~np.isnan(result)] + if len(valid) > 0: + assert valid.min() >= 0.0 + assert valid.max() <= 100.0 + + def test_column_matches_single_series(self): + result = batch_rsi(self.C2D, timeperiod=14) + for j in range(2): + expected = RSI(self.C2D[:, j], timeperiod=14) + assert np.allclose(result[:, j], expected, equal_nan=True) + + +class TestBatchApply: + C2D = np.random.default_rng(11).random((40, 3)) + 50.0 + + def test_custom_fn(self): + """batch_apply should delegate to any single-series function.""" + from ferro_ta import BBANDS + + def mid(c, **kw): + return BBANDS(c, **kw)[1] + + result = batch_apply(self.C2D, mid, timeperiod=5) + assert result.shape == (40, 3) + + def test_3d_raises(self): + with pytest.raises(ValueError, match="1-D or 2-D"): + batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3) + + def test_sma_fastpath_matches_batch_sma(self): + from ferro_ta.data.batch import batch_sma + + fast = batch_apply(self.C2D, SMA, timeperiod=10) + direct = batch_sma(self.C2D, timeperiod=10) + assert np.allclose(fast, direct, equal_nan=True) + + +class TestBatchShapeValidation: + def test_batch_atr_shape_mismatch_raises(self): + high = np.ones((5, 2), dtype=np.float64) + low = np.ones((4, 2), dtype=np.float64) + close = np.ones((5, 2), dtype=np.float64) + with pytest.raises(ValueError, match="shape"): + batch_atr(high, low, close, timeperiod=3) + + +# --------------------------------------------------------------------------- +# Release playbook and version consistency +# --------------------------------------------------------------------------- + +import os +import re +import runpy +import subprocess + +try: + import tomllib # Python 3.11+ +except ImportError: + try: + import tomli as tomllib # type: ignore[no-redef] # fallback for Python < 3.11 + except ImportError: + tomllib = None # type: ignore[assignment] + + +def _read_cargo_version() -> str: + """Extract version from root Cargo.toml.""" + if tomllib is None: + raise ImportError("tomllib/tomli not available") + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + cargo_toml = os.path.join(root, "Cargo.toml") + with open(cargo_toml, "rb") as f: + data = tomllib.load(f) + return data["package"]["version"] + + +def _read_pyproject_version() -> str: + """Extract version from pyproject.toml.""" + if tomllib is None: + raise ImportError("tomllib/tomli not available") + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + pyproject_toml = os.path.join(root, "pyproject.toml") + with open(pyproject_toml, "rb") as f: + data = tomllib.load(f) + return data["project"]["version"] + + +def _read_conda_version() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conda_meta = os.path.join(root, "conda", "meta.yaml") + text = open(conda_meta).read() + match = re.search(r'{% set version = "([^"]+)" %}', text) + if not match: + raise ValueError("Could not find conda version") + return match.group(1) + + +def _read_docs_release() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conf_py = os.path.join(root, "docs", "conf.py") + old_env = os.environ.pop("FERRO_TA_VERSION", None) + try: + data = runpy.run_path(conf_py) + return data["release"] + finally: + if old_env is not None: + os.environ["FERRO_TA_VERSION"] = old_env + + +def _run_bump_version_check() -> subprocess.CompletedProcess[str]: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + return subprocess.run( + ["python3", "scripts/bump_version.py", "--check"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + + +class TestVersionConsistency: + """Public version strings should stay aligned with the package version.""" + + def test_versions_match(self): + try: + cargo_ver = _read_cargo_version() + pyproject_ver = _read_pyproject_version() + except Exception: + pytest.skip("tomllib unavailable or files not found") + assert cargo_ver == pyproject_ver, ( + f"Version mismatch: Cargo.toml={cargo_ver!r}, " + f"pyproject.toml={pyproject_ver!r}" + ) + + def test_package_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + assert ferro_ta.__version__ == cargo_ver + + def test_conda_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + conda_ver = _read_conda_version() + assert conda_ver == cargo_ver + + def test_docs_release_matches_project_version(self): + cargo_ver = _read_cargo_version() + docs_release = _read_docs_release() + assert docs_release == cargo_ver + + def test_docs_changelog_mentions_current_version(self): + cargo_ver = _read_cargo_version() + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + changelog_rst = os.path.join(root, "docs", "changelog.rst") + text = open(changelog_rst).read() + assert cargo_ver in text + + def test_api_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + try: + from api.main import app + except Exception: + pytest.skip("api/main.py not importable") + assert app.version == cargo_ver + + def test_bump_version_check_passes(self): + result = _run_bump_version_check() + assert result.returncode == 0, result.stdout + result.stderr + + def test_release_md_exists(self): + """RELEASE.md must exist in the repository root.""" + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + release_md = os.path.join(root, "RELEASE.md") + assert os.path.isfile(release_md), "RELEASE.md not found" + + def test_release_md_has_key_sections(self): + """RELEASE.md must mention tagging and PyPI.""" + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + release_md = os.path.join(root, "RELEASE.md") + if not os.path.isfile(release_md): + pytest.skip("RELEASE.md not found") + text = open(release_md).read() + assert "git tag" in text or "tag" in text.lower() + assert "pypi" in text.lower() or "PyPI" in text + + +# --------------------------------------------------------------------------- +# GPU backend (PyTorch, CPU fallback always available) +# --------------------------------------------------------------------------- + +from ferro_ta.tools.gpu import ema as gpu_ema +from ferro_ta.tools.gpu import rsi as gpu_rsi +from ferro_ta.tools.gpu import sma as gpu_sma # noqa: E402 + +CLOSE_15 = 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, + ] +) + + +class TestGPUCPUFallback: + """GPU module falls back to CPU when CuPy is not available.""" + + def test_sma_cpu_fallback_length(self): + result = gpu_sma(CLOSE_15, timeperiod=5) + assert len(result) == len(CLOSE_15) + + def test_sma_cpu_fallback_values(self): + from ferro_ta import SMA + + result = gpu_sma(CLOSE_15, timeperiod=5) + expected = SMA(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_ema_cpu_fallback_values(self): + from ferro_ta import EMA + + result = gpu_ema(CLOSE_15, timeperiod=5) + expected = EMA(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_rsi_cpu_fallback_values(self): + from ferro_ta import RSI + + result = gpu_rsi(CLOSE_15, timeperiod=5) + expected = RSI(CLOSE_15, timeperiod=5) + np.testing.assert_allclose(result, expected, equal_nan=True) + + def test_sma_returns_numpy_for_numpy_input(self): + result = gpu_sma(CLOSE_15, timeperiod=5) + assert isinstance(result, np.ndarray) + + def test_rsi_finite_values_in_range(self): + result = gpu_rsi(CLOSE_15, timeperiod=5) + finite = result[np.isfinite(result)] + assert len(finite) > 0 + assert np.all(finite >= 0.0) + assert np.all(finite <= 100.0) + + def test_gpu_module_all_exports(self): + from ferro_ta.tools import gpu as gpu_mod + + for name in gpu_mod.__all__: + assert callable(getattr(gpu_mod, name)) + + +# --------------------------------------------------------------------------- +# Indicator pipeline +# --------------------------------------------------------------------------- + +from ferro_ta import BBANDS # noqa: E402 (already imported) +from ferro_ta.tools.pipeline import Pipeline, make_pipeline # noqa: E402 + +CLOSE_20 = np.random.default_rng(99).random(20) * 100 + 50 + + +class TestPipeline: + """Tests for ferro_ta.pipeline.Pipeline.""" + + def test_pipeline_run_returns_dict(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + assert isinstance(result, dict) + assert "sma5" in result + + def test_pipeline_result_length_matches_input(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + assert len(result["sma5"]) == len(CLOSE_20) + + def test_pipeline_multiple_steps(self): + pipe = ( + Pipeline() + .add("sma5", SMA, timeperiod=5) + .add("ema5", EMA, timeperiod=5) + .add("rsi7", RSI, timeperiod=7) + ) + result = pipe.run(CLOSE_20) + assert set(result.keys()) == {"sma5", "ema5", "rsi7"} + + def test_pipeline_multi_output_with_output_keys(self): + pipe = Pipeline().add( + "bb", + BBANDS, + timeperiod=5, + nbdevup=2.0, + nbdevdn=2.0, + output_keys=["upper", "mid", "lower"], + ) + result = pipe.run(CLOSE_20) + assert "upper" in result + assert "mid" in result + assert "lower" in result + assert "bb" not in result + + def test_pipeline_multi_output_without_output_keys(self): + pipe = Pipeline().add("bb", BBANDS, timeperiod=5, nbdevup=2.0, nbdevdn=2.0) + result = pipe.run(CLOSE_20) + # Should auto-name as bb_0, bb_1, bb_2 + assert "bb_0" in result + assert "bb_1" in result + assert "bb_2" in result + + def test_pipeline_remove_step(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5) + pipe.remove("sma5") + assert pipe.steps() == ["ema5"] + + def test_pipeline_len(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5).add("ema5", EMA, timeperiod=5) + assert len(pipe) == 2 + + def test_pipeline_duplicate_name_raises(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + with pytest.raises(ValueError, match="sma5"): + pipe.add("sma5", SMA, timeperiod=10) + + def test_make_pipeline_factory(self): + pipe = make_pipeline( + sma5=(SMA, {"timeperiod": 5}), + rsi7=(RSI, {"timeperiod": 7}), + ) + result = pipe.run(CLOSE_20) + assert "sma5" in result + assert "rsi7" in result + + def test_pipeline_sma_values_match_direct_call(self): + pipe = Pipeline().add("sma5", SMA, timeperiod=5) + result = pipe.run(CLOSE_20) + direct = SMA(CLOSE_20, timeperiod=5) + np.testing.assert_allclose(result["sma5"], direct, equal_nan=True) + + +# --------------------------------------------------------------------------- +# Polars integration (skipped if polars not installed) +# --------------------------------------------------------------------------- + + +class TestPolarsIntegration: + """Transparent polars.Series support via polars_wrap.""" + + @pytest.fixture(autouse=True) + def skip_if_no_polars(self): + pytest.importorskip("polars") + + def test_sma_returns_polars_series(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = SMA(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_sma_values_match_numpy(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = SMA(s, timeperiod=5) + expected = SMA(CLOSE_20, timeperiod=5) + np.testing.assert_allclose(result.to_numpy(), expected, equal_nan=True) + + def test_rsi_returns_polars_series(self): + import polars as pl + + s = pl.Series("close", CLOSE_20.tolist()) + result = RSI(s, timeperiod=5) + assert isinstance(result, pl.Series) + + def test_numpy_input_still_returns_numpy(self): + result = SMA(CLOSE_20, timeperiod=5) + assert isinstance(result, np.ndarray) + + +# --------------------------------------------------------------------------- +# Configuration defaults +# --------------------------------------------------------------------------- + +import ferro_ta.core.config as ftconfig # noqa: E402 + + +class TestConfig: + """Tests for ferro_ta.config module.""" + + def setup_method(self): + """Reset config state before each test.""" + ftconfig.reset() + + def teardown_method(self): + """Clean up after each test.""" + ftconfig.reset() + + def test_set_and_get_default(self): + ftconfig.set_default("timeperiod", 20) + assert ftconfig.get_default("timeperiod") == 20 + + def test_get_default_fallback(self): + assert ftconfig.get_default("nonexistent") is None + assert ftconfig.get_default("nonexistent", -1) == -1 + + def test_reset_single_key(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.reset("timeperiod") + assert ftconfig.get_default("timeperiod") is None + + def test_reset_all(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + ftconfig.reset() + assert ftconfig.list_defaults() == {} + + def test_list_defaults(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + defaults = ftconfig.list_defaults() + assert defaults == {"timeperiod": 20, "RSI.timeperiod": 14} + + def test_get_defaults_for_indicator(self): + ftconfig.set_default("timeperiod", 20) + ftconfig.set_default("RSI.timeperiod", 14) + rsi_defaults = ftconfig.get_defaults_for("RSI") + assert rsi_defaults == {"timeperiod": 14} + sma_defaults = ftconfig.get_defaults_for("SMA") + assert sma_defaults == {"timeperiod": 20} + + def test_config_context_manager(self): + ftconfig.set_default("timeperiod", 20) + with ftconfig.Config(timeperiod=5): + assert ftconfig.get_default("timeperiod") == 5 + assert ftconfig.get_default("timeperiod") == 20 + + def test_config_context_manager_restores_on_exception(self): + ftconfig.set_default("timeperiod", 20) + try: + with ftconfig.Config(timeperiod=5): + raise RuntimeError("test error") + except RuntimeError: + pass + assert ftconfig.get_default("timeperiod") == 20 + + def test_config_context_manager_new_key_removed_on_exit(self): + # Key doesn't exist before context + assert ftconfig.get_default("nbdevup") is None + with ftconfig.Config(nbdevup=2.5): + assert ftconfig.get_default("nbdevup") == 2.5 + assert ftconfig.get_default("nbdevup") is None diff --git a/vendor/ferro-ta-main/tests/unit/test_known_values.py b/vendor/ferro-ta-main/tests/unit/test_known_values.py new file mode 100644 index 0000000..382a7eb --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_known_values.py @@ -0,0 +1,562 @@ +""" +Known-value oracle tests: permanent ground truth (Priority 2 - no optional deps). + +Hand-computable ground truth that never depends on external libraries. +These tests encode fundamental mathematical properties and serve as a permanent +oracle for correctness. + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np + +import ferro_ta + +# --------------------------------------------------------------------------- +# SMA Known Values +# --------------------------------------------------------------------------- + + +class TestSMAKnownValues: + """SMA is the simple average over a window.""" + + def test_sma_simple_sequence(self): + """SMA([1,2,3,4,5], 3) == [nan, nan, 2.0, 3.0, 4.0].""" + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = ferro_ta.SMA(data, timeperiod=3) + + assert np.isnan(result[0]) + assert np.isnan(result[1]) + assert np.abs(result[2] - 2.0) < 1e-10 # (1+2+3)/3 = 2.0 + assert np.abs(result[3] - 3.0) < 1e-10 # (2+3+4)/3 = 3.0 + assert np.abs(result[4] - 4.0) < 1e-10 # (3+4+5)/3 = 4.0 + + def test_sma_period_one_is_identity(self): + """SMA with period=1 should be the identity function.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.SMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + def test_sma_constant_series(self): + """SMA of constant series should equal that constant.""" + data = np.ones(10) * 42.0 + result = ferro_ta.SMA(data, timeperiod=5) + + # After warmup, all values should be 42.0 + assert np.allclose(result[4:], 42.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# EMA Known Values +# --------------------------------------------------------------------------- + + +class TestEMAKnownValues: + """EMA is an exponentially weighted moving average.""" + + def test_ema_period_one_is_identity(self): + """EMA with period=1 should be the identity function (alpha=1).""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.EMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + def test_ema_constant_series_converges(self): + """EMA of constant series should converge to that constant.""" + data = np.ones(100) * 42.0 + result = ferro_ta.EMA(data, timeperiod=10) + + # After sufficient warmup, should converge to 42.0 + assert np.allclose(result[-10:], 42.0, atol=1e-6) + + def test_ema_monotone_rising_is_increasing(self): + """EMA of monotone rising series should be strictly increasing after warmup.""" + data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 + result = ferro_ta.EMA(data, timeperiod=10) + + # After warmup, EMA should be strictly increasing + for i in range(20, len(result) - 1): + assert result[i + 1] > result[i], ( + f"EMA not increasing at index {i}: {result[i]} >= {result[i + 1]}" + ) + + +# --------------------------------------------------------------------------- +# WMA Known Values +# --------------------------------------------------------------------------- + + +class TestWMAKnownValues: + """WMA is a linearly weighted moving average.""" + + def test_wma_manual_calculation(self): + """WMA([3,5,7], 2) at index 2 = (1*5 + 2*7)/(1+2) = 6.333...""" + data = np.array([3.0, 5.0, 7.0]) + result = ferro_ta.WMA(data, timeperiod=2) + + # Index 0: warmup (NaN) + assert np.isnan(result[0]) + + # Index 1: (1*3 + 2*5)/(1+2) = 13/3 = 4.333... + expected_1 = (1 * 3.0 + 2 * 5.0) / (1 + 2) + assert np.abs(result[1] - expected_1) < 1e-10 + + # Index 2: (1*5 + 2*7)/(1+2) = 19/3 = 6.333... + expected_2 = (1 * 5.0 + 2 * 7.0) / (1 + 2) + assert np.abs(result[2] - expected_2) < 1e-10 + + def test_wma_period_one_is_identity(self): + """WMA with period=1 should be the identity function.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0]) + result = ferro_ta.WMA(data, timeperiod=1) + + assert np.allclose(result, data, atol=1e-10) + + +# --------------------------------------------------------------------------- +# BBANDS Known Values +# --------------------------------------------------------------------------- + + +class TestBBANDSKnownValues: + """Bollinger Bands: middle = SMA, upper/lower = middle ± (nbdevup/nbdevdn * stddev).""" + + def test_bbands_constant_series(self): + """For constant series: upper == middle == lower (stddev=0).""" + data = np.ones(20) * 50.0 + upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) + + # After warmup, all three bands should be 50.0 + assert np.allclose(upper[4:], 50.0, atol=1e-10) + assert np.allclose(middle[4:], 50.0, atol=1e-10) + assert np.allclose(lower[4:], 50.0, atol=1e-10) + + def test_bbands_middle_is_sma(self): + """Middle band should equal SMA.""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0]) + upper, middle, lower = ferro_ta.BBANDS(data, timeperiod=5) + sma = ferro_ta.SMA(data, timeperiod=5) + + assert np.allclose(middle, sma, atol=1e-10, equal_nan=True) + + def test_bbands_symmetric(self): + """Bands should be symmetric: upper-middle == middle-lower (with same nbdev).""" + data = np.array([10.0, 12.0, 15.0, 11.0, 13.0, 14.0, 16.0, 12.0, 18.0, 10.0]) + upper, middle, lower = ferro_ta.BBANDS( + data, timeperiod=5, nbdevup=2.0, nbdevdn=2.0 + ) + + # After warmup, bands should be symmetric + upper_dist = upper[4:] - middle[4:] + lower_dist = middle[4:] - lower[4:] + + assert np.allclose(upper_dist, lower_dist, atol=1e-10) + + +# --------------------------------------------------------------------------- +# RSI Known Values +# --------------------------------------------------------------------------- + + +class TestRSIKnownValues: + """RSI measures momentum: monotone rising → RSI > 50, monotone falling → RSI < 50.""" + + def test_rsi_monotone_rising(self): + """Monotone rising series should produce RSI > 50 after warmup.""" + data = np.arange(1.0, 51.0) # 1, 2, 3, ..., 50 + result = ferro_ta.RSI(data, timeperiod=14) + + # After warmup, RSI should be > 50 (strong uptrend) + assert np.all(result[20:] > 50.0), "RSI of rising series should be > 50" + + def test_rsi_monotone_falling(self): + """Monotone falling series should produce RSI < 50 after warmup.""" + data = np.arange(50.0, 0.0, -1.0) # 50, 49, 48, ..., 1 + result = ferro_ta.RSI(data, timeperiod=14) + + # After warmup, RSI should be < 50 (strong downtrend) + assert np.all(result[20:] < 50.0), "RSI of falling series should be < 50" + + def test_rsi_constant_series(self): + """Constant series should produce RSI = 100 or NaN (no momentum). + + Note: For constant series with no change, ferro_ta returns 100 + (no downward movement), which is mathematically correct. + """ + data = np.ones(30) * 42.0 + result = ferro_ta.RSI(data, timeperiod=14) + + # Constant series has no momentum; RSI should be NaN or 100 + # ferro_ta returns 100 (no down movement = 100% bullish) + valid_values = result[~np.isnan(result)] + if len(valid_values) > 0: + # Should be either NaN everywhere or 100 everywhere + assert np.all(np.abs(valid_values - 100.0) < 1e-10) or np.all( + np.abs(valid_values - 50.0) < 5.0 + ), "RSI of constant series should be 100 (no down movement) or close to 50" + + +# --------------------------------------------------------------------------- +# ATR Known Values +# --------------------------------------------------------------------------- + + +class TestATRKnownValues: + """ATR measures volatility: H==L==C → ATR=0.""" + + def test_atr_zero_range(self): + """When H==L==C, ATR should be 0 (no volatility).""" + n = 30 + high = np.ones(n) * 50.0 + low = np.ones(n) * 50.0 + close = np.ones(n) * 50.0 + + result = ferro_ta.ATR(high, low, close, timeperiod=14) + + # After warmup, ATR should be 0 + assert np.allclose(result[14:], 0.0, atol=1e-10) + + def test_atr_manual_tr_calculation(self): + """Manually verify TR formula for 3-bar sequence. + + Note: ATR requires warmup period. For period=14, first 13 bars are NaN. + We test with longer period to see TR values. + """ + # Bar 0: H=11, L=9, C=10 + # Bar 1: H=13, L=10, C=12 → TR = max(13-10, |13-10|, |10-10|) = 3 + # Bar 2: H=14, L=11, C=13 → TR = max(14-11, |14-12|, |11-12|) = 3 + high = np.array( + [ + 11.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + 24.0, + 25.0, + 26.0, + ] + ) + low = np.array( + [ + 9.0, + 10.0, + 11.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + ] + ) + close = np.array( + [ + 10.0, + 12.0, + 13.0, + 14.0, + 15.0, + 16.0, + 17.0, + 18.0, + 19.0, + 20.0, + 21.0, + 22.0, + 23.0, + 24.0, + 25.0, + ] + ) + + # For period=1, ATR still has warmup. Use TRANGE to check TR values directly + tr = ferro_ta.TRANGE(high, low, close) + + # TR[0] = H-L = 11-9 = 2 + # TR[1] = max(13-10, |13-10|, |10-10|) = max(3, 3, 0) = 3 + # TR[2] = max(14-11, |14-12|, |11-12|) = max(3, 2, 1) = 3 + + assert np.abs(tr[0] - 2.0) < 1e-10 + assert np.abs(tr[1] - 3.0) < 1e-10 + assert np.abs(tr[2] - 3.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# MOM Known Values +# --------------------------------------------------------------------------- + + +class TestMOMKnownValues: + """MOM is the difference: close[i] - close[i - period].""" + + def test_mom_manual_calculation(self): + """MOM([10,12,15,11], period=2) == [nan,nan,5,-1].""" + data = np.array([10.0, 12.0, 15.0, 11.0]) + result = ferro_ta.MOM(data, timeperiod=2) + + assert np.isnan(result[0]) + assert np.isnan(result[1]) + assert np.abs(result[2] - 5.0) < 1e-10 # 15 - 10 = 5 + assert np.abs(result[3] - (-1.0)) < 1e-10 # 11 - 12 = -1 + + +# --------------------------------------------------------------------------- +# ROC Known Values +# --------------------------------------------------------------------------- + + +class TestROCKnownValues: + """ROC is the percentage change: 100 * (close[i] - close[i-period]) / close[i-period].""" + + def test_roc_manual_calculation(self): + """ROC([10,12], period=1)[1] == 20.0.""" + data = np.array([10.0, 12.0]) + result = ferro_ta.ROC(data, timeperiod=1) + + # ROC[1] = 100 * (12 - 10) / 10 = 100 * 0.2 = 20.0 + assert np.abs(result[1] - 20.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# MACD Known Values +# --------------------------------------------------------------------------- + + +class TestMACDKnownValues: + """MACD: histogram == macd - signal always.""" + + def test_macd_histogram_identity(self): + """histogram should always equal macd - signal.""" + data = np.arange(1.0, 51.0) + macd, signal, histogram = ferro_ta.MACD( + data, fastperiod=12, slowperiod=26, signalperiod=9 + ) + + # histogram = macd - signal (within floating-point tolerance) + expected_histogram = macd - signal + assert np.allclose(histogram, expected_histogram, atol=1e-10, equal_nan=True) + + +# --------------------------------------------------------------------------- +# VWAP Known Values +# --------------------------------------------------------------------------- + + +class TestVWAPKnownValues: + """VWAP: period=1 VWAP == TYPPRICE.""" + + def test_vwap_period_one_equals_typprice(self): + """For period=1, VWAP should equal typical price (H+L+C)/3.""" + high = np.array([11.0, 13.0, 14.0]) + low = np.array([9.0, 10.0, 11.0]) + close = np.array([10.0, 12.0, 13.0]) + volume = np.array([1000.0, 1000.0, 1000.0]) + + result = ferro_ta.VWAP(high, low, close, volume, timeperiod=1) + expected = ferro_ta.TYPPRICE(high, low, close) + + assert np.allclose(result, expected, atol=1e-10) + + def test_vwap_cumulative_manual(self): + """Manually verify cumulative VWAP for simple 3-bar sequence.""" + # Bar 0: TP=10, Vol=100 → VWAP = (10*100)/(100) = 10.0 + # Bar 1: TP=12, Vol=200 → VWAP = (10*100 + 12*200)/(100+200) = 3400/300 = 11.333... + # Bar 2: TP=11, Vol=150 → VWAP = (10*100 + 12*200 + 11*150)/(100+200+150) = 5050/450 = 11.222... + high = np.array([11.0, 13.0, 12.0]) + low = np.array([9.0, 11.0, 10.0]) + close = np.array([10.0, 12.0, 11.0]) + volume = np.array([100.0, 200.0, 150.0]) + + result = ferro_ta.VWAP(high, low, close, volume, timeperiod=0) # cumulative + + typ = (high + low + close) / 3.0 + + expected_0 = typ[0] + expected_1 = (typ[0] * volume[0] + typ[1] * volume[1]) / (volume[0] + volume[1]) + expected_2 = (typ[0] * volume[0] + typ[1] * volume[1] + typ[2] * volume[2]) / ( + volume[0] + volume[1] + volume[2] + ) + + assert np.abs(result[0] - expected_0) < 1e-10 + assert np.abs(result[1] - expected_1) < 1e-10 + assert np.abs(result[2] - expected_2) < 1e-10 + + +# --------------------------------------------------------------------------- +# DONCHIAN Known Values +# --------------------------------------------------------------------------- + + +class TestDONCHIANKnownValues: + """DONCHIAN: upper = MAX(high), lower = MIN(low), middle = (upper+lower)/2.""" + + def test_donchian_structure(self): + """upper == MAX(high), lower == MIN(low), middle == (upper+lower)/2.""" + high = np.array([11.0, 13.0, 14.0, 12.0, 15.0]) + low = np.array([9.0, 10.0, 11.0, 10.0, 12.0]) + + period = 3 + upper, middle, lower = ferro_ta.DONCHIAN(high, low, timeperiod=period) + + # upper should match rolling max of high + max_high = ferro_ta.MAX(high, timeperiod=period) + assert np.allclose(upper, max_high, atol=1e-10, equal_nan=True) + + # lower should match rolling min of low + min_low = ferro_ta.MIN(low, timeperiod=period) + assert np.allclose(lower, min_low, atol=1e-10, equal_nan=True) + + # middle should be (upper + lower) / 2 + expected_middle = (upper + lower) / 2.0 + assert np.allclose(middle, expected_middle, atol=1e-10, equal_nan=True) + + +# --------------------------------------------------------------------------- +# PIVOT_POINTS Known Values +# --------------------------------------------------------------------------- + + +class TestPIVOT_POINTSKnownValues: + """PIVOT_POINTS classic formula: P=(H+L+C)/3, R1=2P-L, S1=2P-H, R2=P+(H-L), S2=P-(H-L).""" + + def test_pivot_points_classic_formula(self): + """Given H=110, L=90, C=100: P=100, R1=110, S1=90, R2=120, S2=80. + + Note: PIVOT_POINTS operates on OHLC bars. Single bar produces valid pivots. + """ + high = np.array([110.0, 110.0]) # Need at least 2 bars + low = np.array([90.0, 90.0]) + close = np.array([100.0, 100.0]) + + pivot, r1, s1, r2, s2 = ferro_ta.PIVOT_POINTS( + high, low, close, method="classic" + ) + + # Check last bar (index 1) which has full history + # P = (110 + 90 + 100) / 3 = 100 + assert np.abs(pivot[1] - 100.0) < 1e-10 + + # R1 = 2*P - L = 2*100 - 90 = 110 + assert np.abs(r1[1] - 110.0) < 1e-10 + + # S1 = 2*P - H = 2*100 - 110 = 90 + assert np.abs(s1[1] - 90.0) < 1e-10 + + # R2 = P + (H - L) = 100 + 20 = 120 + assert np.abs(r2[1] - 120.0) < 1e-10 + + # S2 = P - (H - L) = 100 - 20 = 80 + assert np.abs(s2[1] - 80.0) < 1e-10 + + +# --------------------------------------------------------------------------- +# Statistic Known Values +# --------------------------------------------------------------------------- + + +class TestStatisticKnownValues: + """Statistical functions: correlation, linear regression.""" + + def test_linearreg_slope_of_linear_sequence(self): + """LINEARREG_SLOPE([0,1,2,3,4], 5) == 1.0.""" + data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + result = ferro_ta.LINEARREG_SLOPE(data, timeperiod=5) + + # Last value should be slope = 1.0 + assert np.abs(result[-1] - 1.0) < 1e-10 + + def test_correl_x_with_x_is_one(self): + """CORREL(x, x) should be 1.0.""" + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) + result = ferro_ta.CORREL(x, x, timeperiod=5) + + # After warmup, correlation should be 1.0 + assert np.allclose(result[4:], 1.0, atol=1e-10) + + def test_correl_x_with_negative_x_is_minus_one(self): + """CORREL(x, -x) should be -1.0.""" + x = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) + neg_x = -x + result = ferro_ta.CORREL(x, neg_x, timeperiod=5) + + # After warmup, correlation should be -1.0 + assert np.allclose(result[4:], -1.0, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Pattern Known Values +# --------------------------------------------------------------------------- + + +class TestPatternKnownValues: + """Candlestick patterns: construct known-good OHLC sequences.""" + + def test_doji_known_sequence(self): + """Construct a perfect doji: open == close, small body.""" + # Doji: open == close (or very close), H and L have range + high = np.array([11.0, 11.0, 11.0, 11.0, 11.0]) + low = np.array([9.0, 9.0, 9.0, 9.0, 9.0]) + close = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) + open_ = np.array([10.0, 10.0, 10.0, 10.0, 10.0]) + + result = ferro_ta.CDLDOJI(open_, high, low, close) + + # Should detect doji (non-zero pattern) + # At least some values should be non-zero + assert np.any(result != 0), "CDLDOJI should detect perfect doji pattern" + + def test_engulfing_known_sequence(self): + """Construct a bullish engulfing pattern.""" + # Bullish engulfing: bar[i-1] is bearish (O > C), bar[i] is bullish (C > O) and engulfs bar[i-1] + # Bar 0: O=12, H=12, L=10, C=10 (bearish) + # Bar 1: O=9, H=13, L=9, C=13 (bullish, engulfs bar 0) + open_ = np.array([12.0, 9.0]) + high = np.array([12.0, 13.0]) + low = np.array([10.0, 9.0]) + close = np.array([10.0, 13.0]) + + result = ferro_ta.CDLENGULFING(open_, high, low, close) + + # Should detect engulfing at index 1 + assert result[1] != 0, "CDLENGULFING should detect bullish engulfing pattern" + + def test_hammer_known_sequence(self): + """Construct a hammer pattern: small body at top, long lower shadow.""" + # Hammer: small body, long lower shadow (>= 2x body), little/no upper shadow + # O=11, H=11.5, L=9, C=11 → body=0, lower_shadow=2, upper_shadow=0.5 + open_ = np.array([11.0]) + high = np.array([11.5]) + low = np.array([9.0]) + close = np.array([11.0]) + + result = ferro_ta.CDLHAMMER(open_, high, low, close) + + # Should detect hammer (non-zero) + # Note: hammer detection depends on lookback, so we test multiple bars + open_ = np.array([10.0, 10.5, 11.0]) + high = np.array([10.5, 11.0, 11.5]) + low = np.array([9.5, 10.0, 9.0]) + close = np.array([10.0, 10.5, 11.0]) + + result = ferro_ta.CDLHAMMER(open_, high, low, close) + + # Last bar has hammer characteristics + # (actual detection may vary based on implementation) + assert result.shape == close.shape diff --git a/vendor/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py b/vendor/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py new file mode 100644 index 0000000..b3e8504 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_math_ops_vs_numpy.py @@ -0,0 +1,357 @@ +""" +Comparison tests: ferro_ta.math_ops vs NumPy (Priority 1 - no optional deps). + +Math operators should be exact numpy wrappers. Zero tolerance for deviation. + +This module validates that all math operators and transforms in ferro_ta.math_ops +produce identical results to their NumPy equivalents within strict tolerances: + - Element-wise transforms: atol=1e-14 (direct numpy calls) + - Binary operators: atol=1e-14 (direct numpy calls) + - Rolling operators: atol=1e-12 (float sum reordering) + - Index operators: exact index matching + +All tests use NO optional dependencies - they run in every CI environment. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from ferro_ta.indicators import math_ops + +# --------------------------------------------------------------------------- +# Test Data (seeded for reproducibility) +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(42) +N = 100 + +# Standard test data +CLOSE = 44.0 + np.cumsum(RNG.standard_normal(N) * 0.5) +CLOSE_POSITIVE = np.abs(CLOSE) + 1.0 # For SQRT, LN, LOG10 +CLOSE_NORMALIZED = CLOSE / np.max(np.abs(CLOSE)) # For ASIN, ACOS (range [-1, 1]) + + +# --------------------------------------------------------------------------- +# Element-wise Transform Tests +# --------------------------------------------------------------------------- + + +class TestElementWiseTransforms: + """Test all 15 unary math transforms against NumPy equivalents. + + Expected tolerance: atol=1e-14 (direct numpy calls) + """ + + def test_sin_exact_match(self): + """SIN should match np.sin exactly.""" + result = math_ops.SIN(CLOSE) + expected = np.sin(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_cos_exact_match(self): + """COS should match np.cos exactly.""" + result = math_ops.COS(CLOSE) + expected = np.cos(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_tan_exact_match(self): + """TAN should match np.tan exactly.""" + result = math_ops.TAN(CLOSE) + expected = np.tan(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_sinh_exact_match(self): + """SINH should match np.sinh exactly.""" + result = math_ops.SINH(CLOSE) + expected = np.sinh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_cosh_exact_match(self): + """COSH should match np.cosh exactly.""" + result = math_ops.COSH(CLOSE) + expected = np.cosh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_tanh_exact_match(self): + """TANH should match np.tanh exactly.""" + result = math_ops.TANH(CLOSE) + expected = np.tanh(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_asin_exact_match(self): + """ASIN should match np.arcsin exactly.""" + result = math_ops.ASIN(CLOSE_NORMALIZED) + expected = np.arcsin(CLOSE_NORMALIZED) + assert np.allclose(result, expected, atol=1e-14) + + def test_acos_exact_match(self): + """ACOS should match np.arccos exactly.""" + result = math_ops.ACOS(CLOSE_NORMALIZED) + expected = np.arccos(CLOSE_NORMALIZED) + assert np.allclose(result, expected, atol=1e-14) + + def test_atan_exact_match(self): + """ATAN should match np.arctan exactly.""" + result = math_ops.ATAN(CLOSE) + expected = np.arctan(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_exp_exact_match(self): + """EXP should match np.exp exactly.""" + # Use smaller values to avoid overflow + small_values = CLOSE / 10.0 + result = math_ops.EXP(small_values) + expected = np.exp(small_values) + assert np.allclose(result, expected, atol=1e-14) + + def test_ln_exact_match(self): + """LN should match np.log exactly.""" + result = math_ops.LN(CLOSE_POSITIVE) + expected = np.log(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_log10_exact_match(self): + """LOG10 should match np.log10 exactly.""" + result = math_ops.LOG10(CLOSE_POSITIVE) + expected = np.log10(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_sqrt_exact_match(self): + """SQRT should match np.sqrt exactly.""" + result = math_ops.SQRT(CLOSE_POSITIVE) + expected = np.sqrt(CLOSE_POSITIVE) + assert np.allclose(result, expected, atol=1e-14) + + def test_ceil_exact_match(self): + """CEIL should match np.ceil exactly.""" + result = math_ops.CEIL(CLOSE) + expected = np.ceil(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + def test_floor_exact_match(self): + """FLOOR should match np.floor exactly.""" + result = math_ops.FLOOR(CLOSE) + expected = np.floor(CLOSE) + assert np.allclose(result, expected, atol=1e-14) + + +# --------------------------------------------------------------------------- +# Binary Operator Tests +# --------------------------------------------------------------------------- + + +class TestBinaryOps: + """Test binary operators against NumPy equivalents. + + Expected tolerance: atol=1e-14 (direct numpy calls) + """ + + def test_add_exact_match(self): + """ADD should match np.add exactly.""" + other = RNG.standard_normal(N) + result = math_ops.ADD(CLOSE, other) + expected = np.add(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_sub_exact_match(self): + """SUB should match np.subtract exactly.""" + other = RNG.standard_normal(N) + result = math_ops.SUB(CLOSE, other) + expected = np.subtract(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_mult_exact_match(self): + """MULT should match np.multiply exactly.""" + other = RNG.standard_normal(N) + result = math_ops.MULT(CLOSE, other) + expected = np.multiply(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + def test_div_exact_match(self): + """DIV should match np.divide exactly.""" + other = RNG.uniform(0.5, 2.0, N) # Avoid division by zero + result = math_ops.DIV(CLOSE, other) + expected = np.divide(CLOSE, other) + assert np.allclose(result, expected, atol=1e-14) + + +# --------------------------------------------------------------------------- +# Rolling Operator Tests +# --------------------------------------------------------------------------- + + +class TestRollingOps: + """Test rolling operators against pandas equivalents. + + Expected tolerance: atol=1e-12 (float sum reordering) + """ + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_sum_matches_pandas_rolling(self, period): + """SUM should match pd.Series.rolling(p).sum().""" + result = math_ops.SUM(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).sum().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_max_matches_pandas_rolling(self, period): + """MAX should match pd.Series.rolling(p).max().""" + result = math_ops.MAX(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).max().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + @pytest.mark.parametrize("period", [5, 10, 20, 30]) + def test_min_matches_pandas_rolling(self, period): + """MIN should match pd.Series.rolling(p).min().""" + result = math_ops.MIN(CLOSE, timeperiod=period) + expected = pd.Series(CLOSE).rolling(period).min().to_numpy() + + # Check NaN positions match + assert np.sum(np.isnan(result)) == np.sum(np.isnan(expected)) + + # Check values match where both are finite + mask = ~np.isnan(result) & ~np.isnan(expected) + assert np.allclose(result[mask], expected[mask], atol=1e-12) + + +# --------------------------------------------------------------------------- +# Index Operator Tests +# --------------------------------------------------------------------------- + + +class TestIndexOps: + """Test MAXINDEX and MININDEX point to correct argmax/argmin in window.""" + + @pytest.mark.parametrize("period", [5, 10, 20]) + def test_maxindex_points_to_max(self, period): + """MAXINDEX should point to the index of the rolling maximum.""" + result_idx = math_ops.MAXINDEX(CLOSE, timeperiod=period) + result_max = math_ops.MAX(CLOSE, timeperiod=period) + + # Skip warmup period + for i in range(period - 1, N): + idx = result_idx[i] + max_val = result_max[i] + + # During warmup, index is -1 + if idx == -1: + assert np.isnan(max_val) + else: + # Index should point to the actual maximum in the window + assert CLOSE[idx] == max_val, ( + f"At position {i}, MAXINDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} " + f"!= MAX={max_val}" + ) + + @pytest.mark.parametrize("period", [5, 10, 20]) + def test_minindex_points_to_min(self, period): + """MININDEX should point to the index of the rolling minimum.""" + result_idx = math_ops.MININDEX(CLOSE, timeperiod=period) + result_min = math_ops.MIN(CLOSE, timeperiod=period) + + # Skip warmup period + for i in range(period - 1, N): + idx = result_idx[i] + min_val = result_min[i] + + # During warmup, index is -1 + if idx == -1: + assert np.isnan(min_val) + else: + # Index should point to the actual minimum in the window + assert CLOSE[idx] == min_val, ( + f"At position {i}, MININDEX={idx} but CLOSE[{idx}]={CLOSE[idx]} " + f"!= MIN={min_val}" + ) + + def test_maxindex_warmup_returns_minus_one(self): + """MAXINDEX should return -1 during warmup period.""" + period = 10 + result = math_ops.MAXINDEX(CLOSE, timeperiod=period) + + # First period-1 values should be -1 + for i in range(period - 1): + assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}" + + def test_minindex_warmup_returns_minus_one(self): + """MININDEX should return -1 during warmup period.""" + period = 10 + result = math_ops.MININDEX(CLOSE, timeperiod=period) + + # First period-1 values should be -1 + for i in range(period - 1): + assert result[i] == -1, f"Expected -1 at index {i}, got {result[i]}" + + +# --------------------------------------------------------------------------- +# Edge Case Tests +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + """Test edge cases and document behavior. + + Documents behavior for: + - LN(negative) → NaN + - SQRT(negative) → NaN + - DIV(by zero) → inf + - ACOS(>1) → NaN + """ + + def test_ln_negative_returns_nan(self): + """LN of negative values should return NaN.""" + negative = np.array([-1.0, -2.0, -3.0]) + result = math_ops.LN(negative) + assert np.all(np.isnan(result)), "LN(negative) should return NaN" + + def test_sqrt_negative_returns_nan(self): + """SQRT of negative values should return NaN.""" + negative = np.array([-1.0, -4.0, -9.0]) + result = math_ops.SQRT(negative) + assert np.all(np.isnan(result)), "SQRT(negative) should return NaN" + + def test_div_by_zero_returns_inf(self): + """DIV by zero should return inf (NumPy behavior).""" + numerator = np.array([1.0, 2.0, 3.0]) + denominator = np.array([0.0, 0.0, 0.0]) + result = math_ops.DIV(numerator, denominator) + assert np.all(np.isinf(result)), "DIV(by zero) should return inf" + + def test_acos_out_of_range_returns_nan(self): + """ACOS of values outside [-1, 1] should return NaN.""" + out_of_range = np.array([1.5, 2.0, -1.5]) + result = math_ops.ACOS(out_of_range) + assert np.all(np.isnan(result)), "ACOS(>1 or <-1) should return NaN" + + def test_asin_out_of_range_returns_nan(self): + """ASIN of values outside [-1, 1] should return NaN.""" + out_of_range = np.array([1.5, 2.0, -1.5]) + result = math_ops.ASIN(out_of_range) + assert np.all(np.isnan(result)), "ASIN(>1 or <-1) should return NaN" + + def test_log10_zero_returns_negative_inf(self): + """LOG10(0) should return -inf.""" + zero = np.array([0.0]) + result = math_ops.LOG10(zero) + assert np.isinf(result[0]) and result[0] < 0, "LOG10(0) should return -inf" + + def test_ln_zero_returns_negative_inf(self): + """LN(0) should return -inf.""" + zero = np.array([0.0]) + result = math_ops.LN(zero) + assert np.isinf(result[0]) and result[0] < 0, "LN(0) should return -inf" diff --git a/vendor/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py b/vendor/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py new file mode 100644 index 0000000..ed7b03f --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_optional_dependency_wrappers.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np + +from ferro_ta._utils import ( + _optional_pandas_module, + _optional_polars_module, + pandas_wrap, + polars_wrap, +) + + +def _missing_only(module_name: str): + real_import = __import__ + attempts: list[str] = [] + + def side_effect(name, globals=None, locals=None, fromlist=(), level=0): + if name == module_name: + attempts.append(name) + raise ImportError(f"{module_name} not installed") + return real_import(name, globals, locals, fromlist, level) + + return attempts, side_effect + + +def test_pandas_wrap_caches_missing_optional_import() -> None: + _optional_pandas_module.cache_clear() + wrapped = pandas_wrap(lambda arr: arr) + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + attempts, side_effect = _missing_only("pandas") + + try: + with patch("builtins.__import__", side_effect=side_effect): + np.testing.assert_array_equal(wrapped(arr), arr) + np.testing.assert_array_equal(wrapped(arr), arr) + finally: + _optional_pandas_module.cache_clear() + + assert attempts == ["pandas"] + + +def test_polars_wrap_caches_missing_optional_import() -> None: + _optional_polars_module.cache_clear() + wrapped = polars_wrap(lambda arr: arr) + arr = np.array([1.0, 2.0, 3.0], dtype=np.float64) + attempts, side_effect = _missing_only("polars") + + try: + with patch("builtins.__import__", side_effect=side_effect): + np.testing.assert_array_equal(wrapped(arr), arr) + np.testing.assert_array_equal(wrapped(arr), arr) + finally: + _optional_polars_module.cache_clear() + + assert attempts == ["polars"] diff --git a/vendor/ferro-ta-main/tests/unit/test_property_based.py b/vendor/ferro-ta-main/tests/unit/test_property_based.py new file mode 100644 index 0000000..5550516 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_property_based.py @@ -0,0 +1,263 @@ +"""Property-based tests (Hypothesis) for ferro-ta.""" + +import numpy as np +import pytest + +from ferro_ta import ATR, BBANDS, CDLDOJI, EMA, MACD, OBV, RSI, SMA, WMA + +try: + from hypothesis import given, settings + from hypothesis.strategies import floats, integers, lists + + HAS_HYPOTHESIS = True +except ImportError: + HAS_HYPOTHESIS = False + +if HAS_HYPOTHESIS: + # Strategy: finite floats, reasonable length + finite_floats = floats( + min_value=1e-6, max_value=1e6, allow_nan=False, allow_infinity=False + ) + price_arrays = lists(finite_floats, min_size=2, max_size=500).map(np.array) + periods = integers(min_value=1, max_value=100) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_sma_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = SMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_ema_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = EMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=50, deadline=5000) + def test_rsi_output_length_matches_input(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = RSI(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given(price_arrays, periods) + @settings(max_examples=30, deadline=5000) + def test_bbands_three_outputs_same_length(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + upper, middle, lower = BBANDS(close, timeperiod=timeperiod) + assert len(upper) == len(close) + assert len(middle) == len(close) + assert len(lower) == len(close) + + @given( + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + lists(finite_floats, min_size=3, max_size=100).map(np.array), + ) + @settings(max_examples=20, deadline=5000) + def test_cdl_pattern_output_values_in_set(open_, high, low, close): + n = min(len(open_), len(high), len(low), len(close)) + open_ = open_[:n] + high = high[:n] + low = low[:n] + close = close[:n] + result = CDLDOJI(open_, high, low, close) + assert len(result) == n + assert all(v in (-100, 0, 100) for v in result) + + # ------------------------------------------------------------------ + # EMA extended properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=50, deadline=5000) + def test_ema_values_finite_when_input_finite(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = EMA(close, timeperiod=timeperiod) + assert np.all(np.isfinite(result) | np.isnan(result)) + # All non-NaN values must be finite + valid = result[~np.isnan(result)] + assert np.all(np.isfinite(valid)) + + @given(price_arrays) + @settings(max_examples=50, deadline=5000) + def test_ema_period_1_equals_input(close): + result = EMA(close, timeperiod=1) + assert len(result) == len(close) + # EMA with period=1 should reproduce the input exactly + np.testing.assert_allclose(result, close, rtol=1e-10) + + # ------------------------------------------------------------------ + # BBANDS extended properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=30, deadline=5000) + def test_bbands_upper_ge_middle_ge_lower(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + upper, middle, lower = BBANDS(close, timeperiod=timeperiod) + # Where all three are finite, upper >= middle >= lower + mask = np.isfinite(upper) & np.isfinite(middle) & np.isfinite(lower) + assert np.all(upper[mask] >= middle[mask] - 1e-10) + assert np.all(middle[mask] >= lower[mask] - 1e-10) + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=30, deadline=5000) + def test_bbands_middle_equals_sma(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + _, middle, _ = BBANDS(close, timeperiod=timeperiod) + sma = SMA(close, timeperiod=timeperiod) + mask = np.isfinite(middle) & np.isfinite(sma) + np.testing.assert_allclose(middle[mask], sma[mask], rtol=1e-10) + + # ------------------------------------------------------------------ + # MACD properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=40, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_macd_output_lengths(close): + macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + assert len(macd) == len(close) + assert len(signal) == len(close) + assert len(hist) == len(close) + + @given( + lists(finite_floats, min_size=40, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_macd_histogram_equals_macd_minus_signal(close): + macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9) + mask = np.isfinite(macd) & np.isfinite(signal) & np.isfinite(hist) + if np.any(mask): + np.testing.assert_allclose( + hist[mask], macd[mask] - signal[mask], atol=1e-10 + ) + + # ------------------------------------------------------------------ + # ATR properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_atr_output_length(prices, timeperiod): + # Build high/low/close from prices with valid OHLC relationships + close = prices + high = prices * 1.01 + low = prices * 0.99 + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = ATR(high, low, close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_atr_non_negative(prices, timeperiod): + close = prices + high = prices * 1.01 + low = prices * 0.99 + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = ATR(high, low, close, timeperiod=timeperiod) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0) + + # ------------------------------------------------------------------ + # WMA properties + # ------------------------------------------------------------------ + + @given(price_arrays, integers(min_value=2, max_value=50)) + @settings(max_examples=50, deadline=5000) + def test_wma_output_length(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 1: + timeperiod = 1 + result = WMA(close, timeperiod=timeperiod) + assert len(result) == len(close) + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + integers(min_value=2, max_value=50), + ) + @settings(max_examples=50, deadline=5000) + def test_wma_leading_nans(close, timeperiod): + if len(close) < timeperiod: + timeperiod = min(timeperiod, len(close)) + if timeperiod < 2: + timeperiod = 2 + result = WMA(close, timeperiod=timeperiod) + # First (timeperiod - 1) values should be NaN + assert np.all(np.isnan(result[: timeperiod - 1])) + + # ------------------------------------------------------------------ + # OBV properties + # ------------------------------------------------------------------ + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + lists(finite_floats, min_size=20, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_obv_output_length(close, volume): + n = min(len(close), len(volume)) + close = close[:n] + volume = volume[:n] + result = OBV(close, volume) + assert len(result) == n + + @given( + lists(finite_floats, min_size=20, max_size=500).map(np.array), + lists(finite_floats, min_size=20, max_size=500).map(np.array), + ) + @settings(max_examples=50, deadline=5000) + def test_obv_all_finite(close, volume): + n = min(len(close), len(volume)) + close = close[:n] + volume = volume[:n] + result = OBV(close, volume) + assert np.all(np.isfinite(result)) + + +@pytest.mark.skipif(not HAS_HYPOTHESIS, reason="hypothesis not installed") +class TestPropertyBased: + """Placeholder for running property-based tests as a class.""" + + def test_import(self): + assert HAS_HYPOTHESIS diff --git a/vendor/ferro-ta-main/tests/unit/test_tools_and_api.py b/vendor/ferro-ta-main/tests/unit/test_tools_and_api.py new file mode 100644 index 0000000..d91f228 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_tools_and_api.py @@ -0,0 +1,1519 @@ +"""Tests for alerts, crypto helpers, chunked processing, +regime detection, performance attribution, and dashboard helpers. +""" + +from __future__ import annotations + +import importlib +import runpy + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Synthetic helpers +# --------------------------------------------------------------------------- + +RNG = np.random.default_rng(31415) + + +def _make_close(n: int = 200) -> np.ndarray: + return np.cumprod(1 + RNG.normal(0, 0.01, n)) * 100.0 + + +def _make_ohlcv(n: int = 200): + close = _make_close(n) + open_ = close * RNG.uniform(0.995, 1.005, n) + high = np.maximum(close, open_) + RNG.uniform(0, 0.5, n) + low = np.minimum(close, open_) - RNG.uniform(0, 0.5, n) + volume = RNG.uniform(500, 5000, n) + return open_, high, low, close, volume + + +# =========================================================================== +# Alerts +# =========================================================================== + + +class TestAlertsLowLevel: + """Tests for low-level alert condition functions.""" + + def test_check_threshold_cross_above(self): + from ferro_ta.tools.alerts import check_threshold + + series = np.array([20.0, 25.0, 30.0, 35.0, 28.0]) + mask = check_threshold(series, level=29.0, direction=1) + # Cross above fires when series was <= level and now > level. + # Bar 0: no prior bar, always 0. + # Bar 2: prev=25 <= 29, curr=30 > 29 → fires + assert mask[0] == 0 + assert mask[2] == 1 + assert mask[1] == 0 + assert mask[3] == 0 # 30 > 29 previously, so no new crossing + + def test_check_threshold_cross_below(self): + from ferro_ta.tools.alerts import check_threshold + + series = np.array([70.0, 65.0, 28.0, 25.0, 35.0]) + mask = check_threshold(series, level=30.0, direction=-1) + # index 2: 65 >= 30 → 28 < 30: cross below + assert mask[2] == 1 + assert mask[0] == 0 + assert mask[4] == 0 # 25 < 30 already, 35 > 30 is not a cross-below + + def test_check_threshold_invalid_direction(self): + from ferro_ta.tools.alerts import check_threshold + + with pytest.raises(Exception): + check_threshold(np.array([1.0, 2.0]), level=1.5, direction=0) + + def test_check_cross_bullish(self): + from ferro_ta.tools.alerts import check_cross + + fast = np.array([10.0, 12.0, 15.0, 14.0, 16.0]) + slow = np.array([13.0, 13.0, 13.0, 13.0, 13.0]) + mask = check_cross(fast, slow) + # fast crosses above slow at index 2 (12 <= 13 → 15 > 13) + assert mask[2] == 1 # bullish + assert mask[0] == 0 + + def test_check_cross_bearish(self): + from ferro_ta.tools.alerts import check_cross + + fast = np.array([15.0, 15.0, 12.0, 11.0]) + slow = np.array([13.0, 13.0, 13.0, 13.0]) + mask = check_cross(fast, slow) + # fast crosses below slow at index 2 (15 >= 13 → 12 < 13) + assert mask[2] == -1 # bearish + + def test_check_cross_length_mismatch_raises(self): + from ferro_ta.tools.alerts import check_cross + + with pytest.raises(Exception): + check_cross(np.array([1.0, 2.0]), np.array([1.0, 2.0, 3.0])) + + def test_collect_alert_bars(self): + from ferro_ta.tools.alerts import collect_alert_bars + + mask = np.array([0, 1, 0, 0, 1, -1], dtype=np.int8) + bars = collect_alert_bars(mask) + assert list(bars) == [1, 4, 5] + + def test_collect_alert_bars_empty(self): + from ferro_ta.tools.alerts import collect_alert_bars + + mask = np.zeros(10, dtype=np.int8) + bars = collect_alert_bars(mask) + assert len(bars) == 0 + + +class TestAlertManager: + """Tests for the AlertManager class.""" + + def test_run_backtest_returns_list(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(200) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + am = AlertManager(symbol="TEST") + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) + events = am.run_backtest() + assert isinstance(events, list) + + def test_backtest_no_external_calls_by_default(self): + """Backtest mode must not invoke callback unless force_live=True.""" + from ferro_ta import SMA + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(100) + sma10 = np.asarray(SMA(close, timeperiod=10), dtype=np.float64) + sma30 = np.asarray(SMA(close, timeperiod=30), dtype=np.float64) + + called = [] + + def cb(ev): + called.append(ev) + + am = AlertManager() + am.add_cross_condition("sma_x", sma10, sma30, callback=cb) + events = am.run_backtest() # default live=False + assert len(called) == 0, "callback must not fire in backtest mode" + assert isinstance(events, list) + + def test_backtest_force_live_invokes_callback(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(300) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + + fired = [] + + def cb(ev): + fired.append(ev) + + am = AlertManager() + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1, callback=cb) + events = am.run_backtest(force_live=True) + assert len(fired) == len(events) + + def test_event_payload_contains_symbol(self): + from ferro_ta import RSI + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(200) + rsi = np.asarray(RSI(close, timeperiod=14), dtype=np.float64) + am = AlertManager(symbol="BTCUSD") + am.add_threshold_condition("rsi_os", rsi, level=30.0, direction=-1) + events = am.run_backtest() + for ev in events: + assert ev.payload.get("symbol") == "BTCUSD" + + def test_event_bar_index_valid(self): + from ferro_ta import SMA + from ferro_ta.tools.alerts import AlertManager + + close = _make_close(100) + sma5 = np.asarray(SMA(close, timeperiod=5), dtype=np.float64) + sma20 = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) + am = AlertManager() + am.add_cross_condition("x", sma5, sma20) + events = am.run_backtest() + for ev in events: + assert 0 <= ev.bar_index < len(close) + + def test_alert_event_to_dict(self): + from ferro_ta.tools.alerts import AlertEvent + + ev = AlertEvent("my_cond", 42, value=27.5, payload={"symbol": "X"}) + d = ev.to_dict() + assert d["condition_id"] == "my_cond" + assert d["bar_index"] == 42 + assert d["symbol"] == "X" + + +# =========================================================================== +# Crypto helpers +# =========================================================================== + + +class TestCryptoFunding: + def test_funding_pnl_shape(self): + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(100) + rate = RNG.normal(0, 0.0001, 100) + pnl = funding_pnl(pos, rate) + assert pnl.shape == (100,) + + def test_funding_pnl_cumulative(self): + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(5) + rate = np.array([0.0001, 0.0002, -0.0001, 0.0001, 0.0001]) + pnl = funding_pnl(pos, rate) + expected = np.cumsum(-pos * rate) + np.testing.assert_allclose(pnl, expected) + + def test_funding_pnl_long_pays_positive_rate(self): + """Long position should pay (negative PnL) when funding rate > 0.""" + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.ones(1) + rate = np.array([0.001]) # positive rate → long pays + pnl = funding_pnl(pos, rate) + assert pnl[0] < 0 + + def test_funding_pnl_short_receives_positive_rate(self): + """Short position should receive (positive PnL) when funding rate > 0.""" + from ferro_ta.analysis.crypto import funding_pnl + + pos = np.array([-1.0]) + rate = np.array([0.001]) + pnl = funding_pnl(pos, rate) + assert pnl[0] > 0 + + def test_funding_pnl_length_mismatch_raises(self): + from ferro_ta.analysis.crypto import funding_pnl + + with pytest.raises(Exception): + funding_pnl(np.ones(5), np.ones(4)) + + +class TestCryptoBarLabels: + def test_continuous_bar_labels_shape(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(10, 3) + assert labels.shape == (10,) + + def test_continuous_bar_labels_values(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(10, 3) + expected = [0, 0, 0, 1, 1, 1, 2, 2, 2, 3] + np.testing.assert_array_equal(labels, expected) + + def test_continuous_bar_labels_period_one(self): + from ferro_ta.analysis.crypto import continuous_bar_labels + + labels = continuous_bar_labels(5, 1) + np.testing.assert_array_equal(labels, [0, 1, 2, 3, 4]) + + def test_session_boundaries_daily(self): + from ferro_ta.analysis.crypto import session_boundaries + + NS_PER_HOUR = np.int64(3_600_000_000_000) + # Use a UTC midnight timestamp as base: 1_699_920_000 seconds = Nov 14, 2023 00:00:00 UTC + base = np.int64(1_699_920_000) * np.int64(1_000_000_000) # midnight UTC + # 48 hourly bars = 2 full days + ts = base + np.arange(48, dtype=np.int64) * NS_PER_HOUR + bounds = session_boundaries(ts) + assert bounds[0] == 0 # first bar always included + # Should have exactly 2 boundaries (day 0 and day 1) + assert len(bounds) == 2 + assert bounds[1] == 24 # second day starts at bar 24 + + +class TestResampleContinuous: + def test_resample_continuous_shape(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(100) + ro, rh, rl, rc, rv = resample_continuous((o, h, l, c, v), period_bars=5) + assert len(rc) == 20 # 100 / 5 + + def test_resample_continuous_high_ge_low(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(100) + _, rh, rl, _, _ = resample_continuous((o, h, l, c, v), period_bars=5) + assert np.all(rh >= rl) + + def test_resample_continuous_invalid_period_raises(self): + from ferro_ta.analysis.crypto import resample_continuous + + o, h, l, c, v = _make_ohlcv(10) + with pytest.raises(ValueError): + resample_continuous((o, h, l, c, v), period_bars=0) + + +# =========================================================================== +# Chunked processing +# =========================================================================== + + +class TestChunked: + def test_make_chunk_ranges_shape(self): + from ferro_ta.data.chunked import make_chunk_ranges + + ranges = make_chunk_ranges(100, 30, 10) + assert ranges.ndim == 2 + assert ranges.shape[1] == 2 + + def test_make_chunk_ranges_coverage(self): + """All input indices must be covered by some range.""" + from ferro_ta.data.chunked import make_chunk_ranges + + n = 97 + ranges = make_chunk_ranges(n, 20, 5) + covered = set() + for start, end in ranges: + covered.update(range(int(start), int(end))) + assert 0 in covered + assert (n - 1) in covered + + def test_trim_overlap_basic(self): + from ferro_ta.data.chunked import trim_overlap + + arr = np.arange(10, dtype=np.float64) + trimmed = trim_overlap(arr, overlap=3) + np.testing.assert_array_equal(trimmed, arr[3:]) + + def test_trim_overlap_zero(self): + from ferro_ta.data.chunked import trim_overlap + + arr = np.arange(5, dtype=np.float64) + trimmed = trim_overlap(arr, overlap=0) + np.testing.assert_array_equal(trimmed, arr) + + def test_stitch_chunks_basic(self): + from ferro_ta.data.chunked import stitch_chunks + + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0]) + result = stitch_chunks([a, b]) + np.testing.assert_array_equal(result, [1, 2, 3, 4, 5]) + + def test_chunk_apply_sma_matches_full(self): + """chunk_apply(SMA, …) should produce the same result as SMA on the full series.""" + from ferro_ta import SMA + from ferro_ta.data.chunked import chunk_apply + + close = _make_close(500) + full_out = np.asarray(SMA(close, timeperiod=20), dtype=np.float64) + chunked_out = chunk_apply(SMA, close, chunk_size=100, overlap=30, timeperiod=20) + + # Compare non-NaN region + valid = ~np.isnan(full_out) + np.testing.assert_allclose( + chunked_out[valid], + full_out[valid], + rtol=1e-10, + err_msg="chunk_apply SMA must match full SMA for non-NaN bars", + ) + + def test_chunk_apply_output_length(self): + from ferro_ta import EMA + from ferro_ta.data.chunked import chunk_apply + + close = _make_close(300) + out = chunk_apply(EMA, close, chunk_size=80, overlap=20, timeperiod=10) + assert len(out) == len(close) + + +# =========================================================================== +# Regime detection +# =========================================================================== + + +class TestRegimeDetection: + def test_regime_adx_shape(self): + from ferro_ta import ADX + from ferro_ta.analysis.regime import regime_adx + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_adx(adx, threshold=25.0) + assert labels.shape == (200,) + + def test_regime_adx_values_valid(self): + from ferro_ta import ADX + from ferro_ta.analysis.regime import regime_adx + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_adx(adx, threshold=25.0) + # Values must be -1, 0, or 1 + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_adx_nan_bars_are_minus_one(self): + from ferro_ta.analysis.regime import regime_adx + + adx = np.full(20, np.nan) + adx[15:] = 30.0 # last 5 are trend + labels = regime_adx(adx, threshold=25.0) + assert np.all(labels[:15] == -1) + assert np.all(labels[15:] == 1) + + def test_regime_combined_shape(self): + from ferro_ta import ADX, ATR + from ferro_ta.analysis.regime import regime_combined + + o, h, l, c, v = _make_ohlcv(200) + adx = np.asarray(ADX(h, l, c, timeperiod=14), dtype=np.float64) + atr = np.asarray(ATR(h, l, c, timeperiod=14), dtype=np.float64) + labels = regime_combined( + adx, atr, c, adx_threshold=25.0, atr_pct_threshold=0.005 + ) + assert labels.shape == (200,) + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_high_level_adx(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(200) + labels = regime((o, h, l, c, v), method="adx", adx_threshold=25.0) + assert labels.shape == (200,) + assert set(labels).issubset({-1, 0, 1}) + + def test_regime_high_level_combined(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(200) + labels = regime((o, h, l, c, v), method="combined") + assert labels.shape == (200,) + + def test_regime_unknown_method_raises(self): + from ferro_ta.analysis.regime import regime + + o, h, l, c, v = _make_ohlcv(50) + with pytest.raises(ValueError): + regime((o, h, l, c, v), method="unknown") + + +class TestStructuralBreaks: + def test_detect_breaks_cusum_shape(self): + from ferro_ta.analysis.regime import detect_breaks_cusum + + series = _make_close(200) + mask = detect_breaks_cusum(series, window=20, threshold=3.0, slack=0.5) + assert mask.shape == (200,) + + def test_detect_breaks_cusum_fires_near_break(self): + """CUSUM should detect a level shift.""" + from ferro_ta.analysis.regime import detect_breaks_cusum + + rng = np.random.default_rng(99) + s1 = rng.normal(0, 1, 100) + s2 = rng.normal(10, 1, 100) # large level shift + series = np.concatenate([s1, s2]) + mask = detect_breaks_cusum(series, window=20, threshold=2.0, slack=0.3) + # Should fire somewhere near the shift + assert mask[100:130].any() + + def test_rolling_variance_break_shape(self): + from ferro_ta.analysis.regime import rolling_variance_break + + series = _make_close(200) + mask = rolling_variance_break( + series, short_window=10, long_window=50, threshold=2.0 + ) + assert mask.shape == (200,) + + def test_structural_breaks_cusum(self): + from ferro_ta.analysis.regime import structural_breaks + + series = _make_close(200) + mask = structural_breaks(series, method="cusum") + assert mask.shape == (200,) + + def test_structural_breaks_variance(self): + from ferro_ta.analysis.regime import structural_breaks + + series = _make_close(200) + mask = structural_breaks(series, method="variance") + assert mask.shape == (200,) + + def test_structural_breaks_unknown_method_raises(self): + from ferro_ta.analysis.regime import structural_breaks + + with pytest.raises(ValueError): + structural_breaks(_make_close(50), method="xyz") + + +# =========================================================================== +# Performance attribution +# =========================================================================== + + +class TestTradeStats: + def test_basic_stats(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([10.0, -5.0, 8.0, -3.0, 15.0, -2.0]) + hold = np.array([5.0, 3.0, 7.0, 2.0, 10.0, 1.0]) + ts = trade_stats(pnl, hold) + assert ts.n_trades == 6 + assert abs(ts.win_rate - 0.5) < 1e-10 # 3 wins out of 6 + assert ts.avg_win > 0 + assert ts.avg_loss < 0 + assert ts.profit_factor > 0 + assert ts.avg_hold_bars == pytest.approx(4.67, abs=0.01) + + def test_all_wins(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([5.0, 10.0, 3.0]) + ts = trade_stats(pnl) + assert ts.win_rate == 1.0 + assert ts.avg_loss == 0.0 + assert ts.profit_factor == float("inf") + + def test_all_losses(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([-5.0, -3.0]) + ts = trade_stats(pnl) + assert ts.win_rate == 0.0 + assert ts.avg_win == 0.0 + assert ts.profit_factor == 0.0 + + def test_empty_raises(self): + from ferro_ta.analysis.attribution import trade_stats + + with pytest.raises(Exception): + trade_stats(np.array([])) + + def test_to_dict(self): + from ferro_ta.analysis.attribution import trade_stats + + pnl = np.array([1.0, -1.0]) + ts = trade_stats(pnl) + d = ts.to_dict() + assert "win_rate" in d + assert "profit_factor" in d + + +class TestFromBacktest: + def test_from_backtest_returns_arrays(self): + from ferro_ta.analysis.attribution import from_backtest + from ferro_ta.analysis.backtest import backtest + + close = _make_close(200) + result = backtest(close, strategy="rsi_30_70") + pnl, hold = from_backtest(result) + assert isinstance(pnl, np.ndarray) + assert isinstance(hold, np.ndarray) + assert len(pnl) == len(hold) + # n_trades counts position *changes* (entries + exits); + # from_backtest counts round-trips (position runs), so len(pnl) <= n_trades + assert len(pnl) <= result.n_trades + # Each hold duration should be >= 1 + if len(hold) > 0: + assert np.all(hold >= 1) + + def test_from_backtest_no_trades(self): + from ferro_ta.analysis.attribution import from_backtest + from ferro_ta.analysis.backtest import BacktestResult + + n = 50 + result = BacktestResult( + signals=np.zeros(n), + positions=np.zeros(n), + bar_returns=np.zeros(n), + strategy_returns=np.zeros(n), + equity=np.ones(n), + ) + pnl, hold = from_backtest(result) + assert len(pnl) == 0 + + +class TestAttribution: + def test_attribution_by_signal_basic(self): + from ferro_ta.analysis.attribution import attribution_by_signal + + ret = np.array([0.01, 0.02, -0.01, 0.03, -0.02]) + labels = np.array([0, 0, 1, 1, -1], dtype=np.int64) + contrib = attribution_by_signal(ret, labels) + assert isinstance(contrib, dict) + assert "signal_0" in contrib + assert "signal_1" in contrib + assert abs(contrib["signal_0"] - 0.03) < 1e-10 # 0.01 + 0.02 + assert abs(contrib["signal_1"] - 0.02) < 1e-10 # -0.01 + 0.03 + + def test_attribution_by_month_returns_dict(self): + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 252) + contrib = attribution_by_month(ret) + assert isinstance(contrib, dict) + assert len(contrib) > 0 + + def test_attribution_by_month_sum_close_to_total(self): + """Sum of monthly contributions should approximate total strategy return.""" + from ferro_ta.analysis.attribution import attribution_by_month + + ret = RNG.normal(0, 0.01, 252) + contrib = attribution_by_month(ret) + total_monthly = sum(contrib.values()) + total_direct = float(np.sum(ret)) + assert abs(total_monthly - total_direct) < 1e-8 + + +# =========================================================================== +# Dashboard (smoke tests, no display) +# =========================================================================== + + +class TestDashboard: + def test_streamlit_app_import(self): + """Module should import without errors even if streamlit not installed.""" + try: + from ferro_ta.tools import dashboard # noqa: F401 + except ImportError: + pytest.skip("dashboard module not importable") + + def test_indicator_widget_raises_without_ipywidgets(self, monkeypatch): + from ferro_ta import SMA + from ferro_ta.tools.dashboard import indicator_widget + + close = _make_close(50) + # If ipywidgets not installed, should raise ImportError + import sys + + fake_modules = dict(sys.modules) + fake_modules["ipywidgets"] = None # type: ignore[assignment] + fake_modules["matplotlib"] = None # type: ignore[assignment] + fake_modules["matplotlib.pyplot"] = None # type: ignore[assignment] + monkeypatch.setattr(sys, "modules", fake_modules) + with pytest.raises((ImportError, TypeError)): + indicator_widget(close, SMA, "timeperiod", range(5, 10)) + + +# =========================================================================== +# Web API (unit test with TestClient if fastapi is available) +# =========================================================================== + + +class TestWebAPI: + @pytest.fixture(scope="class") + def client(self): + try: + from fastapi.testclient import TestClient + except ImportError: + pytest.skip("fastapi not installed") + import os + import sys + + # Insert project root so that `api.main` is importable + project_root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if project_root not in sys.path: + sys.path.insert(0, project_root) + try: + from api.main import app + except ImportError: + pytest.skip("api/main.py not importable") + return TestClient(app) + + def test_health(self, client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_sma_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/sma", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + result = resp.json()["result"] + assert len(result) == 30 + assert result[0] is None # warm-up is null + + def test_ema_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/ema", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + assert len(resp.json()["result"]) == 30 + + def test_rsi_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/rsi", json={"close": close, "timeperiod": 14}) + assert resp.status_code == 200 + + def test_macd_endpoint(self, client): + close = list(np.linspace(100, 120, 60)) + resp = client.post("/indicators/macd", json={"close": close}) + assert resp.status_code == 200 + keys = resp.json()["result"].keys() + assert {"macd", "signal", "hist"} == set(keys) + + def test_bbands_endpoint(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post("/indicators/bbands", json={"close": close, "timeperiod": 5}) + assert resp.status_code == 200 + keys = resp.json()["result"].keys() + assert {"upper", "middle", "lower"} == set(keys) + + def test_backtest_endpoint(self, client): + close = list( + np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100 + ) + resp = client.post( + "/backtest", + json={"close": close, "strategy": "rsi_30_70"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "final_equity" in body + assert "n_trades" in body + + def test_unknown_strategy_returns_422(self, client): + close = list(np.linspace(100, 110, 30)) + resp = client.post( + "/backtest", + json={"close": close, "strategy": "no_such_strategy"}, + ) + assert resp.status_code == 422 + + def test_too_short_series_returns_422(self, client): + resp = client.post("/indicators/sma", json={"close": [100.0], "timeperiod": 5}) + assert resp.status_code == 422 + + +# =========================================================================== +# Benchmark suite sanity +# =========================================================================== + + +class TestBenchmarkSuite: + def test_canonical_fixture_exists(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + assert fixture.exists(), f"Canonical fixture not found: {fixture}" + + def test_canonical_fixture_loadable(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + if not fixture.exists(): + pytest.skip("Canonical fixture not found") + data = np.load(fixture) + for key in ["open", "high", "low", "close", "volume"]: + assert key in data.files, f"Missing key '{key}' in fixture" + assert len(data["close"]) == 2000 + + def test_benchmark_indicators_run(self): + import pathlib + + fixture = ( + pathlib.Path(__file__).parent.parent.parent + / "benchmarks" + / "fixtures" + / "canonical_ohlcv.npz" + ) + if not fixture.exists(): + pytest.skip("Canonical fixture not found") + import ferro_ta as ft + + data = np.load(fixture) + close = data["close"] + high = data["high"] + low = data["low"] + + out_sma = np.asarray(ft.SMA(close, timeperiod=20)) + out_rsi = np.asarray(ft.RSI(close, timeperiod=14)) + out_atr = np.asarray(ft.ATR(high, low, close, timeperiod=14)) + + assert len(out_sma) == len(close) + assert len(out_rsi) == len(close) + assert len(out_atr) == len(close) + # Last value should be finite + assert np.isfinite(out_sma[-1]) + assert np.isfinite(out_rsi[-1]) + assert np.isfinite(out_atr[-1]) + + +# =========================================================================== +# Options / IV helpers +# =========================================================================== + + +class TestIVRank: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(100) + result = iv_rank(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(50) + result = iv_rank(iv, window=10) + assert np.all(np.isnan(result[:9])) + assert not np.isnan(result[9]) + + def test_values_in_0_1(self): + from ferro_ta.analysis.options import iv_rank + + iv = _make_close(100) + result = iv_rank(iv, window=20) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0.0) + assert np.all(valid <= 1.0) + + def test_max_value_is_1(self): + from ferro_ta.analysis.options import iv_rank + + # The maximum of a window should produce rank = 1 + iv = np.array([10.0, 20.0, 30.0, 40.0, 50.0]) + result = iv_rank(iv, window=5) + assert result[4] == pytest.approx(1.0) + + def test_min_value_is_0(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.array([50.0, 40.0, 30.0, 20.0, 10.0]) + result = iv_rank(iv, window=5) + assert result[4] == pytest.approx(0.0) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_rank + + with pytest.raises(Exception): + iv_rank(np.array([]), window=5) + + def test_window_1(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.array([10.0, 20.0, 30.0]) + result = iv_rank(iv, window=1) + # With window=1, all values are equal to min=max, so rank=0 + assert np.all(result == 0.0) + + def test_invalid_window_raises(self): + from ferro_ta.analysis.options import iv_rank + + with pytest.raises(Exception): + iv_rank(np.array([1.0, 2.0]), window=0) + + def test_flat_series(self): + from ferro_ta.analysis.options import iv_rank + + iv = np.ones(30) * 25.0 + result = iv_rank(iv, window=10) + valid = result[~np.isnan(result)] + assert np.all(valid == 0.0) + + +class TestIVPercentile: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(100) + result = iv_percentile(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(50) + result = iv_percentile(iv, window=10) + assert np.all(np.isnan(result[:9])) + + def test_values_in_0_1(self): + from ferro_ta.analysis.options import iv_percentile + + iv = _make_close(100) + result = iv_percentile(iv, window=20) + valid = result[~np.isnan(result)] + assert np.all(valid >= 0.0) + assert np.all(valid <= 1.0) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_percentile + + with pytest.raises(Exception): + iv_percentile(np.array([]), window=5) + + def test_known_value(self): + from ferro_ta.analysis.options import iv_percentile + + iv = np.array([10.0, 20.0, 30.0, 15.0, 22.0]) + result = iv_percentile(iv, window=3) + # At index 2: window=[10,20,30], current=30. All 3 <= 30 → 3/3 = 1.0 + assert result[2] == pytest.approx(1.0) + # At index 3: window=[20,30,15], current=15. Only 15 <= 15 → 1/3 + assert result[3] == pytest.approx(1.0 / 3.0) + + +class TestIVZScore: + def test_basic_shape(self): + from ferro_ta.analysis.options import iv_zscore + + iv = _make_close(100) + result = iv_zscore(iv, window=20) + assert result.shape == (100,) + + def test_warmup_nan(self): + from ferro_ta.analysis.options import iv_zscore + + iv = _make_close(50) + result = iv_zscore(iv, window=10) + assert np.all(np.isnan(result[:9])) + + def test_flat_is_nan(self): + from ferro_ta.analysis.options import iv_zscore + + # Flat series has std=0, so z-score should be NaN + iv = np.ones(30) * 20.0 + result = iv_zscore(iv, window=10) + valid = result[~np.isnan(result)] + assert len(valid) == 0 or np.all(np.isnan(valid)) + + def test_empty_raises(self): + from ferro_ta.analysis.options import iv_zscore + + with pytest.raises(Exception): + iv_zscore(np.array([]), window=5) + + def test_known_value(self): + from ferro_ta.analysis.options import iv_zscore + + iv = np.array([10.0, 20.0, 30.0]) + result = iv_zscore(iv, window=3) + # mean=20, std=std([10,20,30],ddof=0)=8.165... + expected = (30.0 - 20.0) / np.std([10.0, 20.0, 30.0], ddof=0) + assert result[2] == pytest.approx(expected, rel=1e-6) + + +# =========================================================================== +# Agentic tools and workflow +# =========================================================================== + + +class TestComputeIndicator: + def test_sma_basic(self): + from ferro_ta.tools import compute_indicator + + close = np.linspace(100, 110, 20) + result = compute_indicator("SMA", close, timeperiod=5) + assert isinstance(result, np.ndarray) + assert result.shape == (20,) + + def test_rsi_basic(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(100) + result = compute_indicator("RSI", close, timeperiod=14) + assert isinstance(result, np.ndarray) + assert result.shape == (100,) + + def test_bbands_multi_output(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(50) + result = compute_indicator("BBANDS", close, timeperiod=10) + assert isinstance(result, dict) + assert "upper" in result + assert "middle" in result + assert "lower" in result + + def test_macd_multi_output(self): + from ferro_ta.tools import compute_indicator + + close = _make_close(100) + result = compute_indicator( + "MACD", close, fastperiod=5, slowperiod=10, signalperiod=3 + ) + assert isinstance(result, dict) + assert "macd" in result + assert "signal" in result + assert "hist" in result + + def test_unknown_indicator_raises(self): + from ferro_ta.tools import compute_indicator + + with pytest.raises(Exception): + compute_indicator("NO_SUCH_INDICATOR", np.ones(20)) + + +class TestRunBacktest: + def test_basic_result_shape(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("rsi_30_70", close) + assert isinstance(summary, dict) + assert "final_equity" in summary + assert "n_trades" in summary + assert "n_bars" in summary + assert "equity" in summary + assert "signals" in summary + assert "max_drawdown" in summary + assert summary["n_bars"] == 200 + assert isinstance(summary["final_equity"], float) + + def test_equity_list(self): + from ferro_ta.tools import run_backtest + + close = _make_close(100) + summary = run_backtest("rsi_30_70", close) + assert isinstance(summary["equity"], list) + assert len(summary["equity"]) == 100 + + def test_sma_crossover_strategy(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("sma_crossover", close, fast=5, slow=20) + assert "final_equity" in summary + + def test_macd_crossover_strategy(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("macd_crossover", close) + assert "final_equity" in summary + + def test_unknown_strategy_raises(self): + from ferro_ta.tools import run_backtest + + with pytest.raises(Exception): + run_backtest("no_such_strategy", _make_close(100)) + + def test_max_drawdown_non_negative(self): + from ferro_ta.tools import run_backtest + + close = _make_close(200) + summary = run_backtest("rsi_30_70", close) + assert summary["max_drawdown"] >= 0.0 + + +class TestListIndicators: + def test_returns_list(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert isinstance(names, list) + assert len(names) > 0 + + def test_contains_sma_rsi(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert "SMA" in names + assert "RSI" in names + + def test_sorted(self): + from ferro_ta.tools import list_indicators + + names = list_indicators() + assert names == sorted(names) + + +class TestDescribeIndicator: + def test_returns_string(self): + from ferro_ta.tools import describe_indicator + + desc = describe_indicator("SMA") + assert isinstance(desc, str) + assert len(desc) > 0 + + def test_unknown_raises(self): + from ferro_ta.tools import describe_indicator + + with pytest.raises(Exception): + describe_indicator("NO_SUCH_INDICATOR") + + +class TestWorkflow: + def test_basic_indicators(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("sma_20", "SMA", timeperiod=20) + .add_indicator("rsi_14", "RSI", timeperiod=14) + .run(close) + ) + assert "sma_20" in result + assert "rsi_14" in result + assert result["sma_20"].shape == (200,) + assert result["rsi_14"].shape == (200,) + + def test_with_strategy(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_strategy("rsi_30_70") + .run(close) + ) + assert "backtest" in result + assert "final_equity" in result["backtest"] + + def test_with_alert(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(200) + result = ( + Workflow() + .add_indicator("rsi_14", "RSI", timeperiod=14) + .add_alert("rsi_14", level=30.0, direction=-1) + .run(close) + ) + assert "rsi_14" in result + # Alert key should be present + alert_keys = [k for k in result if k.startswith("alert_")] + assert len(alert_keys) > 0 + + def test_empty_workflow(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(50) + result = Workflow().run(close) + assert isinstance(result, dict) + assert len(result) == 0 + + def test_multi_output_indicator(self): + from ferro_ta.tools.workflow import Workflow + + close = _make_close(100) + result = Workflow().add_indicator("bb", "BBANDS", timeperiod=10).run(close) + assert "bb" in result + # BBANDS returns dict from compute_indicator + assert isinstance(result["bb"], dict) + + +class TestRunPipeline: + def test_basic_pipeline(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={ + "sma_20": {"name": "SMA", "timeperiod": 20}, + "rsi_14": {"name": "RSI", "timeperiod": 14}, + }, + ) + assert "sma_20" in result + assert "rsi_14" in result + + def test_with_strategy(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, + strategy="rsi_30_70", + ) + assert "backtest" in result + + def test_no_indicators(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(100) + result = run_pipeline(close) + assert isinstance(result, dict) + + def test_with_alert(self): + from ferro_ta.tools.workflow import run_pipeline + + close = _make_close(200) + result = run_pipeline( + close, + indicators={"rsi_14": {"name": "RSI", "timeperiod": 14}}, + alert_indicator="rsi_14", + alert_level=30.0, + alert_direction=-1, + ) + assert "rsi_14" in result + alert_keys = [k for k in result if k.startswith("alert_")] + assert len(alert_keys) > 0 + + +# =========================================================================== +# MCP server +# =========================================================================== + + +class TestMCPListTools: + def test_list_tools_returns_dict(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + assert isinstance(result, dict) + assert "tools" in result + + def test_list_tools_has_required_tools(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + names = [t["name"] for t in result["tools"]] + assert len(names) > 250 + for expected in ( + "sma", + "ema", + "rsi", + "macd", + "backtest", + "SMA", + "compute_indicator", + "about", + "check_cross", + "TickAggregator", + "call_instance_method", + "call_stored_callable", + "delete_instance", + ): + assert expected in names, f"Expected tool '{expected}' not found" + + def test_each_tool_has_schema(self): + from ferro_ta.mcp import handle_list_tools + + result = handle_list_tools() + for tool in result["tools"]: + assert "name" in tool + assert "description" in tool + assert "inputSchema" in tool + + +class TestMCPCallTool: + def test_sma_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("sma", {"close": close, "timeperiod": 5}) + assert "content" in result + import json + + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 30 + + def test_ema_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("ema", {"close": close, "timeperiod": 5}) + assert "content" in result + + def test_rsi_call(self): + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(50)) + result = handle_call_tool("rsi", {"close": close, "timeperiod": 14}) + assert "content" in result + + def test_macd_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool("macd", {"close": close}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert "macd" in payload + + def test_backtest_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(200)) + result = handle_call_tool("backtest", {"close": close, "strategy": "rsi_30_70"}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert "final_equity" in payload + assert "n_trades" in payload + + def test_top_level_sma_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(np.linspace(100, 110, 30)) + result = handle_call_tool("SMA", {"close": close, "timeperiod": 5}) + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 30 + + def test_compute_indicator_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool( + "compute_indicator", + { + "name": "MACD", + "args": [close], + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert "macd" in payload + + def test_about_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("about", {}) + payload = json.loads(result["content"][0]["text"]) + assert payload["indicator_count"] >= 200 + assert payload["method_count"] >= 400 + + def test_check_cross_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool( + "check_cross", + { + "fast": [1.0, 2.0, 3.0, 2.0, 1.0], + "slow": [2.0, 2.0, 2.0, 2.0, 2.0], + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert len(payload) == 5 + + def test_list_indicators_call(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("list_indicators", {}) + assert "content" in result + payload = json.loads(result["content"][0]["text"]) + assert isinstance(payload, list) + assert "SMA" in payload + + def test_describe_indicator_call(self): + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("describe_indicator", {"name": "SMA"}) + assert "content" in result + text = result["content"][0]["text"] + assert isinstance(text, str) + assert len(text) > 0 + + def test_unknown_tool_returns_error(self): + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool("no_such_tool", {}) + assert result.get("isError") is True + + def test_tool_error_handling(self): + from ferro_ta.mcp import handle_call_tool + + # Pass an invalid series to trigger an error + result = handle_call_tool("sma", {"close": [], "timeperiod": 5}) + # Should return error content, not raise + assert "content" in result or "isError" in result + + def test_backtest_unknown_strategy(self): + from ferro_ta.mcp import handle_call_tool + + close = list(_make_close(100)) + result = handle_call_tool( + "backtest", {"close": close, "strategy": "no_strategy"} + ) + assert result.get("isError") is True or "content" in result + + def test_tick_aggregator_instance_lifecycle(self): + import json + + from ferro_ta.mcp import handle_call_tool + + created = json.loads( + handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"] + ) + instance_id = created["instance_id"] + + described = json.loads( + handle_call_tool("describe_instance", {"instance_id": instance_id})[ + "content" + ][0]["text"] + ) + method_names = [item["name"] for item in described["methods"]] + assert "aggregate" in method_names + + aggregated = json.loads( + handle_call_tool( + "call_instance_method", + { + "instance_id": instance_id, + "method": "aggregate", + "args": [ + { + "price": [1.0, 2.0, 3.0, 4.0], + "size": [1.0, 1.0, 1.0, 1.0], + } + ], + }, + )["content"][0]["text"] + ) + assert "open" in aggregated + assert "close" in aggregated + + deleted = json.loads( + handle_call_tool("delete_instance", {"instance_id": instance_id})[ + "content" + ][0]["text"] + ) + assert deleted["deleted"] is True + + def test_stored_callable_can_be_invoked(self): + import json + + from ferro_ta.mcp import handle_call_tool + + wrapped = json.loads( + handle_call_tool("traced", {"func": {"callable": "SMA"}})["content"][0][ + "text" + ] + ) + instance_id = wrapped["instance_id"] + + called = json.loads( + handle_call_tool( + "call_stored_callable", + { + "instance_id": instance_id, + "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], + "kwargs": {"timeperiod": 3}, + }, + )["content"][0]["text"] + ) + assert len(called) == 5 + + handle_call_tool("delete_instance", {"instance_id": instance_id}) + + def test_benchmark_accepts_callable_reference(self): + import json + + from ferro_ta.mcp import handle_call_tool + + result = handle_call_tool( + "benchmark", + { + "func": {"callable": "SMA"}, + "args": [[1.0, 2.0, 3.0, 4.0, 5.0]], + "kwargs": {"timeperiod": 3}, + "n": 2, + "warmup": 0, + }, + ) + payload = json.loads(result["content"][0]["text"]) + assert payload["n"] == 2.0 + assert "mean_ms" in payload + + +class TestMCPServer: + def test_create_server_requires_mcp_dependency(self, monkeypatch): + import ferro_ta.mcp as mcp_mod + + real_import_module = importlib.import_module + + def fake_import_module(name, package=None): + if name.startswith("mcp"): + raise ImportError("No module named 'mcp'") + return real_import_module(name, package) + + mcp_mod.create_server.cache_clear() + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + with pytest.raises(RuntimeError, match='pip install "ferro-ta\\[mcp\\]"'): + mcp_mod.create_server() + + def test_main_entrypoint_invokes_run_server(self, monkeypatch): + import ferro_ta.mcp as mcp_mod + + calls: list[str] = [] + + monkeypatch.setattr(mcp_mod, "run_server", lambda: calls.append("called")) + runpy.run_module("ferro_ta.mcp.__main__", run_name="__main__") + + assert calls == ["called"] + + def test_create_server_registers_generated_tools(self): + import ferro_ta.mcp as mcp_mod + + server = mcp_mod.create_server() + tool_names = [tool.name for tool in server._tool_manager.list_tools()] + + assert "SMA" in tool_names + assert "TickAggregator" in tool_names + assert "call_instance_method" in tool_names diff --git a/vendor/ferro-ta-main/tests/unit/test_validation.py b/vendor/ferro-ta-main/tests/unit/test_validation.py new file mode 100644 index 0000000..e19d8f8 --- /dev/null +++ b/vendor/ferro-ta-main/tests/unit/test_validation.py @@ -0,0 +1,155 @@ +"""Tests for validation and error handling.""" + +import numpy as np +import pytest + +from ferro_ta import ( + ATR, + BBANDS, + CDLDOJI, + MACD, + RSI, + SMA, + FerroTAInputError, + FerroTAValueError, +) +from ferro_ta.core.exceptions import check_min_length, check_timeperiod + +# --------------------------------------------------------------------------- +# Invalid timeperiod / period parameters → FerroTAValueError +# --------------------------------------------------------------------------- + + +class TestInvalidTimeperiod: + """Invalid period parameters must raise FerroTAValueError.""" + + def test_sma_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_sma_timeperiod_negative(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=-1) + + def test_rsi_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + RSI(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_macd_fast_slow_periods(self): + close = np.array([1.0, 2.0, 3.0, 4.0, 5.0] * 10) + with pytest.raises(FerroTAValueError): + MACD(close, fastperiod=26, slowperiod=12) + + def test_bbands_timeperiod_zero(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + BBANDS(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_atr_timeperiod_zero(self): + h = np.array([1.0, 2.0, 3.0]) + low = np.array([0.5, 1.5, 2.5]) + c = np.array([0.8, 1.8, 2.8]) + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + ATR(h, low, c, timeperiod=0) + + +# --------------------------------------------------------------------------- +# Mismatched array lengths → FerroTAInputError +# --------------------------------------------------------------------------- + + +class TestMismatchedLengths: + """Mismatched OHLCV lengths must raise FerroTAInputError.""" + + def test_atr_mismatched_lengths(self): + h = np.array([1.0, 2.0, 3.0]) + low = np.array([0.5, 1.5]) + c = np.array([0.8, 1.8, 2.8]) + with pytest.raises(FerroTAInputError, match="same length"): + ATR(h, low, c, timeperiod=2) + + def test_cdl_pattern_mismatched_lengths(self): + open_ = np.array([1.0, 2.0, 3.0]) + high = np.array([1.1, 2.1]) + low = np.array([0.9, 1.9, 2.9]) + close = np.array([1.05, 2.05, 3.05]) + with pytest.raises(FerroTAInputError, match="same length"): + CDLDOJI(open_, high, low, close) + + +# --------------------------------------------------------------------------- +# Empty and short arrays (defined behaviour or clear exception) +# --------------------------------------------------------------------------- + + +class TestEmptyAndShortArrays: + """Empty or too-short arrays have defined behaviour or raise.""" + + def test_sma_empty_array(self): + # Empty array: _to_f64 returns shape (0,); Rust may return empty or raise. + arr = np.array([], dtype=np.float64) + result = SMA(arr, timeperiod=1) + assert result.shape == (0,) + + def test_sma_single_element_timeperiod_one(self): + arr = np.array([1.0]) + result = SMA(arr, timeperiod=1) + assert len(result) == 1 + assert result[0] == 1.0 + + def test_sma_short_array_timeperiod_larger_than_length(self): + # len=3, timeperiod=5 → output is all NaN for warmup + arr = np.array([1.0, 2.0, 3.0]) + result = SMA(arr, timeperiod=5) + assert len(result) == 3 + assert np.all(np.isnan(result)) + + def test_rsi_all_nan_input(self): + # All-NaN input: output is all NaN (propagation) + arr = np.array([np.nan, np.nan, np.nan, np.nan, np.nan]) + result = RSI(arr, timeperiod=2) + assert len(result) == 5 + assert np.all(np.isnan(result)) + + +# --------------------------------------------------------------------------- +# Validation helpers (check_timeperiod, check_min_length) +# --------------------------------------------------------------------------- + + +class TestValidationHelpers: + """Exported validation helpers behave as documented.""" + + def test_check_timeperiod_ok(self): + check_timeperiod(5) + check_timeperiod(1) + + def test_check_timeperiod_raises(self): + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + check_timeperiod(0) + with pytest.raises(FerroTAValueError, match="timeperiod must be >= 1"): + check_timeperiod(-1) + + def test_check_min_length_ok(self): + check_min_length(np.array([1.0, 2.0, 3.0]), 2) + check_min_length([1, 2, 3], 3) + + def test_check_min_length_raises(self): + with pytest.raises(FerroTAInputError, match="at least 3 elements"): + check_min_length(np.array([1.0, 2.0]), 3, name="input") + + +# --------------------------------------------------------------------------- +# Exception inheritance (ValueError still works) +# --------------------------------------------------------------------------- + + +class TestExceptionInheritance: + """FerroTAValueError/FerroTAInputError are ValueErrors for backward compatibility.""" + + def test_catch_value_error(self): + with pytest.raises(ValueError, match="timeperiod must be >= 1"): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) + + def test_catch_ferro_ta_value_error(self): + with pytest.raises(FerroTAValueError): + SMA(np.array([1.0, 2.0, 3.0]), timeperiod=0) diff --git a/vendor/ferro-ta-main/tests/unit/tools/__init__.py b/vendor/ferro-ta-main/tests/unit/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vendor/ferro-ta-main/uv.lock b/vendor/ferro-ta-main/uv.lock new file mode 100644 index 0000000..363a20c --- /dev/null +++ b/vendor/ferro-ta-main/uv.lock @@ -0,0 +1,4456 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[manifest] +constraints = [ + { name = "pygments", specifier = ">=2.20.0" }, + { name = "requests", specifier = ">=2.33.0" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anywidget" +version = "0.9.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipywidgets" }, + { name = "psygnal" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/5e/cbea445bf062b81e4d366ca29dae4f0aedc7a64f384afc24670e07bec560/anywidget-0.9.21.tar.gz", hash = "sha256:b8d0172029ac426573053c416c6a587838661612208bb390fa0607862e594b27", size = 390517, upload-time = "2025-11-12T17:06:03.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/03/c17464bbf682ea87e7e3de2ddc63395e359a78ae9c01f55fc78759ecbd79/anywidget-0.9.21-py3-none-any.whl", hash = "sha256:78c268e0fbdb1dfd15da37fb578f9cf0a0df58a430e68d9156942b7a9391a761", size = 231797, upload-time = "2025-11-12T17:06:01.564Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backtesting" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bokeh" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/cc/a3bf58f45e1a58c28681fe1f173cdf748bd91e7cde60e3dcc29c8e9aa194/backtesting-0.6.5.tar.gz", hash = "sha256:738a1dee28fc53df2eda35ea2f2d1a1c37ddba01df14223fc9e87d80a1efbc2e", size = 194025, upload-time = "2025-07-30T05:57:05.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/b6/cf57538b968c5caa60ee626ec8be1c31e420067d2a4cf710d81605356f8c/backtesting-0.6.5-py3-none-any.whl", hash = "sha256:8ac2fa500c8fd83dc783b72957b600653a72687986fe3ca86d6ef6c8b8d74363", size = 192105, upload-time = "2025-07-30T05:57:03.322Z" }, +] + +[[package]] +name = "backtrader" +version = "1.9.78.123" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ef/328c6ec332435f63b3e18febd263686b8ba07e990676a862cc8522ba38f5/backtrader-1.9.78.123-py2.py3-none-any.whl", hash = "sha256:9a07a516b0de9155539a35c56e9404d8711dd7020b3d37b30495e83e1b9d5dfd", size = 419517, upload-time = "2023-04-19T14:13:18.842Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "bokeh" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jinja2" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tornado", marker = "sys_platform != 'emscripten'" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/0d/fabb70707646217e4b0e3943e05730eab8c1f7b7e7485145f8594b52e606/bokeh-3.9.0.tar.gz", hash = "sha256:775219714a8496973ddbae16b1861606ba19fe670a421e4d43267b41148e07a3", size = 5740345, upload-time = "2026-03-11T17:58:34.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/0b/bdf449df87be3f07b23091ceafee8c3ef569cf6d2fb7edec6e3b12b3faa4/bokeh-3.9.0-py3-none-any.whl", hash = "sha256:b252bfb16a505f0e0c57d532d0df308ae1667235bafc622aa9441fe9e7c5ce4a", size = 6396068, upload-time = "2026-03-11T17:58:31.645Z" }, +] + +[[package]] +name = "build" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/18/94eaffda7b329535d91f00fe605ab1f1e5cd68b2074d03f255c7d250687d/build-1.4.0.tar.gz", hash = "sha256:f1b91b925aa322be454f8330c6fb48b465da993d1e7e7e6fa35027ec49f3c936", size = 50054, upload-time = "2026-01-08T16:41:47.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl", hash = "sha256:6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596", size = 24141, upload-time = "2026-01-08T16:41:46.453Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, + { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, + { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, + { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, + { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, + { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +curand = [ + { name = "nvidia-curand", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusolver = [ + { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and sys_platform == 'win32') or sys_platform == 'linux'" }, +] + +[[package]] +name = "curl-cffi" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/3d/f39ca1f8fdf14408888e7c25e15eed63eac5f47926e206fb93300d28378c/curl_cffi-0.13.0.tar.gz", hash = "sha256:62ecd90a382bd5023750e3606e0aa7cb1a3a8ba41c14270b8e5e149ebf72c5ca", size = 151303, upload-time = "2025-08-06T13:05:42.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/d1/acabfd460f1de26cad882e5ef344d9adde1507034528cb6f5698a2e6a2f1/curl_cffi-0.13.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:434cadbe8df2f08b2fc2c16dff2779fb40b984af99c06aa700af898e185bb9db", size = 5686337, upload-time = "2025-08-06T13:05:28.985Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1c/cdb4fb2d16a0e9de068e0e5bc02094e105ce58a687ff30b4c6f88e25a057/curl_cffi-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:59afa877a9ae09efa04646a7d068eeea48915a95d9add0a29854e7781679fcd7", size = 2994613, upload-time = "2025-08-06T13:05:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3e/fdf617c1ec18c3038b77065d484d7517bb30f8fb8847224eb1f601a4e8bc/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d06ed389e45a7ca97b17c275dbedd3d6524560270e675c720e93a2018a766076", size = 7931353, upload-time = "2025-08-06T13:05:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/3d/10/6f30c05d251cf03ddc2b9fd19880f3cab8c193255e733444a2df03b18944/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4e0de45ab3b7a835c72bd53640c2347415111b43421b5c7a1a0b18deae2e541", size = 7486378, upload-time = "2025-08-06T13:05:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/77/81/5bdb7dd0d669a817397b2e92193559bf66c3807f5848a48ad10cf02bf6c7/curl_cffi-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8eb4083371bbb94e9470d782de235fb5268bf43520de020c9e5e6be8f395443f", size = 8328585, upload-time = "2025-08-06T13:05:35.28Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/df5c6b4cfad41c08442e0f727e449f4fb5a05f8aa564d1acac29062e9e8e/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:28911b526e8cd4aa0e5e38401bfe6887e8093907272f1f67ca22e6beb2933a51", size = 8739831, upload-time = "2025-08-06T13:05:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/1a/91/6dd1910a212f2e8eafe57877bcf97748eb24849e1511a266687546066b8a/curl_cffi-0.13.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6d433ffcb455ab01dd0d7bde47109083aa38b59863aa183d29c668ae4c96bf8e", size = 8711908, upload-time = "2025-08-06T13:05:38.741Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e4/15a253f9b4bf8d008c31e176c162d2704a7e0c5e24d35942f759df107b68/curl_cffi-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:66a6b75ce971de9af64f1b6812e275f60b88880577bac47ef1fa19694fa21cd3", size = 1614510, upload-time = "2025-08-06T13:05:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0f/9c5275f17ad6ff5be70edb8e0120fdc184a658c9577ca426d4230f654beb/curl_cffi-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:d438a3b45244e874794bc4081dc1e356d2bb926dcc7021e5a8fef2e2105ef1d8", size = 1365753, upload-time = "2025-08-06T13:05:41.879Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dateparser" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + +[[package]] +name = "ferro-ta" +version = "1.2.0" +source = { editable = "." } +dependencies = [ + { name = "numpy" }, +] + +[package.optional-dependencies] +all = [ + { name = "pandas" }, + { name = "polars" }, + { name = "pytest" }, + { name = "pytest-benchmark" }, +] +benchmark = [ + { name = "pytest" }, + { name = "pytest-benchmark" }, +] +comparison = [ + { name = "backtesting" }, + { name = "backtrader" }, + { name = "pandas" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, + { name = "pytest" }, + { name = "quantstats" }, + { name = "ta" }, + { name = "ta-lib" }, + { name = "vectorbt" }, +] +dev = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "hypothesis" }, + { name = "matplotlib" }, + { name = "maturin" }, + { name = "mypy" }, + { name = "pandas" }, + { name = "polars" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +docs = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] +gpu = [ + { name = "torch" }, +] +mcp = [ + { name = "mcp" }, +] +pandas = [ + { name = "pandas" }, +] +polars = [ + { name = "polars" }, +] +test = [ + { name = "hypothesis" }, + { name = "pytest" }, +] + +[package.dev-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "maturin" }, + { name = "mypy" }, + { name = "pandas" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12'" }, + { name = "polars" }, + { name = "pre-commit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ta" }, +] + +[package.metadata] +requires-dist = [ + { name = "backtesting", marker = "extra == 'comparison'", specifier = ">=0.6" }, + { name = "backtrader", marker = "extra == 'comparison'", specifier = ">=1.9" }, + { name = "fastapi", marker = "extra == 'dev'", specifier = ">=0.135.1" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.24" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.0" }, + { name = "matplotlib", marker = "extra == 'dev'", specifier = ">=3.5" }, + { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.0,<2.0" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "numpy", specifier = ">=1.20" }, + { name = "pandas", marker = "extra == 'all'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'comparison'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pandas", marker = "extra == 'pandas'", specifier = ">=1.0" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12' and extra == 'comparison'", specifier = ">=0.3" }, + { name = "polars", marker = "extra == 'all'", specifier = ">=0.19" }, + { name = "polars", marker = "extra == 'dev'", specifier = ">=0.19" }, + { name = "polars", marker = "extra == 'polars'", specifier = ">=0.19" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, + { name = "pytest", marker = "extra == 'all'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'benchmark'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'comparison'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0" }, + { name = "pytest-benchmark", marker = "extra == 'all'", specifier = ">=4.0" }, + { name = "pytest-benchmark", marker = "extra == 'benchmark'", specifier = ">=4.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" }, + { name = "scipy", marker = "extra == 'dev'", specifier = ">=1.10" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" }, + { name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" }, + { name = "ta-lib", marker = "extra == 'comparison'", specifier = ">=0.4" }, + { name = "torch", marker = "extra == 'gpu'", specifier = ">=2.0" }, + { name = "vectorbt", marker = "extra == 'comparison'", specifier = ">=0.28" }, +] +provides-extras = ["test", "benchmark", "pandas", "polars", "docs", "comparison", "gpu", "options", "mcp", "all", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "hypothesis", specifier = ">=6.0" }, + { name = "maturin", specifier = ">=1.0,<2.0" }, + { name = "mypy", specifier = ">=1.0" }, + { name = "pandas", specifier = ">=1.0" }, + { name = "pandas-ta", marker = "python_full_version >= '3.12'", specifier = ">=0.3" }, + { name = "polars", specifier = ">=0.19" }, + { name = "pre-commit", specifier = ">=3.0" }, + { name = "pyright", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.3" }, + { name = "scipy", specifier = ">=1.15.3" }, + { name = "ta", specifier = ">=0.10" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/94/8a28707adb00bed1bf22dac16ccafe60faf2ade353dcb32c3617ee917307/fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24", size = 2854799, upload-time = "2025-12-12T17:29:27.5Z" }, + { url = "https://files.pythonhosted.org/packages/94/93/c2e682faaa5ee92034818d8f8a8145ae73eb83619600495dcf8503fa7771/fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958", size = 2403032, upload-time = "2025-12-12T17:29:30.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/62/1748f7e7e1ee41aa52279fd2e3a6d0733dc42a673b16932bad8e5d0c8b28/fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da", size = 4897863, upload-time = "2025-12-12T17:29:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/69/69/4ca02ee367d2c98edcaeb83fc278d20972502ee071214ad9d8ca85e06080/fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6", size = 4859076, upload-time = "2025-12-12T17:29:34.907Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f5/660f9e3cefa078861a7f099107c6d203b568a6227eef163dd173bfc56bdc/fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1", size = 4875623, upload-time = "2025-12-12T17:29:37.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/d1/9d7c5091d2276ed47795c131c1bf9316c3c1ab2789c22e2f59e0572ccd38/fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881", size = 4993327, upload-time = "2025-12-12T17:29:39.781Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2d/28def73837885ae32260d07660a052b99f0aa00454867d33745dfe49dbf0/fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47", size = 1502180, upload-time = "2025-12-12T17:29:42.217Z" }, + { url = "https://files.pythonhosted.org/packages/63/fa/bfdc98abb4dd2bd491033e85e3ba69a2313c850e759a6daa014bc9433b0f/fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6", size = 1550654, upload-time = "2025-12-12T17:29:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/69/12/bf9f4eaa2fad039356cc627587e30ed008c03f1cebd3034376b5ee8d1d44/fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09", size = 2852213, upload-time = "2025-12-12T17:29:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/ac/49/4138d1acb6261499bedde1c07f8c2605d1d8f9d77a151e5507fd3ef084b6/fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37", size = 2401689, upload-time = "2025-12-12T17:29:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/e5/fe/e6ce0fe20a40e03aef906af60aa87668696f9e4802fa283627d0b5ed777f/fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb", size = 5058809, upload-time = "2025-12-12T17:29:51.701Z" }, + { url = "https://files.pythonhosted.org/packages/79/61/1ca198af22f7dd22c17ab86e9024ed3c06299cfdb08170640e9996d501a0/fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9", size = 5036039, upload-time = "2025-12-12T17:29:53.659Z" }, + { url = "https://files.pythonhosted.org/packages/99/cc/fa1801e408586b5fce4da9f5455af8d770f4fc57391cd5da7256bb364d38/fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87", size = 5034714, upload-time = "2025-12-12T17:29:55.592Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/b7aeafe65adb1b0a925f8f25725e09f078c635bc22754f3fecb7456955b0/fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56", size = 5158648, upload-time = "2025-12-12T17:29:57.861Z" }, + { url = "https://files.pythonhosted.org/packages/99/f9/08ea7a38663328881384c6e7777bbefc46fd7d282adfd87a7d2b84ec9d50/fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a", size = 2280681, upload-time = "2025-12-12T17:29:59.943Z" }, + { url = "https://files.pythonhosted.org/packages/07/ad/37dd1ae5fa6e01612a1fbb954f0927681f282925a86e86198ccd7b15d515/fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7", size = 2331951, upload-time = "2025-12-12T17:30:02.254Z" }, + { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, + { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, + { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cf/00ba28b0990982530addb8dc3e9e6f2fa9cb5c20df2abdda7baa755e8fe1/fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c", size = 2846454, upload-time = "2025-12-12T17:30:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ca/468c9a8446a2103ae645d14fee3f610567b7042aba85031c1c65e3ef7471/fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e", size = 2398191, upload-time = "2025-12-12T17:30:27.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/d67eedaed19def5967fade3297fed8161b25ba94699efc124b14fb68cdbc/fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5", size = 4928410, upload-time = "2025-12-12T17:30:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/b0/8d/6fb3494dfe61a46258cd93d979cf4725ded4eb46c2a4ca35e4490d84daea/fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd", size = 4984460, upload-time = "2025-12-12T17:30:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f1/a47f1d30b3dc00d75e7af762652d4cbc3dff5c2697a0dbd5203c81afd9c3/fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3", size = 4925800, upload-time = "2025-12-12T17:30:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/a7/01/e6ae64a0981076e8a66906fab01539799546181e32a37a0257b77e4aa88b/fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d", size = 5067859, upload-time = "2025-12-12T17:30:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/73/aa/28e40b8d6809a9b5075350a86779163f074d2b617c15d22343fce81918db/fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c", size = 2267821, upload-time = "2025-12-12T17:30:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/1a/59/453c06d1d83dc0951b69ef692d6b9f1846680342927df54e9a1ca91c6f90/fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b", size = 2318169, upload-time = "2025-12-12T17:30:40.951Z" }, + { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +] + +[[package]] +name = "frozendict" +version = "2.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" }, + { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" }, + { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" }, + { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" }, + { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.151.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/e1/ef365ff480903b929d28e057f57b76cae51a30375943e33374ec9a165d9c/hypothesis-6.151.9.tar.gz", hash = "sha256:2f284428dda6c3c48c580de0e18470ff9c7f5ef628a647ee8002f38c3f9097ca", size = 463534, upload-time = "2026-02-16T22:59:23.09Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, +] + +[[package]] +name = "ipython" +version = "9.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, + { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306, upload-time = "2025-01-20T11:12:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096, upload-time = "2025-01-20T11:12:24.544Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859, upload-time = "2025-01-20T11:12:31.839Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199, upload-time = "2025-01-20T11:12:40.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381, upload-time = "2025-01-20T11:12:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e2/86b245397052386595ad726f9742e5223d7aea999b18c518a50e96c3aca4/llvmlite-0.44.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:eed7d5f29136bda63b6d7804c279e2b72e08c952b7c5df61f45db408e0ee52f3", size = 28132305, upload-time = "2025-01-20T11:12:53.936Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ec/506902dc6870249fbe2466d9cf66d531265d0f3a1157213c8f986250c033/llvmlite-0.44.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ace564d9fa44bb91eb6e6d8e7754977783c68e90a471ea7ce913bff30bd62427", size = 26201090, upload-time = "2025-01-20T11:12:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/d030f1849ebb1f394bb3f7adad5e729b634fb100515594aca25c354ffc62/llvmlite-0.44.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5d22c3bfc842668168a786af4205ec8e3ad29fb1bc03fd11fd48460d0df64c1", size = 42361858, upload-time = "2025-01-20T11:13:07.623Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7a/ce6174664b9077fc673d172e4c888cb0b128e707e306bc33fff8c2035f0d/llvmlite-0.44.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f01a394e9c9b7b1d4e63c327b096d10f6f0ed149ef53d38a09b3749dcf8c9610", size = 41184200, upload-time = "2025-01-20T11:13:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/258801143975a6d09a373f2641237992496e15567b907a4d401839d671b8/llvmlite-0.44.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8489634d43c20cd0ad71330dde1d5bc7b9966937a263ff1ec1cebb90dc50955", size = 30331193, upload-time = "2025-01-20T11:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" }, + { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "maturin" +version = "1.12.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/18/8b2eebd3ea086a5ec73d7081f95ec64918ceda1900075902fc296ea3ad55/maturin-1.12.6.tar.gz", hash = "sha256:d37be3a811a7f2ee28a0fa0964187efa50e90f21da0c6135c27787fa0b6a89db", size = 269165, upload-time = "2026-03-01T14:54:04.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/8b/9ddfde8a485489e3ebdc50ee3042ef1c854f00dfea776b951068f6ffe451/maturin-1.12.6-py3-none-linux_armv6l.whl", hash = "sha256:6892b4176992fcc143f9d1c1c874a816e9a041248eef46433db87b0f0aff4278", size = 9789847, upload-time = "2026-03-01T14:54:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e8/5f7fd3763f214a77ac0388dbcc71cc30aec5490016bd0c8e6bd729fc7b0a/maturin-1.12.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c0c742beeeef7fb93b6a81bd53e75507887e396fd1003c45117658d063812dad", size = 19023833, upload-time = "2026-03-01T14:53:46.743Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/706ff3839c8b2046436d4c2bc97596c558728264d18abc298a1ad862a4be/maturin-1.12.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cb41139295eed6411d3cdafc7430738094c2721f34b7eeb44f33cac516115dc", size = 9821620, upload-time = "2026-03-01T14:54:12.04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9c/70917fb123c8dd6b595e913616c9c72d730cbf4a2b6cac8077dc02a12586/maturin-1.12.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:351f3af1488a7cbdcff3b6d8482c17164273ac981378a13a4a9937a49aec7d71", size = 9849107, upload-time = "2026-03-01T14:53:48.971Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/f1d6ad95c0a12fbe761a7c28a57540341f188564dbe8ad730a4d1788cd32/maturin-1.12.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6dbddfe4dc7ddee60bbac854870bd7cfec660acb54d015d24597d59a1c828f61", size = 10242855, upload-time = "2026-03-01T14:53:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/93/1b/2419843a4f1d2fb4747f3dc3d9c4a2881cd97a3274dd94738fcdf0835e79/maturin-1.12.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8fdb0f63e77ee3df0f027a120e9af78dbc31edf0eb0f263d55783c250c33b728", size = 9674972, upload-time = "2026-03-01T14:53:52.763Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b60ab2fc996d904b40e55bd475599dcdccd8f7ad3e649bf95e87970df466/maturin-1.12.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fa84b7493a2e80759cacc2e668fa5b444d55b9994e90707c42904f55d6322c1e", size = 9645755, upload-time = "2026-03-01T14:53:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/a4/96/03f2b55a8c226805115232fc23c4a4f33f0c9d39e11efab8166dc440f80d/maturin-1.12.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:e90dc12bc6a38e9495692a36c9e231c4d7e0c9bfde60719468ab7d8673db3c45", size = 12737612, upload-time = "2026-03-01T14:54:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c2/648667022c5b53cdccefa67c245e8a984970f3045820f00c2e23bdb2aff4/maturin-1.12.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:06fc8d089f98623ce924c669b70911dfed30f9a29956c362945f727f9abc546b", size = 10455028, upload-time = "2026-03-01T14:54:07.349Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/5b5efe3ca0c043357ed3f8d2b2d556169fdbf1ff75e50e8e597708a359d2/maturin-1.12.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:75133e56274d43b9227fd49dca9a86e32f1fd56a7b55544910c4ce978c2bb5aa", size = 10014531, upload-time = "2026-03-01T14:53:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/39c594c27b1a8b32a0cb95fff9ad60b888c4352d1d1c389ac1bd20dc1e16/maturin-1.12.6-py3-none-win32.whl", hash = "sha256:3f32e0a3720b81423c9d35c14e728cb1f954678124749776dc72d533ea1115e8", size = 8553012, upload-time = "2026-03-01T14:53:50.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/66/b262832a91747e04051e21f986bd01a8af81fbffafacc7d66a11e79aab5f/maturin-1.12.6-py3-none-win_amd64.whl", hash = "sha256:977290159d252db946054a0555263c59b3d0c7957135c69e690f4b1558ee9983", size = 9890470, upload-time = "2026-03-01T14:53:56.659Z" }, + { url = "https://files.pythonhosted.org/packages/e3/47/76b8ca470ddc8d7d36aa8c15f5a6aed1841806bb93a0f4ead8ee61e9a088/maturin-1.12.6-py3-none-win_arm64.whl", hash = "sha256:bae91976cdc8148038e13c881e1e844e5c63e58e026e8b9945aa2d19b3b4ae89", size = 8606158, upload-time = "2026-03-01T14:54:02.423Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.18.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/96/45218c2fdec4c9f22178f905086e85ef1a6d63862dcc3cd68eb60f1867f5/narwhals-2.18.1.tar.gz", hash = "sha256:652a1fcc9d432bbf114846688884c215f17eb118aa640b7419295d2f910d2a8b", size = 620578, upload-time = "2026-03-24T15:11:25.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/c3/06490e98393dcb4d6ce2bf331a39335375c300afaef526897881fbeae6ab/narwhals-2.18.1-py3-none-any.whl", hash = "sha256:a0a8bb80205323851338888ba3a12b4f65d352362c8a94be591244faf36504ad", size = 444952, upload-time = "2026-03-24T15:11:23.801Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numba" +version = "0.61.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663, upload-time = "2025-04-09T02:57:34.143Z" }, + { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344, upload-time = "2025-04-09T02:57:36.609Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054, upload-time = "2025-04-09T02:57:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531, upload-time = "2025-04-09T02:57:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612, upload-time = "2025-04-09T02:57:41.559Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/c99d1056aed767503c228f7099dc11c402906b42a4757fec2819329abb98/numba-0.61.2-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:efd3db391df53aaa5cfbee189b6c910a5b471488749fd6606c3f33fc984c2ae2", size = 2775825, upload-time = "2025-04-09T02:57:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/9e/63c549f37136e892f006260c3e2613d09d5120672378191f2dc387ba65a2/numba-0.61.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49c980e4171948ffebf6b9a2520ea81feed113c1f4890747ba7f59e74be84b1b", size = 2778695, upload-time = "2025-04-09T02:57:44.968Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/8740616c8436c86c1b9a62e72cb891177d2c34c2d24ddcde4c390371bf4c/numba-0.61.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3945615cd73c2c7eba2a85ccc9c1730c21cd3958bfcf5a44302abae0fb07bb60", size = 3829227, upload-time = "2025-04-09T02:57:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/fc/06/66e99ae06507c31d15ff3ecd1f108f2f59e18b6e08662cd5f8a5853fbd18/numba-0.61.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbfdf4eca202cebade0b7d43896978e146f39398909a42941c9303f82f403a18", size = 3523422, upload-time = "2025-04-09T02:57:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a4/2b309a6a9f6d4d8cfba583401c7c2f9ff887adb5d54d8e2e130274c0973f/numba-0.61.2-cp311-cp311-win_amd64.whl", hash = "sha256:76bcec9f46259cedf888041b9886e257ae101c6268261b19fda8cfbc52bec9d1", size = 2831505, upload-time = "2025-04-09T02:57:50.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.0.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" }, + { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas-ta" +version = "0.4.71b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numba", marker = "python_full_version >= '3.12'" }, + { name = "numpy", marker = "python_full_version >= '3.12'" }, + { name = "pandas", marker = "python_full_version >= '3.12'" }, + { name = "tqdm", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/6d/60b88a0334a8c6a5be114ed2c46c8f3e164127d0eccd9ff99b50773f2b20/pandas_ta-0.4.71b0.tar.gz", hash = "sha256:782ef8a874d2e0bdf80f445136617bda084f1fc5d14d3b1c525b282a152de37a", size = 1310317, upload-time = "2025-09-14T19:08:36.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/c67d49afd31c3b02a02ecb5dd07399ed35298042e1b50d166efe2068bb0e/pandas_ta-0.4.71b0-py3-none-any.whl", hash = "sha256:b1f37831811462685be3ef456cfebc0615ce9c8a4eb31bbaa6b341e1a7767a84", size = 240265, upload-time = "2025-09-14T19:08:34.83Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "peewee" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/50/1c269015e71612a6794cfccebced95561c8addf993075de61618f32db3b4/peewee-4.0.3.tar.gz", hash = "sha256:a3062505505e12cdf386066cda43d93a98f38a995dd9664cac0534378b2f6d1e", size = 717971, upload-time = "2026-03-26T22:41:50.992Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/31/93950b2c7145ea10aa454397ffa308c9aadc98dcb4103b676396571bfadd/peewee-4.0.3-py3-none-any.whl", hash = "sha256:4bc50ccdd95bf3fb79957a79ec51e5d1f8f8cab238031f0d0bb14cb8694a2525", size = 144477, upload-time = "2026-03-26T22:41:49.453Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "plotly" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.39.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, + { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, + { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psygnal" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/44/ab13cb6147d010258826a43e574ad94599af0de29df13795fff9efee656c/psygnal-0.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ee55e3997f796fd84d4fdbd829bb1b19d323e087c43d072744604a3016c8851", size = 587322, upload-time = "2026-01-04T16:38:04.827Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/68c042a607ca613e9450dfee99cc5c2a4d10d95392fb1de2ba932dd0a605/psygnal-0.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:912bcf110bfe7b4aa121d24987b6a58afb967ff090a049dad136eaf3cbcc7bea", size = 576207, upload-time = "2026-01-04T16:38:06.183Z" }, + { url = "https://files.pythonhosted.org/packages/4b/86/123c7b169ad32994a0cd801cd1f11c1a2be84555807e9c8a8a4682c67a9f/psygnal-0.15.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2e860c11fe075fd80c93a24081c577ef7ec5c9da41f0e75990aa4cccf3f79cf", size = 864261, upload-time = "2026-01-04T16:38:07.895Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/886cec7bec2f27fe453cfa32bfcaac08a83aab2a04895af68f93e1c493b8/psygnal-0.15.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8bebcf99699ef50b6ef572868a490f6d191dc4466e5bd9818ca27e17cd581", size = 872582, upload-time = "2026-01-04T16:38:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/21/a3/da972a05568ee8a9dc6c6567bee2c0cc5af8c681baebcb9fdbbf3cceb4f7/psygnal-0.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:06e0a90490e1205620d97ac52fbbe3282a22b126a26d02b3e1196bb46de16c7a", size = 411043, upload-time = "2026-01-04T16:38:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/69495410025cc4298765545ce3b8c635cd4c8d3a362b7fbbc15b80e9fc8f/psygnal-0.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1adc41515f648696990964433f1e25d8dfd306813a3645366c85e01986ba57a0", size = 581002, upload-time = "2026-01-04T16:38:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/75/1f/19a8126ccf3cd3974ba5d08a435a049b666961d90f5848ba83599d7a29de/psygnal-0.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:38ff18455b2ac73d4e8eea82ef298ce904b52e4dfdc603a24380c9c440e37519", size = 567775, upload-time = "2026-01-04T16:38:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/54/c5/b1348880d603edb82128a721397a1ddcf3dfcf5384fe5689db6e471118ae/psygnal-0.15.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c923c322eeefb1140886927cfe7bda7c32341087e290e812b9c69a624ab72d54", size = 855961, upload-time = "2026-01-04T16:38:15.612Z" }, + { url = "https://files.pythonhosted.org/packages/e6/42/3da2d6f3583bd1a849f7faa2fd3492b14bfda05012519ceaea5992658af0/psygnal-0.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2714ddaa41ea3134c0ee91cebd5fb11a88f254ea1d5948806ab0ad5f8be603d5", size = 862721, upload-time = "2026-01-04T16:38:17.059Z" }, + { url = "https://files.pythonhosted.org/packages/4d/14/6fc7e97fdecf7e8c5c105684bab784920312a3259800d8b53e3cf8783f42/psygnal-0.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:877516056a5a383427a647fff2fad5179eaa3e12de2c083c273e748435414aef", size = 415696, upload-time = "2026-01-04T16:38:18.355Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/b7bbca96bc477aa9ac2264e5907b2f4ccfcd1319f776dd1f35eec06cc2f4/psygnal-0.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d56f0f35eaf4a21f660de76885222faf9e8c7112454528d3394d464f3d4d1a3", size = 598340, upload-time = "2026-01-04T16:38:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/56577465a1b42a5e6780bb5fab53fb68f8bfd72f0131ed397576529af724/psygnal-0.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0febcf757a1323d9b8bd75735ee3569213d8110012a7bf0f478e85c5ab459fc6", size = 575311, upload-time = "2026-01-04T16:38:21.137Z" }, + { url = "https://files.pythonhosted.org/packages/79/81/f642ac08104049383076f83480ed412c9626e068769a1c34873c595bec0e/psygnal-0.15.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5e4837dfbfa4974dabe0795e32be9aadcd87603adf734738ce1114f72238a05", size = 889770, upload-time = "2026-01-04T16:38:22.629Z" }, + { url = "https://files.pythonhosted.org/packages/de/43/e571fa40b72780abed080ef829e5ad98017b6fe48d28c15a2404e006b676/psygnal-0.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07b4c4e03bbf4e8cad7e25f4fbc1ba9575fb9c3d14991bc7edfeb8b09c8d6d54", size = 881105, upload-time = "2026-01-04T16:38:23.896Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/ef3ab825eb08eaecbbceeeb56383694fe64ce399dbfd1d0767bb85688785/psygnal-0.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:4f0ce91b9c18e92281bf2c3fc4bb4e808d90f0b023d0a37b302d354188520338", size = 418969, upload-time = "2026-01-04T16:38:25.731Z" }, + { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2e/975bd61727578d88df62797f78390965ca7905780cf01eb59cb095a13638/psygnal-0.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:803fc33c4280c822c6f4b22e6c3ea7c4483e190f3cc69e69350098b3799476f3", size = 595706, upload-time = "2026-01-04T16:38:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/b8/55/e487f1d91497eb75e86c3fdfef69a21b1cab24d023383dd7648b08797d6a/psygnal-0.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4f53b4b83355b0a785b745987fd04e59bbf169a9028ed81a68ca7e05fb76d458", size = 575133, upload-time = "2026-01-04T16:38:35.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2f/f286355accd0e68d3eef52e63c8b9ab6ba33ec3107177719a036b3319657/psygnal-0.15.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcbca12190f5aa65c1f8fb04a81fa6f4463c5f5dde25cd74c3a56ceff6f37b02", size = 889565, upload-time = "2026-01-04T16:38:37.003Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/40c6026c88d7f9220ecc913afe0501045a512c9b82f9b7e036bb089dc287/psygnal-0.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1ac399566852fe4354ce26a1acbe12319232e8c2b615fe5ad1e114c547095cf6", size = 880863, upload-time = "2026-01-04T16:38:38.381Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/b4f45ec3057c473b5622fc002b3a636a698c34d3a0917a064ff5247f1984/psygnal-0.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:d3a03055f331ce91d44581c71edb79938ccc133a94af2ce7ad3a18fa57ac7be5", size = 423654, upload-time = "2026-01-04T16:38:39.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "quantstats" +version = "0.0.81" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "python-dateutil" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "seaborn" }, + { name = "tabulate" }, + { name = "yfinance" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/a8/33f31a0d179b6c4ffefa1a4318a78075ea96f7ace7292663f1a99acebdd6/quantstats-0.0.81.tar.gz", hash = "sha256:91f44895e4481167255384c2297193233255b427e3a09a3fa111a5ce77e9b44a", size = 87569, upload-time = "2026-01-13T18:18:20.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/d4/484041d5c5a5d3ec8df5c74fef3054fec004dab554f6c3c00187888f8cc1/quantstats-0.0.81-py3-none-any.whl", hash = "sha256:6af2b501f61917c8c960faaf8007eb858d970ab02a3cf0d7dc19f048953e15f3", size = 90067, upload-time = "2026-01-13T18:18:18.451Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, + { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/87/f6/dc9ef48c61b79c8201585bf37fa70cd781977da86e466cd94e8e95d2443b/regex-2026.2.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6d63a07e5ec8ce7184452cb00c41c37b49e67dc4f73b2955b5b8e782ea970784", size = 489311, upload-time = "2026-02-28T02:17:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/95/c8/c20390f2232d3f7956f420f4ef1852608ad57aa26c3dd78516cb9f3dc913/regex-2026.2.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e59bc8f30414d283ae8ee1617b13d8112e7135cb92830f0ec3688cb29152585a", size = 291285, upload-time = "2026-02-28T02:17:24.355Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/ba1068a631ebd71a230e7d8013fcd284b7c89c35f46f34a7da02082141b1/regex-2026.2.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de0cf053139f96219ccfabb4a8dd2d217c8c82cb206c91d9f109f3f552d6b43d", size = 289051, upload-time = "2026-02-28T02:17:26.722Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/7cc3b7af4c244c204b7a80924bd3d85aecd9ba5bc82b485c5806ee8cda9e/regex-2026.2.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb4db2f17e6484904f986c5a657cec85574c76b5c5e61c7aae9ffa1bc6224f95", size = 796842, upload-time = "2026-02-28T02:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/87/26bd03efc60e0d772ac1e7b60a2e6325af98d974e2358f659c507d3c76db/regex-2026.2.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52b017b35ac2214d0db5f4f90e303634dc44e4aba4bd6235a27f97ecbe5b0472", size = 863083, upload-time = "2026-02-28T02:17:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/ae/54/aeaf4afb1aa0a65e40de52a61dc2ac5b00a83c6cb081c8a1d0dda74f3010/regex-2026.2.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69fc560ccbf08a09dc9b52ab69cacfae51e0ed80dc5693078bdc97db2f91ae96", size = 909412, upload-time = "2026-02-28T02:17:33.248Z" }, + { url = "https://files.pythonhosted.org/packages/12/2f/049901def913954e640d199bbc6a7ca2902b6aeda0e5da9d17f114100ec2/regex-2026.2.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e61eea47230eba62a31f3e8a0e3164d0f37ef9f40529fb2c79361bc6b53d2a92", size = 802101, upload-time = "2026-02-28T02:17:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/512fb9ff7f5b15ea204bb1967ebb649059446decacccb201381f9fa6aad4/regex-2026.2.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4f5c0b182ad4269e7381b7c27fdb0408399881f7a92a4624fd5487f2971dfc11", size = 775260, upload-time = "2026-02-28T02:17:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/9a92935878aba19bd72706b9db5646a6f993d99b3f6ed42c02ec8beb1d61/regex-2026.2.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:96f6269a2882fbb0ee76967116b83679dc628e68eaea44e90884b8d53d833881", size = 784311, upload-time = "2026-02-28T02:17:39.855Z" }, + { url = "https://files.pythonhosted.org/packages/09/d3/fc51a8a738a49a6b6499626580554c9466d3ea561f2b72cfdc72e4149773/regex-2026.2.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b5acd4b6a95f37c3c3828e5d053a7d4edaedb85de551db0153754924cb7c83e3", size = 856876, upload-time = "2026-02-28T02:17:42.317Z" }, + { url = "https://files.pythonhosted.org/packages/08/b7/2e641f3d084b120ca4c52e8c762a78da0b32bf03ef546330db3e2635dc5f/regex-2026.2.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2234059cfe33d9813a3677ef7667999caea9eeaa83fef98eb6ce15c6cf9e0215", size = 763632, upload-time = "2026-02-28T02:17:45.073Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/0009021d97e79ee99f3d8641f0a8d001eed23479ade4c3125a5480bf3e2d/regex-2026.2.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c15af43c72a7fb0c97cbc66fa36a43546eddc5c06a662b64a0cbf30d6ac40944", size = 849320, upload-time = "2026-02-28T02:17:47.192Z" }, + { url = "https://files.pythonhosted.org/packages/05/7a/51cfbad5758f8edae430cb21961a9c8d04bce1dae4d2d18d4186eec7cfa1/regex-2026.2.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9185cc63359862a6e80fe97f696e04b0ad9a11c4ac0a4a927f979f611bfe3768", size = 790152, upload-time = "2026-02-28T02:17:49.067Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/a83e2b6b3daa142acb8c41d51de3876186307d5cb7490087031747662500/regex-2026.2.28-cp313-cp313-win32.whl", hash = "sha256:fb66e5245db9652abd7196ace599b04d9c0e4aa7c8f0e2803938377835780081", size = 266398, upload-time = "2026-02-28T02:17:50.744Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/16e9ebb1fe5425e11b9596c8d57bf8877dcb32391da0bfd33742e3290637/regex-2026.2.28-cp313-cp313-win_amd64.whl", hash = "sha256:71a911098be38c859ceb3f9a9ce43f4ed9f4c6720ad8684a066ea246b76ad9ff", size = 277282, upload-time = "2026-02-28T02:17:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/07/b4/92851335332810c5a89723bf7a7e35c7209f90b7d4160024501717b28cc9/regex-2026.2.28-cp313-cp313-win_arm64.whl", hash = "sha256:39bb5727650b9a0275c6a6690f9bb3fe693a7e6cc5c3155b1240aedf8926423e", size = 270382, upload-time = "2026-02-28T02:17:54.888Z" }, + { url = "https://files.pythonhosted.org/packages/24/07/6c7e4cec1e585959e96cbc24299d97e4437a81173217af54f1804994e911/regex-2026.2.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:97054c55db06ab020342cc0d35d6f62a465fa7662871190175f1ad6c655c028f", size = 492541, upload-time = "2026-02-28T02:17:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/55eb22ada7f43d4f4bb3815b6132183ebc331c81bd496e2d1f3b8d862e0d/regex-2026.2.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0d25a10811de831c2baa6aef3c0be91622f44dd8d31dd12e69f6398efb15e48b", size = 292984, upload-time = "2026-02-28T02:17:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/c301f8cb29ce9644a5ef85104c59244e6e7e90994a0f458da4d39baa8e17/regex-2026.2.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d6cfe798d8da41bb1862ed6e0cba14003d387c3c0c4a5d45591076ae9f0ce2f8", size = 291509, upload-time = "2026-02-28T02:18:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/aabe384ec1994b91796e903582427bc2ffaed9c4103819ed3c16d8e749f3/regex-2026.2.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd0ce43e71d825b7c0661f9c54d4d74bd97c56c3fd102a8985bcfea48236bacb", size = 809429, upload-time = "2026-02-28T02:18:02.328Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/8d2d987a816720c4f3109cee7c06a4b24ad0e02d4fc74919ab619e543737/regex-2026.2.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00945d007fd74a9084d2ab79b695b595c6b7ba3698972fadd43e23230c6979c1", size = 869422, upload-time = "2026-02-28T02:18:04.23Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/2c004509e763c0c3719f97c03eca26473bffb3868d54c5f280b8cd4f9e3d/regex-2026.2.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec23c11cbbf09a4df32fe50d57cbdd777bc442269b6e39a1775654f1c95dee2", size = 915175, upload-time = "2026-02-28T02:18:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/fd429066da487ef555a9da73bf214894aec77fc8c66a261ee355a69871a8/regex-2026.2.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5cdcc17d935c8f9d3f4db5c2ebe2640c332e3822ad5d23c2f8e0228e6947943a", size = 812044, upload-time = "2026-02-28T02:18:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ca/feedb7055c62a3f7f659971bf45f0e0a87544b6b0cf462884761453f97c5/regex-2026.2.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a448af01e3d8031c89c5d902040b124a5e921a25c4e5e07a861ca591ce429341", size = 782056, upload-time = "2026-02-28T02:18:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/1aa959ed0d25c1dd7dd5047ea8ba482ceaef38ce363c401fd32a6b923e60/regex-2026.2.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:10d28e19bd4888e4abf43bd3925f3c134c52fdf7259219003588a42e24c2aa25", size = 798743, upload-time = "2026-02-28T02:18:13.025Z" }, + { url = "https://files.pythonhosted.org/packages/3b/1f/dadb9cf359004784051c897dcf4d5d79895f73a1bbb7b827abaa4814ae80/regex-2026.2.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:99985a2c277dcb9ccb63f937451af5d65177af1efdeb8173ac55b61095a0a05c", size = 864633, upload-time = "2026-02-28T02:18:16.84Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f1/b9a25eb24e1cf79890f09e6ec971ee5b511519f1851de3453bc04f6c902b/regex-2026.2.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:e1e7b24cb3ae9953a560c563045d1ba56ee4749fbd05cf21ba571069bd7be81b", size = 770862, upload-time = "2026-02-28T02:18:18.892Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/c5cb10b7aa6f182f9247a30cc9527e326601f46f4df864ac6db588d11fcd/regex-2026.2.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d8511a01d0e4ee1992eb3ba19e09bc1866fe03f05129c3aec3fdc4cbc77aad3f", size = 854788, upload-time = "2026-02-28T02:18:21.475Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/414ba0731c4bd40b011fa4703b2cc86879ec060c64f2a906e65a56452589/regex-2026.2.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:aaffaecffcd2479ce87aa1e74076c221700b7c804e48e98e62500ee748f0f550", size = 800184, upload-time = "2026-02-28T02:18:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/0c7290987f97e7e6830b0d853f69dc4dc5852c934aae63e7fdcd76b4c383/regex-2026.2.28-cp313-cp313t-win32.whl", hash = "sha256:ef77bdde9c9eba3f7fa5b58084b29bbcc74bcf55fdbeaa67c102a35b5bd7e7cc", size = 269137, upload-time = "2026-02-28T02:18:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/ef26ff90e74ceb4051ad6efcbbb8a4be965184a57e879ebcbdef327d18fa/regex-2026.2.28-cp313-cp313t-win_amd64.whl", hash = "sha256:98adf340100cbe6fbaf8e6dc75e28f2c191b1be50ffefe292fb0e6f6eefdb0d8", size = 280682, upload-time = "2026-02-28T02:18:27.205Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/fbad9c52e83ffe8f97e3ed1aa0516e6dff6bb633a41da9e64645bc7efdc5/regex-2026.2.28-cp313-cp313t-win_arm64.whl", hash = "sha256:2fb950ac1d88e6b6a9414381f403797b236f9fa17e1eee07683af72b1634207b", size = 271735, upload-time = "2026-02-28T02:18:29.015Z" }, + { url = "https://files.pythonhosted.org/packages/cf/03/691015f7a7cb1ed6dacb2ea5de5682e4858e05a4c5506b2839cd533bbcd6/regex-2026.2.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:78454178c7df31372ea737996fb7f36b3c2c92cccc641d251e072478afb4babc", size = 489497, upload-time = "2026-02-28T02:18:30.889Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ba/8db8fd19afcbfa0e1036eaa70c05f20ca8405817d4ad7a38a6b4c2f031ac/regex-2026.2.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:5d10303dd18cedfd4d095543998404df656088240bcfd3cd20a8f95b861f74bd", size = 291295, upload-time = "2026-02-28T02:18:33.426Z" }, + { url = "https://files.pythonhosted.org/packages/5a/79/9aa0caf089e8defef9b857b52fc53801f62ff868e19e5c83d4a96612eba1/regex-2026.2.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:19a9c9e0a8f24f39d575a6a854d516b48ffe4cbdcb9de55cb0570a032556ecff", size = 289275, upload-time = "2026-02-28T02:18:35.247Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/ee53117066a30ef9c883bf1127eece08308ccf8ccd45c45a966e7a665385/regex-2026.2.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09500be324f49b470d907b3ef8af9afe857f5cca486f853853f7945ddbf75911", size = 797176, upload-time = "2026-02-28T02:18:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/05/1b/67fb0495a97259925f343ae78b5d24d4a6624356ae138b57f18bd43006e4/regex-2026.2.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb1c4ff62277d87a7335f2c1ea4e0387b8f2b3ad88a64efd9943906aafad4f33", size = 863813, upload-time = "2026-02-28T02:18:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/93ac9bbafc53618091c685c7ed40239a90bf9f2a82c983f0baa97cb7ae07/regex-2026.2.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b8b3f1be1738feadc69f62daa250c933e85c6f34fa378f54a7ff43807c1b9117", size = 908678, upload-time = "2026-02-28T02:18:41.619Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/a8f5e0561702b25239846a16349feece59712ae20598ebb205580332a471/regex-2026.2.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc8ed8c3f41c27acb83f7b6a9eb727a73fc6663441890c5cb3426a5f6a91ce7d", size = 801528, upload-time = "2026-02-28T02:18:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/ed6d4cbde80309854b1b9f42d9062fee38ade15f7eb4909f6ef2440403b5/regex-2026.2.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa539be029844c0ce1114762d2952ab6cfdd7c7c9bd72e0db26b94c3c36dcc5a", size = 775373, upload-time = "2026-02-28T02:18:46.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e9/6e53c34e8068b9deec3e87210086ecb5b9efebdefca6b0d3fa43d66dcecb/regex-2026.2.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7900157786428a79615a8264dac1f12c9b02957c473c8110c6b1f972dcecaddf", size = 784859, upload-time = "2026-02-28T02:18:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/48/3c/736e1c7ca7f0dcd2ae33819888fdc69058a349b7e5e84bc3e2f296bbf794/regex-2026.2.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0b1d2b07614d95fa2bf8a63fd1e98bd8fa2b4848dc91b1efbc8ba219fdd73952", size = 857813, upload-time = "2026-02-28T02:18:50.576Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/48c4659ad9da61f58e79dbe8c05223e0006696b603c16eb6b5cbfbb52c27/regex-2026.2.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b389c61aa28a79c2e0527ac36da579869c2e235a5b208a12c5b5318cda2501d8", size = 763705, upload-time = "2026-02-28T02:18:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/bc1c261789283128165f71b71b4b221dd1b79c77023752a6074c102f18d8/regex-2026.2.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f467cb602f03fbd1ab1908f68b53c649ce393fde056628dc8c7e634dab6bfc07", size = 848734, upload-time = "2026-02-28T02:18:54.595Z" }, + { url = "https://files.pythonhosted.org/packages/10/d8/979407faf1397036e25a5ae778157366a911c0f382c62501009f4957cf86/regex-2026.2.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c8cb2deba42f5ec1ede46374e990f8adc5e6456a57ac1a261b19be6f28e4e6", size = 789871, upload-time = "2026-02-28T02:18:57.34Z" }, + { url = "https://files.pythonhosted.org/packages/03/23/da716821277115fcb1f4e3de1e5dc5023a1e6533598c486abf5448612579/regex-2026.2.28-cp314-cp314-win32.whl", hash = "sha256:9036b400b20e4858d56d117108d7813ed07bb7803e3eed766675862131135ca6", size = 271825, upload-time = "2026-02-28T02:18:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/91/ff/90696f535d978d5f16a52a419be2770a8d8a0e7e0cfecdbfc31313df7fab/regex-2026.2.28-cp314-cp314-win_amd64.whl", hash = "sha256:1d367257cd86c1cbb97ea94e77b373a0bbc2224976e247f173d19e8f18b4afa7", size = 280548, upload-time = "2026-02-28T02:19:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/69/f9/5e1b5652fc0af3fcdf7677e7df3ad2a0d47d669b34ac29a63bb177bb731b/regex-2026.2.28-cp314-cp314-win_arm64.whl", hash = "sha256:5e68192bb3a1d6fb2836da24aa494e413ea65853a21505e142e5b1064a595f3d", size = 273444, upload-time = "2026-02-28T02:19:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/d3/eb/8389f9e940ac89bcf58d185e230a677b4fd07c5f9b917603ad5c0f8fa8fe/regex-2026.2.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a5dac14d0872eeb35260a8e30bac07ddf22adc1e3a0635b52b02e180d17c9c7e", size = 492546, upload-time = "2026-02-28T02:19:05.378Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c7/09441d27ce2a6fa6a61ea3150ea4639c1dcda9b31b2ea07b80d6937b24dd/regex-2026.2.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ec0c608b7a7465ffadb344ed7c987ff2f11ee03f6a130b569aa74d8a70e8333c", size = 292986, upload-time = "2026-02-28T02:19:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/fb/69/4144b60ed7760a6bd235e4087041f487aa4aa62b45618ce018b0c14833ea/regex-2026.2.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7815afb0ca45456613fdaf60ea9c993715511c8d53a83bc468305cbc0ee23c7", size = 291518, upload-time = "2026-02-28T02:19:09.698Z" }, + { url = "https://files.pythonhosted.org/packages/2d/be/77e5426cf5948c82f98c53582009ca9e94938c71f73a8918474f2e2990bb/regex-2026.2.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b059e71ec363968671693a78c5053bd9cb2fe410f9b8e4657e88377ebd603a2e", size = 809464, upload-time = "2026-02-28T02:19:12.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/99/2c8c5ac90dc7d05c6e7d8e72c6a3599dc08cd577ac476898e91ca787d7f1/regex-2026.2.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8cf76f1a29f0e99dcfd7aef1551a9827588aae5a737fe31442021165f1920dc", size = 869553, upload-time = "2026-02-28T02:19:15.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/34/daa66a342f0271e7737003abf6c3097aa0498d58c668dbd88362ef94eb5d/regex-2026.2.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:180e08a435a0319e6a4821c3468da18dc7001987e1c17ae1335488dfe7518dd8", size = 915289, upload-time = "2026-02-28T02:19:17.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c7/e22c2aaf0a12e7e22ab19b004bb78d32ca1ecc7ef245949935463c5567de/regex-2026.2.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e496956106fd59ba6322a8ea17141a27c5040e5ee8f9433ae92d4e5204462a0", size = 812156, upload-time = "2026-02-28T02:19:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bb/2dc18c1efd9051cf389cd0d7a3a4d90f6804b9fff3a51b5dc3c85b935f71/regex-2026.2.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bba2b18d70eeb7b79950f12f633beeecd923f7c9ad6f6bae28e59b4cb3ab046b", size = 782215, upload-time = "2026-02-28T02:19:22.047Z" }, + { url = "https://files.pythonhosted.org/packages/17/1e/9e4ec9b9013931faa32226ec4aa3c71fe664a6d8a2b91ac56442128b332f/regex-2026.2.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6db7bfae0f8a2793ff1f7021468ea55e2699d0790eb58ee6ab36ae43aa00bc5b", size = 798925, upload-time = "2026-02-28T02:19:24.173Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/a505927e449a9ccb41e2cc8d735e2abe3444b0213d1cf9cb364a8c1f2524/regex-2026.2.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0b02e8b7e5874b48ae0f077ecca61c1a6a9f9895e9c6dfb191b55b242862033", size = 864701, upload-time = "2026-02-28T02:19:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/c62cb60cdd93e13eac5b3d9d6bd5d284225ed0e3329426f94d2552dd7cca/regex-2026.2.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:25b6eb660c5cf4b8c3407a1ed462abba26a926cc9965e164268a3267bcc06a43", size = 770899, upload-time = "2026-02-28T02:19:29.38Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5a/874f861f5c3d5ab99633e8030dee1bc113db8e0be299d1f4b07f5b5ec349/regex-2026.2.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5a932ea8ad5d0430351ff9c76c8db34db0d9f53c1d78f06022a21f4e290c5c18", size = 854727, upload-time = "2026-02-28T02:19:31.494Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/d2c03b0efde47e13db895b975b2be6a73ed90b8ba963677927283d43bf74/regex-2026.2.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2c95e1a2b0f89d01e821ff4de1be4b5d73d1f4b0bf679fa27c1ad8d2327f1a", size = 800366, upload-time = "2026-02-28T02:19:34.248Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/ee13b20b763b8989f7c75d592bfd5de37dc1181814a2a2747fedcf97e3ba/regex-2026.2.28-cp314-cp314t-win32.whl", hash = "sha256:bbb882061f742eb5d46f2f1bd5304055be0a66b783576de3d7eef1bed4778a6e", size = 274936, upload-time = "2026-02-28T02:19:36.313Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/d8020e39414c93af7f0d8688eabcecece44abfd5ce314b21dfda0eebd3d8/regex-2026.2.28-cp314-cp314t-win_amd64.whl", hash = "sha256:6591f281cb44dc13de9585b552cec6fc6cf47fb2fe7a48892295ee9bc4a612f9", size = 284779, upload-time = "2026-02-28T02:19:38.625Z" }, + { url = "https://files.pythonhosted.org/packages/13/c0/ad225f4a405827486f1955283407cf758b6d2fb966712644c5f5aef33d1b/regex-2026.2.28-cp314-cp314t-win_arm64.whl", hash = "sha256:dee50f1be42222f89767b64b283283ef963189da0dda4a515aa54a5563c62dec", size = 275010, upload-time = "2026-02-28T02:19:40.65Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "schedule" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/dee5acf66837852e8e68df6d8d3a6cb22d3df997b733b032f513d95205b7/scikit_learn-1.7.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fa8f63940e29c82d1e67a45d5297bdebbcb585f5a5a50c4914cc2e852ab77f33", size = 9208906, upload-time = "2025-09-09T08:21:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/3c/30/9029e54e17b87cb7d50d51a5926429c683d5b4c1732f0507a6c3bed9bf65/scikit_learn-1.7.2-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f95dc55b7902b91331fa4e5845dd5bde0580c9cd9612b1b2791b7e80c3d32615", size = 8627836, upload-time = "2025-09-09T08:21:20.695Z" }, + { url = "https://files.pythonhosted.org/packages/60/18/4a52c635c71b536879f4b971c2cedf32c35ee78f48367885ed8025d1f7ee/scikit_learn-1.7.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9656e4a53e54578ad10a434dc1f993330568cfee176dff07112b8785fb413106", size = 9426236, upload-time = "2025-09-09T08:21:22.645Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/290362f6ab582128c53445458a5befd471ed1ea37953d5bcf80604619250/scikit_learn-1.7.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96dc05a854add0e50d3f47a1ef21a10a595016da5b007c7d9cd9d0bffd1fcc61", size = 9312593, upload-time = "2025-09-09T08:21:24.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/87/24f541b6d62b1794939ae6422f8023703bbf6900378b2b34e0b4384dfefd/scikit_learn-1.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:bb24510ed3f9f61476181e4db51ce801e2ba37541def12dc9333b946fc7a9cf8", size = 8820007, upload-time = "2025-09-09T08:21:26.713Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version == '3.11.*'" }, + { name = "babel", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "imagesize", marker = "python_full_version == '3.11.*'" }, + { name = "jinja2", marker = "python_full_version == '3.11.*'" }, + { name = "packaging", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "requests", marker = "python_full_version == '3.11.*'" }, + { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, + { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "ta" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/9a/37d92a6b470dc9088612c2399a68f1a9ac22872d4e1eff416818e22ab11b/ta-0.11.0.tar.gz", hash = "sha256:de86af43418420bd6b088a2ea9b95483071bf453c522a8441bc2f12bcf8493fd", size = 25308, upload-time = "2023-11-02T13:53:35.434Z" } + +[[package]] +name = "ta-lib" +version = "0.6.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "build" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ec/27114f6255e6723783d4c4366810620a4347375ebf66f8aea86d9dd58ffd/ta_lib-0.6.8.tar.gz", hash = "sha256:3a9195299df9d7d2a6e9d16bebd6b706b0ea99e4b871864c4b034c2577e21a77", size = 380772, upload-time = "2025-10-20T20:49:56.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/3b/615c476a24ecccdaab4acb891aaf1766cde860a00f437f021f0e781562ce/ta_lib-0.6.8-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:71506116eac0d3e3598d6325b4b818c3a0f6acb3222b24d30ad726e8c4bf7ea8", size = 1078886, upload-time = "2025-10-20T20:48:35.739Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/04c00b6577da762238465cc755b26b0cf0a637212672354fe07d6452506e/ta_lib-0.6.8-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:b3b017d9103e7a7372a146773be32b184ff7330bd708d40b1f56f06a686756ed", size = 986216, upload-time = "2025-10-20T20:48:37.333Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/581fb525e429d07a816e7b749b103add5344fa5d8d35ddcdad55bbdfd0f5/ta_lib-0.6.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1fd18e45c39d5a4be4b0d6a20c141e43fe46daeb1b2e2f304ebae7015ab6e6", size = 3885277, upload-time = "2025-10-20T20:48:38.911Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/30b752b1be853b20a12330548d73a0039681ba731c72c77ad2b2afbb50e0/ta_lib-0.6.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c6a1e8f98de92e817491b50aa4d01d69a1b41a4ed3173747e8f16f0d4cf81cc", size = 3956167, upload-time = "2025-10-20T20:48:40.546Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0d/d1a5546fec669c528db7cc61ccbfc03b931d70c2f02184af305d780ebe5a/ta_lib-0.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7333e907bff3e3997e54f89733ffa8d619842a3e1cd962bca34bdc11944c28", size = 3515254, upload-time = "2025-10-20T20:48:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ca/36/7a6169a57117b654b569a88592cf3ecdca4d0bcaafc2d3fa476629c6cc1d/ta_lib-0.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87c1cc1057d903b78a8257a7c5f497db6fd5284f5080392bd57b66031d7389a3", size = 3617671, upload-time = "2025-10-20T20:48:44.065Z" }, + { url = "https://files.pythonhosted.org/packages/0f/b1/ba03d935b2f44e226c3c8e31fe64e62867d3eb7fcca44aa88b2b5dbc758d/ta_lib-0.6.8-cp310-cp310-win32.whl", hash = "sha256:7a5cc6bf60791d8274edfdfe2dd7cec3f00f656dcc92e2b0a9af06c8b18ce6a6", size = 774814, upload-time = "2025-10-20T20:48:47.979Z" }, + { url = "https://files.pythonhosted.org/packages/57/4e/588f1790c2f6e45f68faad08bb03e3af6445a07266927c261f23942bada2/ta_lib-0.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:cce8de9d48289927ed18aaa420740efd52b2cd9289da32e3799afbb3a02822e8", size = 920112, upload-time = "2025-10-20T20:48:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/99/1c/28967945b741ce060907901dc5a19fd2b4ff87b5d7481980842cd7d3b6f4/ta_lib-0.6.8-cp310-cp310-win_arm64.whl", hash = "sha256:4aa0fe08383f3e5fc7d2f8cf9b42ac778f4d53fd75bcd2799a858225954eab89", size = 760436, upload-time = "2025-10-20T20:48:46.515Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8b/f0da03cb80bd7f92a362ef86b64c6aede5485cb4b1a5ef1a669a28e974f1/ta_lib-0.6.8-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:f823d0f6b04a6797fbe253bcf91666e71a6b63c290683819650c68b2468ebe64", size = 1094199, upload-time = "2025-10-20T20:48:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/7a/35dcba621814e0c94f222791255bf0c90bb57db870491e375c3cc748ea50/ta_lib-0.6.8-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:30de46b55873b51be945a09edf486afcc190dc47eff9fb5d2b12c9f7e3d743da", size = 1000124, upload-time = "2025-10-20T20:48:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/79dc81240708bd6ab5489b73b6fce52a49b90f16ca80326155c106a0cca4/ta_lib-0.6.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:490e19a45cd3cdd6dfe6b46019f7ffe1103500750b41b51996a870e7c1c5f066", size = 4040093, upload-time = "2025-10-20T20:48:52.107Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3b/49f5524798ab407abc6125047de031bfe3dac41218ad99b3932185743719/ta_lib-0.6.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a395524b0fafa10446d11e11acb4742e919523de58aac03b791f26d7a783bcf0", size = 4110325, upload-time = "2025-10-20T20:48:53.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/de/ebc78aa4b391339443932cc3ef0b4fc43e90587826bfe4526c242c602f3a/ta_lib-0.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:11a373c9308eae3bac2d56d37017f9ab63968cc074a8b95be879aae3d13133aa", size = 3668236, upload-time = "2025-10-20T20:48:55.455Z" }, + { url = "https://files.pythonhosted.org/packages/59/ba/728ddf00c372fb188a85114fdb22b4ae8edee599ba1d4e18d14bcc1b98b4/ta_lib-0.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:282e49c766b5952dd8796f77d7ed3ae412cdd88e31f845b1fbbb86ac6cb7bebf", size = 3770085, upload-time = "2025-10-20T20:48:57.746Z" }, + { url = "https://files.pythonhosted.org/packages/b6/64/fc0c3f67af28dc46f99cf9f0996c2b4a3bfc69f8d7a97c6d80d8d8664bcc/ta_lib-0.6.8-cp311-cp311-win32.whl", hash = "sha256:0a08a29690a922ba92a6cf42902a8a93c6fbda4cfed62c3c5b0471560ef60135", size = 774941, upload-time = "2025-10-20T20:49:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/2f/83/dcd76d7253a2d159d5e58821aea917fb7b4671eb07a71d0d3c770d7077d3/ta_lib-0.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:c01809fb602e2fefc8cbfb3b603bb59d2a2eaee8708410896d48a835ba00e7c5", size = 920197, upload-time = "2025-10-20T20:48:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9b/8e997dfbd94e30c0dff29b914cae832fbf7d573933fd9faf7fcdd7247608/ta_lib-0.6.8-cp311-cp311-win_arm64.whl", hash = "sha256:a89734a7bcb2ea3b6fd600a74d6fbcdb8d3fa3f7917dbd978e039710b5509c9c", size = 760103, upload-time = "2025-10-20T20:49:00.213Z" }, + { url = "https://files.pythonhosted.org/packages/18/2b/72b8b180410f1f0e76187fe1b81d4f2df26820749245ffa2d2d2fbaa82de/ta_lib-0.6.8-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:128ec92e6a0e9ff7a38edef80e3b74f15bb2ed1c531d5d3252c8dca22677651b", size = 1072242, upload-time = "2025-10-20T20:49:03.068Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9a/0ec4bbc961c9490bf59b765e18fc2c986b5a3feeda0a8537365145f1454f/ta_lib-0.6.8-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:66a8e1c1e899d15a2f7510e43527fba22d895e7f6058d027db3e3837d88a69de", size = 984850, upload-time = "2025-10-20T20:49:04.242Z" }, + { url = "https://files.pythonhosted.org/packages/5f/50/fbb0b41bf9e771ffacf212b5ff2bacb66410bcbe162e0382ff871603dd0f/ta_lib-0.6.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5929c83bd8cb7572d1c17ffdbf0eac235bf3c4d53cde1950cf89d944eaf97525", size = 3987776, upload-time = "2025-10-20T20:49:05.743Z" }, + { url = "https://files.pythonhosted.org/packages/59/79/7513f1c39a2f6cd4f35651940f3b71a0ae9d13c04fe9235a57bdeeec621c/ta_lib-0.6.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:094677b279a59c3f01c3aca8a889fda3523fd641a3805f69a2d642121b72e55e", size = 4099228, upload-time = "2025-10-20T20:49:07.294Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/0ca307d5c9306b47c40b889aeed3b23a982a661d66450232863a54904175/ta_lib-0.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e920c272cd9e70a6b10eae9203cc96845da142e1dd4482de9343dda3738a9862", size = 3611568, upload-time = "2025-10-20T20:49:09.078Z" }, + { url = "https://files.pythonhosted.org/packages/79/ca/0df51f6c065f5716371929e9d991ee16909ab3eb8b0c16dab47b05a4ece6/ta_lib-0.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d556d1c256b3700b60b6b061664a667b2e49d599c2772d46a9f2348f2dc4ab5c", size = 3729898, upload-time = "2025-10-20T20:49:10.764Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/c34a82164bb86585075dfe6a77051a1e45d8a2e57cbaabc7c74a5ed760d4/ta_lib-0.6.8-cp312-cp312-win32.whl", hash = "sha256:2b369cabb48485fbf444beb3f5a878075367b99c2c86db2f796afeabebc749e0", size = 771847, upload-time = "2025-10-20T20:49:14.828Z" }, + { url = "https://files.pythonhosted.org/packages/73/8f/adbbbd5849284c593675c0deb34ef56ce55c7e307663e7c28a598a423892/ta_lib-0.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:e781eeb65b2007af553389c8a7fb7bc53cb856118b0fcffb2c26b0f49561c686", size = 888271, upload-time = "2025-10-20T20:49:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/979d6db422a26e9417c833e49a077e12a9c1d3aa4a690dff874cde557c34/ta_lib-0.6.8-cp312-cp312-win_arm64.whl", hash = "sha256:fa7e9f2e80a9535f9692e113d02b4268b5f88675a730d1b0ef0abeb74c9a4e80", size = 753328, upload-time = "2025-10-20T20:49:13.296Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0f/0a1e6a3fff0df62d53ed4c71b5b91da6dfe2670991c94ff0a2116eb79773/ta_lib-0.6.8-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:6cf029b886cfb28a2701503b7c602b811f2daa45276bd6459b0c71e051deb497", size = 1071270, upload-time = "2025-10-20T20:49:16.223Z" }, + { url = "https://files.pythonhosted.org/packages/53/ee/036845c31209173f57f41e3a841e24c70e587fe7256e79642398154d8fb6/ta_lib-0.6.8-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ddf7453acd03b966624ebefdb38169b5bbbeea1a1a58c90b095667247f9de327", size = 985136, upload-time = "2025-10-20T20:49:17.408Z" }, + { url = "https://files.pythonhosted.org/packages/93/c8/8b6bc9f29ea361fcbb1e8fe895f9c155b51fe73b9000088f883fa5ac9b56/ta_lib-0.6.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c32fc0f546ceecc47dd45f33d72ab4a1e341b80d9081c2d77b100add5d49104", size = 3968073, upload-time = "2025-10-20T20:49:19.177Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/a46be776d1fc45d232c959aab9458182e937cc66829b820077dfd3950530/ta_lib-0.6.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2bf714333788bf5175f2512b86d2ed129e89ae6f6c2923e8a297a1e3395e13b5", size = 4065499, upload-time = "2025-10-20T20:49:20.745Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b3/0f8edd802d5026a99f6bd6a7307b017a714f45e3de04f94e9b7d76665e89/ta_lib-0.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3845e4c2fa32963fb7f384ebbaa2761b0e6b96145239bf80e956d4aff4b071c", size = 3597383, upload-time = "2025-10-20T20:49:22.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/58/078ccdee5286015dfa1864b6469f639ce6b613abbea17b167bb802a4a8b3/ta_lib-0.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4795e93d130c9b7fb661f0cead49752ae6a980437df74b99d5918026c212443e", size = 3687745, upload-time = "2025-10-20T20:49:24.127Z" }, + { url = "https://files.pythonhosted.org/packages/69/b5/8d50404307dd429aeb6d222dfbf02dca2f60e937f13e81a491a681db1a63/ta_lib-0.6.8-cp313-cp313-win32.whl", hash = "sha256:691a62926ba09f2653ec0908554b3635497efb7751c5d46b916cd1ebbb1d3c25", size = 771319, upload-time = "2025-10-20T20:49:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/1b/90/b0bdf9f3e1e88ea4052f4cc1476c86b40f6dbe3f3d201e310e93471a593b/ta_lib-0.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:34e3b12407ddf99f6627435aa8a165f094339bb7dc33de92e1d7472e9f237304", size = 887583, upload-time = "2025-10-20T20:49:25.414Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fe/03d58ea7997d9bef0e1b10e3cf160c016dd890b66413c292051e9c9b257a/ta_lib-0.6.8-cp313-cp313-win_arm64.whl", hash = "sha256:0ccd478ff5735831bf2a61d653466bfda8afadc26ad58ca6b1edb9e7521cc674", size = 753631, upload-time = "2025-10-20T20:49:26.615Z" }, + { url = "https://files.pythonhosted.org/packages/db/61/c47098dfb28c468d29fccfbb2ba35a10001d37dd51c4200a4e50c788ede6/ta_lib-0.6.8-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:36b2a516fce57309840f5ef3fa2fd0c4449293fc72536a0400d2e1e26b414da8", size = 1075848, upload-time = "2025-10-20T20:49:29.517Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e9/a30e770902c1df915a94a43e652f432e7647b710c0e1120751c05805d4bc/ta_lib-0.6.8-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7993164e8e9f78ec31d38c47850ca6ba5451788b5b49a8a2dbb3322b36b5693b", size = 986649, upload-time = "2025-10-20T20:49:30.702Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2f/8961a9e7434a2d10b8f625bb4d5c049484a898e76e9c5e40398da410aec0/ta_lib-0.6.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:613cf06313331f49dd7b85a5a24fbddb1156c9723b6921a231906241726e5aee", size = 3971825, upload-time = "2025-10-20T20:49:32.185Z" }, + { url = "https://files.pythonhosted.org/packages/75/c1/352bc32394549ac9886829a24070a507a30abf45265135b60ee77354f7da/ta_lib-0.6.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce2bc1ea01200b6d8130ab917296d05d77a1a571ec6c1ee25cfca6d55cd5db4a", size = 3991433, upload-time = "2025-10-20T20:49:34.182Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b3/7bde1867df3bf015f48d510d2ba7491359ce13c79ecf5127acae3d308272/ta_lib-0.6.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a63a52221f8c73f82f4e00493351d987f594931198589287aee96f8da673cfd5", size = 3585925, upload-time = "2025-10-20T20:49:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/8d389f60bb085b6991764d7535f066dd6009fc4f5a45dbd26dc9eaaa3c0a/ta_lib-0.6.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559326d8f3d904cd4aa61f6a392d5626f35eec6a9f6cc83bcddb0abf88c40516", size = 3629696, upload-time = "2025-10-20T20:49:37.299Z" }, + { url = "https://files.pythonhosted.org/packages/82/bc/d2e4c2b752baaee592095feb69514764b004fe53af7cc893ba9c3854cc30/ta_lib-0.6.8-cp314-cp314-win32.whl", hash = "sha256:f5b6174bf4bf9152e368561dff410203c6921e4dd2afbcda3283a95957158112", size = 766352, upload-time = "2025-10-20T20:49:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/40/98/0f2755b5bde81d7b1eaf96b4204f18fabea38b0efc869cb0ea05d57e0afc/ta_lib-0.6.8-cp314-cp314-win_amd64.whl", hash = "sha256:1fb4028437201e19014e4e374272b739867c8a3eb655da46675ef4c2ff14b616", size = 886955, upload-time = "2025-10-20T20:49:38.513Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4c/d341020377f8b183405bdf3c5717fc2ca04a8d33b5c59b2348377ee459d9/ta_lib-0.6.8-cp314-cp314-win_arm64.whl", hash = "sha256:bfad1202fb1f9140e3810cc607058395f59032d9128cc0d716900c78bea5f337", size = 755896, upload-time = "2025-10-20T20:49:39.9Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f2/c1690994afe461aae2d0cac62251e6802a703dec0a6c549c02ecd0de92a9/torch-2.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2c0d7fcfbc0c4e8bb5ebc3907cbc0c6a0da1b8f82b1fc6e14e914fa0b9baf74e", size = 80526521, upload-time = "2026-03-23T18:12:06.86Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f0/98ae802fa8c09d3149b0c8690741f3f5753c90e779bd28c9613257295945/torch-2.11.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:4cf8687f4aec3900f748d553483ef40e0ac38411c3c48d0a86a438f6d7a99b18", size = 419723025, upload-time = "2026-03-23T18:11:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/18a9b10b4bd34f12d4e561c52b0ae7158707b8193c6cfc0aad2b48167090/torch-2.11.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1b32ceda909818a03b112006709b02be1877240c31750a8d9c6b7bf5f2d8a6e5", size = 530589207, upload-time = "2026-03-23T18:11:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/2d532e8c0e23705be9d1debce5bc37b68d59a39bda7584c26fe9668076fe/torch-2.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3c712ae6fb8e7a949051a953fc412fe0a6940337336c3b6f905e905dac5157f", size = 114518313, upload-time = "2026-03-23T18:11:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/98b410492609e34a155fa8b121b55c7dca229f39636851c3a9ec20edea21/torch-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7b6a60d48062809f58595509c524b88e6ddec3ebe25833d6462eeab81e5f2ce4", size = 80529712, upload-time = "2026-03-23T18:12:02.608Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/acea680005f098f79fd70c1d9d5ccc0cb4296ec2af539a0450108232fc0c/torch-2.11.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d91aac77f24082809d2c5a93f52a5f085032740a1ebc9252a7b052ef5a4fddc6", size = 419718178, upload-time = "2026-03-23T18:10:46.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8b/d7be22fbec9ffee6cff31a39f8750d4b3a65d349a286cf4aec74c2375662/torch-2.11.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7aa2f9bbc6d4595ba72138026b2074be1233186150e9292865e04b7a63b8c67a", size = 530604548, upload-time = "2026-03-23T18:10:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/9912d30b68845256aabbb4a40aeefeef3c3b20db5211ccda653544ada4b6/torch-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:73e24aaf8f36ab90d95cd1761208b2eb70841c2a9ca1a3f9061b39fc5331b708", size = 114519675, upload-time = "2026-03-23T18:11:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/69e3008d78e5cee2b30183340cc425081b78afc5eff3d080daab0adda9aa/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b5866312ee6e52ea625cd211dcb97d6a2cdc1131a5f15cc0d87eec948f6dd34", size = 80606338, upload-time = "2026-03-23T18:11:34.781Z" }, + { url = "https://files.pythonhosted.org/packages/13/16/42e5915ebe4868caa6bac83a8ed59db57f12e9a61b7d749d584776ed53d5/torch-2.11.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f99924682ef0aa6a4ab3b1b76f40dc6e273fca09f367d15a524266db100a723f", size = 419731115, upload-time = "2026-03-23T18:11:06.944Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c9/82638ef24d7877510f83baf821f5619a61b45568ce21c0a87a91576510aa/torch-2.11.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f68f4ac6d95d12e896c3b7a912b5871619542ec54d3649cf48cc1edd4dd2756", size = 530712279, upload-time = "2026-03-23T18:10:31.481Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ff/6756f1c7ee302f6d202120e0f4f05b432b839908f9071157302cedfc5232/torch-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:fbf39280699d1b869f55eac536deceaa1b60bd6788ba74f399cc67e60a5fab10", size = 114556047, upload-time = "2026-03-23T18:10:55.931Z" }, + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/8ed2173589cbfe744ed54e5a73efc107c0085ba5777ee93a5f4c1ab90553/torch-2.11.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:63a68fa59de8f87acc7e85a5478bb2dddbb3392b7593ec3e78827c793c4b73fd", size = 419732382, upload-time = "2026-03-23T18:08:30.835Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e1/b73f7c575a4b8f87a5928f50a1e35416b5e27295d8be9397d5293e7e8d4c/torch-2.11.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cc89b9b173d9adfab59fd227f0ab5e5516d9a52b658ae41d64e59d2e55a418db", size = 530711509, upload-time = "2026-03-23T18:08:47.213Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/3e3fcdd388fbe54e29fd3f991f36846ff4ac90b0d0181e9c8f7236565f82/torch-2.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4dda3b3f52d121063a731ddb835f010dc137b920d7fec2778e52f60d8e4bf0cd", size = 114555842, upload-time = "2026-03-23T18:09:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/56bfb37073e7136e6dd86bfc6af7339946dd684e0ecf2155ac0eee687ae1/torch-2.11.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:2658f34ce7e2dabf4ec73b45e2ca68aedad7a5be87ea756ad656eaf32bf1e1ea", size = 419732324, upload-time = "2026-03-23T18:09:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/1b666b6d61d3394cca306ea543ed03a64aad0a201b6cd159f1d41010aeb1/torch-2.11.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:98bb213c3084cfe176302949bdc360074b18a9da7ab59ef2edc9d9f742504778", size = 530596026, upload-time = "2026-03-23T18:09:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/48/6b/30d1459fa7e4b67e9e3fe1685ca1d8bb4ce7c62ef436c3a615963c6c866c/torch-2.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a97b94bbf62992949b4730c6cd2cc9aee7b335921ee8dc207d930f2ed09ae2db", size = 114793702, upload-time = "2026-03-23T18:09:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" }, + { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, + { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ba/b1b04f4b291a3205d95ebd24465de0e5bf010a2df27a4e58a9b5f039d8f2/triton-3.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c723cfb12f6842a0ae94ac307dba7e7a44741d720a40cf0e270ed4a4e3be781", size = 175972180, upload-time = "2026-01-20T16:15:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2c/96f92f3c60387e14cc45aed49487f3486f89ea27106c1b1376913c62abe4/triton-3.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49df5ef37379c0c2b5c0012286f80174fcf0e073e5ade1ca9a86c36814553651", size = 176081190, upload-time = "2026-01-20T16:16:00.523Z" }, + { url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/17/5d/08201db32823bdf77a0e2b9039540080b2e5c23a20706ddba942924ebcd6/triton-3.6.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:374f52c11a711fd062b4bfbb201fd9ac0a5febd28a96fb41b4a0f51dde3157f4", size = 176128243, upload-time = "2026-01-20T16:16:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "h11", marker = "python_full_version < '3.11' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[[package]] +name = "vectorbt" +version = "0.28.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anywidget" }, + { name = "dateparser" }, + { name = "dill" }, + { name = "imageio" }, + { name = "ipywidgets" }, + { name = "matplotlib" }, + { name = "mypy-extensions" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "plotly" }, + { name = "pytz" }, + { name = "requests" }, + { name = "schedule" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/92/d8f895bf16daac55311b8e218ccd84f6cf3a3e0a10d8b69282494fdff55f/vectorbt-0.28.5.tar.gz", hash = "sha256:79009c1048b80b4744d29abea3198fe8ec06acea66a42ae583745e8ac4c85fe1", size = 498084, upload-time = "2026-03-26T22:18:01.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/61/558641336106d99b8437415ddde6dd49c3c2342fccb9df481bedff989266/vectorbt-0.28.5-py3-none-any.whl", hash = "sha256:8820f2d13472947d0207f931ec1b0e041fc797aaf3db8eccd43cacd00663b894", size = 421680, upload-time = "2026-03-26T22:18:00.07Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xyzservices" +version = "2025.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, +] + +[[package]] +name = "yfinance" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "frozendict" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/1b/431d0ebd6a1e9deaffc8627cc4d26fd869841f31a1429cab7443eced0766/yfinance-1.2.0.tar.gz", hash = "sha256:80cec643eb983330ca63debab1b5492334fa1e6338d82cb17dd4e7b95079cfab", size = 140501, upload-time = "2026-02-16T19:52:34.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/60/462859de757ac56830824da7e8cf314b8b0321af5853df867c84cd6c2128/yfinance-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c27d1ebfc6275f476721cc6dba035a49d0cf9a806d6aa1785c9e10cf8a610d8", size = 130247, upload-time = "2026-02-16T19:52:33.109Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/vendor/ferro-ta-main/wasm/Cargo.lock b/vendor/ferro-ta-main/wasm/Cargo.lock new file mode 100644 index 0000000..07d562e --- /dev/null +++ b/vendor/ferro-ta-main/wasm/Cargo.lock @@ -0,0 +1,420 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +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 = "ferro_ta_core" +version = "1.2.0" + +[[package]] +name = "ferro_ta_wasm" +version = "1.2.0" +dependencies = [ + "ferro_ta_core", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-test", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +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 = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/vendor/ferro-ta-main/wasm/Cargo.toml b/vendor/ferro-ta-main/wasm/Cargo.toml new file mode 100644 index 0000000..128921e --- /dev/null +++ b/vendor/ferro-ta-main/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ferro_ta_wasm" +version = "1.2.0" +edition = "2021" +description = "WebAssembly bindings for ferro-ta technical analysis indicators" +license = "MIT" + +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +wasm-bindgen = "0.2" +js-sys = "0.3" +ferro_ta_core = { path = "../crates/ferro_ta_core", default-features = false } + +[dev-dependencies] +wasm-bindgen-test = "0.3" + +[profile.release] +opt-level = "s" # optimise for size in WASM +lto = true diff --git a/vendor/ferro-ta-main/wasm/README.md b/vendor/ferro-ta-main/wasm/README.md new file mode 100644 index 0000000..a78c0a7 --- /dev/null +++ b/vendor/ferro-ta-main/wasm/README.md @@ -0,0 +1,125 @@ +# ferro-ta WASM + +WebAssembly bindings for the [ferro-ta](https://github.com/pratikbhadane24/ferro-ta) technical analysis library. Full feature parity with the Python and Rust core packages. + +## Install from npm + +```bash +npm install ferro-ta-wasm +``` + +```javascript +const ferro = require('ferro-ta-wasm'); + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); +console.log('SMA:', Array.from(ferro.sma(close, 3))); +console.log('RSI:', Array.from(ferro.rsi(close, 14))); +``` + +## Available Indicators (200+ exports) + +| Category | Functions | Examples | +|----------|-----------|----------| +| Overlap Studies (20) | Moving averages, bands, SAR | `sma`, `ema`, `wma`, `dema`, `tema`, `trima`, `kama`, `t3`, `bbands`, `macd`, `macdfix`, `macdext`, `sar`, `sarext`, `mama`, `midpoint`, `midprice`, `ma`, `mavp`, `hull_ma` | +| Momentum (26) | Oscillators, directional movement | `rsi`, `mom`, `stoch`, `stochf`, `adx`, `adxr`, `dx`, `plus_di`, `minus_di`, `roc`, `willr`, `aroon`, `aroonosc`, `cci`, `bop`, `stochrsi`, `apo`, `ppo`, `cmo`, `trix_indicator`, `ultosc` | +| Candlestick Patterns (61) | All TA-Lib patterns | `cdlhammer`, `cdlengulfing`, `cdldoji`, `cdlmorningstar`, `cdlshootingstar`, ... (all 61) | +| Volatility (3) | True range, ATR | `atr`, `natr`, `trange` | +| Volume (6) | On-balance volume, accumulation | `obv`, `mfi`, `vwap`, `vwma`, `ad`, `adosc` | +| Price Transforms (4) | Synthetic prices | `avgprice`, `medprice`, `typprice`, `wclprice` | +| Cycle / Hilbert (6) | Hilbert Transform suite | `ht_trendline`, `ht_dcperiod`, `ht_dcphase`, `ht_phasor`, `ht_sine`, `ht_trendmode` | +| Statistics (10) | Regression, correlation | `stddev`, `var`, `linearreg`, `linearreg_slope`, `linearreg_intercept`, `linearreg_angle`, `tsf`, `beta_rolling`, `correl` | +| Math (19) | Operators and transforms | `math_add`, `math_sub`, `math_mult`, `math_div`, `transform_sin`, `transform_cos`, `transform_exp`, `transform_sqrt`, ... | +| Extended (10) | Supertrend, channels, Ichimoku | `supertrend`, `donchian`, `keltner_channels`, `ichimoku`, `pivot_points`, `chandelier_exit`, `choppiness_index` | +| Streaming API (9 classes) | Bar-by-bar stateful | `WasmStreamingSMA`, `WasmStreamingEMA`, `WasmStreamingRSI`, `WasmStreamingATR`, `WasmStreamingBBands`, `WasmStreamingMACD`, `WasmStreamingStoch`, `WasmStreamingVWAP`, `WasmStreamingSupertrend` | +| Options (14) | Pricing, Greeks, IV | `black_scholes_price`, `black_76_price`, `black_scholes_greeks`, `implied_volatility`, `iv_rank`, `smile_metrics`, ... | +| Futures (12) | Basis, roll, curve | `futures_basis`, `annualized_basis`, `roll_yield`, `weighted_continuous`, `calendar_spreads`, `curve_summary`, ... | +| Backtesting (9) | Signal generation, engines | `backtest_core`, `backtest_ohlcv`, `rsi_threshold_signals`, `macd_crossover_signals`, `walk_forward_indices`, `monte_carlo_bootstrap`, ... | +| Alerts & Regime (7) | Signals and regime detection | `check_threshold`, `check_cross`, `regime_adx`, `regime_combined`, `detect_breaks_cusum` | +| Batch & Portfolio (9) | Multi-asset analytics | `batch_sma`, `batch_ema`, `batch_rsi`, `correlation_matrix`, `portfolio_volatility`, `drawdown_series` | +| Aggregation (8) | Tick/volume/time bars | `aggregate_tick_bars`, `aggregate_volume_bars_ticks`, `volume_bars`, `ohlcv_agg` | + +## Prerequisites + +```bash +# Install Rust (if not already present) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Install wasm-pack +curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh +``` + +## Build + +```bash +cd wasm/ + +# Build both Node.js and web targets +npm run build + +# Or build individually: +npm run build:node # → node/ +npm run build:web # → web/ +``` + +This produces two directories: +- `node/` -- CommonJS glue for Node.js (`require()`) +- `web/` -- ESM glue for browsers and web workers (`import`) + +Both contain `ferro_ta_wasm.js`, `ferro_ta_wasm_bg.wasm`, and `ferro_ta_wasm.d.ts`. + +## Usage (Node.js) + +```javascript +const { + sma, ema, rsi, bbands, macd, atr, adx, obv, mfi, + cdlhammer, cdlengulfing, + WasmStreamingSMA, +} = require('ferro-ta-wasm'); + +const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); +const high = new Float64Array([45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); +const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + +// Indicators +console.log('SMA:', Array.from(sma(close, 3))); +console.log('RSI:', Array.from(rsi(close, 5))); + +// Multi-output +const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0); +const [macdLine, signal, hist] = macd(close, 3, 5, 2); + +// Streaming (bar-by-bar) +const stream = new WasmStreamingSMA(3); +for (const price of close) { + console.log('streaming SMA:', stream.update(price)); +} +``` + +## Usage (Browser) + +```html + +``` + +## Run Tests + +```bash +cd wasm/ +wasm-pack test --node +``` + +## Limitations + +- Large arrays (> 10M bars) may be slow due to JS-WASM memory copies. For high-throughput use cases prefer the Python (PyO3) binding. +- WASM does not support multi-threading natively in browsers (SharedArrayBuffer requires COOP/COEP headers). +- The npm package ships both Node.js (`require`) and browser/web worker (`import`) builds. Conditional exports in `package.json` select the right one automatically. + +## License + +MIT diff --git a/vendor/ferro-ta-main/wasm/bench.js b/vendor/ferro-ta-main/wasm/bench.js new file mode 100644 index 0000000..2fd0995 --- /dev/null +++ b/vendor/ferro-ta-main/wasm/bench.js @@ -0,0 +1,118 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { performance } = require("node:perf_hooks"); + +const wasm = require("./node/ferro_ta_wasm.js"); + +function parseArgs(argv) { + const args = { bars: 100000, json: null }; + for (let idx = 0; idx < argv.length; idx += 1) { + const token = argv[idx]; + if (token === "--bars") { + args.bars = Number(argv[idx + 1]); + idx += 1; + } else if (token === "--json") { + args.json = argv[idx + 1]; + idx += 1; + } + } + return args; +} + +function makeSeries(length) { + const close = new Float64Array(length); + const high = new Float64Array(length); + const low = new Float64Array(length); + const volume = new Float64Array(length); + let value = 100.0; + for (let idx = 0; idx < length; idx += 1) { + value += Math.sin(idx / 13.0) * 0.35 + Math.cos(idx / 29.0) * 0.18; + close[idx] = value; + high[idx] = value + 1.25; + low[idx] = value - 1.10; + volume[idx] = 1000.0 + Math.abs(Math.sin(idx / 7.0) * 300.0) + (idx % 100); + } + return { close, high, low, volume }; +} + +function timeMin(fn, rounds = 7) { + fn(); + let best = Number.POSITIVE_INFINITY; + for (let round = 0; round < rounds; round += 1) { + const started = performance.now(); + fn(); + best = Math.min(best, performance.now() - started); + } + return best; +} + +function runBenchmark({ bars }) { + const { close, high, low, volume } = makeSeries(bars); + const cases = [ + ["SMA", () => wasm.sma(close, 20)], + ["EMA", () => wasm.ema(close, 20)], + ["WMA", () => wasm.wma(close, 20)], + ["RSI", () => wasm.rsi(close, 14)], + ["ADX", () => wasm.adx(high, low, close, 14)], + ["MFI", () => wasm.mfi(high, low, close, volume, 14)], + ["ATR", () => wasm.atr(high, low, close, 14)], + ["BBANDS", () => wasm.bbands(close, 20, 2.0, 2.0)], + ]; + + const results = cases.map(([name, fn]) => { + const elapsedMs = timeMin(fn); + return { + indicator: name, + elapsed_ms: Number(elapsedMs.toFixed(4)), + ns_per_bar: Number(((elapsedMs * 1e6) / bars).toFixed(2)), + million_bars_per_second: Number((((bars / 1e6) / (elapsedMs / 1000))).toFixed(2)), + }; + }); + + return { + metadata: { + suite: "wasm", + runtime: { + generated_at_utc: new Date().toISOString(), + node_version: process.version, + platform: process.platform, + arch: process.arch, + }, + dataset: { + bars, + }, + }, + results, + }; +} + +function printResults(payload) { + const bars = payload.metadata.dataset.bars; + console.log(`WASM Benchmark: ${bars} bars`); + console.log("----------------------------------------------------------------"); + console.log( + `${"Indicator".padEnd(12)}${"Elapsed (ms)".padStart(14)}${"ns/bar".padStart(12)}${"M bars/s".padStart(12)}` + ); + console.log("----------------------------------------------------------------"); + for (const row of payload.results) { + console.log( + `${row.indicator.padEnd(12)}${row.elapsed_ms.toFixed(2).padStart(14)}${row.ns_per_bar + .toFixed(2) + .padStart(12)}${row.million_bars_per_second.toFixed(2).padStart(12)}` + ); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const payload = runBenchmark(args); + printResults(payload); + + if (args.json) { + const outputPath = path.resolve(args.json); + fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + console.log(`\nWrote JSON results to ${outputPath}`); + } +} + +main(); diff --git a/vendor/ferro-ta-main/wasm/package.json b/vendor/ferro-ta-main/wasm/package.json new file mode 100644 index 0000000..717da6e --- /dev/null +++ b/vendor/ferro-ta-main/wasm/package.json @@ -0,0 +1,41 @@ +{ + "name": "ferro-ta-wasm", + "version": "1.2.0", + "description": "WebAssembly bindings for ferro-ta technical analysis indicators", + "main": "node/ferro_ta_wasm.js", + "module": "web/ferro_ta_wasm.js", + "types": "node/ferro_ta_wasm.d.ts", + "exports": { + ".": { + "node": { + "types": "./node/ferro_ta_wasm.d.ts", + "require": "./node/ferro_ta_wasm.js" + }, + "default": { + "types": "./web/ferro_ta_wasm.d.ts", + "import": "./web/ferro_ta_wasm.js" + } + } + }, + "files": ["node", "web"], + "scripts": { + "build": "npm run build:node && npm run build:web", + "build:node": "wasm-pack build --target nodejs --out-dir node && node -e \"require('fs').rmSync('node/.gitignore', { force: true }); require('fs').rmSync('node/package.json', { force: true });\"", + "build:web": "wasm-pack build --target web --out-dir web && node -e \"require('fs').rmSync('web/.gitignore', { force: true }); require('fs').rmSync('web/package.json', { force: true });\"", + "bench": "node bench.js", + "prepack": "npm run build", + "test": "wasm-pack test --node" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/pratikbhadane24/ferro-ta.git", + "directory": "wasm" + }, + "homepage": "https://github.com/pratikbhadane24/ferro-ta#readme", + "bugs": "https://github.com/pratikbhadane24/ferro-ta/issues", + "publishConfig": { + "access": "public" + }, + "devDependencies": {} +} diff --git a/vendor/ferro-ta-main/wasm/src/lib.rs b/vendor/ferro-ta-main/wasm/src/lib.rs new file mode 100644 index 0000000..c2ebfa9 --- /dev/null +++ b/vendor/ferro-ta-main/wasm/src/lib.rs @@ -0,0 +1,3607 @@ +/*! +# ferro-ta WASM bindings + +WebAssembly bindings for the ferro-ta technical analysis library. + +All functions accept `Float64Array` inputs and return `Float64Array` (or a +`js_sys::Array` of `Float64Array` for multi-output indicators such as `BBANDS` +and `MACD`). + +## Overlap Studies +- [`sma`] — Simple Moving Average +- [`ema`] — Exponential Moving Average +- [`wma`] — Weighted Moving Average +- [`bbands`] — Bollinger Bands (returns `[upper, middle, lower]`) + +## Momentum Indicators +- [`rsi`] — Relative Strength Index (Wilder smoothing) +- [`macd`] — Moving Average Convergence/Divergence (returns `[macd, signal, hist]`) +- [`mom`] — Momentum (close[i] - close[i-period]) +- [`stochf`] — Fast Stochastic (returns `[fastk, fastd]`) +- [`adx`] — Average Directional Movement Index + +## Volatility Indicators +- [`atr`] — Average True Range (Wilder smoothing) + +## Volume Indicators +- [`obv`] — On-Balance Volume +- [`mfi`] — Money Flow Index +*/ + +use js_sys::{Array, Float64Array}; +use wasm_bindgen::prelude::*; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Copy a `Float64Array` into a `Vec`. +fn to_vec(arr: &Float64Array) -> Vec { + let n = arr.length() as usize; + let mut v = vec![0.0f64; n]; + arr.copy_to(&mut v); + v +} + +/// Create a `Float64Array` from a `Vec`. +fn from_vec(v: Vec) -> Float64Array { + // Safety: Float64Array::view requires the backing Vec to stay alive for the + // duration of the copy. We immediately copy via `Float64Array::from` so + // there is no aliasing. + let arr = Float64Array::new_with_length(v.len() as u32); + arr.copy_from(&v); + arr +} + +// --------------------------------------------------------------------------- +// SMA — Simple Moving Average +// --------------------------------------------------------------------------- + +/// Simple Moving Average. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn sma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::sma(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// EMA — Exponential Moving Average +// --------------------------------------------------------------------------- + +/// Exponential Moving Average (SMA-seeded). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn ema(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::ema(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// BBANDS — Bollinger Bands +// --------------------------------------------------------------------------- + +/// Bollinger Bands (SMA ± k × rolling standard deviation). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 5, minimum 1). +/// - `nbdevup` – multiplier for the upper band (default 2.0). +/// - `nbdevdn` – multiplier for the lower band (default 2.0). +/// +/// # Returns +/// A `js_sys::Array` containing three `Float64Array` elements: +/// `[upperband, middleband, lowerband]`. +#[wasm_bindgen] +pub fn bbands( + close: &Float64Array, + timeperiod: usize, + nbdevup: f64, + nbdevdn: f64, +) -> Array { + let prices = to_vec(close); + let (upper, middle, lower) = ferro_ta_core::overlap::bbands(&prices, timeperiod, nbdevup, nbdevdn); + let out = Array::new(); + out.push(&from_vec(upper)); + out.push(&from_vec(middle)); + out.push(&from_vec(lower)); + out +} + +// --------------------------------------------------------------------------- +// RSI — Relative Strength Index (Wilder smoothing, TA-Lib compatible) +// --------------------------------------------------------------------------- + +/// Relative Strength Index (Wilder smoothing). +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array` — values in `[0, 100]`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn rsi(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::momentum::rsi(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// ATR — Average True Range (Wilder smoothing) +// --------------------------------------------------------------------------- + +/// Average True Range (Wilder smoothing, TA-Lib compatible). +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn atr( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::volatility::atr(&h, &l, &c, timeperiod)) +} + +// --------------------------------------------------------------------------- +// OBV — On-Balance Volume +// --------------------------------------------------------------------------- + +/// On-Balance Volume. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `volume` – `Float64Array` of volume values. +/// +/// # Returns +/// `Float64Array` — cumulative OBV. +#[wasm_bindgen] +pub fn obv(close: &Float64Array, volume: &Float64Array) -> Float64Array { + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::volume::obv(&c, &v)) +} + +// --------------------------------------------------------------------------- +// WMA — Weighted Moving Average +// --------------------------------------------------------------------------- + +/// Weighted Moving Average. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 30, minimum 1). +/// +/// # Returns +/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`. +#[wasm_bindgen] +pub fn wma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::overlap::wma(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MOM — Momentum +// --------------------------------------------------------------------------- + +/// Momentum — difference between current close and close *timeperiod* bars ago. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back window (default 10, minimum 1). +/// +/// # Returns +/// `Float64Array`; first `timeperiod` values are `NaN`. +#[wasm_bindgen] +pub fn mom(close: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(close); + from_vec(ferro_ta_core::momentum::mom(&prices, timeperiod)) +} + +// --------------------------------------------------------------------------- +// STOCHF — Fast Stochastic Oscillator +// --------------------------------------------------------------------------- + +/// Fast Stochastic Oscillator. +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `fastk_period` – fast-%K look-back window (default 5, minimum 1). +/// - `fastd_period` – fast-%D SMA smoothing period (default 3, minimum 1). +/// +/// # Returns +/// A `js_sys::Array` containing two `Float64Array` elements: `[fastk, fastd]`. +#[wasm_bindgen] +pub fn stochf( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + fastk_period: usize, + fastd_period: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + // stoch with slowk_period=1 yields fastk as slowk, fastd as slowd + let (fastk, fastd) = ferro_ta_core::momentum::stoch(&h, &l, &c, fastk_period, 1, fastd_period); + let out = Array::new(); + out.push(&from_vec(fastk)); + out.push(&from_vec(fastd)); + out +} + +// --------------------------------------------------------------------------- +// ADX — Average Directional Movement Index +// --------------------------------------------------------------------------- + +/// Average Directional Movement Index (Wilder smoothing). +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; warm-up values are `NaN`. +#[wasm_bindgen] +pub fn adx( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + if h.len() != l.len() || h.len() != c.len() { + return from_vec(vec![f64::NAN; c.len()]); + } + from_vec(ferro_ta_core::momentum::adx(&h, &l, &c, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MFI — Money Flow Index +// --------------------------------------------------------------------------- + +/// Money Flow Index. +/// +/// # Arguments +/// - `high` – `Float64Array` of high prices. +/// - `low` – `Float64Array` of low prices. +/// - `close` – `Float64Array` of close prices. +/// - `volume` – `Float64Array` of volume values. +/// - `timeperiod` – look-back period (default 14, minimum 1). +/// +/// # Returns +/// `Float64Array`; warm-up values are `NaN`. +#[wasm_bindgen] +pub fn mfi( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + let n = c.len(); + if h.len() != n || l.len() != n || v.len() != n { + return from_vec(vec![f64::NAN; n]); + } + from_vec(ferro_ta_core::volume::mfi(&h, &l, &c, &v, timeperiod)) +} + +// --------------------------------------------------------------------------- +// MACD — Moving Average Convergence/Divergence +// --------------------------------------------------------------------------- + +/// Moving Average Convergence/Divergence. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `fastperiod` – fast EMA period (default 12). +/// - `slowperiod` – slow EMA period (default 26). +/// - `signalperiod` – signal EMA period (default 9). +/// +/// # Returns +/// A `js_sys::Array` containing three `Float64Array` elements: +/// `[macd_line, signal_line, histogram]`. +#[wasm_bindgen] +pub fn macd( + close: &Float64Array, + fastperiod: usize, + slowperiod: usize, + signalperiod: usize, +) -> Array { + let prices = to_vec(close); + let (macd_line, signal_line, histogram) = + ferro_ta_core::overlap::macd(&prices, fastperiod, slowperiod, signalperiod); + let out = Array::new(); + out.push(&from_vec(macd_line)); + out.push(&from_vec(signal_line)); + out.push(&from_vec(histogram)); + out +} + +// --------------------------------------------------------------------------- +// CommissionModel — advanced commission and tax model for Indian and global markets +// --------------------------------------------------------------------------- + +/// Advanced commission and tax model (WASM binding). +/// +/// All `_rate` fields are fractions (e.g. 0.001 = 0.1%). +/// Per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g. INR). +/// +/// Use the static factory methods for built-in presets, or construct and +/// set fields individually. +#[wasm_bindgen] +pub struct CommissionModel { + inner: ferro_ta_core::commission::CommissionModel, +} + +#[wasm_bindgen] +impl CommissionModel { + /// Create a zero-commission model. + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { inner: ferro_ta_core::commission::CommissionModel::default() } + } + + // ---- Field getters/setters ------------------------------------------ + + #[wasm_bindgen(getter)] pub fn flat_per_order(&self) -> f64 { self.inner.flat_per_order } + #[wasm_bindgen(setter)] pub fn set_flat_per_order(&mut self, v: f64) { self.inner.flat_per_order = v; } + + #[wasm_bindgen(getter)] pub fn rate_of_value(&self) -> f64 { self.inner.rate_of_value } + #[wasm_bindgen(setter)] pub fn set_rate_of_value(&mut self, v: f64) { self.inner.rate_of_value = v; } + + #[wasm_bindgen(getter)] pub fn per_lot(&self) -> f64 { self.inner.per_lot } + #[wasm_bindgen(setter)] pub fn set_per_lot(&mut self, v: f64) { self.inner.per_lot = v; } + + #[wasm_bindgen(getter)] pub fn max_brokerage(&self) -> f64 { self.inner.max_brokerage } + #[wasm_bindgen(setter)] pub fn set_max_brokerage(&mut self, v: f64) { self.inner.max_brokerage = v; } + + #[wasm_bindgen(getter)] pub fn stt_rate(&self) -> f64 { self.inner.stt_rate } + #[wasm_bindgen(setter)] pub fn set_stt_rate(&mut self, v: f64) { self.inner.stt_rate = v; } + + #[wasm_bindgen(getter)] pub fn stt_on_buy(&self) -> bool { self.inner.stt_on_buy } + #[wasm_bindgen(setter)] pub fn set_stt_on_buy(&mut self, v: bool) { self.inner.stt_on_buy = v; } + + #[wasm_bindgen(getter)] pub fn stt_on_sell(&self) -> bool { self.inner.stt_on_sell } + #[wasm_bindgen(setter)] pub fn set_stt_on_sell(&mut self, v: bool) { self.inner.stt_on_sell = v; } + + #[wasm_bindgen(getter)] pub fn exchange_charges_rate(&self) -> f64 { self.inner.exchange_charges_rate } + #[wasm_bindgen(setter)] pub fn set_exchange_charges_rate(&mut self, v: f64) { self.inner.exchange_charges_rate = v; } + + #[wasm_bindgen(getter)] pub fn regulatory_charges_rate(&self) -> f64 { self.inner.regulatory_charges_rate } + #[wasm_bindgen(setter)] pub fn set_regulatory_charges_rate(&mut self, v: f64) { self.inner.regulatory_charges_rate = v; } + + #[wasm_bindgen(getter)] pub fn gst_rate(&self) -> f64 { self.inner.gst_rate } + #[wasm_bindgen(setter)] pub fn set_gst_rate(&mut self, v: f64) { self.inner.gst_rate = v; } + + #[wasm_bindgen(getter)] pub fn stamp_duty_rate(&self) -> f64 { self.inner.stamp_duty_rate } + #[wasm_bindgen(setter)] pub fn set_stamp_duty_rate(&mut self, v: f64) { self.inner.stamp_duty_rate = v; } + + #[wasm_bindgen(getter)] pub fn lot_size(&self) -> f64 { self.inner.lot_size } + #[wasm_bindgen(setter)] pub fn set_lot_size(&mut self, v: f64) { self.inner.lot_size = v; } + + // ---- Compute -------------------------------------------------------- + + /// Total transaction cost in absolute currency units. + pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 { + self.inner.total_cost(trade_value, num_lots, is_buy) + } + + /// Cost as fraction of `initial_capital` (for normalised equity loops). + pub fn cost_fraction(&self, trade_value: f64, num_lots: f64, is_buy: bool, initial_capital: f64) -> f64 { + self.inner.cost_fraction(trade_value, num_lots, is_buy, initial_capital) + } + + // ---- Presets (static constructors) ---------------------------------- + + /// Zero-commission model. + pub fn zero() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::zero() } + } + + /// Indian equity delivery preset. + pub fn equity_delivery_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::equity_delivery_india() } + } + + /// Indian equity intraday preset. + pub fn equity_intraday_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::equity_intraday_india() } + } + + /// Indian index futures preset. + pub fn futures_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::futures_india() } + } + + /// Indian index options preset. + pub fn options_india() -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::options_india() } + } + + /// Simple proportional model (no taxes, `rate` fraction both ways). + pub fn proportional(rate: f64) -> CommissionModel { + CommissionModel { inner: ferro_ta_core::commission::CommissionModel::proportional(rate) } + } + + // ---- JSON (minimal manual serialization — no serde in WASM) ---------- + + /// Serialize key fields to a JSON string (no serde dependency). + pub fn to_json_string(&self) -> String { + let m = &self.inner; + format!( + r#"{{"flat_per_order":{},"rate_of_value":{},"per_lot":{},"max_brokerage":{},"stt_rate":{},"stt_on_buy":{},"stt_on_sell":{},"exchange_charges_rate":{},"regulatory_charges_rate":{},"gst_rate":{},"stamp_duty_rate":{},"lot_size":{},"spread_bps":{},"short_borrow_rate_annual":{}}}"#, + m.flat_per_order, m.rate_of_value, m.per_lot, m.max_brokerage, + m.stt_rate, m.stt_on_buy, m.stt_on_sell, + m.exchange_charges_rate, m.regulatory_charges_rate, + m.gst_rate, m.stamp_duty_rate, m.lot_size, + m.spread_bps, m.short_borrow_rate_annual, + ) + } +} + +// =========================================================================== +// Price Transform +// =========================================================================== + +/// Average Price: (open + high + low + close) / 4. +#[wasm_bindgen] +pub fn avgprice( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::avgprice(&o, &h, &l, &c)) +} + +/// Median Price: (high + low) / 2. +#[wasm_bindgen] +pub fn medprice(high: &Float64Array, low: &Float64Array) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::price_transform::medprice(&h, &l)) +} + +/// Typical Price: (high + low + close) / 3. +#[wasm_bindgen] +pub fn typprice( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::typprice(&h, &l, &c)) +} + +/// Weighted Close Price: (high + low + close * 2) / 4. +#[wasm_bindgen] +pub fn wclprice( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::price_transform::wclprice(&h, &l, &c)) +} + +// =========================================================================== +// Alerts +// =========================================================================== + +/// Fire an alert when series crosses a threshold level. +/// direction: 1 = cross above, -1 = cross below. +/// Returns Int8Array: 1 at crossing bars, 0 elsewhere. +#[wasm_bindgen] +pub fn check_threshold(series: &Float64Array, level: f64, direction: i32) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::alerts::check_threshold(&s, level, direction); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect cross-over/cross-under events between fast and slow series. +/// Returns Int8Array: 1 = bullish, -1 = bearish, 0 = none. +#[wasm_bindgen] +pub fn check_cross(fast: &Float64Array, slow: &Float64Array) -> js_sys::Int8Array { + let f = to_vec(fast); + let s = to_vec(slow); + let result = ferro_ta_core::alerts::check_cross(&f, &s); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Collect bar indices where mask is non-zero. +#[wasm_bindgen] +pub fn collect_alert_bars(mask: &js_sys::Int8Array) -> Float64Array { + let n = mask.length() as usize; + let mut m = vec![0i8; n]; + mask.copy_to(&mut m); + let result = ferro_ta_core::alerts::collect_alert_bars(&m); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Signals +// =========================================================================== + +/// Compute fractional rank of each element (1-based, ascending). +#[wasm_bindgen] +pub fn rank_series(x: &Float64Array) -> Float64Array { + let xv = to_vec(x); + from_vec(ferro_ta_core::signals::rank_values(&xv)) +} + +/// Return indices of the N largest values. +#[wasm_bindgen] +pub fn top_n_indices(x: &Float64Array, n: usize) -> Float64Array { + let xv = to_vec(x); + let result = ferro_ta_core::signals::top_n_indices(&xv, n); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Return indices of the N smallest values. +#[wasm_bindgen] +pub fn bottom_n_indices(x: &Float64Array, n: usize) -> Float64Array { + let xv = to_vec(x); + let result = ferro_ta_core::signals::bottom_n_indices(&xv, n); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Crypto +// =========================================================================== + +/// Cumulative PnL from funding rate payments. +#[wasm_bindgen] +pub fn funding_cumulative_pnl( + position_size: &Float64Array, + funding_rate: &Float64Array, +) -> Float64Array { + let pos = to_vec(position_size); + let rate = to_vec(funding_rate); + from_vec(ferro_ta_core::crypto::funding_cumulative_pnl(&pos, &rate)) +} + +/// Assign sequential integer labels based on fixed period size. +#[wasm_bindgen] +pub fn continuous_bar_labels(n_bars: usize, period_bars: usize) -> Float64Array { + let result = ferro_ta_core::crypto::continuous_bar_labels(n_bars, period_bars); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Math Ops +// =========================================================================== + +/// Rolling sum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_sum(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_sum(&prices, timeperiod)) +} + +/// Rolling maximum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_max(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_max(&prices, timeperiod)) +} + +/// Rolling minimum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_min(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + from_vec(ferro_ta_core::math_ops::rolling_min(&prices, timeperiod)) +} + +/// Index of rolling maximum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_maxindex(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + let result = ferro_ta_core::math_ops::rolling_maxindex(&prices, timeperiod); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Index of rolling minimum over timeperiod bars. +#[wasm_bindgen] +pub fn rolling_minindex(real: &Float64Array, timeperiod: usize) -> Float64Array { + let prices = to_vec(real); + let result = ferro_ta_core::math_ops::rolling_minindex(&prices, timeperiod); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Regime +// =========================================================================== + +/// Label bars as trend (1), range (0), or NaN (-1) based on ADX threshold. +#[wasm_bindgen] +pub fn regime_adx(adx: &Float64Array, threshold: f64) -> js_sys::Int8Array { + let a = to_vec(adx); + let result = ferro_ta_core::regime::regime_adx(&a, threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Label bars using ADX + ATR-ratio combined rule. +#[wasm_bindgen] +pub fn regime_combined( + adx: &Float64Array, + atr: &Float64Array, + close: &Float64Array, + adx_threshold: f64, + atr_pct_threshold: f64, +) -> js_sys::Int8Array { + let a = to_vec(adx); + let r = to_vec(atr); + let c = to_vec(close); + let result = ferro_ta_core::regime::regime_combined(&a, &r, &c, adx_threshold, atr_pct_threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect structural breaks using CUSUM approach. +#[wasm_bindgen] +pub fn detect_breaks_cusum( + series: &Float64Array, + window: usize, + threshold: f64, + slack: f64, +) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::regime::detect_breaks_cusum(&s, window, threshold, slack); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Detect volatility regime breaks using rolling variance ratio. +#[wasm_bindgen] +pub fn rolling_variance_break( + series: &Float64Array, + short_window: usize, + long_window: usize, + threshold: f64, +) -> js_sys::Int8Array { + let s = to_vec(series); + let result = ferro_ta_core::regime::rolling_variance_break(&s, short_window, long_window, threshold); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +// =========================================================================== +// Chunked +// =========================================================================== + +/// Remove first overlap elements from an array. +#[wasm_bindgen] +pub fn trim_overlap(chunk_out: &Float64Array, overlap: usize) -> Float64Array { + let s = to_vec(chunk_out); + from_vec(ferro_ta_core::chunked::trim_overlap(&s, overlap)) +} + +/// Compute (start, end) index pairs for chunked processing. +/// Returns flat Float64Array: [start0, end0, start1, end1, ...]. +#[wasm_bindgen] +pub fn make_chunk_ranges(n: usize, chunk_size: usize, overlap: usize) -> Float64Array { + let result = ferro_ta_core::chunked::make_chunk_ranges(n, chunk_size, overlap); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +/// Forward-fill NaN values in a 1-D array. +#[wasm_bindgen] +pub fn forward_fill_nan(values: &Float64Array) -> Float64Array { + let input = to_vec(values); + from_vec(ferro_ta_core::chunked::forward_fill_nan(&input)) +} + +// =========================================================================== +// Extended Indicators (Sprint 2) +// =========================================================================== + +/// Volume Weighted Average Price (cumulative or rolling). +#[wasm_bindgen] +pub fn vwap( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::extended::vwap(&h, &l, &c, &v, timeperiod)) +} + +/// Volume Weighted Moving Average. +#[wasm_bindgen] +pub fn vwma(close: &Float64Array, volume: &Float64Array, timeperiod: usize) -> Float64Array { + let c = to_vec(close); + let v = to_vec(volume); + from_vec(ferro_ta_core::extended::vwma(&c, &v, timeperiod)) +} + +/// ATR-based Supertrend indicator. +/// Returns `[supertrend_line, direction_as_f64]`. +#[wasm_bindgen] +pub fn supertrend( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (line, direction) = ferro_ta_core::extended::supertrend(&h, &l, &c, timeperiod, multiplier); + let dir_f64: Vec = direction.iter().map(|&d| d as f64).collect(); + let out = Array::new(); + out.push(&from_vec(line)); + out.push(&from_vec(dir_f64)); + out +} + +/// Donchian Channels — rolling highest high / lowest low. +/// Returns `[upper, middle, lower]`. +#[wasm_bindgen] +pub fn donchian(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let (upper, middle, lower) = ferro_ta_core::extended::donchian(&h, &l, timeperiod); + let out = Array::new(); + out.push(&from_vec(upper)); + out.push(&from_vec(middle)); + out.push(&from_vec(lower)); + out +} + +/// Choppiness Index — measures market choppiness vs trending. +#[wasm_bindgen] +pub fn choppiness_index( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, +) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::extended::choppiness_index(&h, &l, &c, timeperiod)) +} + +/// Keltner Channels — EMA +/- (multiplier x ATR). +/// Returns `[upper, middle, lower]`. +#[wasm_bindgen] +pub fn keltner_channels( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + atr_period: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (upper, middle, lower) = + ferro_ta_core::extended::keltner_channels(&h, &l, &c, timeperiod, atr_period, multiplier); + let out = Array::new(); + out.push(&from_vec(upper)); + out.push(&from_vec(middle)); + out.push(&from_vec(lower)); + out +} + +/// Hull Moving Average (HMA). +#[wasm_bindgen] +pub fn hull_ma(close: &Float64Array, timeperiod: usize) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::extended::hull_ma(&c, timeperiod)) +} + +/// Chandelier Exit — ATR-based trailing stop levels. +/// Returns `[long_exit, short_exit]`. +#[wasm_bindgen] +pub fn chandelier_exit( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + timeperiod: usize, + multiplier: f64, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (long_exit, short_exit) = + ferro_ta_core::extended::chandelier_exit(&h, &l, &c, timeperiod, multiplier); + let out = Array::new(); + out.push(&from_vec(long_exit)); + out.push(&from_vec(short_exit)); + out +} + +/// Ichimoku Cloud (Ichimoku Kinko Hyo). +/// Returns `[tenkan, kijun, senkou_a, senkou_b, chikou]`. +#[wasm_bindgen] +pub fn ichimoku( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + tenkan: usize, + kijun: usize, + senkou_b: usize, + displacement: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (tenkan_out, kijun_out, senkou_a_out, senkou_b_out, chikou_out) = + ferro_ta_core::extended::ichimoku(&h, &l, &c, tenkan, kijun, senkou_b, displacement); + let out = Array::new(); + out.push(&from_vec(tenkan_out)); + out.push(&from_vec(kijun_out)); + out.push(&from_vec(senkou_a_out)); + out.push(&from_vec(senkou_b_out)); + out.push(&from_vec(chikou_out)); + out +} + +/// Pivot Points — support / resistance levels. +/// Returns `[pivot, r1, s1, r2, s2]`. +#[wasm_bindgen(js_name = "pivot_points")] +pub fn pivot_points( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + method: &str, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (pivot, r1, s1, r2, s2) = ferro_ta_core::extended::pivot_points(&h, &l, &c, method); + let out = Array::new(); + out.push(&from_vec(pivot)); + out.push(&from_vec(r1)); + out.push(&from_vec(s1)); + out.push(&from_vec(r2)); + out.push(&from_vec(s2)); + out +} + +// =========================================================================== +// Portfolio Analytics (Sprint 2) +// =========================================================================== + +/// Full-sample OLS beta of asset vs benchmark returns. +#[wasm_bindgen] +pub fn beta_full(asset_returns: &Float64Array, benchmark_returns: &Float64Array) -> f64 { + let a = to_vec(asset_returns); + let b = to_vec(benchmark_returns); + ferro_ta_core::portfolio::beta_full(&a, &b) +} + +/// Rolling beta of asset vs benchmark over a sliding window. +#[wasm_bindgen] +pub fn rolling_beta( + asset: &Float64Array, + benchmark: &Float64Array, + window: usize, +) -> Float64Array { + let a = to_vec(asset); + let b = to_vec(benchmark); + from_vec(ferro_ta_core::portfolio::rolling_beta(&a, &b, window)) +} + +/// Drawdown series and maximum drawdown for an equity curve. +/// Returns `[dd_array, max_dd_as_single_element]`. +#[wasm_bindgen] +pub fn drawdown_series(equity: &Float64Array) -> Array { + let eq = to_vec(equity); + let (dd, max_dd) = ferro_ta_core::portfolio::drawdown_series(&eq); + let out = Array::new(); + out.push(&from_vec(dd)); + out.push(&from_vec(vec![max_dd])); + out +} + +/// Relative strength of asset vs benchmark (cumulative return ratio). +#[wasm_bindgen] +pub fn relative_strength( + asset_returns: &Float64Array, + benchmark_returns: &Float64Array, +) -> Float64Array { + let a = to_vec(asset_returns); + let b = to_vec(benchmark_returns); + from_vec(ferro_ta_core::portfolio::relative_strength(&a, &b)) +} + +/// Spread between two series: a - hedge * b. +#[wasm_bindgen] +pub fn spread(a: &Float64Array, b: &Float64Array, hedge: f64) -> Float64Array { + let av = to_vec(a); + let bv = to_vec(b); + from_vec(ferro_ta_core::portfolio::spread(&av, &bv, hedge)) +} + +/// Ratio between two series: a / b (NaN where b is zero). +#[wasm_bindgen] +pub fn ratio(a: &Float64Array, b: &Float64Array) -> Float64Array { + let av = to_vec(a); + let bv = to_vec(b); + from_vec(ferro_ta_core::portfolio::ratio(&av, &bv)) +} + +/// Rolling Z-score of a 1-D series. +#[wasm_bindgen] +pub fn zscore_series(x: &Float64Array, window: usize) -> Float64Array { + let xv = to_vec(x); + from_vec(ferro_ta_core::portfolio::zscore_series(&xv, window)) +} + +// =========================================================================== +// Attribution (Sprint 2) +// =========================================================================== + +/// Trade-level statistics from trade PnL and hold durations. +/// Returns `[win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars]` as Float64Array. +#[wasm_bindgen] +pub fn trade_stats(pnl: &Float64Array, hold_bars: &Float64Array) -> Array { + let p = to_vec(pnl); + let h = to_vec(hold_bars); + let (win_rate, avg_win, avg_loss, profit_factor, avg_hold) = + ferro_ta_core::attribution::trade_stats(&p, &h); + let out = Array::new(); + out.push(&from_vec(vec![win_rate, avg_win, avg_loss, profit_factor, avg_hold])); + out +} + +/// Group per-bar returns by month index and sum each month's contribution. +/// Returns `[months_as_f64, contributions]`. +#[wasm_bindgen] +pub fn monthly_contribution( + bar_returns: &Float64Array, + month_index: &Float64Array, +) -> Array { + let ret = to_vec(bar_returns); + let mi_f64 = to_vec(month_index); + let mi: Vec = mi_f64.iter().map(|&v| v as i64).collect(); + let (months, contributions) = ferro_ta_core::attribution::monthly_contribution(&ret, &mi); + let months_f64: Vec = months.iter().map(|&m| m as f64).collect(); + let out = Array::new(); + out.push(&from_vec(months_f64)); + out.push(&from_vec(contributions)); + out +} + +/// Attribute per-bar returns to each signal label. +/// Returns `[labels_as_f64, contributions]`. +#[wasm_bindgen] +pub fn signal_attribution( + bar_returns: &Float64Array, + signal_labels: &Float64Array, +) -> Array { + let ret = to_vec(bar_returns); + let sl_f64 = to_vec(signal_labels); + let sl: Vec = sl_f64.iter().map(|&v| v as i64).collect(); + let (labels, contributions) = ferro_ta_core::attribution::signal_attribution(&ret, &sl); + let labels_f64: Vec = labels.iter().map(|&l| l as f64).collect(); + let out = Array::new(); + out.push(&from_vec(labels_f64)); + out.push(&from_vec(contributions)); + out +} + +/// Extract trade PnL and hold durations from positions and strategy returns. +/// Returns `[pnl, hold_durations]`. +#[wasm_bindgen] +pub fn extract_trades( + positions: &Float64Array, + strategy_returns: &Float64Array, +) -> Array { + let pos = to_vec(positions); + let sr = to_vec(strategy_returns); + let (pnl, hold) = ferro_ta_core::attribution::extract_trades(&pos, &sr); + let out = Array::new(); + out.push(&from_vec(pnl)); + out.push(&from_vec(hold)); + out +} + +// =========================================================================== +// Resampling (Sprint 2) +// =========================================================================== + +/// Aggregate OHLCV data into volume bars of a fixed volume threshold. +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn volume_bars( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + volume_threshold: f64, +) -> Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::volume_bars(&o, &h, &l, &c, &v, volume_threshold); + let out = Array::new(); + out.push(&from_vec(ro)); + out.push(&from_vec(rh)); + out.push(&from_vec(rl)); + out.push(&from_vec(rc)); + out.push(&from_vec(rv)); + out +} + +/// Aggregate OHLCV bars by integer group labels. +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn ohlcv_agg( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + volume: &Float64Array, + labels: &Float64Array, +) -> Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let v = to_vec(volume); + let lbl_f64 = to_vec(labels); + let lbl: Vec = lbl_f64.iter().map(|&x| x as i64).collect(); + let (ro, rh, rl, rc, rv) = + ferro_ta_core::resampling::ohlcv_agg(&o, &h, &l, &c, &v, &lbl); + let out = Array::new(); + out.push(&from_vec(ro)); + out.push(&from_vec(rh)); + out.push(&from_vec(rl)); + out.push(&from_vec(rc)); + out.push(&from_vec(rv)); + out +} + +// =========================================================================== +// Aggregation (Sprint 2) +// =========================================================================== + +/// Aggregate tick/trade data into tick bars (every N ticks become one bar). +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn aggregate_tick_bars( + price: &Float64Array, + size: &Float64Array, + ticks_per_bar: usize, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let (o, h, l, c, v) = ferro_ta_core::aggregation::aggregate_tick_bars(&p, &s, ticks_per_bar); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out +} + +/// Aggregate tick data into volume bars (fixed volume threshold). +/// Returns `[open, high, low, close, volume]`. +#[wasm_bindgen] +pub fn aggregate_volume_bars_ticks( + price: &Float64Array, + size: &Float64Array, + volume_threshold: f64, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let (o, h, l, c, v) = + ferro_ta_core::aggregation::aggregate_volume_bars_ticks(&p, &s, volume_threshold); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out +} + +/// Aggregate tick data into time bars using pre-computed integer bucket labels. +/// Returns `[open, high, low, close, volume, labels_as_f64]`. +#[wasm_bindgen] +pub fn aggregate_time_bars( + price: &Float64Array, + size: &Float64Array, + labels: &Float64Array, +) -> Array { + let p = to_vec(price); + let s = to_vec(size); + let lbl_f64 = to_vec(labels); + let lbl: Vec = lbl_f64.iter().map(|&x| x as i64).collect(); + let (o, h, l, c, v, out_labels) = + ferro_ta_core::aggregation::aggregate_time_bars(&p, &s, &lbl); + let labels_out: Vec = out_labels.iter().map(|&x| x as f64).collect(); + let out = Array::new(); + out.push(&from_vec(o)); + out.push(&from_vec(h)); + out.push(&from_vec(l)); + out.push(&from_vec(c)); + out.push(&from_vec(v)); + out.push(&from_vec(labels_out)); + out +} + +// =========================================================================== +// Cycle Indicators +// =========================================================================== + +#[wasm_bindgen] +pub fn ht_trendline(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_trendline(&c)) +} + +#[wasm_bindgen] +pub fn ht_dcperiod(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_dcperiod(&c)) +} + +#[wasm_bindgen] +pub fn ht_dcphase(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + from_vec(ferro_ta_core::cycle::ht_dcphase(&c)) +} + +#[wasm_bindgen] +pub fn ht_phasor(close: &Float64Array) -> Array { + let c = to_vec(close); + let (inphase, quad) = ferro_ta_core::cycle::ht_phasor(&c); + let arr = Array::new(); + arr.push(&from_vec(inphase)); arr.push(&from_vec(quad)); + arr +} + +#[wasm_bindgen] +pub fn ht_sine(close: &Float64Array) -> Array { + let c = to_vec(close); + let (sine, leadsine) = ferro_ta_core::cycle::ht_sine(&c); + let arr = Array::new(); + arr.push(&from_vec(sine)); arr.push(&from_vec(leadsine)); + arr +} + +#[wasm_bindgen] +pub fn ht_trendmode(close: &Float64Array) -> Float64Array { + let c = to_vec(close); + let result = ferro_ta_core::cycle::ht_trendmode(&c); + let out: Vec = result.into_iter().map(|v| v as f64).collect(); + from_vec(out) +} + +// =========================================================================== +// Volume (additional exports) +// =========================================================================== + +#[wasm_bindgen] +pub fn ad(high: &Float64Array, low: &Float64Array, close: &Float64Array, volume: &Float64Array) -> Float64Array { + let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); let v = to_vec(volume); + from_vec(ferro_ta_core::volume::ad(&h, &l, &c, &v)) +} + +#[wasm_bindgen] +pub fn adosc(high: &Float64Array, low: &Float64Array, close: &Float64Array, volume: &Float64Array, fastperiod: usize, slowperiod: usize) -> Float64Array { + let h = to_vec(high); let l = to_vec(low); let c = to_vec(close); let v = to_vec(volume); + from_vec(ferro_ta_core::volume::adosc(&h, &l, &c, &v, fastperiod, slowperiod)) +} + +// =========================================================================== +// Momentum (additional exports) +// =========================================================================== + +/// Full Stochastic Oscillator (slow %K and slow %D). +#[wasm_bindgen] +pub fn stoch( + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + fastk_period: usize, + slowk_period: usize, + slowd_period: usize, +) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (slowk, slowd) = ferro_ta_core::momentum::stoch(&h, &l, &c, fastk_period, slowk_period, slowd_period); + let out = Array::new(); + out.push(&from_vec(slowk)); + out.push(&from_vec(slowd)); + out +} + +/// Plus Directional Movement (+DM). +#[wasm_bindgen] +pub fn plus_dm(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::momentum::plus_dm(&h, &l, timeperiod)) +} + +/// Minus Directional Movement (-DM). +#[wasm_bindgen] +pub fn minus_dm(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + from_vec(ferro_ta_core::momentum::minus_dm(&h, &l, timeperiod)) +} + +/// Plus Directional Indicator (+DI). +#[wasm_bindgen] +pub fn plus_di(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::momentum::plus_di(&h, &l, &c, timeperiod)) +} + +/// Minus Directional Indicator (-DI). +#[wasm_bindgen] +pub fn minus_di(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::momentum::minus_di(&h, &l, &c, timeperiod)) +} + +/// Directional Movement Index (DX). +#[wasm_bindgen] +pub fn dx(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::momentum::dx(&h, &l, &c, timeperiod)) +} + +/// Average Directional Movement Index Rating (ADXR). +#[wasm_bindgen] +pub fn adxr(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::momentum::adxr(&h, &l, &c, timeperiod)) +} + +/// All ADX components: returns [+DM, -DM, +DI, -DI, DX, ADX]. +#[wasm_bindgen] +pub fn adx_all(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + let (pdm, mdm, pdi, mdi, dxv, adxv) = ferro_ta_core::momentum::adx_all(&h, &l, &c, timeperiod); + let out = Array::new(); + out.push(&from_vec(pdm)); + out.push(&from_vec(mdm)); + out.push(&from_vec(pdi)); + out.push(&from_vec(mdi)); + out.push(&from_vec(dxv)); + out.push(&from_vec(adxv)); + out +} + +// =========================================================================== +// Overlap Studies (additional exports) +// =========================================================================== + +/// Double Exponential Moving Average. +#[wasm_bindgen] +pub fn dema(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::dema(&to_vec(close), timeperiod)) +} + +/// Triple Exponential Moving Average. +#[wasm_bindgen] +pub fn tema(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::tema(&to_vec(close), timeperiod)) +} + +/// Triangular Moving Average. +#[wasm_bindgen] +pub fn trima(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::trima(&to_vec(close), timeperiod)) +} + +/// Kaufman Adaptive Moving Average. +#[wasm_bindgen] +pub fn kama(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::kama(&to_vec(close), timeperiod)) +} + +/// Tillson T3. +#[wasm_bindgen] +pub fn t3(close: &Float64Array, timeperiod: usize, vfactor: f64) -> Float64Array { + from_vec(ferro_ta_core::overlap::t3(&to_vec(close), timeperiod, vfactor)) +} + +/// Parabolic SAR. +#[wasm_bindgen] +pub fn sar(high: &Float64Array, low: &Float64Array, acceleration: f64, maximum: f64) -> Float64Array { + from_vec(ferro_ta_core::overlap::sar(&to_vec(high), &to_vec(low), acceleration, maximum)) +} + +/// Parabolic SAR Extended. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn sarext( + high: &Float64Array, low: &Float64Array, + startvalue: f64, offsetonreverse: f64, + accelerationinitlong: f64, accelerationlong: f64, accelerationmaxlong: f64, + accelerationinitshort: f64, accelerationshort: f64, accelerationmaxshort: f64, +) -> Float64Array { + from_vec(ferro_ta_core::overlap::sarext( + &to_vec(high), &to_vec(low), + startvalue, offsetonreverse, + accelerationinitlong, accelerationlong, accelerationmaxlong, + accelerationinitshort, accelerationshort, accelerationmaxshort, + )) +} + +/// MESA Adaptive Moving Average. Returns [mama, fama]. +#[wasm_bindgen] +pub fn mama(close: &Float64Array, fastlimit: f64, slowlimit: f64) -> Array { + let (m, f) = ferro_ta_core::overlap::mama(&to_vec(close), fastlimit, slowlimit); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(f)); + out +} + +/// Midpoint over rolling window. +#[wasm_bindgen] +pub fn midpoint(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::midpoint(&to_vec(close), timeperiod)) +} + +/// MidPrice over rolling window. +#[wasm_bindgen] +pub fn midprice(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::midprice(&to_vec(high), &to_vec(low), timeperiod)) +} + +/// MACD with fixed 12/26 periods. Returns [macd, signal, histogram]. +#[wasm_bindgen] +pub fn macdfix(close: &Float64Array, signalperiod: usize) -> Array { + let (m, s, h) = ferro_ta_core::overlap::macdfix(&to_vec(close), signalperiod); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// MACD with configurable MA types. Returns [macd, signal, histogram]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn macdext( + close: &Float64Array, fastperiod: usize, fastmatype: u8, + slowperiod: usize, slowmatype: u8, signalperiod: usize, signalmatype: u8, +) -> Array { + let (m, s, h) = ferro_ta_core::overlap::macdext( + &to_vec(close), fastperiod, fastmatype, slowperiod, slowmatype, signalperiod, signalmatype, + ); + let out = Array::new(); + out.push(&from_vec(m)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// Generic Moving Average (matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3). +#[wasm_bindgen] +pub fn ma(close: &Float64Array, timeperiod: usize, matype: u8) -> Float64Array { + from_vec(ferro_ta_core::overlap::ma(&to_vec(close), timeperiod, matype)) +} + +/// Moving Average with Variable Period. +#[wasm_bindgen] +pub fn mavp(close: &Float64Array, periods: &Float64Array, minperiod: usize, maxperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::overlap::mavp(&to_vec(close), &to_vec(periods), minperiod, maxperiod)) +} + +// =========================================================================== +// Momentum (additional exports — new core indicators) +// =========================================================================== + +/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`. +#[wasm_bindgen] +pub fn roc(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::roc(&to_vec(close), timeperiod)) +} + +/// Rate of Change Percentage. +#[wasm_bindgen] +pub fn rocp(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocp(&to_vec(close), timeperiod)) +} + +/// Rate of Change Ratio. +#[wasm_bindgen] +pub fn rocr(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocr(&to_vec(close), timeperiod)) +} + +/// Rate of Change Ratio x 100. +#[wasm_bindgen] +pub fn rocr100(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::rocr100(&to_vec(close), timeperiod)) +} + +/// Williams %R. +#[wasm_bindgen] +pub fn willr(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::willr(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +/// Aroon indicator. Returns [aroon_down, aroon_up]. +#[wasm_bindgen] +pub fn aroon(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Array { + let (down, up) = ferro_ta_core::momentum::aroon(&to_vec(high), &to_vec(low), timeperiod); + let out = Array::new(); + out.push(&from_vec(down)); + out.push(&from_vec(up)); + out +} + +/// Aroon Oscillator. +#[wasm_bindgen] +pub fn aroonosc(high: &Float64Array, low: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::aroonosc(&to_vec(high), &to_vec(low), timeperiod)) +} + +/// Commodity Channel Index. +#[wasm_bindgen] +pub fn cci(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::cci(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +/// Balance of Power. +#[wasm_bindgen] +pub fn bop(open: &Float64Array, high: &Float64Array, low: &Float64Array, close: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::momentum::bop(&to_vec(open), &to_vec(high), &to_vec(low), &to_vec(close))) +} + +/// Stochastic RSI. Returns [fastk, fastd]. +#[wasm_bindgen] +pub fn stochrsi(close: &Float64Array, timeperiod: usize, fastk_period: usize, fastd_period: usize) -> Array { + let (k, d) = ferro_ta_core::momentum::stochrsi(&to_vec(close), timeperiod, fastk_period, fastd_period); + let out = Array::new(); + out.push(&from_vec(k)); + out.push(&from_vec(d)); + out +} + +/// Absolute Price Oscillator. +#[wasm_bindgen] +pub fn apo(close: &Float64Array, fastperiod: usize, slowperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::apo(&to_vec(close), fastperiod, slowperiod)) +} + +/// Percentage Price Oscillator. Returns [ppo, signal, histogram]. +#[wasm_bindgen] +pub fn ppo(close: &Float64Array, fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Array { + let (p, s, h) = ferro_ta_core::momentum::ppo(&to_vec(close), fastperiod, slowperiod, signalperiod); + let out = Array::new(); + out.push(&from_vec(p)); + out.push(&from_vec(s)); + out.push(&from_vec(h)); + out +} + +/// Chande Momentum Oscillator. +#[wasm_bindgen] +pub fn cmo(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::cmo(&to_vec(close), timeperiod)) +} + +/// TRIX: 1-period rate of change of triple-smoothed EMA. +#[wasm_bindgen] +pub fn trix_indicator(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::trix(&to_vec(close), timeperiod)) +} + +/// Ultimate Oscillator. +#[wasm_bindgen] +pub fn ultosc(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod1: usize, timeperiod2: usize, timeperiod3: usize) -> Float64Array { + from_vec(ferro_ta_core::momentum::ultosc(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod1, timeperiod2, timeperiod3)) +} + +// =========================================================================== +// Volatility (additional exports) +// =========================================================================== + +/// True Range. +#[wasm_bindgen] +pub fn trange(high: &Float64Array, low: &Float64Array, close: &Float64Array) -> Float64Array { + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_vec(ferro_ta_core::volatility::trange(&h, &l, &c)) +} + +/// Normalized Average True Range: ATR / close * 100. +#[wasm_bindgen] +pub fn natr(high: &Float64Array, low: &Float64Array, close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::volatility::natr(&to_vec(high), &to_vec(low), &to_vec(close), timeperiod)) +} + +// =========================================================================== +// Statistic (additional exports) +// =========================================================================== + +/// Rolling population standard deviation scaled by `nbdev`. +#[wasm_bindgen] +pub fn stddev(close: &Float64Array, timeperiod: usize, nbdev: f64) -> Float64Array { + from_vec(ferro_ta_core::statistic::stddev(&to_vec(close), timeperiod, nbdev)) +} + +/// Rolling population variance scaled by `nbdev²`. +#[wasm_bindgen] +pub fn var(close: &Float64Array, timeperiod: usize, nbdev: f64) -> Float64Array { + from_vec(ferro_ta_core::statistic::var(&to_vec(close), timeperiod, nbdev)) +} + +/// Linear regression fitted value. +#[wasm_bindgen] +pub fn linearreg(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg(&to_vec(close), timeperiod)) +} + +/// Linear regression slope. +#[wasm_bindgen] +pub fn linearreg_slope(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_slope(&to_vec(close), timeperiod)) +} + +/// Linear regression intercept. +#[wasm_bindgen] +pub fn linearreg_intercept(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_intercept(&to_vec(close), timeperiod)) +} + +/// Linear regression angle in degrees. +#[wasm_bindgen] +pub fn linearreg_angle(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::linearreg_angle(&to_vec(close), timeperiod)) +} + +/// Time Series Forecast. +#[wasm_bindgen] +pub fn tsf(close: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::tsf(&to_vec(close), timeperiod)) +} + +/// Rolling beta (return-based regression). +#[wasm_bindgen] +pub fn beta_rolling(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::beta(&to_vec(real0), &to_vec(real1), timeperiod)) +} + +/// Rolling Pearson correlation. +#[wasm_bindgen] +pub fn correl(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) -> Float64Array { + from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod)) +} + +/// Dynamic Time Warping distance between two series. +/// +/// Returns the accumulated Euclidean cost along the optimal warping path. +/// Pass `window` as `0` for unconstrained (no Sakoe-Chiba band). +#[wasm_bindgen] +pub fn dtw_distance(series1: &Float64Array, series2: &Float64Array, window: usize) -> f64 { + let s1 = to_vec(series1); + let s2 = to_vec(series2); + let w = if window == 0 { None } else { Some(window) }; + ferro_ta_core::statistic::dtw_distance(&s1, &s2, w) +} + +// =========================================================================== +// Streaming / Stateful API +// =========================================================================== + +/// Streaming Simple Moving Average. +#[wasm_bindgen] +pub struct WasmStreamingSMA { + inner: ferro_ta_core::streaming::StreamingSMA, +} + +#[wasm_bindgen] +impl WasmStreamingSMA { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingSMA::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Exponential Moving Average. +#[wasm_bindgen] +pub struct WasmStreamingEMA { + inner: ferro_ta_core::streaming::StreamingEMA, +} + +#[wasm_bindgen] +impl WasmStreamingEMA { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingEMA::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Relative Strength Index. +#[wasm_bindgen] +pub struct WasmStreamingRSI { + inner: ferro_ta_core::streaming::StreamingRSI, +} + +#[wasm_bindgen] +impl WasmStreamingRSI { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingRSI::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> f64 { self.inner.update(value) } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Average True Range. +#[wasm_bindgen] +pub struct WasmStreamingATR { + inner: ferro_ta_core::streaming::StreamingATR, +} + +#[wasm_bindgen] +impl WasmStreamingATR { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingATR::new(period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 { + self.inner.update(high, low, close) + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming Bollinger Bands. Returns [upper, middle, lower] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingBBands { + inner: ferro_ta_core::streaming::StreamingBBands, +} + +#[wasm_bindgen] +impl WasmStreamingBBands { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result { + let inner = ferro_ta_core::streaming::StreamingBBands::new(period, nbdevup, nbdevdn) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> Array { + let (u, m, l) = self.inner.update(value); + let out = Array::new(); + out.push(&JsValue::from_f64(u)); + out.push(&JsValue::from_f64(m)); + out.push(&JsValue::from_f64(l)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming MACD. Returns [macd, signal, histogram] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingMACD { + inner: ferro_ta_core::streaming::StreamingMACD, +} + +#[wasm_bindgen] +impl WasmStreamingMACD { + #[wasm_bindgen(constructor)] + pub fn new(fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingMACD::new(fastperiod, slowperiod, signalperiod) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, value: f64) -> Array { + let (m, s, h) = self.inner.update(value); + let out = Array::new(); + out.push(&JsValue::from_f64(m)); + out.push(&JsValue::from_f64(s)); + out.push(&JsValue::from_f64(h)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn fast_period(&self) -> usize { self.inner.fast_period() } + #[wasm_bindgen(getter)] + pub fn slow_period(&self) -> usize { self.inner.slow_period() } + #[wasm_bindgen(getter)] + pub fn signal_period(&self) -> usize { self.inner.signal_period() } +} + +/// Streaming Stochastic Oscillator. Returns [slowk, slowd] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingStoch { + inner: ferro_ta_core::streaming::StreamingStoch, +} + +#[wasm_bindgen] +impl WasmStreamingStoch { + #[wasm_bindgen(constructor)] + pub fn new(fastk_period: usize, slowk_period: usize, slowd_period: usize) -> Result { + let inner = ferro_ta_core::streaming::StreamingStoch::new(fastk_period, slowk_period, slowd_period) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Array { + let (sk, sd) = self.inner.update(high, low, close); + let out = Array::new(); + out.push(&JsValue::from_f64(sk)); + out.push(&JsValue::from_f64(sd)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +/// Streaming cumulative VWAP. +#[wasm_bindgen] +pub struct WasmStreamingVWAP { + inner: ferro_ta_core::streaming::StreamingVWAP, +} + +#[wasm_bindgen] +impl WasmStreamingVWAP { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmStreamingVWAP { + Self { inner: ferro_ta_core::streaming::StreamingVWAP::new() } + } + pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 { + self.inner.update(high, low, close, volume) + } + pub fn reset(&mut self) { self.inner.reset(); } +} + +/// Streaming Supertrend. Returns [line, direction] from `update()`. +#[wasm_bindgen] +pub struct WasmStreamingSupertrend { + inner: ferro_ta_core::streaming::StreamingSupertrend, +} + +#[wasm_bindgen] +impl WasmStreamingSupertrend { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, multiplier: f64) -> Result { + let inner = ferro_ta_core::streaming::StreamingSupertrend::new(period, multiplier) + .map_err(|e| JsError::new(&e.0))?; + Ok(Self { inner }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Array { + let (line, dir) = self.inner.update(high, low, close); + let out = Array::new(); + out.push(&JsValue::from_f64(line)); + out.push(&JsValue::from_f64(dir as f64)); + out + } + pub fn reset(&mut self) { self.inner.reset(); } + #[wasm_bindgen(getter)] + pub fn period(&self) -> usize { self.inner.period() } +} + +// =========================================================================== +// Batch Operations +// =========================================================================== + +/// Convert a js_sys::Array of Float64Array into Vec>. +fn array_of_f64arr_to_vecs(arr: &Array) -> Vec> { + (0..arr.length()) + .map(|i| { + let item: Float64Array = arr.get(i).unchecked_into(); + to_vec(&item) + }) + .collect() +} + +/// Convert Vec> into a js_sys::Array of Float64Array. +fn vecs_to_array_of_f64arr(data: Vec>) -> Array { + let out = Array::new(); + for v in data { + out.push(&from_vec(v)); + } + out +} + +/// Batch SMA: compute SMA on each column of 2D data. +#[wasm_bindgen] +pub fn batch_sma(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_sma(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch EMA: compute EMA on each column of 2D data. +#[wasm_bindgen] +pub fn batch_ema(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_ema(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch RSI: compute RSI on each column of 2D data. +#[wasm_bindgen] +pub fn batch_rsi(data: &Array, timeperiod: usize) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + match ferro_ta_core::batch::batch_rsi(&vecs, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +// =========================================================================== +// Portfolio (additional exports) +// =========================================================================== + +/// Portfolio volatility: sqrt(w' * cov * w). +#[wasm_bindgen] +pub fn portfolio_volatility(cov_matrix: &Array, weights: &Float64Array) -> f64 { + let cov = array_of_f64arr_to_vecs(cov_matrix); + let w = to_vec(weights); + ferro_ta_core::portfolio::portfolio_volatility(&cov, &w) +} + +/// Pairwise correlation matrix. +#[wasm_bindgen] +pub fn correlation_matrix(data: &Array) -> Array { + let vecs = array_of_f64arr_to_vecs(data); + vecs_to_array_of_f64arr(ferro_ta_core::portfolio::correlation_matrix(&vecs)) +} + +/// Weighted composite of multiple series. +#[wasm_bindgen] +pub fn compose_weighted(data: &Array, weights: &Float64Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(data); + let w = to_vec(weights); + from_vec(ferro_ta_core::portfolio::compose_weighted(&vecs, &w)) +} + +// =========================================================================== +// Crypto (additional exports) +// =========================================================================== + +/// Mark session boundaries from nanosecond timestamps. +#[wasm_bindgen] +pub fn mark_session_boundaries(timestamps_ns: &Float64Array) -> Float64Array { + let ts: Vec = to_vec(timestamps_ns).iter().map(|&v| v as i64).collect(); + let result = ferro_ta_core::crypto::mark_session_boundaries(&ts); + from_vec(result.iter().map(|&v| v as f64).collect()) +} + +// =========================================================================== +// Chunked (additional exports) +// =========================================================================== + +/// Stitch multiple chunks into a single array. +#[wasm_bindgen] +pub fn stitch_chunks(chunks: &Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(chunks); + let slices: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect(); + from_vec(ferro_ta_core::chunked::stitch_chunks(&slices)) +} + +// =========================================================================== +// Math Operators & Transforms +// =========================================================================== + +/// Element-wise addition. +#[wasm_bindgen] +pub fn math_add(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::add(&to_vec(a), &to_vec(b))) +} + +/// Element-wise subtraction. +#[wasm_bindgen] +pub fn math_sub(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::sub(&to_vec(a), &to_vec(b))) +} + +/// Element-wise multiplication. +#[wasm_bindgen] +pub fn math_mult(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::mult(&to_vec(a), &to_vec(b))) +} + +/// Element-wise division. +#[wasm_bindgen] +pub fn math_div(a: &Float64Array, b: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::div(&to_vec(a), &to_vec(b))) +} + +macro_rules! math_transform_wrapper { + ($wasm_name:ident, $core_name:ident) => { + #[wasm_bindgen] + pub fn $wasm_name(real: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::math::$core_name(&to_vec(real))) + } + }; +} + +math_transform_wrapper!(transform_acos, math_acos); +math_transform_wrapper!(transform_asin, math_asin); +math_transform_wrapper!(transform_atan, math_atan); +math_transform_wrapper!(transform_ceil, math_ceil); +math_transform_wrapper!(transform_cos, math_cos); +math_transform_wrapper!(transform_cosh, math_cosh); +math_transform_wrapper!(transform_exp, math_exp); +math_transform_wrapper!(transform_floor, math_floor); +math_transform_wrapper!(transform_ln, math_ln); +math_transform_wrapper!(transform_log10, math_log10); +math_transform_wrapper!(transform_sin, math_sin); +math_transform_wrapper!(transform_sinh, math_sinh); +math_transform_wrapper!(transform_sqrt, math_sqrt); +math_transform_wrapper!(transform_tan, math_tan); +math_transform_wrapper!(transform_tanh, math_tanh); + +// =========================================================================== +// Candlestick Patterns (61 functions via macro) +// =========================================================================== + +/// Convert a `Vec` into a `js_sys::Int32Array`. +fn from_i32_vec(v: Vec) -> js_sys::Int32Array { + let arr = js_sys::Int32Array::new_with_length(v.len() as u32); + arr.copy_from(&v); + arr +} + +macro_rules! cdl_wrapper { + ($($name:ident),* $(,)?) => {$( + #[wasm_bindgen] + pub fn $name( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + ) -> js_sys::Int32Array { + let o = to_vec(open); + let h = to_vec(high); + let l = to_vec(low); + let c = to_vec(close); + from_i32_vec(ferro_ta_core::pattern::$name(&o, &h, &l, &c)) + } + )*}; +} + +cdl_wrapper!( + cdl2crows, + cdl3blackcrows, + cdl3inside, + cdl3linestrike, + cdl3outside, + cdl3starsinsouth, + cdl3whitesoldiers, + cdlabandonedbaby, + cdladvanceblock, + cdlbelthold, + cdlbreakaway, + cdlclosingmarubozu, + cdlconcealbabyswall, + cdlcounterattack, + cdldarkcloudcover, + cdldoji, + cdldojistar, + cdldragonflydoji, + cdlengulfing, + cdleveningdojistar, + cdleveningstar, + cdlgapsidesidewhite, + cdlgravestonedoji, + cdlhammer, + cdlhangingman, + cdlharami, + cdlharamicross, + cdlhighwave, + cdlhikkake, + cdlhikkakemod, + cdlhomingpigeon, + cdlidentical3crows, + cdlinneck, + cdlinvertedhammer, + cdlkicking, + cdlkickingbylength, + cdlladderbottom, + cdllongleggeddoji, + cdllongline, + cdlmarubozu, + cdlmatchinglow, + cdlmathold, + cdlmorningdojistar, + cdlmorningstar, + cdlonneck, + cdlpiercing, + cdlrickshawman, + cdlrisefall3methods, + cdlseparatinglines, + cdlshootingstar, + cdlshortline, + cdlspinningtop, + cdlstalledpattern, + cdlsticksandwich, + cdltakuri, + cdltasukigap, + cdlthrusting, + cdltristar, + cdlunique3river, + cdlupsidegap2crows, + cdlxsidegap3methods, +); + +// =========================================================================== +// Signals (additional) +// =========================================================================== + +/// Rank values (percentile ranking [0, 100]). +#[wasm_bindgen] +pub fn rank_values(x: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::signals::rank_values(&to_vec(x))) +} + +/// Composite rank across multiple signal arrays. +#[wasm_bindgen] +pub fn compose_rank(signals: &Array) -> Float64Array { + let vecs = array_of_f64arr_to_vecs(signals); + let slices: Vec<&[f64]> = vecs.iter().map(|v| v.as_slice()).collect(); + from_vec(ferro_ta_core::signals::compose_rank(&slices)) +} + +// =========================================================================== +// Batch (additional) +// =========================================================================== + +/// Batch ATR across multiple HLC column sets. +#[wasm_bindgen] +pub fn batch_atr(high: &Array, low: &Array, close: &Array, timeperiod: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_atr(&h, &l, &c, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +/// Batch Stochastic across multiple HLC column sets. Returns [Array[slowk_cols], Array[slowd_cols]]. +#[wasm_bindgen] +pub fn batch_stoch(high: &Array, low: &Array, close: &Array, fastk_period: usize, slowk_period: usize, slowd_period: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_stoch(&h, &l, &c, fastk_period, slowk_period, slowd_period) { + Ok((sk, sd)) => { + let out = Array::new(); + out.push(&vecs_to_array_of_f64arr(sk)); + out.push(&vecs_to_array_of_f64arr(sd)); + out + } + Err(_) => Array::new(), + } +} + +/// Batch ADX across multiple HLC column sets. +#[wasm_bindgen] +pub fn batch_adx(high: &Array, low: &Array, close: &Array, timeperiod: usize) -> Array { + let h = array_of_f64arr_to_vecs(high); + let l = array_of_f64arr_to_vecs(low); + let c = array_of_f64arr_to_vecs(close); + match ferro_ta_core::batch::batch_adx(&h, &l, &c, timeperiod) { + Ok(r) => vecs_to_array_of_f64arr(r), + Err(_) => Array::new(), + } +} + +// =========================================================================== +// Options Analytics +// =========================================================================== + +fn parse_option_kind(kind: &str) -> ferro_ta_core::options::OptionKind { + match kind.to_lowercase().as_str() { + "put" | "p" => ferro_ta_core::options::OptionKind::Put, + _ => ferro_ta_core::options::OptionKind::Call, + } +} + +fn parse_pricing_model(model: &str) -> ferro_ta_core::options::PricingModel { + match model.to_lowercase().as_str() { + "black76" | "b76" => ferro_ta_core::options::PricingModel::Black76, + _ => ferro_ta_core::options::PricingModel::BlackScholes, + } +} + +/// Black-Scholes-Merton option price. +#[wasm_bindgen] +pub fn black_scholes_price( + spot: f64, strike: f64, rate: f64, dividend_yield: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + ferro_ta_core::options::pricing::black_scholes_price( + spot, strike, rate, dividend_yield, time_to_expiry, volatility, parse_option_kind(kind), + ) +} + +/// Black-76 option price (futures). +#[wasm_bindgen] +pub fn black_76_price( + forward: f64, strike: f64, rate: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + ferro_ta_core::options::pricing::black_76_price( + forward, strike, rate, time_to_expiry, volatility, parse_option_kind(kind), + ) +} + +/// Black-Scholes Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn black_scholes_greeks( + spot: f64, strike: f64, rate: f64, dividend_yield: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + let g = ferro_ta_core::options::greeks::black_scholes_greeks( + spot, strike, rate, dividend_yield, time_to_expiry, volatility, parse_option_kind(kind), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Black-76 Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn black_76_greeks( + forward: f64, strike: f64, rate: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + let g = ferro_ta_core::options::greeks::black_76_greeks( + forward, strike, rate, time_to_expiry, volatility, parse_option_kind(kind), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Implied volatility via Newton-Raphson. +#[wasm_bindgen] +pub fn implied_volatility( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, target_price: f64, + initial_guess: f64, tolerance: f64, max_iterations: usize, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), + underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + let config = IvSolverConfig { initial_guess, tolerance, max_iterations }; + iv::implied_volatility(contract, target_price, config) +} + +/// IV Rank over a rolling window. +#[wasm_bindgen] +pub fn iv_rank(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_rank(&to_vec(iv_series), window)) +} + +/// IV Percentile over a rolling window. +#[wasm_bindgen] +pub fn iv_percentile(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_percentile(&to_vec(iv_series), window)) +} + +/// IV Z-Score over a rolling window. +#[wasm_bindgen] +pub fn iv_zscore(iv_series: &Float64Array, window: usize) -> Float64Array { + from_vec(ferro_ta_core::options::iv::iv_zscore(&to_vec(iv_series), window)) +} + +/// ATM index in a strikes array. +#[wasm_bindgen] +pub fn atm_index(strikes: &Float64Array, reference_price: f64) -> f64 { + match ferro_ta_core::options::chain::atm_index(&to_vec(strikes), reference_price) { + Some(idx) => idx as f64, + None => f64::NAN, + } +} + +/// Label moneyness of strikes. Returns Int8Array. +#[wasm_bindgen] +pub fn label_moneyness(strikes: &Float64Array, reference_price: f64, kind: &str) -> js_sys::Int8Array { + let result = ferro_ta_core::options::chain::label_moneyness( + &to_vec(strikes), reference_price, parse_option_kind(kind), + ); + let arr = js_sys::Int8Array::new_with_length(result.len() as u32); + arr.copy_from(&result); + arr +} + +/// Model-dispatched option price (model: "bs" or "b76"). +#[wasm_bindgen] +pub fn model_price( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + pricing::model_price(input) +} + +/// Model-dispatched Greeks. Returns [delta, gamma, vega, theta, rho]. +#[wasm_bindgen] +pub fn model_greeks( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> Array { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + let g = greeks::model_greeks(input); + let out = Array::new(); + out.push(&JsValue::from_f64(g.delta)); + out.push(&JsValue::from_f64(g.gamma)); + out.push(&JsValue::from_f64(g.vega)); + out.push(&JsValue::from_f64(g.theta)); + out.push(&JsValue::from_f64(g.rho)); + out +} + +/// Model theta (numerical). +#[wasm_bindgen] +pub fn model_theta( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, volatility: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let input = OptionEvaluation { + contract: OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }, + volatility, + }; + greeks::model_theta(input) +} + +/// Price lower bound. +#[wasm_bindgen] +pub fn price_lower_bound( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + pricing::price_lower_bound(contract) +} + +/// Price upper bound. +#[wasm_bindgen] +pub fn price_upper_bound( + model: &str, underlying: f64, strike: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, +) -> f64 { + use ferro_ta_core::options::*; + let contract = OptionContract { + model: parse_pricing_model(model), underlying, strike, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + pricing::price_upper_bound(contract) +} + +/// Select strike by offset from ATM. +#[wasm_bindgen] +pub fn select_strike_by_offset(strikes: &Float64Array, reference_price: f64, offset: i32) -> f64 { + match ferro_ta_core::options::chain::select_strike_by_offset( + &to_vec(strikes), reference_price, offset as isize, + ) { + Some(v) => v, + None => f64::NAN, + } +} + +/// Smile metrics. Returns [atm_iv, risk_reversal_25d, butterfly_25d, skew_slope, convexity]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn smile_metrics( + strikes: &Float64Array, vols: &Float64Array, reference_price: f64, + rate: f64, carry: f64, time_to_expiry: f64, model: &str, +) -> Array { + let m = ferro_ta_core::options::surface::smile_metrics( + &to_vec(strikes), &to_vec(vols), reference_price, + rate, carry, time_to_expiry, parse_pricing_model(model), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(m.atm_iv)); + out.push(&JsValue::from_f64(m.risk_reversal_25d)); + out.push(&JsValue::from_f64(m.butterfly_25d)); + out.push(&JsValue::from_f64(m.skew_slope)); + out.push(&JsValue::from_f64(m.convexity)); + out +} + +/// Linear interpolation helper. +#[wasm_bindgen] +pub fn linear_interpolate(xs: &Float64Array, ys: &Float64Array, target: f64) -> f64 { + ferro_ta_core::options::surface::linear_interpolate(&to_vec(xs), &to_vec(ys), target) +} + +/// Select strike by delta target. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn select_strike_by_delta( + strikes: &Float64Array, vols: &Float64Array, + model: &str, reference_price: f64, rate: f64, carry: f64, + time_to_expiry: f64, kind: &str, target_delta: f64, +) -> f64 { + use ferro_ta_core::options::*; + let ctx = ChainGreeksContext { + model: parse_pricing_model(model), + reference_price, rate, carry, time_to_expiry, + kind: parse_option_kind(kind), + }; + match chain::select_strike_by_delta(&to_vec(strikes), &to_vec(vols), ctx, target_delta) { + Some(v) => v, + None => f64::NAN, + } +} + +/// ATM implied volatility interpolated from strikes/vols. +#[wasm_bindgen] +pub fn atm_iv(strikes: &Float64Array, vols: &Float64Array, reference_price: f64) -> f64 { + ferro_ta_core::options::surface::atm_iv(&to_vec(strikes), &to_vec(vols), reference_price) +} + +/// Term structure slope. +#[wasm_bindgen] +pub fn term_structure_slope(tenors: &Float64Array, atm_ivs: &Float64Array) -> f64 { + ferro_ta_core::options::surface::term_structure_slope(&to_vec(tenors), &to_vec(atm_ivs)) +} + +// =========================================================================== +// Futures Analytics +// =========================================================================== + +/// Futures basis: future - spot. +#[wasm_bindgen] +pub fn futures_basis(spot: f64, future: f64) -> f64 { + ferro_ta_core::futures::basis::basis(spot, future) +} + +/// Annualized basis. +#[wasm_bindgen] +pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::annualized_basis(spot, future, time_to_expiry) +} + +/// Implied carry rate. +#[wasm_bindgen] +pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::implied_carry_rate(spot, future, time_to_expiry) +} + +/// Carry spread. +#[wasm_bindgen] +pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::basis::carry_spread(spot, future, rate, time_to_expiry) +} + +/// Calendar spreads between consecutive futures prices. +#[wasm_bindgen] +pub fn calendar_spreads(futures_prices: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::curve::calendar_spreads(&to_vec(futures_prices))) +} + +/// Curve slope (linear regression). +#[wasm_bindgen] +pub fn curve_slope(tenors: &Float64Array, futures_prices: &Float64Array) -> f64 { + ferro_ta_core::futures::curve::curve_slope(&to_vec(tenors), &to_vec(futures_prices)) +} + +/// Curve summary. Returns [front_basis, average_basis, slope, is_contango (1.0 or 0.0)]. +#[wasm_bindgen] +pub fn curve_summary(spot: f64, tenors: &Float64Array, futures_prices: &Float64Array) -> Array { + let s = ferro_ta_core::futures::curve::curve_summary(spot, &to_vec(tenors), &to_vec(futures_prices)); + let out = Array::new(); + out.push(&JsValue::from_f64(s.front_basis)); + out.push(&JsValue::from_f64(s.average_basis)); + out.push(&JsValue::from_f64(s.slope)); + out.push(&JsValue::from_f64(if s.is_contango { 1.0 } else { 0.0 })); + out +} + +/// Roll yield. +#[wasm_bindgen] +pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::roll::roll_yield(front_price, next_price, time_to_expiry) +} + +/// Weighted continuous contract. +#[wasm_bindgen] +pub fn weighted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::weighted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Back-adjusted continuous contract. +#[wasm_bindgen] +pub fn back_adjusted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::back_adjusted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Ratio-adjusted continuous contract. +#[wasm_bindgen] +pub fn ratio_adjusted_continuous(front: &Float64Array, next: &Float64Array, next_weights: &Float64Array) -> Float64Array { + from_vec(ferro_ta_core::futures::roll::ratio_adjusted_continuous(&to_vec(front), &to_vec(next), &to_vec(next_weights))) +} + +/// Synthetic forward price from put-call parity. +#[wasm_bindgen] +pub fn synthetic_forward(call_price: f64, put_price: f64, strike: f64, rate: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::synthetic_forward(call_price, put_price, strike, rate, time_to_expiry) +} + +/// Synthetic spot implied by put-call parity. +#[wasm_bindgen] +pub fn synthetic_spot(call_price: f64, put_price: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::synthetic_spot(call_price, put_price, strike, rate, carry, time_to_expiry) +} + +/// Put-call parity residual. +#[wasm_bindgen] +pub fn parity_gap(call_price: f64, put_price: f64, spot: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64) -> f64 { + ferro_ta_core::futures::synthetic::parity_gap(call_price, put_price, spot, strike, rate, carry, time_to_expiry) +} + +// =========================================================================== +// Backtesting (signal generators + utilities) +// =========================================================================== + +/// Backtest core: close-only vectorized backtest. Returns [positions, bar_returns, strategy_returns, equity]. +#[wasm_bindgen] +pub fn backtest_core( + close: &Float64Array, signals: &Float64Array, + slippage_bps: f64, initial_capital: f64, commission_per_trade: f64, +) -> Array { + match ferro_ta_core::backtest::backtest_core( + &to_vec(close), &to_vec(signals), None, slippage_bps, initial_capital, commission_per_trade, + ) { + Ok(result) => { + let out = Array::new(); + out.push(&from_vec(result.positions)); + out.push(&from_vec(result.bar_returns)); + out.push(&from_vec(result.strategy_returns)); + out.push(&from_vec(result.equity)); + out + } + Err(_) => Array::new(), + } +} + +/// Simple single-asset backtest. Returns [positions, strategy_returns, equity]. +#[wasm_bindgen] +pub fn single_asset_backtest( + close: &Float64Array, signals: &Float64Array, + commission_per_trade: f64, slippage_bps: f64, +) -> Array { + let (pos, strat_ret, eq) = ferro_ta_core::backtest::single_asset_backtest( + &to_vec(close), &to_vec(signals), commission_per_trade, slippage_bps, + ); + let out = Array::new(); + out.push(&from_vec(pos)); + out.push(&from_vec(strat_ret)); + out.push(&from_vec(eq)); + out +} + +/// Walk-forward train/test indices. Returns flat array [train_start, train_end, test_start, test_end, ...]. +#[wasm_bindgen] +pub fn walk_forward_indices( + n_bars: usize, train_bars: usize, test_bars: usize, anchored: bool, step_bars: usize, +) -> Float64Array { + match ferro_ta_core::backtest::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars) { + Ok(indices) => { + let flat: Vec = indices.iter() + .flat_map(|fold| vec![fold[0] as f64, fold[1] as f64, fold[2] as f64, fold[3] as f64]) + .collect(); + from_vec(flat) + } + Err(_) => from_vec(vec![]), + } +} + +/// Monte Carlo bootstrap of strategy returns. Returns Array of Float64Array (one per simulation). +#[wasm_bindgen] +pub fn monte_carlo_bootstrap( + strategy_returns: &Float64Array, n_sims: usize, seed: f64, block_size: usize, +) -> Array { + match ferro_ta_core::backtest::monte_carlo_bootstrap( + &to_vec(strategy_returns), n_sims, seed as u64, block_size, + ) { + Ok(sims) => vecs_to_array_of_f64arr(sims), + Err(_) => Array::new(), + } +} + +/// Kelly fraction. +#[wasm_bindgen] +pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + ferro_ta_core::backtest::kelly_fraction(win_rate, avg_win, avg_loss).unwrap_or(f64::NAN) +} + +/// Half-Kelly fraction. +#[wasm_bindgen] +pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> f64 { + ferro_ta_core::backtest::half_kelly_fraction(win_rate, avg_win, avg_loss).unwrap_or(f64::NAN) +} + +/// Compute performance metrics from strategy returns and equity. +/// Returns Float64Array with 22 metrics in order: +/// [total_return, cagr, annualized_vol, sharpe, sortino, calmar, max_drawdown, +/// avg_drawdown, max_dd_duration, avg_dd_duration, ulcer_index, omega_ratio, +/// win_rate, profit_factor, r_expectancy, avg_win, avg_loss, tail_ratio, +/// skewness, kurtosis, best_bar, worst_bar] +#[wasm_bindgen] +pub fn compute_performance_metrics( + strategy_returns: &Float64Array, equity: &Float64Array, + periods_per_year: f64, risk_free_rate: f64, +) -> Float64Array { + match ferro_ta_core::backtest::compute_performance_metrics( + &to_vec(strategy_returns), &to_vec(equity), periods_per_year, risk_free_rate, None, + ) { + Ok(m) => from_vec(vec![ + m.total_return, m.cagr, m.annualized_vol, m.sharpe, m.sortino, m.calmar, + m.max_drawdown, m.avg_drawdown, m.max_drawdown_duration_bars as f64, + m.avg_drawdown_duration_bars, m.ulcer_index, m.omega_ratio, + m.win_rate, m.profit_factor, m.r_expectancy, m.avg_win, m.avg_loss, + m.tail_ratio, m.skewness, m.kurtosis, m.best_bar, m.worst_bar, + ]), + Err(_) => from_vec(vec![]), + } +} + +/// OHLCV-aware backtest. Returns [positions, fill_prices, bar_returns, strategy_returns, equity]. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn backtest_ohlcv( + open: &Float64Array, high: &Float64Array, low: &Float64Array, close: &Float64Array, + signals: &Float64Array, slippage_bps: f64, initial_capital: f64, commission_per_trade: f64, + stop_loss_pct: f64, take_profit_pct: f64, trailing_stop_pct: f64, max_hold_bars: usize, +) -> Array { + let mut config = ferro_ta_core::backtest::BacktestConfig::default(); + config.slippage_bps = slippage_bps; + config.initial_capital = initial_capital; + config.commission_per_trade = commission_per_trade; + config.stop_loss_pct = stop_loss_pct; + config.take_profit_pct = take_profit_pct; + config.trailing_stop_pct = trailing_stop_pct; + config.max_hold_bars = max_hold_bars; + match ferro_ta_core::backtest::backtest_ohlcv_core( + &to_vec(open), &to_vec(high), &to_vec(low), &to_vec(close), + &to_vec(signals), &config, None, + ) { + Ok(r) => { + let out = Array::new(); + out.push(&from_vec(r.positions)); + out.push(&from_vec(r.fill_prices)); + out.push(&from_vec(r.bar_returns)); + out.push(&from_vec(r.strategy_returns)); + out.push(&from_vec(r.equity)); + out + } + Err(_) => Array::new(), + } +} + +/// RSI threshold signals. +#[wasm_bindgen] +pub fn rsi_threshold_signals(close: &Float64Array, timeperiod: usize, oversold: f64, overbought: f64) -> Float64Array { + from_vec(ferro_ta_core::backtest::rsi_threshold_signals(&to_vec(close), timeperiod, oversold, overbought)) +} + +/// SMA crossover signals. +#[wasm_bindgen] +pub fn sma_crossover_signals(close: &Float64Array, fast: usize, slow: usize) -> Float64Array { + match ferro_ta_core::backtest::sma_crossover_signals(&to_vec(close), fast, slow) { + Ok(v) => from_vec(v), + Err(_) => from_vec(vec![f64::NAN; close.length() as usize]), + } +} + +/// MACD crossover signals. +#[wasm_bindgen] +pub fn macd_crossover_signals(close: &Float64Array, fastperiod: usize, slowperiod: usize, signalperiod: usize) -> Float64Array { + match ferro_ta_core::backtest::macd_crossover_signals(&to_vec(close), fastperiod, slowperiod, signalperiod) { + Ok(v) => from_vec(v), + Err(_) => from_vec(vec![f64::NAN; close.length() as usize]), + } +} + +// =========================================================================== +// New Options Features (extended Greeks, digital, American, vol estimators, +// vol cone, expected move, put-call parity, strategy payoff/value/Greeks) +// =========================================================================== + +// --------------------------------------------------------------------------- +// Helpers shared by the new features +// --------------------------------------------------------------------------- + +fn parse_digital_kind(digital_type: &str) -> ferro_ta_core::options::digital::DigitalKind { + match digital_type.to_ascii_lowercase().as_str() { + "asset_or_nothing" | "asset" => ferro_ta_core::options::digital::DigitalKind::AssetOrNothing, + _ => ferro_ta_core::options::digital::DigitalKind::CashOrNothing, + } +} + +/// Convert a Float64Array to a Vec (for instrument/side/option_type codes). +fn to_i64_vec(arr: &Float64Array) -> Vec { + to_vec(arr).into_iter().map(|x| x as i64).collect() +} + +/// Convert a Float64Array to a Vec (for window sizes). +fn to_usize_vec(arr: &Float64Array) -> Vec { + to_vec(arr).into_iter().map(|x| x as usize).collect() +} + +// --------------------------------------------------------------------------- +// Put-call parity check +// --------------------------------------------------------------------------- + +/// Put-call parity deviation: `C - P - (S·e^{-qT} - K·e^{-rT})`. +/// +/// Returns 0 at no-arbitrage. +#[wasm_bindgen] +pub fn put_call_parity_deviation( + call_price: f64, + put_price: f64, + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, +) -> f64 { + ferro_ta_core::options::pricing::put_call_parity_deviation( + call_price, put_price, spot, strike, rate, carry, time_to_expiry, + ) +} + +// --------------------------------------------------------------------------- +// Extended (higher-order) Greeks +// --------------------------------------------------------------------------- + +/// Extended BSM Greeks: vanna, volga, charm, speed, color. +/// +/// # Returns +/// `js_sys::Array` of five f64 values: `[vanna, volga, charm, speed, color]`. +#[wasm_bindgen] +pub fn extended_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> Array { + use ferro_ta_core::options::{greeks::model_extended_greeks, OptionContract, OptionEvaluation, PricingModel}; + let k = parse_option_kind(kind); + // In this codebase, `carry` = dividend yield q (same convention as all other WASM/PyO3 APIs). + let eg = model_extended_greeks(OptionEvaluation { + contract: OptionContract { + model: PricingModel::BlackScholes, + underlying: spot, + strike, + rate, + carry, + time_to_expiry, + kind: k, + }, + volatility, + }); + let out = Array::new(); + out.push(&JsValue::from_f64(eg.vanna)); + out.push(&JsValue::from_f64(eg.volga)); + out.push(&JsValue::from_f64(eg.charm)); + out.push(&JsValue::from_f64(eg.speed)); + out.push(&JsValue::from_f64(eg.color)); + out +} + +// --------------------------------------------------------------------------- +// Digital options +// --------------------------------------------------------------------------- + +/// Price a digital (binary) option. +/// +/// # Arguments +/// - `kind` – `"call"` or `"put"` +/// - `digital_type` – `"cash_or_nothing"` (default) or `"asset_or_nothing"` +#[wasm_bindgen] +pub fn digital_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, + digital_type: &str, +) -> f64 { + ferro_ta_core::options::digital::digital_price( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + parse_digital_kind(digital_type), + ) +} + +/// Greeks for a digital option (numerical central differences). +/// +/// # Returns +/// `js_sys::Array` of three f64 values: `[delta, gamma, vega]`. +#[wasm_bindgen] +pub fn digital_greeks( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, + digital_type: &str, +) -> Array { + let (delta, gamma, vega) = ferro_ta_core::options::digital::digital_greeks( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + parse_digital_kind(digital_type), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(delta)); + out.push(&JsValue::from_f64(gamma)); + out.push(&JsValue::from_f64(vega)); + out +} + +// --------------------------------------------------------------------------- +// American options (Barone-Adesi-Whaley) +// --------------------------------------------------------------------------- + +/// American option price using the Barone-Adesi-Whaley approximation. +#[wasm_bindgen] +pub fn american_price( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> f64 { + ferro_ta_core::options::american::american_price_baw( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + ) +} + +/// Early exercise premium: `american_price - european_price`. +#[wasm_bindgen] +pub fn early_exercise_premium( + spot: f64, + strike: f64, + rate: f64, + carry: f64, + time_to_expiry: f64, + volatility: f64, + kind: &str, +) -> f64 { + ferro_ta_core::options::american::early_exercise_premium( + spot, + strike, + rate, + carry, + time_to_expiry, + volatility, + parse_option_kind(kind), + ) +} + +// --------------------------------------------------------------------------- +// Historical volatility estimators +// --------------------------------------------------------------------------- + +/// Close-to-close realised volatility (rolling). +/// +/// First `window - 1` values are `NaN`. +#[wasm_bindgen] +pub fn close_to_close_vol( + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::close_to_close_vol(&to_vec(close), window, trading_days)) +} + +/// Parkinson (high-low) volatility estimator (rolling). +#[wasm_bindgen] +pub fn parkinson_vol( + high: &Float64Array, + low: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::parkinson_vol( + &to_vec(high), + &to_vec(low), + window, + trading_days, + )) +} + +/// Garman-Klass OHLC volatility estimator (rolling). +#[wasm_bindgen] +pub fn garman_klass_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::garman_klass_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +/// Rogers-Satchell OHLC volatility estimator (rolling). +#[wasm_bindgen] +pub fn rogers_satchell_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::rogers_satchell_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +/// Yang-Zhang OHLC volatility estimator (rolling). +/// +/// Most efficient estimator — handles overnight gaps. +#[wasm_bindgen] +pub fn yang_zhang_vol( + open: &Float64Array, + high: &Float64Array, + low: &Float64Array, + close: &Float64Array, + window: usize, + trading_days: f64, +) -> Float64Array { + from_vec(ferro_ta_core::options::realized_vol::yang_zhang_vol( + &to_vec(open), + &to_vec(high), + &to_vec(low), + &to_vec(close), + window, + trading_days, + )) +} + +// --------------------------------------------------------------------------- +// Volatility cone +// --------------------------------------------------------------------------- + +/// Volatility cone: percentile distribution of close-to-close vol across windows. +/// +/// # Arguments +/// - `close` – `Float64Array` of close prices. +/// - `windows` – `Float64Array` of window sizes (e.g. `[21, 42, 63, 126, 252]`). +/// - `trading_days` – annualisation factor (default 252). +/// +/// # Returns +/// `js_sys::Array` of length `n_windows`, each element an `Array`: +/// `[window, min, p25, median, p75, max]`. +#[wasm_bindgen] +pub fn vol_cone( + close: &Float64Array, + windows: &Float64Array, + trading_days: f64, +) -> Array { + let c = to_vec(close); + let wins = to_usize_vec(windows); + let slices = ferro_ta_core::options::realized_vol::vol_cone(&c, &wins, trading_days); + let out = Array::new(); + for s in slices { + let row = Array::new(); + row.push(&JsValue::from_f64(s.window as f64)); + row.push(&JsValue::from_f64(s.min)); + row.push(&JsValue::from_f64(s.p25)); + row.push(&JsValue::from_f64(s.median)); + row.push(&JsValue::from_f64(s.p75)); + row.push(&JsValue::from_f64(s.max)); + out.push(&row); + } + out +} + +// --------------------------------------------------------------------------- +// Expected move +// --------------------------------------------------------------------------- + +/// Expected move over `days_to_expiry` trading days. +/// +/// Uses log-normal: `spot · e^{±σ√(days/trading_days)} − spot`. +/// +/// # Returns +/// `js_sys::Array` of two f64 values: `[lower_move, upper_move]` (signed). +#[wasm_bindgen] +pub fn expected_move( + spot: f64, + iv: f64, + days_to_expiry: f64, + trading_days_per_year: f64, +) -> Array { + let (lower, upper) = ferro_ta_core::options::surface::expected_move(spot, iv, days_to_expiry, trading_days_per_year); + let out = Array::new(); + out.push(&JsValue::from_f64(lower)); + out.push(&JsValue::from_f64(upper)); + out +} + +// --------------------------------------------------------------------------- +// Strategy payoff / value (Feature 8 — WASM exposure) +// --------------------------------------------------------------------------- + +/// Aggregate strategy payoff over a spot grid at expiry. +/// +/// Instrument codes: `0`=option, `1`=future, `2`=stock. +/// Side codes: `1`=long, `-1`=short. +/// Option type codes: `1`=call, `-1`=put. +/// +/// # Returns +/// `Float64Array` of aggregate P&L per spot grid point. +#[wasm_bindgen] +pub fn strategy_payoff_dense( + spot_grid: &Float64Array, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + premiums: &Float64Array, + entry_prices: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, +) -> Float64Array { + from_vec(ferro_ta_core::options::payoff::strategy_payoff_dense( + &to_vec(spot_grid), + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(premiums), + &to_vec(entry_prices), + &to_vec(quantities), + &to_vec(multipliers), + )) +} + +/// Aggregate BSM Greeks across option and futures/stock legs at a single spot. +/// +/// # Returns +/// `js_sys::Array` of five f64 values: `[delta, gamma, vega, theta, rho]`. +#[wasm_bindgen] +pub fn aggregate_greeks_dense( + spot: f64, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + volatilities: &Float64Array, + time_to_expiries: &Float64Array, + rates: &Float64Array, + carries: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, +) -> Array { + let (delta, gamma, vega, theta, rho) = ferro_ta_core::options::payoff::aggregate_greeks_dense( + spot, + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(volatilities), + &to_vec(time_to_expiries), + &to_vec(rates), + &to_vec(carries), + &to_vec(quantities), + &to_vec(multipliers), + ); + let out = Array::new(); + out.push(&JsValue::from_f64(delta)); + out.push(&JsValue::from_f64(gamma)); + out.push(&JsValue::from_f64(vega)); + out.push(&JsValue::from_f64(theta)); + out.push(&JsValue::from_f64(rho)); + out +} + +/// Current BSM mid-price value of a multi-leg strategy over a spot grid (pre-expiry). +/// +/// Unlike `strategy_payoff_dense`, this uses live BSM pricing for option legs. +/// +/// # Returns +/// `Float64Array` of strategy value (P&L vs premium paid) per spot grid point. +#[wasm_bindgen] +pub fn strategy_value_grid( + spot_grid: &Float64Array, + instruments: &Float64Array, + sides: &Float64Array, + option_types: &Float64Array, + strikes: &Float64Array, + premiums: &Float64Array, + entry_prices: &Float64Array, + quantities: &Float64Array, + multipliers: &Float64Array, + time_to_expiries: &Float64Array, + volatilities: &Float64Array, + rates: &Float64Array, + carries: &Float64Array, +) -> Float64Array { + from_vec(ferro_ta_core::options::payoff::strategy_value_grid( + &to_vec(spot_grid), + &to_i64_vec(instruments), + &to_i64_vec(sides), + &to_i64_vec(option_types), + &to_vec(strikes), + &to_vec(premiums), + &to_vec(entry_prices), + &to_vec(quantities), + &to_vec(multipliers), + &to_vec(time_to_expiries), + &to_vec(volatilities), + &to_vec(rates), + &to_vec(carries), + )) +} + +// --------------------------------------------------------------------------- +// WASM tests (run with `wasm-pack test --node`) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + fn make_arr(v: &[f64]) -> Float64Array { + let arr = Float64Array::new_with_length(v.len() as u32); + arr.copy_from(v); + arr + } + + fn get_finite(arr: &Float64Array) -> Vec { + let mut v = vec![0.0f64; arr.length() as usize]; + arr.copy_to(&mut v); + v.into_iter().filter(|x| x.is_finite()).collect() + } + + // ----------------------------------------------------------------------- + // SMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_sma_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = sma(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_sma_known_value() { + // SMA(3) of [1,2,3,4,5]: first valid at index 2 = (1+2+3)/3 = 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = sma(&close, 3); + let vals: Vec = { + let mut v = vec![0.0f64; 5]; + out.copy_to(&mut v); + v + }; + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - 2.0).abs() < 1e-10); + assert!((vals[3] - 3.0).abs() < 1e-10); + assert!((vals[4] - 4.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // EMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_ema_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = ema(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_ema_seed_equals_sma() { + // Seed of EMA(3) at index 2 should equal SMA(3) = 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = ema(&close, 3); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!((vals[2] - 2.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // BBANDS tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_bbands_returns_three_arrays() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = bbands(&close, 3, 2.0, 2.0); + assert_eq!(out.length(), 3); + } + + #[wasm_bindgen_test] + fn test_bbands_middle_equals_sma() { + let data = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]; + let close = make_arr(&data); + let bands = bbands(&close, 3, 2.0, 2.0); + + // Middle band should equal SMA(3) + let middle = Float64Array::from(bands.get(1)); + let sma_out = sma(&close, 3); + + let mut m = vec![0.0f64; 7]; + middle.copy_to(&mut m); + let mut s = vec![0.0f64; 7]; + sma_out.copy_to(&mut s); + + for i in 2..7 { + assert!((m[i] - s[i]).abs() < 1e-10, "middle[{i}] != sma[{i}]"); + } + } + + #[wasm_bindgen_test] + fn test_bbands_upper_greater_than_lower() { + let data = [44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]; + let close = make_arr(&data); + let bands = bbands(&close, 3, 2.0, 2.0); + let upper = Float64Array::from(bands.get(0)); + let lower = Float64Array::from(bands.get(2)); + let mut u = vec![0.0f64; 7]; + let mut l = vec![0.0f64; 7]; + upper.copy_to(&mut u); + lower.copy_to(&mut l); + for i in 2..7 { + assert!(u[i] >= l[i], "upper[{i}] < lower[{i}]"); + } + } + + // ----------------------------------------------------------------------- + // RSI tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_rsi_output_length() { + let close = make_arr(&[ + 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, + ]); + let out = rsi(&close, 14); + assert_eq!(out.length(), 15); + } + + #[wasm_bindgen_test] + fn test_rsi_range_0_to_100() { + let close = make_arr(&[ + 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, + ]); + let out = rsi(&close, 5); + let finite = get_finite(&out); + for v in finite { + assert!(v >= 0.0 && v <= 100.0, "RSI out of range: {v}"); + } + } + + // ----------------------------------------------------------------------- + // ATR tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_atr_output_length() { + let high = make_arr(&[45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); + let low = make_arr(&[43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + let close = make_arr(&[44.0, 45.0, 46.0, 45.0, 44.0, 43.0, 44.0]); + let out = atr(&high, &low, &close, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_atr_all_positive() { + let high = make_arr(&[45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]); + let low = make_arr(&[43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]); + let close = make_arr(&[44.0, 45.0, 46.0, 45.0, 44.0, 43.0, 44.0]); + let out = atr(&high, &low, &close, 3); + let finite = get_finite(&out); + assert!(!finite.is_empty()); + for v in finite { + assert!(v > 0.0, "ATR should be positive, got {v}"); + } + } + + // ----------------------------------------------------------------------- + // OBV tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_obv_output_length() { + let close = make_arr(&[10.0, 11.0, 10.0, 12.0, 11.0]); + let volume = make_arr(&[100.0, 200.0, 150.0, 300.0, 250.0]); + let out = obv(&close, &volume); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_obv_known_values() { + // close: 10 → 11 (up, +200) → 10 (dn, -150) → 12 (up, +300) → 11 (dn, -250) + // OBV starts at 0: 0, 200, 50, 350, 100 + let close = make_arr(&[10.0, 11.0, 10.0, 12.0, 11.0]); + let volume = make_arr(&[100.0, 200.0, 150.0, 300.0, 250.0]); + let out = obv(&close, &volume); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!((vals[0] - 0.0).abs() < 1e-10); + assert!((vals[1] - 200.0).abs() < 1e-10); + assert!((vals[2] - 50.0).abs() < 1e-10); + assert!((vals[3] - 350.0).abs() < 1e-10); + assert!((vals[4] - 100.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // MACD tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_macd_returns_three_arrays() { + let data = [ + 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, + 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, + ]; + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + assert_eq!(out.length(), 3); + } + + #[wasm_bindgen_test] + fn test_macd_output_length() { + let data: Vec = (1..=30).map(|x| x as f64 * 1.0).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let macd_line = Float64Array::from(out.get(0)); + assert_eq!(macd_line.length(), 30); + } + + #[wasm_bindgen_test] + fn test_macd_finite_values_after_warmup() { + // With fastperiod=3, slowperiod=5, signalperiod=2: + // MACD line valid from index 4; signal from index 5. + let data: Vec = (1..=20).map(|x| x as f64).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let signal = Float64Array::from(out.get(1)); + let finite = get_finite(&signal); + assert!(!finite.is_empty(), "signal should have finite values"); + } + + #[wasm_bindgen_test] + fn test_macd_histogram_is_macd_minus_signal() { + let data: Vec = (1..=20).map(|x| x as f64).collect(); + let close = make_arr(&data); + let out = macd(&close, 3, 5, 2); + let macd_arr = Float64Array::from(out.get(0)); + let sig_arr = Float64Array::from(out.get(1)); + let hist_arr = Float64Array::from(out.get(2)); + + let n = macd_arr.length() as usize; + let mut m = vec![0.0f64; n]; + let mut s = vec![0.0f64; n]; + let mut h = vec![0.0f64; n]; + macd_arr.copy_to(&mut m); + sig_arr.copy_to(&mut s); + hist_arr.copy_to(&mut h); + + for i in 0..n { + if m[i].is_finite() && s[i].is_finite() { + assert!((h[i] - (m[i] - s[i])).abs() < 1e-10, + "histogram[{i}] != macd[{i}] - signal[{i}]"); + } + } + } + + // ----------------------------------------------------------------------- + // MOM tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_mom_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]); + let out = mom(&close, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_mom_known_values() { + // MOM(2) of [1,2,3,4,5]: NaN, NaN, 2.0, 2.0, 2.0 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = mom(&close, 2); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - 2.0).abs() < 1e-10, "MOM[2] should be 2.0"); + assert!((vals[3] - 2.0).abs() < 1e-10, "MOM[3] should be 2.0"); + assert!((vals[4] - 2.0).abs() < 1e-10, "MOM[4] should be 2.0"); + } + + // ----------------------------------------------------------------------- + // STOCHF tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_stochf_returns_two_arrays() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + assert_eq!(out.length(), 2); + } + + #[wasm_bindgen_test] + fn test_stochf_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + let fastk = Float64Array::from(out.get(0)); + assert_eq!(fastk.length(), 7); + } + + #[wasm_bindgen_test] + fn test_stochf_fastk_in_0_to_100() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.0, 10.0, 12.0, 13.0]); + let l = make_arr(&[8.0, 9.0, 10.0, 9.0, 8.0, 10.0, 11.0]); + let c = make_arr(&[9.0, 10.0, 11.0, 10.0, 9.0, 11.0, 12.0]); + let out = stochf(&h, &l, &c, 3, 2); + let fastk = Float64Array::from(out.get(0)); + let finite = get_finite(&fastk); + assert!(!finite.is_empty(), "fastk should have finite values"); + for v in finite { + assert!(v >= 0.0 && v <= 100.0, "fastk value {v} out of [0, 100]"); + } + } + + // ----------------------------------------------------------------------- + // WMA tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_wma_output_length() { + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = wma(&close, 3); + assert_eq!(out.length(), 5); + } + + #[wasm_bindgen_test] + fn test_wma_known_value() { + // WMA(3) at index 2 = (1*1 + 2*2 + 3*3) / 6 = 14/6 + let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]); + let out = wma(&close, 3); + let mut vals = vec![0.0f64; 5]; + out.copy_to(&mut vals); + assert!(vals[0].is_nan()); + assert!(vals[1].is_nan()); + assert!((vals[2] - (14.0 / 6.0)).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // ADX tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_adx_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]); + let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]); + let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]); + let out = adx(&h, &l, &c, 3); + assert_eq!(out.length(), 8); + } + + #[wasm_bindgen_test] + fn test_adx_values_in_range() { + let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]); + let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]); + let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]); + let out = adx(&h, &l, &c, 3); + for v in get_finite(&out) { + assert!((0.0..=100.0).contains(&v), "ADX out of range: {v}"); + } + } + + // ----------------------------------------------------------------------- + // MFI tests + // ----------------------------------------------------------------------- + + #[wasm_bindgen_test] + fn test_mfi_output_length() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]); + let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]); + let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]); + let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]); + let out = mfi(&h, &l, &c, &v, 3); + assert_eq!(out.length(), 7); + } + + #[wasm_bindgen_test] + fn test_mfi_values_in_range() { + let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]); + let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]); + let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]); + let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]); + let out = mfi(&h, &l, &c, &v, 3); + for val in get_finite(&out) { + assert!((0.0..=100.0).contains(&val), "MFI out of range: {val}"); + } + } +}