From 2d5000262f0f1439546bd4872235aae0333880a4 Mon Sep 17 00:00:00 2001 From: Pratik Bhadane Date: Tue, 24 Mar 2026 02:02:10 +0530 Subject: [PATCH 1/3] chore: release v1.0.2 --- .github/workflows/ci-python.yml | 40 ++ .github/workflows/ci-wasm.yml | 15 + CHANGELOG.md | 22 +- Cargo.lock | 4 +- Cargo.toml | 4 +- benchmarks/README.md | 48 ++ benchmarks/artifacts/latest/batch.json | 71 +++ .../artifacts/latest/benchmark_vs_talib.json | 262 +++++++++ .../artifacts/latest/indicator_latency.json | 163 ++++++ benchmarks/artifacts/latest/manifest.json | 62 ++ .../artifacts/latest/runtime_hotspots.json | 96 +++ benchmarks/artifacts/latest/simd.json | 285 +++++++++ benchmarks/artifacts/latest/streaming.json | 73 +++ benchmarks/artifacts/latest/wasm.json | 46 ++ benchmarks/bench_batch.py | 256 ++++++-- benchmarks/bench_simd.py | 158 +++++ benchmarks/bench_streaming.py | 189 ++++++ benchmarks/check_hotspot_regression.py | 107 ++++ benchmarks/metadata.py | 79 +++ benchmarks/profile_runtime_hotspots.py | 269 +++++++++ benchmarks/run_perf_contract.py | 211 +++++++ benchmarks/test_benchmark_suite.py | 59 +- crates/ferro_ta_core/Cargo.toml | 2 +- crates/ferro_ta_core/README.md | 2 +- docs/performance.md | 93 ++- pyproject.toml | 2 +- python/ferro_ta/__init__.py | 1 + python/ferro_ta/__init__.pyi | 1 + python/ferro_ta/analysis/backtest.py | 21 +- python/ferro_ta/analysis/features.py | 83 +-- python/ferro_ta/analysis/options.py | 41 +- python/ferro_ta/analysis/signals.py | 9 +- python/ferro_ta/data/batch.py | 153 +++++ src/batch/mod.rs | 545 +++++++++++++----- src/signals/mod.rs | 86 ++- src/statistic/beta.rs | 150 +++-- src/statistic/common.rs | 54 ++ src/statistic/correl.rs | 93 ++- src/statistic/linearreg.rs | 49 +- src/statistic/mod.rs | 2 +- tests/unit/indicators/test_statistic.py | 82 +++ tests/unit/test_data_pipeline.py | 84 +++ tests/unit/test_infrastructure.py | 51 +- uv.lock | 2 +- wasm/Cargo.toml | 2 +- wasm/bench.js | 113 ++++ wasm/package.json | 3 +- 47 files changed, 3821 insertions(+), 422 deletions(-) create mode 100644 benchmarks/artifacts/latest/batch.json create mode 100644 benchmarks/artifacts/latest/benchmark_vs_talib.json create mode 100644 benchmarks/artifacts/latest/indicator_latency.json create mode 100644 benchmarks/artifacts/latest/manifest.json create mode 100644 benchmarks/artifacts/latest/runtime_hotspots.json create mode 100644 benchmarks/artifacts/latest/simd.json create mode 100644 benchmarks/artifacts/latest/streaming.json create mode 100644 benchmarks/artifacts/latest/wasm.json create mode 100644 benchmarks/bench_simd.py create mode 100644 benchmarks/bench_streaming.py create mode 100644 benchmarks/check_hotspot_regression.py create mode 100644 benchmarks/metadata.py create mode 100644 benchmarks/profile_runtime_hotspots.py create mode 100644 benchmarks/run_perf_contract.py create mode 100644 wasm/bench.js diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index dc87723..20daf3d 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -125,3 +125,43 @@ jobs: with: name: benchmark-vs-talib path: benchmark_vs_talib.json + + perf-smoke: + name: Performance smoke and contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install maturin and perf dependencies + run: | + pip install maturin numpy pytest + + - name: Build and install ferro_ta + run: | + maturin build --release --out dist + pip install dist/*.whl + + - name: Generate reproducible perf artifacts + run: | + python benchmarks/run_perf_contract.py \ + --output-dir perf-contract \ + --skip-talib \ + --batch-samples 20000 \ + --batch-series 32 \ + --streaming-bars 20000 \ + --price-bars 20000 \ + --iv-bars 50000 + + - name: Enforce hotspot regression policy + run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json + + - name: Upload perf artifacts + uses: actions/upload-artifact@v7 + with: + name: perf-contract + path: perf-contract/ diff --git a/.github/workflows/ci-wasm.yml b/.github/workflows/ci-wasm.yml index 91d493f..d1636ef 100644 --- a/.github/workflows/ci-wasm.yml +++ b/.github/workflows/ci-wasm.yml @@ -13,6 +13,11 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install Rust (stable) uses: dtolnay/rust-toolchain@v1 with: @@ -30,8 +35,18 @@ jobs: working-directory: wasm run: wasm-pack build --target nodejs --out-dir pkg + - name: Benchmark WASM package + working-directory: wasm + run: node bench.js --json ../wasm_benchmark.json + - name: Upload WASM package artifact uses: actions/upload-artifact@v7 with: name: wasm-pkg path: wasm/pkg/ + + - name: Upload WASM benchmark artifact + uses: actions/upload-artifact@v7 + with: + name: wasm-benchmark + path: wasm_benchmark.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c819211..597a0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ and the project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [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 @@ -274,6 +293,7 @@ and the project uses [Semantic Versioning](https://semver.org/). --- -[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...HEAD +[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/Cargo.lock b/Cargo.lock index ff595fa..5340f3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "ferro_ta" -version = "1.0.1" +version = "1.0.2" dependencies = [ "criterion", "ferro_ta_core", @@ -222,7 +222,7 @@ dependencies = [ [[package]] name = "ferro_ta_core" -version = "1.0.1" +version = "1.0.2" dependencies = [ "criterion", "wide", diff --git a/Cargo.toml b/Cargo.toml index e394178..83e017d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [package] name = "ferro_ta" -version = "1.0.1" +version = "1.0.2" edition = "2021" license = "MIT" publish = false @@ -23,7 +23,7 @@ ndarray = "0.16" rayon = "1.10" log = "0.4" pyo3-log = "0.12" -ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.1" } +ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.2" } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/benchmarks/README.md b/benchmarks/README.md index b55a55c..c534d7e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -40,6 +40,30 @@ from benchmarks.data_generator import SMALL, MEDIUM, LARGE - **Machine info:** Stored in `benchmarks/results.json` (`machine_info`, `commit_info`) for reproducibility. - **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped. +## 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) @@ -141,10 +165,34 @@ uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark # 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. +### WASM + +From the `wasm/` directory: + +```bash +wasm-pack build --target nodejs --out-dir pkg +node bench.js --json ../wasm_benchmark.json +``` + --- ## Indicator coverage diff --git a/benchmarks/artifacts/latest/batch.json b/benchmarks/artifacts/latest/batch.json new file mode 100644 index 0000000..89a46a4 --- /dev/null +++ b/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 + } + ] +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/benchmark_vs_talib.json b/benchmarks/artifacts/latest/benchmark_vs_talib.json new file mode 100644 index 0000000..116cf5e --- /dev/null +++ b/benchmarks/artifacts/latest/benchmark_vs_talib.json @@ -0,0 +1,262 @@ +{ + "schema_version": 1, + "command": "python benchmarks/bench_vs_talib.py", + "n_warmup": 1, + "n_runs": 7, + "sizes": [ + 10000, + 100000 + ], + "talib_available": true, + "runtime": { + "generated_at_utc": "2026-03-23T20:26:40.738132+00:00", + "python_version": "3.13.5", + "platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O", + "machine": "arm64" + }, + "git": { + "commit": "9011250f992119170242cf17a67834c67b91bcdb", + "dirty": true + }, + "summary": { + "total_rows": 24, + "by_size": [ + { + "size": 10000, + "rows": 12, + "wins": 8, + "win_rate": 0.6666666666666666, + "median_speedup": 1.0546, + "min_speedup": 0.7174, + "max_speedup": 2.0135 + }, + { + "size": 100000, + "rows": 12, + "wins": 7, + "win_rate": 0.5833333333333334, + "median_speedup": 1.0896, + "min_speedup": 0.4965, + "max_speedup": 3.6029 + } + ] + }, + "results": [ + { + "indicator": "SMA", + "size": 10000, + "ferro_ta_ms": 0.0093, + "talib_ms": 0.0167, + "speedup": 1.7937, + "ferro_ta_m_bars_s": 1076.19, + "talib_m_bars_s": 599.99 + }, + { + "indicator": "SMA", + "size": 100000, + "ferro_ta_ms": 0.0765, + "talib_ms": 0.1346, + "speedup": 1.7589, + "ferro_ta_m_bars_s": 1306.49, + "talib_m_bars_s": 742.8 + }, + { + "indicator": "EMA", + "size": 10000, + "ferro_ta_ms": 0.02, + "talib_ms": 0.0214, + "speedup": 1.0687, + "ferro_ta_m_bars_s": 500.0, + "talib_m_bars_s": 467.84 + }, + { + "indicator": "EMA", + "size": 100000, + "ferro_ta_ms": 0.1998, + "talib_ms": 0.1883, + "speedup": 0.9425, + "ferro_ta_m_bars_s": 500.42, + "talib_m_bars_s": 530.97 + }, + { + "indicator": "RSI", + "size": 10000, + "ferro_ta_ms": 0.0475, + "talib_ms": 0.048, + "speedup": 1.0088, + "ferro_ta_m_bars_s": 210.34, + "talib_m_bars_s": 208.52 + }, + { + "indicator": "RSI", + "size": 100000, + "ferro_ta_ms": 0.4804, + "talib_ms": 0.4635, + "speedup": 0.9647, + "ferro_ta_m_bars_s": 208.15, + "talib_m_bars_s": 215.77 + }, + { + "indicator": "BBANDS", + "size": 10000, + "ferro_ta_ms": 0.0215, + "talib_ms": 0.0433, + "speedup": 2.0135, + "ferro_ta_m_bars_s": 465.12, + "talib_m_bars_s": 230.99 + }, + { + "indicator": "BBANDS", + "size": 100000, + "ferro_ta_ms": 0.1686, + "talib_ms": 0.4275, + "speedup": 2.535, + "ferro_ta_m_bars_s": 593.03, + "talib_m_bars_s": 233.94 + }, + { + "indicator": "MACD", + "size": 10000, + "ferro_ta_ms": 0.0495, + "talib_ms": 0.065, + "speedup": 1.3134, + "ferro_ta_m_bars_s": 202.19, + "talib_m_bars_s": 153.95 + }, + { + "indicator": "MACD", + "size": 100000, + "ferro_ta_ms": 0.4459, + "talib_ms": 0.6334, + "speedup": 1.4205, + "ferro_ta_m_bars_s": 224.28, + "talib_m_bars_s": 157.88 + }, + { + "indicator": "ATR", + "size": 10000, + "ferro_ta_ms": 0.0483, + "talib_ms": 0.0502, + "speedup": 1.0405, + "ferro_ta_m_bars_s": 207.07, + "talib_m_bars_s": 199.0 + }, + { + "indicator": "ATR", + "size": 100000, + "ferro_ta_ms": 0.4705, + "talib_ms": 0.4788, + "speedup": 1.0174, + "ferro_ta_m_bars_s": 212.52, + "talib_m_bars_s": 208.88 + }, + { + "indicator": "STOCH", + "size": 10000, + "ferro_ta_ms": 0.0915, + "talib_ms": 0.066, + "speedup": 0.721, + "ferro_ta_m_bars_s": 109.24, + "talib_m_bars_s": 151.52 + }, + { + "indicator": "STOCH", + "size": 100000, + "ferro_ta_ms": 1.5285, + "talib_ms": 0.7589, + "speedup": 0.4965, + "ferro_ta_m_bars_s": 65.42, + "talib_m_bars_s": 131.77 + }, + { + "indicator": "ADX", + "size": 10000, + "ferro_ta_ms": 0.0692, + "talib_ms": 0.0522, + "speedup": 0.7538, + "ferro_ta_m_bars_s": 144.49, + "talib_m_bars_s": 191.7 + }, + { + "indicator": "ADX", + "size": 100000, + "ferro_ta_ms": 0.6209, + "talib_ms": 0.5731, + "speedup": 0.923, + "ferro_ta_m_bars_s": 161.05, + "talib_m_bars_s": 174.49 + }, + { + "indicator": "CCI", + "size": 10000, + "ferro_ta_ms": 0.0731, + "talib_ms": 0.0829, + "speedup": 1.1333, + "ferro_ta_m_bars_s": 136.75, + "talib_m_bars_s": 120.66 + }, + { + "indicator": "CCI", + "size": 100000, + "ferro_ta_ms": 0.7028, + "talib_ms": 0.8164, + "speedup": 1.1617, + "ferro_ta_m_bars_s": 142.3, + "talib_m_bars_s": 122.49 + }, + { + "indicator": "OBV", + "size": 10000, + "ferro_ta_ms": 0.0156, + "talib_ms": 0.0112, + "speedup": 0.7174, + "ferro_ta_m_bars_s": 640.0, + "talib_m_bars_s": 892.14 + }, + { + "indicator": "OBV", + "size": 100000, + "ferro_ta_ms": 0.2953, + "talib_ms": 0.2808, + "speedup": 0.9509, + "ferro_ta_m_bars_s": 338.6, + "talib_m_bars_s": 356.08 + }, + { + "indicator": "MFI", + "size": 10000, + "ferro_ta_ms": 0.0239, + "talib_ms": 0.0203, + "speedup": 0.8516, + "ferro_ta_m_bars_s": 418.85, + "talib_m_bars_s": 491.81 + }, + { + "indicator": "MFI", + "size": 100000, + "ferro_ta_ms": 0.1721, + "talib_ms": 0.62, + "speedup": 3.6029, + "ferro_ta_m_bars_s": 581.11, + "talib_m_bars_s": 161.29 + }, + { + "indicator": "WMA", + "size": 10000, + "ferro_ta_ms": 0.0105, + "talib_ms": 0.0203, + "speedup": 1.9363, + "ferro_ta_m_bars_s": 956.21, + "talib_m_bars_s": 493.83 + }, + { + "indicator": "WMA", + "size": 100000, + "ferro_ta_ms": 0.0869, + "talib_ms": 0.1868, + "speedup": 2.1506, + "ferro_ta_m_bars_s": 1151.08, + "talib_m_bars_s": 535.24 + } + ] +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/indicator_latency.json b/benchmarks/artifacts/latest/indicator_latency.json new file mode 100644 index 0000000..c931fb1 --- /dev/null +++ b/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 + } + ] +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/manifest.json b/benchmarks/artifacts/latest/manifest.json new file mode 100644 index 0000000..a9325c5 --- /dev/null +++ b/benchmarks/artifacts/latest/manifest.json @@ -0,0 +1,62 @@ +{ + "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" + } + } +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/runtime_hotspots.json b/benchmarks/artifacts/latest/runtime_hotspots.json new file mode 100644 index 0000000..6c32fe8 --- /dev/null +++ b/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 + } + ] +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/simd.json b/benchmarks/artifacts/latest/simd.json new file mode 100644 index 0000000..01d8d65 --- /dev/null +++ b/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 + } + ] + } + } +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/streaming.json b/benchmarks/artifacts/latest/streaming.json new file mode 100644 index 0000000..c635596 --- /dev/null +++ b/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 + } + ] +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/wasm.json b/benchmarks/artifacts/latest/wasm.json new file mode 100644 index 0000000..32a796a --- /dev/null +++ b/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/benchmarks/bench_batch.py b/benchmarks/bench_batch.py index d2335a2..31d9b65 100644 --- a/benchmarks/bench_batch.py +++ b/benchmarks/bench_batch.py @@ -1,65 +1,221 @@ +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 -def _time_fn(fn, *args, **kwargs): - times = [] - # Warmup +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) - for _ in range(5): + times: list[float] = [] + for _ in range(rounds): t0 = time.perf_counter() fn(*args, **kwargs) times.append(time.perf_counter() - t0) return min(times) -def main(): - n_samples = 100_000 - n_series = 100 - print(f"Batch Benchmark: {n_samples} bars, {n_series} series (Total: {n_samples*n_series/1e6:.1f} M bars)") - - np.random.seed(42) - # contiguous array in row-major - close2d = np.random.uniform(100.0, 200.0, (n_samples, n_series)) - h2d = close2d + np.random.uniform(0.1, 2.0, (n_samples, n_series)) - l2d = close2d - np.random.uniform(0.1, 2.0, (n_samples, n_series)) - - print("-" * 50) - print(f"{'Indicator':<15} {'Batch (ms)':>12} {'Loop (ms)':>12} {'Speedup':>10}") - print("-" * 50) - # 1. SMA - kwargs = {"timeperiod": 14} - def loop_sma(arr): - for j in range(arr.shape[1]): - ferro_ta.SMA(arr[:, j], **kwargs) - - t_batch_sma = _time_fn(ferro_ta.batch.batch_sma, close2d, **kwargs) - t_loop_sma = _time_fn(loop_sma, close2d) - print(f"SMA {t_batch_sma*1000:12.1f} {t_loop_sma*1000:12.1f} {t_loop_sma/t_batch_sma:9.1f}x") +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] - # 2. RSI - def loop_rsi(arr): - for j in range(arr.shape[1]): - ferro_ta.RSI(arr[:, j], **kwargs) - t_batch_rsi = _time_fn(ferro_ta.batch.batch_rsi, close2d, **kwargs) - t_loop_rsi = _time_fn(loop_rsi, close2d) - print(f"RSI {t_batch_rsi*1000:12.1f} {t_loop_rsi*1000:12.1f} {t_loop_rsi/t_batch_rsi:9.1f}x") + batch_rows: list[dict[str, Any]] = [] + grouped_rows: list[dict[str, Any]] = [] - # 3. ATR - def loop_atr(h, l, c): - for j in range(h.shape[1]): - ferro_ta.ATR(h[:, j], l[:, j], c[:, j], **kwargs) - t_batch_atr = _time_fn(ferro_ta.batch.batch_atr, h2d, l2d, close2d, **kwargs) - t_loop_atr = _time_fn(loop_atr, h2d, l2d, close2d) - print(f"ATR {t_batch_atr*1000:12.1f} {t_loop_atr*1000:12.1f} {t_loop_atr/t_batch_atr:9.1f}x") + 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) + ], + ), + ] - # 4. ADX - def loop_adx(h, l, c): - for j in range(h.shape[1]): - ferro_ta.ADX(h[:, j], l[:, j], c[:, j], **kwargs) - t_batch_adx = _time_fn(ferro_ta.batch.batch_adx, h2d, l2d, close2d, **kwargs) - t_loop_adx = _time_fn(loop_adx, h2d, l2d, close2d) - print(f"ADX {t_batch_adx*1000:12.1f} {t_loop_adx*1000:12.1f} {t_loop_adx/t_batch_adx:9.1f}x") + 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), + } + ) -if __name__ == '__main__': - main() + 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/benchmarks/bench_simd.py b/benchmarks/bench_simd.py new file mode 100644 index 0000000..82a27de --- /dev/null +++ b/benchmarks/bench_simd.py @@ -0,0 +1,158 @@ +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]: + variants = [ + ("portable_release", []), + ("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/benchmarks/bench_streaming.py b/benchmarks/bench_streaming.py new file mode 100644 index 0000000..10a071d --- /dev/null +++ b/benchmarks/bench_streaming.py @@ -0,0 +1,189 @@ +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/benchmarks/check_hotspot_regression.py b/benchmarks/check_hotspot_regression.py new file mode 100644 index 0000000..4902baa --- /dev/null +++ b/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.80", + ], + 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/benchmarks/metadata.py b/benchmarks/metadata.py new file mode 100644 index 0000000..cecb48d --- /dev/null +++ b/benchmarks/metadata.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import hashlib +import platform +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def git_info() -> dict[str, Any]: + """Best-effort git metadata for reproducible benchmark artifacts.""" + try: + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL + ).strip() + except Exception: + commit = None + + try: + dirty = bool( + subprocess.check_output( + ["git", "status", "--porcelain"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + ) + except Exception: + dirty = None + + try: + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except Exception: + branch = None + + return {"commit": commit, "dirty": dirty, "branch": branch} + + +def runtime_info() -> dict[str, Any]: + return { + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "python_version": sys.version.split()[0], + "platform": platform.platform(), + "machine": platform.machine(), + "processor": platform.processor() or None, + } + + +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(), + } + if fixtures: + metadata["fixtures"] = [file_info(path) for path in fixtures] + if extra: + metadata.update(extra) + return metadata diff --git a/benchmarks/profile_runtime_hotspots.py b/benchmarks/profile_runtime_hotspots.py new file mode 100644 index 0000000..ba92797 --- /dev/null +++ b/benchmarks/profile_runtime_hotspots.py @@ -0,0 +1,269 @@ +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/benchmarks/run_perf_contract.py b/benchmarks/run_perf_contract.py new file mode 100644 index 0000000..682fbd7 --- /dev/null +++ b/benchmarks/run_perf_contract.py @@ -0,0 +1,211 @@ +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/benchmarks/test_benchmark_suite.py b/benchmarks/test_benchmark_suite.py index d95a7a9..05c669a 100644 --- a/benchmarks/test_benchmark_suite.py +++ b/benchmarks/test_benchmark_suite.py @@ -32,7 +32,8 @@ from __future__ import annotations import pathlib import time -from typing import Any, Callable, Dict, List +from collections.abc import Callable +from typing import Any import numpy as np import pytest @@ -46,7 +47,7 @@ BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz" @pytest.fixture(scope="session") -def ohlcv() -> Dict[str, np.ndarray]: +def ohlcv() -> dict[str, np.ndarray]: """Load canonical OHLCV fixture.""" if not FIXTURE_PATH.exists(): pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}") @@ -60,7 +61,7 @@ def ohlcv() -> Dict[str, np.ndarray]: # Each entry: (name, callable, kwargs) # The callable receives (close,) or (high, low, close,) based on 'inputs' key. -INDICATOR_SUITE: List[Dict[str, Any]] = [ +INDICATOR_SUITE: list[dict[str, Any]] = [ { "name": "SMA_20", "inputs": "close", @@ -131,6 +132,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [ "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", @@ -138,6 +153,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [ "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", @@ -161,12 +190,14 @@ def _load_fn(fn_name: str) -> Callable[..., Any]: return getattr(ft, fn_name) -def _run_indicator(entry: Dict[str, Any], data: Dict[str, np.ndarray]) -> np.ndarray: +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"]) - else: # hlc + 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) @@ -184,7 +215,7 @@ class TestNumericalRegression: "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] + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] ) -> None: """Indicator output length must equal input length.""" out = _run_indicator(entry, ohlcv) @@ -196,7 +227,7 @@ class TestNumericalRegression: "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] + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] ) -> None: """First bar must be NaN (warm-up).""" out = _run_indicator(entry, ohlcv) @@ -205,7 +236,7 @@ class TestNumericalRegression: @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: + 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" @@ -214,7 +245,7 @@ class TestNumericalRegression: "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] + 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) @@ -230,7 +261,7 @@ class TestNumericalRegression: "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] + self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray] ) -> None: """Compare last 10 values to stored baselines.""" baselines = np.load(BASELINE_PATH) @@ -265,8 +296,8 @@ class TestPerformance: ) def test_timing( self, - entry: Dict[str, Any], - ohlcv: Dict[str, np.ndarray], + entry: dict[str, Any], + ohlcv: dict[str, np.ndarray], request: pytest.FixtureRequest, ) -> None: """Time the indicator on the canonical dataset.""" @@ -302,7 +333,7 @@ class TestPerformance: # --------------------------------------------------------------------------- -def update_baselines(ohlcv_data: Dict[str, np.ndarray]) -> None: +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:: @@ -314,7 +345,7 @@ def update_baselines(ohlcv_data: Dict[str, np.ndarray]) -> None: update_baselines(data) " """ - store: Dict[str, np.ndarray] = {} + store: dict[str, np.ndarray] = {} for entry in INDICATOR_SUITE: out = _run_indicator(entry, ohlcv_data) valid = out[~np.isnan(out)] diff --git a/crates/ferro_ta_core/Cargo.toml b/crates/ferro_ta_core/Cargo.toml index 093efd5..b432a32 100644 --- a/crates/ferro_ta_core/Cargo.toml +++ b/crates/ferro_ta_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_core" -version = "1.0.1" +version = "1.0.2" edition = "2021" description = "Pure Rust core indicator library — no PyO3, no numpy dependency" license = "MIT" diff --git a/crates/ferro_ta_core/README.md b/crates/ferro_ta_core/README.md index e4859ce..5a49414 100644 --- a/crates/ferro_ta_core/README.md +++ b/crates/ferro_ta_core/README.md @@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for: ```toml [dependencies] -ferro_ta_core = "1.0.1" +ferro_ta_core = "1.0.2" ``` ## Design diff --git a/docs/performance.md b/docs/performance.md index ffd32aa..4fddcf9 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -14,6 +14,7 @@ practical advice on how to get the best speed from the library. | 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 @@ -41,10 +42,12 @@ fast. The bottlenecks for most users are in the Python wrapping layer: np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of earlier versions. -4. **Batch** — `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch functions - for 2-D input (single GIL release for all columns). The generic - `batch_apply` runs any indicator in a Python loop over columns; use the - dedicated batch functions when available. +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. --- @@ -156,6 +159,25 @@ 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) @@ -208,6 +230,53 @@ wrapper with validation and `_to_f64`; all computation runs in the extension. 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. + +## 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/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) @@ -247,17 +316,19 @@ bottlenecks are fixed or deferred. (unlike `_to_f64` for 1-D); could avoid a potential copy. **Options** (`python/ferro_ta/options.py`): -- `iv_rank`, `iv_percentile`, `iv_zscore` use Python loops over windows - (O(n) iterations with per-window NumPy). Could move to Rust or vectorize. - See also `docs/options-volatility.md`. +- `iv_rank`, `iv_percentile`, and `iv_zscore` are vectorized now, but + `iv_percentile`/`iv_zscore` still spend meaningful time in NumPy window + materialization on very long series. **Features** (`python/ferro_ta/features.py`): -- With `nan_policy="fill"` and no pandas, a Python loop fills NaN per column. -- Indicators are run in a Python loop (one call per indicator); no bulk API. +- `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")` uses a list comprehension over columns (one - Python round-trip per column). Could add a Rust batch rank for 2-D input. +- `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. diff --git a/pyproject.toml b/pyproject.toml index 5ad0309..556cd98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ferro-ta" -version = "1.0.1" +version = "1.0.2" description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3" readme = "README.md" license = { text = "MIT" } diff --git a/python/ferro_ta/__init__.py b/python/ferro_ta/__init__.py index 0f042ea..8d32d3f 100644 --- a/python/ferro_ta/__init__.py +++ b/python/ferro_ta/__init__.py @@ -525,6 +525,7 @@ from ferro_ta.data.batch import ( # noqa: F401, E402 batch_ema, batch_rsi, batch_sma, + compute_many, ) from ferro_ta.data.chunked import ( # noqa: F401, E402 chunk_apply, diff --git a/python/ferro_ta/__init__.pyi b/python/ferro_ta/__init__.pyi index 705d896..46fa809 100644 --- a/python/ferro_ta/__init__.pyi +++ b/python/ferro_ta/__init__.pyi @@ -710,6 +710,7 @@ 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) diff --git a/python/ferro_ta/analysis/backtest.py b/python/ferro_ta/analysis/backtest.py index 6c0fc14..af4ddc3 100644 --- a/python/ferro_ta/analysis/backtest.py +++ b/python/ferro_ta/analysis/backtest.py @@ -360,10 +360,10 @@ def backtest( bar_returns[1:] = np.diff(c) / c[:-1] strategy_returns = positions * bar_returns + position_changed = np.concatenate([[False], positions[1:] != positions[:-1]]) # Slippage: on each position change, reduce return by slippage_bps/10000 (one-way) if slippage_bps > 0: - position_changed = np.concatenate([[False], positions[1:] != positions[:-1]]) strategy_returns = strategy_returns.copy() strategy_returns[position_changed] -= slippage_bps / 10_000.0 @@ -371,13 +371,18 @@ def backtest( if commission_per_trade <= 0: equity = np.cumprod(1.0 + strategy_returns) else: - equity = np.empty(len(c), dtype=np.float64) - equity[0] = 1.0 - position_changed = np.concatenate([[False], positions[1:] != positions[:-1]]) - for i in range(1, len(c)): - equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]) - if position_changed[i]: - equity[i] -= commission_per_trade + gross_equity = np.cumprod(1.0 + strategy_returns) + if np.any(gross_equity == 0.0): + equity = np.empty(len(c), dtype=np.float64) + equity[0] = 1.0 + for i in range(1, len(c)): + equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]) + if position_changed[i]: + equity[i] -= commission_per_trade + else: + commissions = position_changed.astype(np.float64) * commission_per_trade + discounted_commissions = np.cumsum(commissions / gross_equity) + equity = gross_equity * (1.0 - discounted_commissions) return BacktestResult( signals=signals, diff --git a/python/ferro_ta/analysis/features.py b/python/ferro_ta/analysis/features.py index be3d5ba..19e8be7 100644 --- a/python/ferro_ta/analysis/features.py +++ b/python/ferro_ta/analysis/features.py @@ -24,12 +24,23 @@ 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 +from ferro_ta.data.batch import compute_many __all__ = [ "feature_matrix", ] + +def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]: + mask = np.isnan(arr) + if not mask.any(): + return arr + + last_valid = np.where(~mask, np.arange(len(arr)), 0) + np.maximum.accumulate(last_valid, out=last_valid) + return arr[last_valid] + + # --------------------------------------------------------------------------- # feature_matrix # --------------------------------------------------------------------------- @@ -117,67 +128,23 @@ def feature_matrix( n = len(close) columns: dict[str, NDArray[np.float64]] = {} - # --- Indicators needing HLCV --- - _multi_input = { - "ATR", - "NATR", - "TRANGE", - "ADX", - "ADXR", - "PLUS_DI", - "MINUS_DI", - "PLUS_DM", - "MINUS_DM", - "DX", - "AROON", - "AROONOSC", - "CCI", - "MFI", - "STOCH", - "STOCHF", - "STOCHRSI", - "WILLR", - "AD", - "ADOSC", - "OBV", - "VWAP", - "DONCHIAN", - "ICHIMOKU", - } + 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, + ) - def _call_indicator(name: str, kwargs: dict[str, Any]) -> Any: - # Try with close only first; if that fails try with hlcv - try: - return _registry_run(name, close, **kwargs) - except (TypeError, Exception): - pass - # Build appropriate positional args from available arrays - if name in _multi_input and high is not None and low is not None: - try: - return _registry_run(name, high, low, close, **kwargs) - except Exception: - pass - if volume is not None: - try: - return _registry_run(name, high, low, close, volume, **kwargs) - except Exception: - pass - raise ValueError( - f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters." - ) - - for spec in indicators: + for spec, result in zip(indicators, results): if isinstance(spec, str): name = spec - kwargs: dict[str, Any] = {} out_key: Optional[Any] = None elif len(spec) == 2: - name, kwargs = spec # type: ignore[misc] + name, _ = spec # type: ignore[misc] out_key = None else: - name, kwargs, out_key = spec # type: ignore[misc] - - result = _call_indicator(name, kwargs) + name, _, out_key = spec # type: ignore[misc] if isinstance(result, tuple): if out_key is not None: @@ -215,8 +182,6 @@ def feature_matrix( mask &= ~np.isnan(arr) return {k: v[mask] for k, v in columns.items()} elif nan_policy == "fill": - for k, arr in columns.items(): - for i in range(1, len(arr)): - if np.isnan(arr[i]): - arr[i] = arr[i - 1] + for key, arr in columns.items(): + columns[key] = _forward_fill_nan(arr) return columns diff --git a/python/ferro_ta/analysis/options.py b/python/ferro_ta/analysis/options.py index 2b14620..44327e9 100644 --- a/python/ferro_ta/analysis/options.py +++ b/python/ferro_ta/analysis/options.py @@ -45,6 +45,7 @@ iv_zscore(iv_series, window) from __future__ import annotations import numpy as np +from numpy.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike, NDArray from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError @@ -103,15 +104,15 @@ def iv_rank( arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) n = len(arr) out = np.full(n, np.nan, dtype=np.float64) + if window > n: + return out - for i in range(window - 1, n): - window_slice = arr[i - window + 1 : i + 1] - lo = float(np.nanmin(window_slice)) - hi = float(np.nanmax(window_slice)) - if hi == lo: - out[i] = 0.0 - else: - out[i] = (arr[i] - lo) / (hi - lo) + windows = sliding_window_view(arr, window_shape=window) + lower = np.nanmin(windows, axis=1) + upper = np.nanmax(windows, axis=1) + current = arr[window - 1 :] + spread = upper - lower + out[window - 1 :] = np.where(spread == 0.0, 0.0, (current - lower) / spread) return out @@ -149,11 +150,12 @@ def iv_percentile( arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) n = len(arr) out = np.full(n, np.nan, dtype=np.float64) + if window > n: + return out - for i in range(window - 1, n): - window_slice = arr[i - window + 1 : i + 1] - current = arr[i] - out[i] = float(np.sum(window_slice <= current)) / window + windows = sliding_window_view(arr, window_shape=window) + current = arr[window - 1 :, None] + out[window - 1 :] = np.sum(windows <= current, axis=1, dtype=np.int64) / window return out @@ -192,14 +194,13 @@ def iv_zscore( arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) n = len(arr) out = np.full(n, np.nan, dtype=np.float64) + if window > n: + return out - for i in range(window - 1, n): - window_slice = arr[i - window + 1 : i + 1] - mu = float(np.nanmean(window_slice)) - sigma = float(np.nanstd(window_slice, ddof=0)) - if sigma == 0.0: - out[i] = np.nan - else: - out[i] = (arr[i] - mu) / sigma + windows = sliding_window_view(arr, window_shape=window) + mean = np.nanmean(windows, axis=1) + std = np.nanstd(windows, axis=1, ddof=0) + current = arr[window - 1 :] + out[window - 1 :] = np.where(std == 0.0, np.nan, (current - mean) / std) return out diff --git a/python/ferro_ta/analysis/signals.py b/python/ferro_ta/analysis/signals.py index 32e8c4f..5920e46 100644 --- a/python/ferro_ta/analysis/signals.py +++ b/python/ferro_ta/analysis/signals.py @@ -33,6 +33,7 @@ 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 @@ -131,13 +132,7 @@ def compose( w = np.full(n_sigs, 1.0 / n_sigs) return _rust_compose_weighted(arr, w) elif method == "rank": - # Replace each column with its rank, then sum (ensure contiguous slices) - ranked = np.column_stack( - [_rust_rank_series(np.ascontiguousarray(arr[:, j])) for j in range(n_sigs)] - ) - ranked = np.ascontiguousarray(ranked) - w = np.full(n_sigs, 1.0) - return _rust_compose_weighted(ranked, w) + return _rust_compose_rank(arr) else: # weighted (default) if weights is None: diff --git a/python/ferro_ta/data/batch.py b/python/ferro_ta/data/batch.py index 1c709c3..f31e92b 100644 --- a/python/ferro_ta/data/batch.py +++ b/python/ferro_ta/data/batch.py @@ -52,6 +52,13 @@ from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import ( batch_stoch as _rust_batch_stoch, ) +from ferro_ta._ferro_ta import ( + run_close_indicators as _rust_run_close_indicators, +) +from ferro_ta._ferro_ta import ( + run_hlc_indicators as _rust_run_hlc_indicators, +) +from ferro_ta.core.registry import run as _registry_run from ferro_ta.indicators.momentum import RSI from ferro_ta.indicators.overlap import EMA, SMA @@ -60,8 +67,154 @@ __all__ = [ "batch_ema", "batch_rsi", "batch_apply", + "compute_many", ] +_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = { + "SMA": 30, + "EMA": 30, + "RSI": 14, + "STDDEV": 5, + "VAR": 5, + "LINEARREG": 14, + "LINEARREG_SLOPE": 14, + "LINEARREG_INTERCEPT": 14, + "LINEARREG_ANGLE": 14, + "TSF": 14, +} + +_HLC_FASTPATH_DEFAULTS: dict[str, int] = { + "ATR": 14, + "NATR": 14, + "ADX": 14, + "ADXR": 14, + "CCI": 14, + "WILLR": 14, +} + + +def _normalize_indicator_spec( + spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object], +) -> tuple[str, dict[str, object], object | None]: + if isinstance(spec, str): + return spec, {}, None + if len(spec) == 2: + name, kwargs = spec + return name, kwargs, None + name, kwargs, out_key = spec + return name, kwargs, out_key + + +def _extract_timeperiod( + name: str, kwargs: dict[str, object], defaults: dict[str, int] +) -> int | None: + if name not in defaults: + return None + extra_keys = set(kwargs) - {"timeperiod"} + if extra_keys: + return None + raw_value = kwargs.get("timeperiod", defaults[name]) + if not isinstance(raw_value, int): + return None + return raw_value + + +def compute_many( + indicators: list[str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]], + *, + close: ArrayLike, + high: ArrayLike | None = None, + low: ArrayLike | None = None, + volume: ArrayLike | None = None, + parallel: bool = True, +) -> list[object]: + """Compute multiple indicators over the same arrays with grouped Rust calls. + + Supported single-output indicators are grouped into one Rust boundary crossing + per input-shape family (`close` only or `high/low/close`). Unsupported specs + fall back to the regular registry path, preserving behavior. + """ + + close_arr = np.ascontiguousarray(close, dtype=np.float64) + high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64) + low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64) + volume_arr = None if volume is None else np.ascontiguousarray(volume, dtype=np.float64) + + normalized = [_normalize_indicator_spec(spec) for spec in indicators] + results: list[object | None] = [None] * len(normalized) + + close_indices: list[int] = [] + close_names: list[str] = [] + close_periods: list[int] = [] + + hlc_indices: list[int] = [] + hlc_names: list[str] = [] + hlc_periods: list[int] = [] + + for idx, (name, kwargs, out_key) in enumerate(normalized): + if out_key is None: + close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS) + if close_period is not None: + close_indices.append(idx) + close_names.append(name) + close_periods.append(close_period) + continue + + hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS) + if ( + hlc_period is not None + and high_arr is not None + and low_arr is not None + ): + hlc_indices.append(idx) + hlc_names.append(name) + hlc_periods.append(hlc_period) + continue + + if close_names: + grouped = _rust_run_close_indicators( + close_arr, close_names, close_periods, parallel + ) + for idx, value in zip(close_indices, grouped): + results[idx] = np.asarray(value, dtype=np.float64) + + if hlc_names and high_arr is not None and low_arr is not None: + grouped = _rust_run_hlc_indicators( + high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel + ) + for idx, value in zip(hlc_indices, grouped): + results[idx] = np.asarray(value, dtype=np.float64) + + for idx, (name, kwargs, _) in enumerate(normalized): + if results[idx] is not None: + continue + try: + results[idx] = _registry_run(name, close_arr, **kwargs) + continue + except (TypeError, Exception): + pass + + if high_arr is not None and low_arr is not None: + try: + results[idx] = _registry_run(name, high_arr, low_arr, close_arr, **kwargs) + continue + except Exception: + pass + if volume_arr is not None: + try: + results[idx] = _registry_run( + name, high_arr, low_arr, close_arr, volume_arr, **kwargs + ) + continue + except Exception: + pass + + raise ValueError( + f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters." + ) + + return [result for result in results] + def batch_apply( data: ArrayLike, diff --git a/src/batch/mod.rs b/src/batch/mod.rs index dbec78b..db813a1 100644 --- a/src/batch/mod.rs +++ b/src/batch/mod.rs @@ -9,11 +9,273 @@ //! the sequential path (`parallel = false`) may be faster due to thread-pool //! overhead. -use ndarray::Array2; -use numpy::{IntoPyArray, PyArray2, PyReadonlyArray2}; +use ndarray::{Array2, ArrayView2}; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use rayon::prelude::*; +use ta::indicators::{Maximum, Minimum}; +use ta::Next; + +fn transpose_to_series_major(data: ArrayView2<'_, f64>) -> Array2 { + let (n_samples, n_series) = data.dim(); + Array2::from_shape_vec((n_series, n_samples), data.t().iter().copied().collect()) + .expect("shape matches transposed data") +} + +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 finish_single_output<'py>( + py: Python<'py>, + n_samples: usize, + n_series: usize, + col_results: Vec>, +) -> Bound<'py, PyArray2> { + let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); + for (series_idx, values) in col_results.into_iter().enumerate() { + debug_assert_eq!(values.len(), n_samples); + for (sample_idx, value) in values.into_iter().enumerate() { + result[[sample_idx, series_idx]] = value; + } + } + result.into_pyarray(py) +} + +fn finish_pair_output<'py>( + py: Python<'py>, + n_samples: usize, + n_series: usize, + col_results: Vec<(Vec, Vec)>, +) -> (Bound<'py, PyArray2>, Bound<'py, PyArray2>) { + let mut result_k = Array2::::from_elem((n_samples, n_series), f64::NAN); + let mut result_d = Array2::::from_elem((n_samples, n_series), f64::NAN); + + for (series_idx, (k_values, d_values)) in col_results.into_iter().enumerate() { + debug_assert_eq!(k_values.len(), n_samples); + debug_assert_eq!(d_values.len(), n_samples); + for (sample_idx, value) in k_values.into_iter().enumerate() { + result_k[[sample_idx, series_idx]] = value; + } + for (sample_idx, value) in d_values.into_iter().enumerate() { + result_d[[sample_idx, series_idx]] = value; + } + } + + (result_k.into_pyarray(py), result_d.into_pyarray(py)) +} + +fn run_unary_batch<'py, F>( + py: Python<'py>, + data: PyReadonlyArray2<'py, f64>, + parallel: bool, + process_col: F, +) -> Bound<'py, PyArray2> +where + F: Fn(&[f64]) -> Vec + Sync, +{ + let arr = data.as_array(); + let (n_samples, n_series) = arr.dim(); + let series_major = transpose_to_series_major(arr); + + let col_results: Vec> = py.allow_threads(|| { + let run = |series_idx: usize| { + let column_row = series_major.row(series_idx); + let column = column_row + .as_slice() + .expect("series-major rows are contiguous"); + process_col(column) + }; + if parallel { + (0..n_series).into_par_iter().map(run).collect() + } else { + (0..n_series).map(run).collect() + } + }); + + finish_single_output(py, n_samples, n_series, col_results) +} + +fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> PyResult<()> { + if names.len() != timeperiods.len() { + return Err(PyValueError::new_err(format!( + "names length ({}) must equal timeperiods length ({})", + names.len(), + timeperiods.len() + ))); + } + for (name, &timeperiod) in names.iter().zip(timeperiods.iter()) { + if timeperiod == 0 { + return Err(PyValueError::new_err(format!( + "{name}: timeperiod must be >= 1" + ))); + } + } + Ok(()) +} + +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]; + 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, +) -> PyResult> { + let n = high.len(); + let mut result = vec![f64::NAN; n]; + let mut max_ind = + Maximum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?; + let mut min_ind = + Minimum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?; + + for (idx, ((&high_value, &low_value), &close_value)) in + high.iter().zip(low.iter()).zip(close.iter()).enumerate() + { + let highest = max_ind.next(high_value); + let lowest = min_ind.next(low_value); + if idx + 1 >= timeperiod { + let range = highest - lowest; + result[idx] = if range != 0.0 { + -100.0 * (highest - close_value) / range + } else { + -50.0 + }; + } + } + + Ok(result) +} + +fn compute_close_indicator(name: &str, close: &[f64], timeperiod: usize) -> PyResult> { + match name { + "SMA" => Ok(ferro_ta_core::overlap::sma(close, timeperiod)), + "EMA" => Ok(ferro_ta_core::overlap::ema(close, timeperiod)), + "RSI" => Ok(ferro_ta_core::momentum::rsi(close, timeperiod)), + "STDDEV" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)), + "VAR" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0) + .into_iter() + .map(|value| if value.is_nan() { value } else { value * value }) + .collect()), + "LINEARREG" => { + use crate::statistic::common::rolling_linreg_apply; + let last_x = (timeperiod - 1) as f64; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope: f64, intercept: f64| intercept + slope * last_x, + )) + } + "LINEARREG_SLOPE" => { + use crate::statistic::common::rolling_linreg_apply; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope: f64, _: f64| slope, + )) + } + "LINEARREG_INTERCEPT" => { + use crate::statistic::common::rolling_linreg_apply; + Ok(rolling_linreg_apply( + close, + timeperiod, + |_: f64, intercept: f64| intercept, + )) + } + "LINEARREG_ANGLE" => { + use crate::statistic::common::rolling_linreg_apply; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope: f64, _: f64| slope.atan() * 180.0 / std::f64::consts::PI, + )) + } + "TSF" => { + use crate::statistic::common::rolling_linreg_apply; + let forecast_x = timeperiod as f64; + Ok(rolling_linreg_apply( + close, + timeperiod, + |slope: f64, intercept: f64| intercept + slope * forecast_x, + )) + } + _ => Err(PyValueError::new_err(format!( + "unsupported close indicator for grouped execution: {name}" + ))), + } +} + +fn compute_hlc_indicator( + name: &str, + high: &[f64], + low: &[f64], + close: &[f64], + timeperiod: usize, +) -> PyResult> { + match name { + "ATR" => Ok(ferro_ta_core::volatility::atr(high, low, close, timeperiod)), + "NATR" => { + let atr = ferro_ta_core::volatility::atr(high, low, close, timeperiod); + Ok(atr + .into_iter() + .zip(close.iter()) + .map(|(atr_value, &close_value)| { + if atr_value.is_nan() || close_value == 0.0 { + f64::NAN + } else { + (atr_value / close_value) * 100.0 + } + }) + .collect()) + } + "ADX" => Ok(ferro_ta_core::momentum::adx(high, low, close, timeperiod)), + "ADXR" => Ok(ferro_ta_core::momentum::adxr(high, low, close, timeperiod)), + "CCI" => Ok(compute_cci(high, low, close, timeperiod)), + "WILLR" => compute_willr(high, low, close, timeperiod), + _ => Err(PyValueError::new_err(format!( + "unsupported HLC indicator for grouped execution: {name}" + ))), + } +} + +type IndicatorArrayList = Vec>>; // --------------------------------------------------------------------------- // batch_sma @@ -43,34 +305,13 @@ pub fn batch_sma<'py>( if timeperiod == 0 { return Err(PyValueError::new_err("timeperiod must be >= 1")); } - let arr = data.as_array(); - let (n_samples, n_series) = arr.dim(); + let (n_samples, n_series) = data.as_array().dim(); log::debug!( "batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - - // Extract columns to owned Vecs so we can release the GIL for parallel work. - let columns: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect()) - .collect(); - - let process_col = |col: &Vec| -> Vec { ferro_ta_core::overlap::sma(col, timeperiod) }; - - let col_results: Vec> = py.allow_threads(|| { - if parallel { - columns.par_iter().map(process_col).collect() - } else { - columns.iter().map(process_col).collect() - } - }); - - let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, col_result) in col_results.iter().enumerate() { - for (i, &val) in col_result.iter().enumerate() { - result[[i, j]] = val; - } - } - Ok(result.into_pyarray(py)) + Ok(run_unary_batch(py, data, parallel, |col| { + ferro_ta_core::overlap::sma(col, timeperiod) + })) } // --------------------------------------------------------------------------- @@ -100,33 +341,13 @@ pub fn batch_ema<'py>( if timeperiod == 0 { return Err(PyValueError::new_err("timeperiod must be >= 1")); } - let arr = data.as_array(); - let (n_samples, n_series) = arr.dim(); + let (n_samples, n_series) = data.as_array().dim(); log::debug!( "batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - - let columns: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect()) - .collect(); - - let process_col = |col: &Vec| -> Vec { ferro_ta_core::overlap::ema(col, timeperiod) }; - - let col_results: Vec> = py.allow_threads(|| { - if parallel { - columns.par_iter().map(process_col).collect() - } else { - columns.iter().map(process_col).collect() - } - }); - - let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, col_result) in col_results.iter().enumerate() { - for (i, &val) in col_result.iter().enumerate() { - result[[i, j]] = val; - } - } - Ok(result.into_pyarray(py)) + Ok(run_unary_batch(py, data, parallel, |col| { + ferro_ta_core::overlap::ema(col, timeperiod) + })) } // --------------------------------------------------------------------------- @@ -157,18 +378,13 @@ pub fn batch_rsi<'py>( if timeperiod == 0 { return Err(PyValueError::new_err("timeperiod must be >= 1")); } - let arr = data.as_array(); - let (n_samples, n_series) = arr.dim(); + let (n_samples, n_series) = data.as_array().dim(); log::debug!( "batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" ); - let columns: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect()) - .collect(); - let period_f = timeperiod as f64; - let process_col = |col: &Vec| -> Vec { + Ok(run_unary_batch(py, data, parallel, |col| { let mut col_result = vec![f64::NAN; n_samples]; if n_samples <= timeperiod { return col_result; @@ -208,23 +424,7 @@ pub fn batch_rsi<'py>( col_result[i] = 100.0 - 100.0 / (1.0 + rs); } col_result - }; - - let col_results: Vec> = py.allow_threads(|| { - if parallel { - columns.par_iter().map(process_col).collect() - } else { - columns.iter().map(process_col).collect() - } - }); - - let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, col_result) in col_results.iter().enumerate() { - for (i, &val) in col_result.iter().enumerate() { - result[[i, j]] = val; - } - } - Ok(result.into_pyarray(py)) + })) } // --------------------------------------------------------------------------- @@ -248,20 +448,28 @@ pub fn batch_atr<'py>( 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 cols_h: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) - .collect(); - let cols_l: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect()) - .collect(); - let cols_c: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect()) - .collect(); + let high_by_series = transpose_to_series_major(arr_h); + let low_by_series = transpose_to_series_major(arr_l); + let close_by_series = transpose_to_series_major(arr_c); let col_results: Vec> = py.allow_threads(|| { - let process_col = |j: usize| -> Vec { - ferro_ta_core::volatility::atr(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod) + let process_col = |series_idx: usize| -> Vec { + let high_row = high_by_series.row(series_idx); + let low_row = low_by_series.row(series_idx); + let close_row = close_by_series.row(series_idx); + let high_col = high_row + .as_slice() + .expect("series-major rows are contiguous"); + let low_col = low_row + .as_slice() + .expect("series-major rows are contiguous"); + let close_col = close_row + .as_slice() + .expect("series-major rows are contiguous"); + ferro_ta_core::volatility::atr(high_col, low_col, close_col, timeperiod) }; if parallel { (0..n_series).into_par_iter().map(process_col).collect() @@ -269,14 +477,7 @@ pub fn batch_atr<'py>( (0..n_series).map(process_col).collect() } }); - - let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, col_result) in col_results.iter().enumerate() { - for (i, &val) in col_result.iter().enumerate() { - result[[i, j]] = val; - } - } - Ok(result.into_pyarray(py)) + Ok(finish_single_output(py, n_samples, n_series, col_results)) } // --------------------------------------------------------------------------- @@ -303,23 +504,31 @@ pub fn batch_stoch<'py>( 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 cols_h: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) - .collect(); - let cols_l: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect()) - .collect(); - let cols_c: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect()) - .collect(); + let high_by_series = transpose_to_series_major(arr_h); + let low_by_series = transpose_to_series_major(arr_l); + let close_by_series = transpose_to_series_major(arr_c); let col_results: Vec<(Vec, Vec)> = py.allow_threads(|| { - let process_col = |j: usize| -> (Vec, Vec) { + let process_col = |series_idx: usize| -> (Vec, Vec) { + let high_row = high_by_series.row(series_idx); + let low_row = low_by_series.row(series_idx); + let close_row = close_by_series.row(series_idx); + let high_col = high_row + .as_slice() + .expect("series-major rows are contiguous"); + let low_col = low_row + .as_slice() + .expect("series-major rows are contiguous"); + let close_col = close_row + .as_slice() + .expect("series-major rows are contiguous"); ferro_ta_core::momentum::stoch( - &cols_h[j], - &cols_l[j], - &cols_c[j], + high_col, + low_col, + close_col, fastk_period, slowk_period, slowd_period, @@ -331,16 +540,7 @@ pub fn batch_stoch<'py>( (0..n_series).map(process_col).collect() } }); - - let mut result_k = Array2::::from_elem((n_samples, n_series), f64::NAN); - let mut result_d = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, (k_col, d_col)) in col_results.iter().enumerate() { - for i in 0..n_samples { - result_k[[i, j]] = k_col[i]; - result_d[[i, j]] = d_col[i]; - } - } - Ok((result_k.into_pyarray(py), result_d.into_pyarray(py))) + Ok(finish_pair_output(py, n_samples, n_series, col_results)) } // --------------------------------------------------------------------------- @@ -364,20 +564,28 @@ pub fn batch_adx<'py>( 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 cols_h: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) - .collect(); - let cols_l: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect()) - .collect(); - let cols_c: Vec> = (0..n_series) - .map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect()) - .collect(); + let high_by_series = transpose_to_series_major(arr_h); + let low_by_series = transpose_to_series_major(arr_l); + let close_by_series = transpose_to_series_major(arr_c); let col_results: Vec> = py.allow_threads(|| { - let process_col = |j: usize| -> Vec { - ferro_ta_core::momentum::adx(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod) + let process_col = |series_idx: usize| -> Vec { + let high_row = high_by_series.row(series_idx); + let low_row = low_by_series.row(series_idx); + let close_row = close_by_series.row(series_idx); + let high_col = high_row + .as_slice() + .expect("series-major rows are contiguous"); + let low_col = low_row + .as_slice() + .expect("series-major rows are contiguous"); + let close_col = close_row + .as_slice() + .expect("series-major rows are contiguous"); + ferro_ta_core::momentum::adx(high_col, low_col, close_col, timeperiod) }; if parallel { (0..n_series).into_par_iter().map(process_col).collect() @@ -385,14 +593,83 @@ pub fn batch_adx<'py>( (0..n_series).map(process_col).collect() } }); + Ok(finish_single_output(py, n_samples, n_series, col_results)) +} - let mut result = Array2::::from_elem((n_samples, n_series), f64::NAN); - for (j, col_result) in col_results.iter().enumerate() { - for (i, &val) in col_result.iter().enumerate() { - result[[i, j]] = val; +// --------------------------------------------------------------------------- +// grouped 1-D execution +// --------------------------------------------------------------------------- + +#[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 { + validate_indicator_requests(&names, &timeperiods)?; + let close_values = close.as_slice()?; + + let results: Vec>> = py.allow_threads(|| { + let run = |idx: usize| compute_close_indicator(&names[idx], close_values, timeperiods[idx]); + if parallel { + (0..names.len()).into_par_iter().map(run).collect() + } else { + (0..names.len()).map(run).collect() } + }); + + results + .into_iter() + .map(|result| result.map(|values| values.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 { + validate_indicator_requests(&names, &timeperiods)?; + 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", + )); } - Ok(result.into_pyarray(py)) + + let results: Vec>> = py.allow_threads(|| { + let run = |idx: usize| { + compute_hlc_indicator( + &names[idx], + high_values, + low_values, + close_values, + timeperiods[idx], + ) + }; + if parallel { + (0..names.len()).into_par_iter().map(run).collect() + } else { + (0..names.len()).map(run).collect() + } + }); + + results + .into_iter() + .map(|result| result.map(|values| values.into_pyarray(py).unbind())) + .collect() } // --------------------------------------------------------------------------- @@ -406,5 +683,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { 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/src/signals/mod.rs b/src/signals/mod.rs index a706cf8..da55cf0 100644 --- a/src/signals/mod.rs +++ b/src/signals/mod.rs @@ -4,10 +4,36 @@ //! - `top_n_indices` — indices of the N largest values in a 1-D array //! - `bottom_n_indices` — indices of the N smallest values in a 1-D array -use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1, PyReadonlyArray2}; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; +fn rank_values(xv: &[f64]) -> Vec { + let n = xv.len(); + let mut order: Vec = (0..n).collect(); + order.sort_by(|&a, &b| { + xv[a] + .partial_cmp(&xv[b]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut ranks = vec![0.0_f64; n]; + let mut i = 0; + while i < n { + let val = xv[order[i]]; + let mut j = i + 1; + while j < n && xv[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 +} + // --------------------------------------------------------------------------- // rank_series // --------------------------------------------------------------------------- @@ -33,30 +59,43 @@ pub fn rank_series<'py>( if n == 0 { return Err(PyValueError::new_err("x must be non-empty")); } - // Sort indices by value - let mut order: Vec = (0..n).collect(); - order.sort_by(|&a, &b| { - xv[a] - .partial_cmp(&xv[b]) - .unwrap_or(std::cmp::Ordering::Equal) + Ok(rank_values(xv).into_pyarray(py)) +} + +// --------------------------------------------------------------------------- +// compose_rank +// --------------------------------------------------------------------------- + +/// Compute rank-based composite scores for a 2-D signal matrix. +/// +/// Each column is ranked independently (ascending, fractional ranks for ties), +/// and the per-row ranks are summed across columns. +#[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 mut scores = vec![0.0_f64; n_bars]; + for sig_idx in 0..n_sigs { + let column: Vec = arr.column(sig_idx).iter().copied().collect(); + let ranks = rank_values(&column); + for (bar_idx, rank) in ranks.into_iter().enumerate() { + scores[bar_idx] += rank; + } + } + scores }); - let mut ranks = vec![0.0_f64; n]; - let mut i = 0; - while i < n { - let val = xv[order[i]]; - let mut j = i + 1; - while j < n && xv[order[j]] == val { - j += 1; - } - // Positions [i..j) all have the same value; average rank = (i+1 + j)/2 - let avg_rank = (i + 1 + j) as f64 / 2.0; - for k in i..j { - ranks[order[k]] = avg_rank; - } - i = j; - } - Ok(ranks.into_pyarray(py)) + Ok(scores.into_pyarray(py)) } // --------------------------------------------------------------------------- @@ -122,6 +161,7 @@ pub fn bottom_n_indices<'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/src/statistic/beta.rs b/src/statistic/beta.rs index f97484b..39d3097 100644 --- a/src/statistic/beta.rs +++ b/src/statistic/beta.rs @@ -2,6 +2,45 @@ 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 in timeperiod..n { + 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; + result[end] = 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. /// @@ -24,39 +63,84 @@ pub fn beta<'py>( let y = real1.as_slice()?; let n = x.len(); validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; - let mut result = vec![f64::NAN; n]; - // Need at least timeperiod+1 bars to compute timeperiod return pairs - #[allow(clippy::needless_range_loop)] - for i in timeperiod..n { - // returns from bar (i - timeperiod) to bar i => timeperiod pairs - let start = i - timeperiod; - let mut rx = vec![0.0_f64; timeperiod]; - let mut ry = vec![0.0_f64; timeperiod]; - for k in 0..timeperiod { - let prev = start + k; - let curr = start + k + 1; - rx[k] = if x[prev] != 0.0 { - x[curr] / x[prev] - 1.0 - } else { - f64::NAN - }; - ry[k] = if y[prev] != 0.0 { - y[curr] / y[prev] - 1.0 - } else { - f64::NAN - }; - } - let mean_x: f64 = rx.iter().sum::() / timeperiod as f64; - let mean_y: f64 = ry.iter().sum::() / timeperiod as f64; - let cov: f64 = rx - .iter() - .zip(ry.iter()) - .map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y)) - .sum::() - / timeperiod as f64; - let var_x: f64 = - rx.iter().map(|&xi| (xi - mean_x).powi(2)).sum::() / timeperiod as f64; - result[i] = if var_x != 0.0 { cov / var_x } else { f64::NAN }; + + 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 in timeperiod..n { + result[end] = 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/src/statistic/common.rs b/src/statistic/common.rs index 722ec7a..d833554 100644 --- a/src/statistic/common.rs +++ b/src/statistic/common.rs @@ -14,3 +14,57 @@ pub(super) fn linreg(window: &[f64]) -> (f64, f64) { 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/src/statistic/correl.rs b/src/statistic/correl.rs index f10ab4a..6131f2f 100644 --- a/src/statistic/correl.rs +++ b/src/statistic/correl.rs @@ -2,6 +2,35 @@ 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 in (timeperiod - 1)..n { + 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; + result[end] = 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))] @@ -16,21 +45,57 @@ pub fn correl<'py>( 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]; - for i in (timeperiod - 1)..n { - let wx = &x[(i + 1 - timeperiod)..=i]; - let wy = &y[(i + 1 - timeperiod)..=i]; - let mean_x: f64 = wx.iter().sum::() / timeperiod as f64; - let mean_y: f64 = wy.iter().sum::() / timeperiod as f64; - let cov: f64 = wx - .iter() - .zip(wy.iter()) - .map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y)) - .sum::(); - let std_x: f64 = (wx.iter().map(|&xi| (xi - mean_x).powi(2)).sum::()).sqrt(); - let std_y: f64 = (wy.iter().map(|&yi| (yi - mean_y).powi(2)).sum::()).sqrt(); - let denom = std_x * std_y; - result[i] = if denom != 0.0 { cov / denom } else { f64::NAN }; + 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 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 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/src/statistic/linearreg.rs b/src/statistic/linearreg.rs index ca0fae3..e22e7ec 100644 --- a/src/statistic/linearreg.rs +++ b/src/statistic/linearreg.rs @@ -1,4 +1,4 @@ -use super::common::linreg; +use super::common::rolling_linreg_apply; use crate::validation; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; @@ -14,13 +14,10 @@ pub fn linearreg<'py>( ) -> 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 (slope, intercept) = linreg(window); - result[i] = intercept + slope * (timeperiod - 1) as f64; - } + 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)) } @@ -34,13 +31,7 @@ pub fn linearreg_slope<'py>( ) -> 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 (slope, _) = linreg(window); - result[i] = slope; - } + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope); Ok(result.into_pyarray(py)) } @@ -54,13 +45,7 @@ pub fn linearreg_intercept<'py>( ) -> 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 (_, intercept) = linreg(window); - result[i] = intercept; - } + let result = rolling_linreg_apply(prices, timeperiod, |_, intercept| intercept); Ok(result.into_pyarray(py)) } @@ -74,13 +59,7 @@ pub fn linearreg_angle<'py>( ) -> 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 (slope, _) = linreg(window); - result[i] = slope.atan() * 180.0 / PI; - } + let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope.atan() * 180.0 / PI); Ok(result.into_pyarray(py)) } @@ -94,13 +73,9 @@ pub fn tsf<'py>( ) -> 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 (slope, intercept) = linreg(window); - // Forecast one period ahead of the last point in the window - result[i] = intercept + slope * timeperiod as f64; - } + 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/src/statistic/mod.rs b/src/statistic/mod.rs index d4a0691..7fb2fb4 100644 --- a/src/statistic/mod.rs +++ b/src/statistic/mod.rs @@ -2,7 +2,7 @@ //! Each function (or closely related group) lives in its own file. mod beta; -mod common; +pub(crate) mod common; mod correl; mod linearreg; mod stddev; diff --git a/tests/unit/indicators/test_statistic.py b/tests/unit/indicators/test_statistic.py index 0d537b3..96e2953 100644 --- a/tests/unit/indicators/test_statistic.py +++ b/tests/unit/indicators/test_statistic.py @@ -27,6 +27,68 @@ 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 # --------------------------------------------------------------------------- @@ -100,6 +162,11 @@ class TestLINEARREG: 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 @@ -179,6 +246,11 @@ class TestBETA: 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 @@ -205,6 +277,11 @@ class TestCOREL: 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 @@ -226,3 +303,8 @@ class TestTSF: 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) diff --git a/tests/unit/test_data_pipeline.py b/tests/unit/test_data_pipeline.py index ca9cfc0..ec2876f 100644 --- a/tests/unit/test_data_pipeline.py +++ b/tests/unit/test_data_pipeline.py @@ -323,6 +323,21 @@ class TestSignalComposition: 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 @@ -574,6 +589,75 @@ class TestFeatureMatrix: 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) diff --git a/tests/unit/test_infrastructure.py b/tests/unit/test_infrastructure.py index f45c6f9..a68ed65 100644 --- a/tests/unit/test_infrastructure.py +++ b/tests/unit/test_infrastructure.py @@ -297,6 +297,40 @@ class TestBacktest: result_no_slip.n_trades == 0 ) + def test_commission_matches_reference_loop(self): + 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 + + commission = 0.02 + result = backtest(close, strategy=strategy, commission_per_trade=commission) + + expected_positions = np.array( + [0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64 + ) + expected_returns = np.empty_like(close) + expected_returns[0] = 0.0 + expected_returns[1:] = np.diff(close) / close[:-1] + expected_strategy_returns = expected_positions * expected_returns + position_changed = np.concatenate( + [[False], expected_positions[1:] != expected_positions[:-1]] + ) + + expected_equity = np.empty_like(close) + expected_equity[0] = 1.0 + for i in range(1, len(close)): + expected_equity[i] = expected_equity[i - 1] * ( + 1.0 + expected_strategy_returns[i] + ) + if position_changed[i]: + expected_equity[i] -= commission + + np.testing.assert_allclose(result.positions, expected_positions) + np.testing.assert_allclose(result.strategy_returns, expected_strategy_returns) + np.testing.assert_allclose(result.equity, expected_equity) + # --------------------------------------------------------------------------- # Plugin / Registry @@ -509,7 +543,13 @@ class TestChoppinessIndex: # --------------------------------------------------------------------------- from ferro_ta import EMA, RSI, SMA -from ferro_ta.data.batch import batch_apply, batch_ema, batch_rsi, batch_sma +from ferro_ta.data.batch import ( + batch_apply, + batch_atr, + batch_ema, + batch_rsi, + batch_sma, +) class TestBatchSMA: @@ -587,6 +627,15 @@ class TestBatchApply: batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3) +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 # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index 9320caa..71931aa 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "ferro-ta" -version = "1.0.0" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "numpy" }, diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index b7472e8..dc524aa 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_wasm" -version = "1.0.0" +version = "1.0.2" edition = "2021" description = "WebAssembly bindings for ferro-ta technical analysis indicators" license = "MIT" diff --git a/wasm/bench.js b/wasm/bench.js new file mode 100644 index 0000000..1f52b48 --- /dev/null +++ b/wasm/bench.js @@ -0,0 +1,113 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { performance } = require("node:perf_hooks"); + +const wasm = require("./pkg/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); + 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; + } + return { close, high, low }; +} + +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 } = makeSeries(bars); + const cases = [ + ["SMA", () => wasm.sma(close, 20)], + ["EMA", () => wasm.ema(close, 20)], + ["RSI", () => wasm.rsi(close, 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/wasm/package.json b/wasm/package.json index 6c4f0ce..f6a78ca 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -1,12 +1,13 @@ { "name": "ferro-ta-wasm", - "version": "1.0.1", + "version": "1.0.2", "description": "WebAssembly bindings for ferro-ta technical analysis indicators", "main": "pkg/ferro_ta_wasm.js", "types": "pkg/ferro_ta_wasm.d.ts", "files": ["pkg"], "scripts": { "build": "wasm-pack build --target nodejs --out-dir pkg", + "bench": "node bench.js", "prepack": "npm run build && node -e \"require('fs').rmSync('pkg/.gitignore', { force: true })\"", "test": "wasm-pack test --node" }, From 602d675749c22d7a15ba41b86c60dca734af1892 Mon Sep 17 00:00:00 2001 From: Pratik Bhadane Date: Tue, 24 Mar 2026 02:41:50 +0530 Subject: [PATCH 2/3] feat: add full derivatives analytics layer (options + futures) Implements all phases of the derivatives expansion plan: Rust core (crates/ferro_ta_core/src/options/, src/futures/): - BSM and Black-76 pricing (scalar + vectorized batch) - Greeks: delta, gamma, vega, theta, rho - Implied volatility solver (Newton + bisection fallback) - Smile/skew metrics: ATM IV, 25-delta RR/BF, skew slope, convexity - Chain helpers: moneyness labels, strike selection by offset or delta - Synthetic forwards, basis, annualized basis, implied carry, carry spread - Continuous contract stitching: weighted, back-adjusted, ratio-adjusted - Curve analytics: calendar spreads, slope, contango/backwardation summary PyO3 bindings (src/options/, src/futures/): - All Rust functions registered and exposed via _ferro_ta extension Python API (python/ferro_ta/analysis/): - options.py: pricing, greeks, IV, smile, chain, legacy iv_rank/percentile/zscore - futures.py: basis, carry, curve, roll, synthetic, continuous contracts - options_strategy.py: typed strategy schemas (expiry/strike selectors, leg presets, risk controls, simulation limits) - derivatives_payoff.py: multi-leg payoff aggregation and Greeks aggregation Bug fix: wrap _to_f64 calls in iv_rank/iv_percentile/iv_zscore to raise FerroTAInputError (not plain ValueError) for 2D array input. Docs: derivatives.rst, derivatives-analytics.md, options-volatility.md, quickstart.rst, index.rst, api/analysis.rst all updated. Tests: 2053 pass, 12 skipped. All CI checks pass locally. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 29 +- benchmarks/test_derivatives_speed.py | 108 +++ crates/ferro_ta_core/benches/indicators.rs | 124 ++- crates/ferro_ta_core/src/futures/basis.rs | 55 ++ crates/ferro_ta_core/src/futures/curve.rs | 83 ++ crates/ferro_ta_core/src/futures/mod.rs | 6 + crates/ferro_ta_core/src/futures/roll.rs | 109 +++ crates/ferro_ta_core/src/futures/synthetic.rs | 78 ++ crates/ferro_ta_core/src/lib.rs | 2 + crates/ferro_ta_core/src/options/chain.rs | 162 ++++ crates/ferro_ta_core/src/options/greeks.rs | 230 ++++++ crates/ferro_ta_core/src/options/iv.rs | 241 ++++++ crates/ferro_ta_core/src/options/mod.rs | 88 ++ crates/ferro_ta_core/src/options/normal.rs | 44 + crates/ferro_ta_core/src/options/pricing.rs | 187 +++++ crates/ferro_ta_core/src/options/surface.rs | 240 ++++++ docs/api/analysis.rst | 22 + docs/api/index.rst | 1 + docs/derivatives-analytics.md | 70 ++ docs/derivatives.rst | 126 +++ docs/index.rst | 5 +- docs/options-volatility.md | 122 ++- docs/performance.md | 10 +- docs/quickstart.rst | 16 + python/ferro_ta/__init__.py | 4 +- python/ferro_ta/analysis/__init__.py | 5 +- .../ferro_ta/analysis/derivatives_payoff.py | 217 +++++ python/ferro_ta/analysis/futures.py | 230 ++++++ python/ferro_ta/analysis/options.py | 772 ++++++++++++++---- python/ferro_ta/analysis/options_strategy.py | 317 +++++++ python/ferro_ta/data/batch.py | 20 +- src/futures/basis.rs | 34 + src/futures/curve.rs | 52 ++ src/futures/mod.rs | 38 + src/futures/roll.rs | 75 ++ src/futures/synthetic.rs | 60 ++ src/lib.rs | 4 + src/options/chain.rs | 64 ++ src/options/greeks.rs | 125 +++ src/options/iv.rs | 149 ++++ src/options/mod.rs | 67 ++ src/options/pricing.rs | 128 +++ src/options/surface.rs | 49 ++ src/statistic/beta.rs | 8 +- src/statistic/correl.rs | 8 +- tests/unit/test_data_pipeline.py | 16 +- tests/unit/test_derivatives.py | 218 +++++ 47 files changed, 4538 insertions(+), 280 deletions(-) create mode 100644 benchmarks/test_derivatives_speed.py create mode 100644 crates/ferro_ta_core/src/futures/basis.rs create mode 100644 crates/ferro_ta_core/src/futures/curve.rs create mode 100644 crates/ferro_ta_core/src/futures/mod.rs create mode 100644 crates/ferro_ta_core/src/futures/roll.rs create mode 100644 crates/ferro_ta_core/src/futures/synthetic.rs create mode 100644 crates/ferro_ta_core/src/options/chain.rs create mode 100644 crates/ferro_ta_core/src/options/greeks.rs create mode 100644 crates/ferro_ta_core/src/options/iv.rs create mode 100644 crates/ferro_ta_core/src/options/mod.rs create mode 100644 crates/ferro_ta_core/src/options/normal.rs create mode 100644 crates/ferro_ta_core/src/options/pricing.rs create mode 100644 crates/ferro_ta_core/src/options/surface.rs create mode 100644 docs/api/analysis.rst create mode 100644 docs/derivatives-analytics.md create mode 100644 docs/derivatives.rst create mode 100644 python/ferro_ta/analysis/derivatives_payoff.py create mode 100644 python/ferro_ta/analysis/futures.py create mode 100644 python/ferro_ta/analysis/options_strategy.py create mode 100644 src/futures/basis.rs create mode 100644 src/futures/curve.rs create mode 100644 src/futures/mod.rs create mode 100644 src/futures/roll.rs create mode 100644 src/futures/synthetic.rs create mode 100644 src/options/chain.rs create mode 100644 src/options/greeks.rs create mode 100644 src/options/iv.rs create mode 100644 src/options/mod.rs create mode 100644 src/options/pricing.rs create mode 100644 src/options/surface.rs create mode 100644 tests/unit/test_derivatives.py diff --git a/README.md b/README.md index 21544bc..4fb1680 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ uv run python benchmarks/benchmark_table.py - **Feature matrix** — multi-indicator DataFrame for ML pipelines (`ferro_ta.features`) - **Charting API** — matplotlib and plotly charts with indicator subplots (`ferro_ta.viz`) - **Data adapters** — pluggable adapter interface with CSV and in-memory implementations (`ferro_ta.adapters`) -- **Options/IV helpers** — IV rank, IV percentile, IV z-score on any IV series (`ferro_ta.options`) +- **Derivatives analytics** — IV rank/percentile/z-score, options pricing/Greeks/IV, futures basis/curve/roll, strategy schemas, and multi-leg payoff helpers (`ferro_ta.analysis.*`) - **Agentic tools** — stable LangChain/agent tool wrappers (`ferro_ta.tools`), end-to-end workflow orchestrator (`ferro_ta.workflow`) - **MCP server** — Model Context Protocol server for Cursor/Claude integration; run with `python -m ferro_ta.mcp` - **Observability / Logging** — `ferro_ta.enable_debug()`, `ferro_ta.log_call()`, `ferro_ta.benchmark()` and `ferro_ta.traced()` decorator for instrumentation @@ -118,7 +118,7 @@ Optional extras: pip install "ferro-ta[pandas]" # transparent pandas.Series support pip install "ferro-ta[polars]" # transparent polars.Series support pip install "ferro-ta[gpu]" # GPU-accelerated SMA/EMA/RSI via PyTorch (CUDA/MPS) -pip install "ferro-ta[options]" # Options/IV helpers (IV rank, percentile, z-score) +pip install "ferro-ta[options]" # Derivatives analytics helpers pip install "ferro-ta[mcp]" # MCP server for Cursor/Claude agent integration pip install "ferro-ta[all]" # all optional extras (excluding gpu) ``` @@ -150,6 +150,28 @@ macd_line, signal, histogram = MACD(close, fastperiod=12, slowperiod=26, signalp upper, middle, lower = BBANDS(close, timeperiod=5, nbdevup=2.0, nbdevdn=2.0) ``` +## Δ Derivatives Analytics + +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price +from ferro_ta.analysis.futures import basis, curve_summary + +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") + +front_basis = basis(100.0, 103.0) +curve = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]) +``` + +The derivatives layer is analytics-only. It includes: + +- options pricing under Black-Scholes-Merton and Black-76 +- delta, gamma, vega, theta, and rho +- implied volatility inversion and smile metrics +- futures basis, carry, curve, and continuous-roll helpers +- typed strategy schemas and multi-leg payoff/Greeks aggregation + **Migrating from TA-Lib?** Just swap the import — the API is identical: ```python @@ -673,7 +695,8 @@ python/ferro_ta/ │ # 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 +│ # signals, features, crypto, options, futures, +│ # options_strategy, derivatives_payoff ├── tools/ # Visualisation, alerting, DSL, pipeline, workflow, │ # api_info, GPU support └── mcp/ # Model Context Protocol server diff --git a/benchmarks/test_derivatives_speed.py b/benchmarks/test_derivatives_speed.py new file mode 100644 index 0000000..8388dda --- /dev/null +++ b/benchmarks/test_derivatives_speed.py @@ -0,0 +1,108 @@ +""" +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 numpy as np +import pytest + +from ferro_ta.analysis.options import implied_volatility, option_price + + +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/crates/ferro_ta_core/benches/indicators.rs b/crates/ferro_ta_core/benches/indicators.rs index 0fd9ec1..9c74666 100644 --- a/crates/ferro_ta_core/benches/indicators.rs +++ b/crates/ferro_ta_core/benches/indicators.rs @@ -4,8 +4,9 @@ //! Or: cd crates/ferro_ta_core && cargo bench //! //! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators. -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use ferro_ta_core::{momentum, overlap, volatility}; +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); @@ -83,12 +84,129 @@ fn bench_bbands(c: &mut Criterion) { 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_bbands, + bench_bsm_price, + bench_implied_volatility, + bench_smile_metrics, + bench_curve_summary ); criterion_main!(benches); diff --git a/crates/ferro_ta_core/src/futures/basis.rs b/crates/ferro_ta_core/src/futures/basis.rs new file mode 100644 index 0000000..f7e4ade --- /dev/null +++ b/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/crates/ferro_ta_core/src/futures/curve.rs b/crates/ferro_ta_core/src/futures/curve.rs new file mode 100644 index 0000000..773d7e4 --- /dev/null +++ b/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/crates/ferro_ta_core/src/futures/mod.rs b/crates/ferro_ta_core/src/futures/mod.rs new file mode 100644 index 0000000..60fc1f6 --- /dev/null +++ b/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/crates/ferro_ta_core/src/futures/roll.rs b/crates/ferro_ta_core/src/futures/roll.rs new file mode 100644 index 0000000..6e08207 --- /dev/null +++ b/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/crates/ferro_ta_core/src/futures/synthetic.rs b/crates/ferro_ta_core/src/futures/synthetic.rs new file mode 100644 index 0000000..02a9acd --- /dev/null +++ b/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/crates/ferro_ta_core/src/lib.rs b/crates/ferro_ta_core/src/lib.rs index a04a659..9ee0cd5 100644 --- a/crates/ferro_ta_core/src/lib.rs +++ b/crates/ferro_ta_core/src/lib.rs @@ -26,8 +26,10 @@ assert!((sma[2] - 2.0).abs() < 1e-10); ``` */ +pub mod futures; pub mod math; pub mod momentum; +pub mod options; pub mod overlap; pub mod statistic; pub mod volatility; diff --git a/crates/ferro_ta_core/src/options/chain.rs b/crates/ferro_ta_core/src/options/chain.rs new file mode 100644 index 0000000..a716ae4 --- /dev/null +++ b/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/crates/ferro_ta_core/src/options/greeks.rs b/crates/ferro_ta_core/src/options/greeks.rs new file mode 100644 index 0000000..fbc24cf --- /dev/null +++ b/crates/ferro_ta_core/src/options/greeks.rs @@ -0,0 +1,230 @@ +//! Option Greeks. + +use super::normal::{cdf, pdf}; +use super::pricing::{black_76_price, black_scholes_price}; +use super::{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, + ), + }) +} + +#[cfg(test)] +mod tests { + use super::{black_76_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()); + } +} diff --git a/crates/ferro_ta_core/src/options/iv.rs b/crates/ferro_ta_core/src/options/iv.rs new file mode 100644 index 0000000..2302886 --- /dev/null +++ b/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/crates/ferro_ta_core/src/options/mod.rs b/crates/ferro_ta_core/src/options/mod.rs new file mode 100644 index 0000000..7046a01 --- /dev/null +++ b/crates/ferro_ta_core/src/options/mod.rs @@ -0,0 +1,88 @@ +//! 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 chain; +pub mod greeks; +pub mod iv; +pub mod normal; +pub mod pricing; +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, +} + +/// 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/crates/ferro_ta_core/src/options/normal.rs b/crates/ferro_ta_core/src/options/normal.rs new file mode 100644 index 0000000..bb66380 --- /dev/null +++ b/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/crates/ferro_ta_core/src/options/pricing.rs b/crates/ferro_ta_core/src/options/pricing.rs new file mode 100644 index 0000000..d853baf --- /dev/null +++ b/crates/ferro_ta_core/src/options/pricing.rs @@ -0,0 +1,187 @@ +//! 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, + ), + } +} + +/// 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/crates/ferro_ta_core/src/options/surface.rs b/crates/ferro_ta_core/src/options/surface.rs new file mode 100644 index 0000000..b3899af --- /dev/null +++ b/crates/ferro_ta_core/src/options/surface.rs @@ -0,0 +1,240 @@ +//! 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) +} + +#[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/docs/api/analysis.rst b/docs/api/analysis.rst new file mode 100644 index 0000000..a04dbe8 --- /dev/null +++ b/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/docs/api/index.rst b/docs/api/index.rst index 18a782f..00b61ad 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -17,3 +17,4 @@ API Reference extended streaming batch + analysis diff --git a/docs/derivatives-analytics.md b/docs/derivatives-analytics.md new file mode 100644 index 0000000..95de932 --- /dev/null +++ b/docs/derivatives-analytics.md @@ -0,0 +1,70 @@ +# Derivatives Analytics + +`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on +research, simulation, and risk analysis. + +## Modules + +- `ferro_ta.analysis.options` + - Black-Scholes-Merton and Black-76 pricing + - Delta, gamma, vega, theta, rho + - Implied volatility inversion with guarded Newton + bisection fallback + - IV rank / percentile / z-score + - Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity + - Chain helpers: moneyness labels and strike selection by offset or delta +- `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 for expiry selectors, strike selectors, multi-leg presets, + risk controls, cost assumptions, and simulation limits +- `ferro_ta.analysis.derivatives_payoff` + - Multi-leg payoff aggregation + - Portfolio-level Greeks aggregation across option and futures legs + +## Model conventions + +- `model="bsm"` expects the underlying input to be spot and `carry` to represent + a continuous dividend yield or generic carry term. +- `model="black76"` expects the underlying input to be the forward price. +- Volatility and rates use decimal units: + - `0.20` means 20% annualized volatility + - `0.05` means 5% annualized rate +- `time_to_expiry` is expressed in years. + +## Quick examples + +```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) +``` + +```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])) +``` + +```python +from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff + +legs = [ + PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0), + PayoffLeg("future", "long", entry_price=100.0), +] +grid = [90.0, 100.0, 110.0] +print(strategy_payoff(grid, legs=legs)) +``` + +## Notes + +- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved. +- The derivatives layer is analytics-only: there is no broker connectivity, + order routing, or execution workflow in this API. diff --git a/docs/derivatives.rst b/docs/derivatives.rst new file mode 100644 index 0000000..9ac93ef --- /dev/null +++ b/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/docs/index.rst b/docs/index.rst index e207834..b0c14e2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ ferro-ta Documentation streaming extended batch + derivatives benchmarks plugins changelog @@ -35,7 +36,7 @@ Features: - Math operators and transforms - Type stubs (.pyi) for IDE auto-completion - WASM binding for browser/Node.js use -- Options/IV helpers (IV rank, IV percentile, IV z-score) — see `Options/IV Helpers `_ +- Options/IV helpers and derivatives analytics — see :doc:`derivatives` - Agentic workflow and LangChain tool wrappers — see `Agentic guide `_ - MCP server for Cursor/Claude integration — see `MCP guide `_ - Sphinx documentation @@ -71,7 +72,7 @@ Further Reading - `API Stability `_ — stability tiers, versioning, and deprecation policy. - `Rust-First Policy `_ — all compute logic belongs in Rust; how to add new indicators. - `Out-of-Core Execution `_ — chunked processing and Dask integration. -- `Options/IV Helpers `_ — IV rank, IV percentile, IV z-score. +- :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers. - `Agentic Workflow `_ — tools.py, workflow.py, LangChain integration. - `MCP Server `_ — run ferro-ta as an MCP server in Cursor/Claude. diff --git a/docs/options-volatility.md b/docs/options-volatility.md index fecd06a..6cdf3b9 100644 --- a/docs/options-volatility.md +++ b/docs/options-volatility.md @@ -1,101 +1,79 @@ # Options and Implied Volatility -ferro-ta provides optional helpers for implied volatility (IV) analysis -via the `ferro_ta.options` module. This document describes the scope, -data format, dependency strategy, and limitations. - ---- +`ferro-ta` exposes options analytics from `ferro_ta.analysis.options`. ## Scope -The `ferro_ta.options` module focuses on **IV series analysis**: +The module now covers both classic IV-series helpers and model-based option +analytics: -- **IV rank** — where today's IV sits relative to the min/max over a look-back window. -- **IV percentile** — fraction of observations over a look-back window at or below today's IV. -- **IV z-score** — how many standard deviations today's IV is above the rolling mean. +- `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 -These functions accept any 1-D IV series (e.g. VIX daily closes, single-name -30-day IV, etc.) and return rolling statistics. +Heavy computation runs in Rust through the `_ferro_ta` extension. -**Out of scope (for now):** Black-Scholes pricing, Greeks, option chain -parsing, synthetic forward construction, dividend adjustment. For full -option-pricing functionality consider `py_vollib`, `mibian`, or similar. +## IV-series helpers ---- - -## Data format - -All functions accept a 1-D NumPy array (or any array-like) of IV values. -IV values are typically in **percentage points** (e.g. VIX = 20 means 20% -annualised volatility), but the helpers are unit-agnostic — they only -compare values within the rolling window. +The original rolling helpers remain available and keep their public names: ```python import numpy as np -from ferro_ta.options import iv_rank, iv_percentile, iv_zscore +from ferro_ta.analysis.options import iv_rank, iv_percentile, iv_zscore -# VIX-like daily close series 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) # rolling IV rank in [0, 1] -pct = iv_percentile(iv, window=5) # rolling IV percentile in [0, 1] -z = iv_zscore(iv, window=5) # rolling z-score +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. -## Dependency strategy +## Pricing and Greeks -The `ferro_ta.options` module uses **only NumPy** (already a core dependency). -No additional packages are required for the helpers described here. +```python +from ferro_ta.analysis.options import greeks, implied_volatility, option_price -For advanced option analytics (Black-Scholes, volatility surface -interpolation), install the optional extra: - -```bash -pip install "ferro-ta[options]" +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") ``` -This may install additional packages in the future (e.g. `py_vollib`). +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. -## API reference +## Smile and chain helpers -### `iv_rank(iv_series, window=252)` +```python +from ferro_ta.analysis.options import label_moneyness, select_strike, smile_metrics -Rolling IV rank. +strikes = [80, 90, 100, 110, 120] +vols = [0.30, 0.25, 0.20, 0.22, 0.27] -``` -rank_t = (IV_t - min(IV[t-window+1:t+1])) / (max(IV[t-window+1:t+1]) - min(IV[t-window+1:t+1])) +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, +) ``` -Returns values in [0, 1]. NaN for the first `window - 1` bars. +## Related futures analytics -### `iv_percentile(iv_series, window=252)` - -Rolling IV percentile: fraction of the *window* bars whose IV was at or -below the current value. - -### `iv_zscore(iv_series, window=252)` - -Rolling z-score: `(IV_t - rolling_mean) / rolling_std`. - ---- - -## Limitations - -- All functions use **O(n × window)** time complexity (pure Python loops). - For large windows or series consider vectorised alternatives. -- No option chain support; the module assumes IV series as input. -- Streaming (bar-by-bar) versions of these functions are not yet - implemented. For live use, maintain a rolling buffer and call the - functions on the buffer at each bar. - ---- - -## See also - -- `ferro_ta.options` — module source. -- `ferro_ta.statistic` — general statistical functions (STDDEV, VAR, CORREL, etc.). -- `ferro_ta.volatility` — price-based volatility indicators (ATR, NATR). +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/docs/performance.md b/docs/performance.md index 4fddcf9..684296b 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -315,10 +315,12 @@ bottlenecks are fixed or deferred. - 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. -**Options** (`python/ferro_ta/options.py`): -- `iv_rank`, `iv_percentile`, and `iv_zscore` are vectorized now, but - `iv_percentile`/`iv_zscore` still spend meaningful time in NumPy window - materialization on very long series. +**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. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 37b1338..04d9d00 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -101,3 +101,19 @@ Extended Indicators # 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/python/ferro_ta/__init__.py b/python/ferro_ta/__init__.py index 8d32d3f..7d1c3a6 100644 --- a/python/ferro_ta/__init__.py +++ b/python/ferro_ta/__init__.py @@ -11,7 +11,7 @@ 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) +* :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) @@ -35,6 +35,8 @@ Sub-modules (also accessible via sub-packages above) * :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 diff --git a/python/ferro_ta/analysis/__init__.py b/python/ferro_ta/analysis/__init__.py index aaa5c90..ed507ea 100644 --- a/python/ferro_ta/analysis/__init__.py +++ b/python/ferro_ta/analysis/__init__.py @@ -11,7 +11,10 @@ Sub-modules * :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 and Greeks +* :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 Example usage:: diff --git a/python/ferro_ta/analysis/derivatives_payoff.py b/python/ferro_ta/analysis/derivatives_payoff.py new file mode 100644 index 0000000..2bda935 --- /dev/null +++ b/python/ferro_ta/analysis/derivatives_payoff.py @@ -0,0 +1,217 @@ +""" +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.analysis.options import OptionGreeks +from ferro_ta.analysis.options import greeks as option_greeks +from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg +from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError + +__all__ = [ + "PayoffLeg", + "option_leg_payoff", + "futures_leg_payoff", + "strategy_payoff", + "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"}: + raise FerroTAValueError("instrument must be 'option' or 'future'.") + 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 == "future" and self.entry_price is None: + raise FerroTAValueError("future 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) + sign = _side_sign(side) * float(quantity) * float(multiplier) + if option_type == "call": + intrinsic = np.maximum(grid - float(strike), 0.0) + elif option_type == "put": + intrinsic = np.maximum(float(strike) - grid, 0.0) + else: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + return sign * (intrinsic - float(premium)) + + +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) + sign = _side_sign(side) * float(quantity) * float(multiplier) + return sign * (grid - float(entry_price)) + + +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, + ) + + +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) + total = np.zeros_like(grid) + for leg in normalized: + if leg.instrument == "option": + if leg.strike is None: + raise FerroTAValueError("Option payoff legs require strike.") + total += option_leg_payoff( + grid, + strike=float(leg.strike), + premium=float(leg.premium), + option_type=str(leg.option_type), + side=str(leg.side), + quantity=float(leg.quantity), + multiplier=float(leg.multiplier), + ) + else: + if leg.entry_price is None: + raise FerroTAValueError("Futures payoff legs require entry_price.") + total += futures_leg_payoff( + grid, + entry_price=float(leg.entry_price), + side=str(leg.side), + quantity=float(leg.quantity), + multiplier=float(leg.multiplier), + ) + return total + + +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) + totals = { + "delta": 0.0, + "gamma": 0.0, + "vega": 0.0, + "theta": 0.0, + "rho": 0.0, + } + for leg in normalized: + leg_sign = _side_sign(leg.side) * float(leg.quantity) * float(leg.multiplier) + if leg.instrument == "future": + totals["delta"] += leg_sign + continue + if leg.strike is None or leg.volatility is None or leg.time_to_expiry is None: + raise FerroTAValueError( + "Option legs require strike, volatility, and time_to_expiry for Greeks aggregation." + ) + leg_greeks = option_greeks( + float(spot), + float(leg.strike), + float(leg.rate), + float(leg.time_to_expiry), + float(leg.volatility), + option_type=str(leg.option_type), + model="bsm", + carry=float(leg.carry), + ) + totals["delta"] += leg_sign * float(leg_greeks.delta) + totals["gamma"] += leg_sign * float(leg_greeks.gamma) + totals["vega"] += leg_sign * float(leg_greeks.vega) + totals["theta"] += leg_sign * float(leg_greeks.theta) + totals["rho"] += leg_sign * float(leg_greeks.rho) + + return OptionGreeks( + totals["delta"], + totals["gamma"], + totals["vega"], + totals["theta"], + totals["rho"], + ) diff --git a/python/ferro_ta/analysis/futures.py b/python/ferro_ta/analysis/futures.py new file mode 100644 index 0000000..6b38643 --- /dev/null +++ b/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/python/ferro_ta/analysis/options.py b/python/ferro_ta/analysis/options.py index 44327e9..10ee640 100644 --- a/python/ferro_ta/analysis/options.py +++ b/python/ferro_ta/analysis/options.py @@ -1,206 +1,632 @@ """ -ferro_ta.options — Options and Implied Volatility Helpers -========================================================= +ferro_ta.analysis.options — Rust-backed derivatives analytics for options. -Optional module that provides helpers for options/IV analysis when supplied -with an implied-volatility series (IV series as input). All heavy compute -delegates to Rust via ``ferro_ta`` core; this module is a thin orchestration -layer. - -.. note:: - Options support is **optional** and does not require any additional - third-party libraries beyond ``numpy``. For advanced option-pricing - functionality (e.g. Black-Scholes, Greeks) install the optional - ``ferro_ta[options]`` extra which may pull in additional dependencies. - -See ``docs/options-volatility.md`` for the full design doc. - -Quick start ------------ ->>> import numpy as np ->>> from ferro_ta.analysis.options import iv_rank, iv_percentile ->>> ->>> # Synthetic IV series (e.g. VIX or single-name IV) ->>> rng = np.random.default_rng(42) ->>> iv = rng.uniform(10, 40, 252) ->>> ->>> rank = iv_rank(iv, window=252) ->>> pct = iv_percentile(iv, window=252) - -API ---- -iv_rank(iv_series, window) - Rolling IV rank: where is today's IV relative to min/max over *window* bars? - Returns values in [0, 1] (NaN during warm-up). - -iv_percentile(iv_series, window) - Rolling IV percentile: fraction of observations over *window* bars that are - ≤ today's IV. Returns values in [0, 1] (NaN during warm-up). - -iv_zscore(iv_series, window) - Rolling IV z-score: (IV - rolling_mean) / rolling_std over *window* bars. - Returns z-score values (NaN during warm-up). +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.lib.stride_tricks import sliding_window_view from numpy.typing import ArrayLike, NDArray -from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError +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 ( + 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 ( + 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", + "SmileMetrics", + "black_scholes_price", + "black_76_price", + "option_price", + "greeks", + "implied_volatility", + "smile_metrics", + "term_structure_slope", + "label_moneyness", + "select_strike", "iv_rank", "iv_percentile", "iv_zscore", ] -def _validate_iv(iv_series: NDArray[np.float64], window: int) -> NDArray[np.float64]: - """Validate and convert iv_series; check window.""" - arr = np.asarray(iv_series, dtype=np.float64) - if arr.ndim != 1: - raise FerroTAInputError("iv_series must be a 1-D array.") +@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}.") - return arr + try: + return np.asarray(_rust_iv_rank(arr, int(window)), dtype=np.float64) + except ValueError as err: + _normalize_rust_error(err) -def iv_rank( - iv_series: ArrayLike, - window: int = 252, -) -> NDArray[np.float64]: - """Compute rolling IV rank. - - IV rank measures where today's IV sits relative to the min/max of IV over - the look-back *window*. A value of 1.0 means current IV is at its - highest, 0.0 means it is at its lowest. - - Parameters - ---------- - iv_series : array-like - 1-D series of implied volatility values (e.g. VIX daily closes or - single-name option IV). Any positive numeric values are accepted. - window : int - Look-back period in bars (default 252 ≈ 1 trading year). - - Returns - ------- - ndarray of float64 - Rolling IV rank in [0, 1]. NaN for bars where the window is not yet - full (i.e. the first ``window - 1`` bars). - - Examples - -------- - >>> import numpy as np - >>> from ferro_ta.analysis.options import iv_rank - >>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0]) - >>> iv_rank(iv, window=3) - array([ nan, nan, 1. , 0. , 0.46666667]) - """ - arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) - n = len(arr) - out = np.full(n, np.nan, dtype=np.float64) - if window > n: - return out - - windows = sliding_window_view(arr, window_shape=window) - lower = np.nanmin(windows, axis=1) - upper = np.nanmax(windows, axis=1) - current = arr[window - 1 :] - spread = upper - lower - out[window - 1 :] = np.where(spread == 0.0, 0.0, (current - lower) / spread) - - return out +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_percentile( - iv_series: ArrayLike, - window: int = 252, -) -> NDArray[np.float64]: - """Compute rolling IV percentile. - - IV percentile measures the fraction of days over the look-back *window* - for which IV was *at or below* today's level. Unlike IV rank (which only - considers min/max), IV percentile uses the full distribution of values. - - Parameters - ---------- - iv_series : array-like - 1-D series of implied volatility values. - window : int - Look-back period in bars (default 252). - - Returns - ------- - ndarray of float64 - Rolling IV percentile in [0, 1]. NaN for bars before the window fills. - - Examples - -------- - >>> import numpy as np - >>> from ferro_ta.analysis.options import iv_percentile - >>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0]) - >>> iv_percentile(iv, window=3) - array([ nan, nan, 1. , 0. , 0.33333333]) - """ - arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) - n = len(arr) - out = np.full(n, np.nan, dtype=np.float64) - if window > n: - return out - - windows = sliding_window_view(arr, window_shape=window) - current = arr[window - 1 :, None] - out[window - 1 :] = np.sum(windows <= current, axis=1, dtype=np.int64) / window - - return out +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 iv_zscore( - iv_series: ArrayLike, - window: int = 252, -) -> NDArray[np.float64]: - """Compute rolling IV z-score. +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) - Measures how many standard deviations today's IV is above (positive) or - below (negative) the rolling mean over *window* bars. - Parameters - ---------- - iv_series : array-like - 1-D series of implied volatility values. - window : int - Look-back period in bars (default 252). +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) - Returns - ------- - ndarray of float64 - Rolling z-score. NaN during warm-up (first ``window - 1`` bars) and - when the rolling standard deviation is zero. - Examples - -------- - >>> import numpy as np - >>> from ferro_ta.analysis.options import iv_zscore - >>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0]) - >>> z = iv_zscore(iv, window=3) - >>> z[2] # (30 - 25) / std([20, 25, 30]) - np.float64(1.2247...) - """ - arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) - n = len(arr) - out = np.full(n, np.nan, dtype=np.float64) - if window > n: - return out +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, + ) - windows = sliding_window_view(arr, window_shape=window) - mean = np.nanmean(windows, axis=1) - std = np.nanstd(windows, axis=1, ddof=0) - current = arr[window - 1 :] - out[window - 1 :] = np.where(std == 0.0, np.nan, (current - mean) / std) - return out +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) diff --git a/python/ferro_ta/analysis/options_strategy.py b/python/ferro_ta/analysis/options_strategy.py new file mode 100644 index 0000000..21978f2 --- /dev/null +++ b/python/ferro_ta/analysis/options_strategy.py @@ -0,0 +1,317 @@ +""" +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 + strike_selector: StrikeSelector + option_type: str + 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.option_type not in {"call", "put"}: + raise FerroTAValueError("option_type must be 'call' or 'put'.") + if self.side not in {"long", "short"}: + raise FerroTAValueError("side must be 'long' or 'short'.") + if self.instrument not in {"option", "future"}: + raise FerroTAValueError("instrument must be 'option' or 'future'.") + 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/python/ferro_ta/data/batch.py b/python/ferro_ta/data/batch.py index f31e92b..8e8d6c2 100644 --- a/python/ferro_ta/data/batch.py +++ b/python/ferro_ta/data/batch.py @@ -29,7 +29,7 @@ Usage from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Sequence import numpy as np from numpy.typing import ArrayLike @@ -120,7 +120,9 @@ def _extract_timeperiod( def compute_many( - indicators: list[str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]], + indicators: Sequence[ + str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object] + ], *, close: ArrayLike, high: ArrayLike | None = None, @@ -138,7 +140,9 @@ def compute_many( close_arr = np.ascontiguousarray(close, dtype=np.float64) high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64) low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64) - volume_arr = None if volume is None else np.ascontiguousarray(volume, dtype=np.float64) + volume_arr = ( + None if volume is None else np.ascontiguousarray(volume, dtype=np.float64) + ) normalized = [_normalize_indicator_spec(spec) for spec in indicators] results: list[object | None] = [None] * len(normalized) @@ -161,11 +165,7 @@ def compute_many( continue hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS) - if ( - hlc_period is not None - and high_arr is not None - and low_arr is not None - ): + if hlc_period is not None and high_arr is not None and low_arr is not None: hlc_indices.append(idx) hlc_names.append(name) hlc_periods.append(hlc_period) @@ -196,7 +196,9 @@ def compute_many( if high_arr is not None and low_arr is not None: try: - results[idx] = _registry_run(name, high_arr, low_arr, close_arr, **kwargs) + results[idx] = _registry_run( + name, high_arr, low_arr, close_arr, **kwargs + ) continue except Exception: pass diff --git a/src/futures/basis.rs b/src/futures/basis.rs new file mode 100644 index 0000000..2175d5b --- /dev/null +++ b/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/src/futures/curve.rs b/src/futures/curve.rs new file mode 100644 index 0000000..725a9b3 --- /dev/null +++ b/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/src/futures/mod.rs b/src/futures/mod.rs new file mode 100644 index 0000000..b05b813 --- /dev/null +++ b/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/src/futures/roll.rs b/src/futures/roll.rs new file mode 100644 index 0000000..03a9819 --- /dev/null +++ b/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/src/futures/synthetic.rs b/src/futures/synthetic.rs new file mode 100644 index 0000000..67a7c35 --- /dev/null +++ b/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/src/lib.rs b/src/lib.rs index ed80206..f7bc361 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,8 +6,10 @@ 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; @@ -57,6 +59,8 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> { 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)?; diff --git a/src/options/chain.rs b/src/options/chain.rs new file mode 100644 index 0000000..c2d055b --- /dev/null +++ b/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/src/options/greeks.rs b/src/options/greeks.rs new file mode 100644 index 0000000..3b66bdf --- /dev/null +++ b/src/options/greeks.rs @@ -0,0 +1,125 @@ +use crate::validation; +use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; +use pyo3::prelude::*; + +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), + )) +} diff --git a/src/options/iv.rs b/src/options/iv.rs new file mode 100644 index 0000000..d5bba3d --- /dev/null +++ b/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/src/options/mod.rs b/src/options/mod.rs new file mode 100644 index 0000000..bd8fe52 --- /dev/null +++ b/src/options/mod.rs @@ -0,0 +1,67 @@ +//! PyO3 wrappers for options analytics. + +mod chain; +mod greeks; +mod iv; +mod pricing; +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::greeks::option_greeks, m)?)?; + m.add_function(pyo3::wrap_pyfunction!( + self::greeks::option_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::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)?)?; + Ok(()) +} diff --git a/src/options/pricing.rs b/src/options/pricing.rs new file mode 100644 index 0000000..9ef5d83 --- /dev/null +++ b/src/options/pricing.rs @@ -0,0 +1,128 @@ +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 = (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/src/options/surface.rs b/src/options/surface.rs new file mode 100644 index 0000000..424d629 --- /dev/null +++ b/src/options/surface.rs @@ -0,0 +1,49 @@ +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, + )) +} diff --git a/src/statistic/beta.rs b/src/statistic/beta.rs index 39d3097..fc1c333 100644 --- a/src/statistic/beta.rs +++ b/src/statistic/beta.rs @@ -13,7 +13,7 @@ fn price_return(curr: f64, prev: f64) -> f64 { fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { let n = x.len(); let mut result = vec![f64::NAN; n]; - for end in timeperiod..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]; @@ -36,7 +36,7 @@ fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { .map(|&value| (value - mean_x).powi(2)) .sum::() / timeperiod as f64; - result[end] = if var_x != 0.0 { cov / var_x } else { f64::NAN }; + *slot = if var_x != 0.0 { cov / var_x } else { f64::NAN }; } result } @@ -102,8 +102,8 @@ pub fn beta<'py>( } } - for end in timeperiod..n { - result[end] = if invalid_pairs == 0 { + 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 diff --git a/src/statistic/correl.rs b/src/statistic/correl.rs index 6131f2f..0070569 100644 --- a/src/statistic/correl.rs +++ b/src/statistic/correl.rs @@ -5,7 +5,7 @@ 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 in (timeperiod - 1)..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; @@ -26,7 +26,7 @@ fn correl_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec { .sum::() .sqrt(); let denom = std_x * std_y; - result[end] = if denom != 0.0 { cov / denom } else { f64::NAN }; + *slot = if denom != 0.0 { cov / denom } else { f64::NAN }; } result } @@ -72,10 +72,10 @@ pub fn correl<'py>( .map(|(&lhs, &rhs)| lhs * rhs) .sum::(); - for end in (timeperiod - 1)..n { + 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; - result[end] = if denom_x > 0.0 && denom_y > 0.0 { + *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 diff --git a/tests/unit/test_data_pipeline.py b/tests/unit/test_data_pipeline.py index ec2876f..cbc8f7c 100644 --- a/tests/unit/test_data_pipeline.py +++ b/tests/unit/test_data_pipeline.py @@ -622,9 +622,15 @@ class TestComputeMany: 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) + 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 @@ -653,7 +659,9 @@ class TestComputeMany: from ferro_ta.data.batch import compute_many _, _, _, close, _ = _make_ohlcv(80) - result = compute_many([("STDDEV", {"timeperiod": 10, "nbdev": 2.0})], close=close) + 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 ) diff --git a/tests/unit/test_derivatives.py b/tests/unit/test_derivatives.py new file mode 100644 index 0000000..1d74e12 --- /dev/null +++ b/tests/unit/test_derivatives.py @@ -0,0 +1,218 @@ +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 From ee6a0b35706542c609314a4b0e9ba6eb839c004d Mon Sep 17 00:00:00 2001 From: Pratik Bhadane Date: Tue, 24 Mar 2026 02:44:04 +0530 Subject: [PATCH 3/3] fix(ci): skip SIMD benchmark in perf-smoke job bench_simd.py re-invokes `maturin develop` at runtime to compare portable vs SIMD builds, which requires an active virtualenv. CI installs into system Python so maturin develop fails with "Couldn't find a virtualenv". --skip-simd avoids this; the SIMD comparison is optional profiling, not a correctness gate. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci-python.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 20daf3d..8a8c2b0 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -151,6 +151,7 @@ jobs: python benchmarks/run_perf_contract.py \ --output-dir perf-contract \ --skip-talib \ + --skip-simd \ --batch-samples 20000 \ --batch-series 32 \ --streaming-bars 20000 \