Merge pull request #4 from pratikbhadane24/feat/performace-1.0.2

chore: release v1.0.2
This commit is contained in:
Pratik Bhadane
2026-03-24 02:44:50 +05:30
committed by GitHub
87 changed files with 8316 additions and 658 deletions
+41
View File
@@ -125,3 +125,44 @@ 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 \
--skip-simd \
--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/
+15
View File
@@ -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
+21 -1
View File
@@ -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
Generated
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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"] }
+26 -3
View File
@@ -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
+48
View File
@@ -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
+71
View File
@@ -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
}
]
}
@@ -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
}
]
}
@@ -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
}
]
}
+62
View File
@@ -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"
}
}
}
@@ -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
}
]
}
+285
View File
@@ -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
}
]
}
}
}
@@ -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
}
]
}
+46
View File
@@ -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
}
]
}
+206 -50
View File
@@ -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())
+158
View File
@@ -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())
+189
View File
@@ -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())
+107
View File
@@ -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())
+79
View File
@@ -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
+269
View File
@@ -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())
+211
View File
@@ -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())
+45 -14
View File
@@ -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)]
+108
View File
@@ -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,
)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+121 -3
View File
@@ -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<f64> {
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<f64> = close.iter().map(|_| 100.0).collect();
let vols: Vec<f64> = 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::<Vec<_>>()
})
});
}
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<f64> = (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::<Vec<_>>()
})
});
}
group.finish();
}
fn bench_smile_metrics(c: &mut Criterion) {
let mut group = c.benchmark_group("SMILE_METRICS");
let strikes: Vec<f64> = (0..41).map(|i| 80.0 + i as f64).collect();
let vols: Vec<f64> = 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);
+55
View File
@@ -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());
}
}
+83
View File
@@ -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::<f64>() / n;
let mean_y = ys.iter().sum::<f64>() / 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<f64> {
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<f64> = futures_prices
.iter()
.map(|&price| basis::basis(spot, price))
.collect();
let average_basis = bases.iter().sum::<f64>() / 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);
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Futures analytics core.
pub mod basis;
pub mod curve;
pub mod roll;
pub mod synthetic;
+109
View File
@@ -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<f64> {
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<usize> {
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<f64> {
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<f64> {
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());
}
}
@@ -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);
}
}
+2
View File
@@ -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;
+162
View File
@@ -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<usize> {
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<i8> {
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<f64> {
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<f64> {
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());
}
}
+230
View File
@@ -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<F>(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());
}
}
+241
View File
@@ -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<f64> {
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<f64> {
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<f64> {
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);
}
}
+88
View File
@@ -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,
}
@@ -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);
}
}
+187
View File
@@ -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);
}
}
+240
View File
@@ -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::<f64>() / n;
let mean_y = ys.iter().sum::<f64>() / 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<f64> = 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);
}
}
+22
View File
@@ -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:
+1
View File
@@ -17,3 +17,4 @@ API Reference
extended
streaming
batch
analysis
+70
View File
@@ -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.
+126
View File
@@ -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`
+3 -2
View File
@@ -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 <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_
- Options/IV helpers and derivatives analytics — see :doc:`derivatives`
- Agentic workflow and LangChain tool wrappers — see `Agentic guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_
- MCP server for Cursor/Claude integration — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
- Sphinx documentation
@@ -71,7 +72,7 @@ Further Reading
- `API Stability <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_ — stability tiers, versioning, and deprecation policy.
- `Rust-First Policy <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/rust_first.md>`_ — all compute logic belongs in Rust; how to add new indicators.
- `Out-of-Core Execution <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/out-of-core.md>`_ — chunked processing and Dask integration.
- `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_ — IV rank, IV percentile, IV z-score.
- :doc:`derivatives`IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers.
- `Agentic Workflow <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_ — tools.py, workflow.py, LangChain integration.
- `MCP Server <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_ — run ferro-ta as an MCP server in Cursor/Claude.
+50 -72
View File
@@ -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.
+85 -12
View File
@@ -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)
@@ -246,18 +315,22 @@ 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`, `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`.
**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`):
- 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.
+16
View File
@@ -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.
+1 -1
View File
@@ -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" }
+4 -1
View File
@@ -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
@@ -525,6 +527,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,
+1
View File
@@ -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)
+4 -1
View File
@@ -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::
+13 -8
View File
@@ -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,
@@ -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"],
)
+24 -59
View File
@@ -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
+230
View File
@@ -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)
+599 -172
View File
@@ -1,205 +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.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)
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)
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)
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
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)
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,
)
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
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<n>, OTM<n>, or DELTA<x>."
)
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)
@@ -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,
)
+2 -7
View File
@@ -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:
+156 -1
View File
@@ -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
@@ -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,156 @@ __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: Sequence[
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,
+412 -133
View File
@@ -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<f64> {
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<Vec<f64>>,
) -> Bound<'py, PyArray2<f64>> {
let mut result = Array2::<f64>::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<f64>, Vec<f64>)>,
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
let mut result_k = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
let mut result_d = Array2::<f64>::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<f64>>
where
F: Fn(&[f64]) -> Vec<f64> + 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<Vec<f64>> = 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<f64> {
let n = high.len();
let typical_price: Vec<f64> = 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::<f64>() / timeperiod as f64;
let mad = window
.iter()
.map(|&value| (value - mean).abs())
.sum::<f64>()
/ 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<Vec<f64>> {
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<Vec<f64>> {
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<Vec<f64>> {
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<Py<PyArray1<f64>>>;
// ---------------------------------------------------------------------------
// 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<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect())
.collect();
let process_col = |col: &Vec<f64>| -> Vec<f64> { ferro_ta_core::overlap::sma(col, timeperiod) };
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
if parallel {
columns.par_iter().map(process_col).collect()
} else {
columns.iter().map(process_col).collect()
}
});
let mut result = Array2::<f64>::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<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect())
.collect();
let process_col = |col: &Vec<f64>| -> Vec<f64> { ferro_ta_core::overlap::ema(col, timeperiod) };
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
if parallel {
columns.par_iter().map(process_col).collect()
} else {
columns.iter().map(process_col).collect()
}
});
let mut result = Array2::<f64>::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<Vec<f64>> = (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<f64>| -> Vec<f64> {
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<Vec<f64>> = py.allow_threads(|| {
if parallel {
columns.par_iter().map(process_col).collect()
} else {
columns.iter().map(process_col).collect()
}
});
let mut result = Array2::<f64>::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<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
.collect();
let cols_l: Vec<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
.collect();
let cols_c: Vec<Vec<f64>> = (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<f64>> = py.allow_threads(|| {
let process_col = |j: usize| -> Vec<f64> {
ferro_ta_core::volatility::atr(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod)
let process_col = |series_idx: usize| -> Vec<f64> {
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::<f64>::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<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
.collect();
let cols_l: Vec<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
.collect();
let cols_c: Vec<Vec<f64>> = (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<f64>, Vec<f64>)> = py.allow_threads(|| {
let process_col = |j: usize| -> (Vec<f64>, Vec<f64>) {
let process_col = |series_idx: usize| -> (Vec<f64>, Vec<f64>) {
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::<f64>::from_elem((n_samples, n_series), f64::NAN);
let mut result_d = Array2::<f64>::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<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
.collect();
let cols_l: Vec<Vec<f64>> = (0..n_series)
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
.collect();
let cols_c: Vec<Vec<f64>> = (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<f64>> = py.allow_threads(|| {
let process_col = |j: usize| -> Vec<f64> {
ferro_ta_core::momentum::adx(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod)
let process_col = |series_idx: usize| -> Vec<f64> {
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::<f64>::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<String>,
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
validate_indicator_requests(&names, &timeperiods)?;
let close_values = close.as_slice()?;
let results: Vec<PyResult<Vec<f64>>> = 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<String>,
timeperiods: Vec<usize>,
parallel: bool,
) -> PyResult<IndicatorArrayList> {
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<PyResult<Vec<f64>>> = 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(())
}
+34
View File
@@ -0,0 +1,34 @@
use pyo3::prelude::*;
#[pyfunction]
pub fn futures_basis(spot: f64, future: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::basis::basis(spot, future))
}
#[pyfunction]
pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> PyResult<f64> {
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<f64> {
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<f64> {
Ok(ferro_ta_core::futures::basis::carry_spread(
spot,
future,
rate,
time_to_expiry,
))
}
+52
View File
@@ -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<Bound<'py, PyArray1<f64>>> {
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<f64> {
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,
))
}
+38
View File
@@ -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(())
}
+75
View File
@@ -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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<f64> {
Ok(ferro_ta_core::futures::roll::roll_yield(
front_price,
next_price,
time_to_expiry,
))
}
+60
View File
@@ -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<f64> {
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<f64> {
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<f64> {
Ok(ferro_ta_core::futures::synthetic::parity_gap(
call_price,
put_price,
spot,
strike,
rate,
carry,
time_to_expiry,
))
}
+4
View File
@@ -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)?;
+64
View File
@@ -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<Bound<'py, PyArray1<i8>>> {
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<Option<f64>> {
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<Option<f64>> {
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,
))
}
+125
View File
@@ -0,0 +1,125 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
type GreekArrays<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
);
#[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<PyReadonlyArray1<'py, f64>>,
) -> PyResult<GreekArrays<'py>> {
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),
))
}
+149
View File
@@ -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<f64> {
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<PyReadonlyArray1<'py, f64>>,
initial_guess: Option<PyReadonlyArray1<'py, f64>>,
tolerance: f64,
max_iterations: usize,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
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<f64> = 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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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))
}
+67
View File
@@ -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<ferro_ta_core::options::OptionKind> {
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<ferro_ta_core::options::PricingModel> {
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(())
}
+128
View File
@@ -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<f64> {
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<f64> {
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<Bound<'py, PyArray1<f64>>> {
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<f64> = 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<Bound<'py, PyArray1<f64>>> {
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<f64> = 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))
}
+49
View File
@@ -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<f64> {
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,
))
}
+63 -23
View File
@@ -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<f64> {
let n = xv.len();
let mut order: Vec<usize> = (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<usize> = (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<Bound<'py, PyArray1<f64>>> {
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<f64> = 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(())
+117 -33
View File
@@ -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<f64> {
let n = x.len();
let mut result = vec![f64::NAN; n];
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) {
let start = end - timeperiod;
let mut rx = vec![0.0_f64; timeperiod];
let mut ry = vec![0.0_f64; timeperiod];
for offset in 0..timeperiod {
let prev = start + offset;
let curr = prev + 1;
rx[offset] = price_return(x[curr], x[prev]);
ry[offset] = price_return(y[curr], y[prev]);
}
let mean_x = rx.iter().sum::<f64>() / timeperiod as f64;
let mean_y = ry.iter().sum::<f64>() / timeperiod as f64;
let cov = rx
.iter()
.zip(ry.iter())
.map(|(&lhs, &rhs)| (lhs - mean_x) * (rhs - mean_y))
.sum::<f64>()
/ timeperiod as f64;
let var_x = rx
.iter()
.map(|&value| (value - mean_x).powi(2))
.sum::<f64>()
/ timeperiod as f64;
*slot = if var_x != 0.0 { cov / var_x } else { f64::NAN };
}
result
}
/// Beta: regression of *real1* daily returns on *real0* daily returns over a
/// rolling window of *timeperiod* return pairs.
///
@@ -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::<f64>() / timeperiod as f64;
let mean_y: f64 = ry.iter().sum::<f64>() / timeperiod as f64;
let cov: f64 = rx
.iter()
.zip(ry.iter())
.map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y))
.sum::<f64>()
/ timeperiod as f64;
let var_x: f64 =
rx.iter().map(|&xi| (xi - mean_x).powi(2)).sum::<f64>() / 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<f64> = x
.windows(2)
.map(|window| price_return(window[1], window[0]))
.collect();
let ry: Vec<f64> = y
.windows(2)
.map(|window| price_return(window[1], window[0]))
.collect();
let period = timeperiod as f64;
let mut invalid_pairs = 0_usize;
let mut sum_rx = 0.0_f64;
let mut sum_ry = 0.0_f64;
let mut sum_rx2 = 0.0_f64;
let mut sum_rxry = 0.0_f64;
for idx in 0..timeperiod {
let ret_x = rx[idx];
let ret_y = ry[idx];
if ret_x.is_finite() && ret_y.is_finite() {
sum_rx += ret_x;
sum_ry += ret_y;
sum_rx2 += ret_x * ret_x;
sum_rxry += ret_x * ret_y;
} else {
invalid_pairs += 1;
}
}
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) {
*slot = if invalid_pairs == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
} else {
f64::NAN
}
} else {
f64::NAN
};
if end + 1 < n {
let outgoing = end - timeperiod;
let incoming = end;
let outgoing_x = rx[outgoing];
let outgoing_y = ry[outgoing];
if outgoing_x.is_finite() && outgoing_y.is_finite() {
sum_rx -= outgoing_x;
sum_ry -= outgoing_y;
sum_rx2 -= outgoing_x * outgoing_x;
sum_rxry -= outgoing_x * outgoing_y;
} else {
invalid_pairs -= 1;
}
let incoming_x = rx[incoming];
let incoming_y = ry[incoming];
if incoming_x.is_finite() && incoming_y.is_finite() {
sum_rx += incoming_x;
sum_ry += incoming_y;
sum_rx2 += incoming_x * incoming_x;
sum_rxry += incoming_x * incoming_y;
} else {
invalid_pairs += 1;
}
}
}
Ok(result.into_pyarray(py))
}
+54
View File
@@ -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<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
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::<f64>();
let mut sum_xy = prices[..timeperiod]
.iter()
.enumerate()
.map(|(idx, &value)| idx as f64 * value)
.sum::<f64>();
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
}
+79 -14
View File
@@ -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<f64> {
let n = x.len();
let mut result = vec![f64::NAN; n];
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) {
let wx = &x[(end + 1 - timeperiod)..=end];
let wy = &y[(end + 1 - timeperiod)..=end];
let mean_x = wx.iter().sum::<f64>() / timeperiod as f64;
let mean_y = wy.iter().sum::<f64>() / timeperiod as f64;
let cov = wx
.iter()
.zip(wy.iter())
.map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y))
.sum::<f64>();
let std_x = wx
.iter()
.map(|&xi| (xi - mean_x).powi(2))
.sum::<f64>()
.sqrt();
let std_y = wy
.iter()
.map(|&yi| (yi - mean_y).powi(2))
.sum::<f64>()
.sqrt();
let denom = std_x * std_y;
*slot = if denom != 0.0 { cov / denom } else { f64::NAN };
}
result
}
/// Pearson correlation coefficient between two series over the rolling window.
#[pyfunction]
#[pyo3(signature = (real0, real1, timeperiod = 30))]
@@ -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::<f64>() / timeperiod as f64;
let mean_y: f64 = wy.iter().sum::<f64>() / timeperiod as f64;
let cov: f64 = wx
.iter()
.zip(wy.iter())
.map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y))
.sum::<f64>();
let std_x: f64 = (wx.iter().map(|&xi| (xi - mean_x).powi(2)).sum::<f64>()).sqrt();
let std_y: f64 = (wy.iter().map(|&yi| (yi - mean_y).powi(2)).sum::<f64>()).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::<f64>();
let mut sum_y = y[..timeperiod].iter().sum::<f64>();
let mut sum_x2 = x[..timeperiod]
.iter()
.map(|value| value * value)
.sum::<f64>();
let mut sum_y2 = y[..timeperiod]
.iter()
.map(|value| value * value)
.sum::<f64>();
let mut sum_xy = x[..timeperiod]
.iter()
.zip(y[..timeperiod].iter())
.map(|(&lhs, &rhs)| lhs * rhs)
.sum::<f64>();
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
*slot = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN
};
if end + 1 < n {
let outgoing = end + 1 - timeperiod;
let incoming = end + 1;
let outgoing_x = x[outgoing];
let outgoing_y = y[outgoing];
let incoming_x = x[incoming];
let incoming_y = y[incoming];
sum_x += incoming_x - outgoing_x;
sum_y += incoming_y - outgoing_y;
sum_x2 += incoming_x * incoming_x - outgoing_x * outgoing_x;
sum_y2 += incoming_y * incoming_y - outgoing_y * outgoing_y;
sum_xy += incoming_x * incoming_y - outgoing_x * outgoing_y;
}
}
Ok(result.into_pyarray(py))
}
+12 -37
View File
@@ -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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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<Bound<'py, PyArray1<f64>>> {
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))
}
+1 -1
View File
@@ -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;
+82
View File
@@ -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)
+92
View File
@@ -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,83 @@ 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)
+218
View File
@@ -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
+50 -1
View File
@@ -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
# ---------------------------------------------------------------------------
Generated
+1 -1
View File
@@ -609,7 +609,7 @@ wheels = [
[[package]]
name = "ferro-ta"
version = "1.0.0"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
+1 -1
View File
@@ -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"
+113
View File
@@ -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();
+2 -1
View File
@@ -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"
},