chore: release v1.0.2

This commit is contained in:
Pratik Bhadane
2026-03-24 02:02:10 +05:30
parent 9011250f99
commit 2d5000262f
47 changed files with 3821 additions and 422 deletions
+40
View File
@@ -125,3 +125,43 @@ jobs:
with: with:
name: benchmark-vs-talib name: benchmark-vs-talib
path: benchmark_vs_talib.json path: benchmark_vs_talib.json
perf-smoke:
name: Performance smoke and contracts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install maturin and perf dependencies
run: |
pip install maturin numpy pytest
- name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Generate reproducible perf artifacts
run: |
python benchmarks/run_perf_contract.py \
--output-dir perf-contract \
--skip-talib \
--batch-samples 20000 \
--batch-series 32 \
--streaming-bars 20000 \
--price-bars 20000 \
--iv-bars 50000
- name: Enforce hotspot regression policy
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Upload perf artifacts
uses: actions/upload-artifact@v7
with:
name: perf-contract
path: perf-contract/
+15
View File
@@ -13,6 +13,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install Rust (stable) - name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1 uses: dtolnay/rust-toolchain@v1
with: with:
@@ -30,8 +35,18 @@ jobs:
working-directory: wasm working-directory: wasm
run: wasm-pack build --target nodejs --out-dir pkg 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 - name: Upload WASM package artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wasm-pkg name: wasm-pkg
path: 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] ## [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 ## [1.0.1] — 2026-03-24
### Added ### 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.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 [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]] [[package]]
name = "ferro_ta" name = "ferro_ta"
version = "1.0.1" version = "1.0.2"
dependencies = [ dependencies = [
"criterion", "criterion",
"ferro_ta_core", "ferro_ta_core",
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.0.1" version = "1.0.2"
dependencies = [ dependencies = [
"criterion", "criterion",
"wide", "wide",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package] [package]
name = "ferro_ta" name = "ferro_ta"
version = "1.0.1" version = "1.0.2"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
publish = false publish = false
@@ -23,7 +23,7 @@ ndarray = "0.16"
rayon = "1.10" rayon = "1.10"
log = "0.4" log = "0.4"
pyo3-log = "0.12" 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] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
+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. - **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. - **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) ## 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 # Optional regression check used in CI
uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json 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. 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 ## 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 import time
from pathlib import Path
from typing import Any
import numpy as np import numpy as np
import ferro_ta import ferro_ta
def _time_fn(fn, *args, **kwargs): try:
times = [] from benchmarks.metadata import benchmark_metadata
# Warmup 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) fn(*args, **kwargs)
for _ in range(5): times: list[float] = []
for _ in range(rounds):
t0 = time.perf_counter() t0 = time.perf_counter()
fn(*args, **kwargs) fn(*args, **kwargs)
times.append(time.perf_counter() - t0) times.append(time.perf_counter() - t0)
return min(times) 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 def run_batch_benchmark(
kwargs = {"timeperiod": 14} *,
def loop_sma(arr): n_samples: int = 100_000,
for j in range(arr.shape[1]): n_series: int = 100,
ferro_ta.SMA(arr[:, j], **kwargs) seed: int = 42,
) -> dict[str, Any]:
t_batch_sma = _time_fn(ferro_ta.batch.batch_sma, close2d, **kwargs) rng = np.random.default_rng(seed)
t_loop_sma = _time_fn(loop_sma, close2d) close2d = rng.uniform(100.0, 200.0, (n_samples, n_series))
print(f"SMA {t_batch_sma*1000:12.1f} {t_loop_sma*1000:12.1f} {t_loop_sma/t_batch_sma:9.1f}x") 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 batch_rows: list[dict[str, Any]] = []
def loop_rsi(arr): grouped_rows: list[dict[str, Any]] = []
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")
# 3. ATR indicators = [
def loop_atr(h, l, c): (
for j in range(h.shape[1]): "SMA",
ferro_ta.ATR(h[:, j], l[:, j], c[:, j], **kwargs) lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True),
t_batch_atr = _time_fn(ferro_ta.batch.batch_atr, h2d, l2d, close2d, **kwargs) lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=False),
t_loop_atr = _time_fn(loop_atr, h2d, l2d, close2d) lambda: [ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series)],
print(f"ATR {t_batch_atr*1000:12.1f} {t_loop_atr*1000:12.1f} {t_loop_atr/t_batch_atr:9.1f}x") ),
(
"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 for name, parallel_fn, sequential_fn, loop_fn in indicators:
def loop_adx(h, l, c): batch_parallel_s = _time_fn(parallel_fn)
for j in range(h.shape[1]): batch_sequential_s = _time_fn(sequential_fn)
ferro_ta.ADX(h[:, j], l[:, j], c[:, j], **kwargs) loop_s = _time_fn(loop_fn)
t_batch_adx = _time_fn(ferro_ta.batch.batch_adx, h2d, l2d, close2d, **kwargs) batch_rows.append(
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") "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__': grouped_cases = [
main() (
"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 pathlib
import time import time
from typing import Any, Callable, Dict, List from collections.abc import Callable
from typing import Any
import numpy as np import numpy as np
import pytest import pytest
@@ -46,7 +47,7 @@ BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def ohlcv() -> Dict[str, np.ndarray]: def ohlcv() -> dict[str, np.ndarray]:
"""Load canonical OHLCV fixture.""" """Load canonical OHLCV fixture."""
if not FIXTURE_PATH.exists(): if not FIXTURE_PATH.exists():
pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}") pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}")
@@ -60,7 +61,7 @@ def ohlcv() -> Dict[str, np.ndarray]:
# Each entry: (name, callable, kwargs) # Each entry: (name, callable, kwargs)
# The callable receives (close,) or (high, low, close,) based on 'inputs' key. # 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", "name": "SMA_20",
"inputs": "close", "inputs": "close",
@@ -131,6 +132,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [
"fn_name": "LINEARREG", "fn_name": "LINEARREG",
"kwargs": {"timeperiod": 14}, "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", "name": "VAR_20",
"inputs": "close", "inputs": "close",
@@ -138,6 +153,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [
"fn_name": "VAR", "fn_name": "VAR",
"kwargs": {"timeperiod": 20}, "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", "name": "CCI_14",
"inputs": "hlc", "inputs": "hlc",
@@ -161,12 +190,14 @@ def _load_fn(fn_name: str) -> Callable[..., Any]:
return getattr(ft, fn_name) 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"]) fn = _load_fn(entry["fn_name"])
if entry["inputs"] == "close": if entry["inputs"] == "close":
result = fn(data["close"], **entry["kwargs"]) result = fn(data["close"], **entry["kwargs"])
else: # hlc elif entry["inputs"] == "hlc":
result = fn(data["high"], data["low"], data["close"], **entry["kwargs"]) 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): if isinstance(result, tuple):
result = result[0] result = result[0]
return np.asarray(result, dtype=np.float64) return np.asarray(result, dtype=np.float64)
@@ -184,7 +215,7 @@ class TestNumericalRegression:
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
) )
def test_output_shape( 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: ) -> None:
"""Indicator output length must equal input length.""" """Indicator output length must equal input length."""
out = _run_indicator(entry, ohlcv) out = _run_indicator(entry, ohlcv)
@@ -196,7 +227,7 @@ class TestNumericalRegression:
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
) )
def test_warmup_is_nan( 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: ) -> None:
"""First bar must be NaN (warm-up).""" """First bar must be NaN (warm-up)."""
out = _run_indicator(entry, ohlcv) out = _run_indicator(entry, ohlcv)
@@ -205,7 +236,7 @@ class TestNumericalRegression:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] "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.""" """Output must not contain infinities."""
out = _run_indicator(entry, ohlcv) out = _run_indicator(entry, ohlcv)
assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf" 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] "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
) )
def test_last_values_stable( 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: ) -> None:
"""Last 10 non-NaN values must be finite and stable (no sudden jumps).""" """Last 10 non-NaN values must be finite and stable (no sudden jumps)."""
out = _run_indicator(entry, ohlcv) out = _run_indicator(entry, ohlcv)
@@ -230,7 +261,7 @@ class TestNumericalRegression:
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE] "entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
) )
def test_regression_vs_baseline( 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: ) -> None:
"""Compare last 10 values to stored baselines.""" """Compare last 10 values to stored baselines."""
baselines = np.load(BASELINE_PATH) baselines = np.load(BASELINE_PATH)
@@ -265,8 +296,8 @@ class TestPerformance:
) )
def test_timing( def test_timing(
self, self,
entry: Dict[str, Any], entry: dict[str, Any],
ohlcv: Dict[str, np.ndarray], ohlcv: dict[str, np.ndarray],
request: pytest.FixtureRequest, request: pytest.FixtureRequest,
) -> None: ) -> None:
"""Time the indicator on the canonical dataset.""" """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. """Write current indicator outputs and timings to baselines.npz.
Call this after intentional changes to update the stored baselines:: 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) update_baselines(data)
" "
""" """
store: Dict[str, np.ndarray] = {} store: dict[str, np.ndarray] = {}
for entry in INDICATOR_SUITE: for entry in INDICATOR_SUITE:
out = _run_indicator(entry, ohlcv_data) out = _run_indicator(entry, ohlcv_data)
valid = out[~np.isnan(out)] valid = out[~np.isnan(out)]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.0.1" version = "1.0.2"
edition = "2021" edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency" description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
```toml ```toml
[dependencies] [dependencies]
ferro_ta_core = "1.0.1" ferro_ta_core = "1.0.2"
``` ```
## Design ## Design
+82 -11
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 | | 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 | | 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 | | 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 **Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked
in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark 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 np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of
earlier versions. earlier versions.
4. **Batch**`batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch functions 4. **Batch / grouped execution**`batch_sma`/`batch_ema`/`batch_rsi` use
for 2-D input (single GIL release for all columns). The generic Rust-side batch functions for 2-D input (single GIL release for all
`batch_apply` runs any indicator in a Python loop over columns; use the columns). `compute_many(...)` groups supported 1-D indicator bundles into
dedicated batch functions when available. 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 functions (single GIL release for all columns). Use `batch_apply` for other
indicators that do not have a dedicated Rust batch implementation. 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) ## 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 5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the
actual bottleneck before assuming a particular layer is slow. 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) ## Performance Improvements (implemented)
@@ -247,17 +316,19 @@ bottlenecks are fixed or deferred.
(unlike `_to_f64` for 1-D); could avoid a potential copy. (unlike `_to_f64` for 1-D); could avoid a potential copy.
**Options** (`python/ferro_ta/options.py`): **Options** (`python/ferro_ta/options.py`):
- `iv_rank`, `iv_percentile`, `iv_zscore` use Python loops over windows - `iv_rank`, `iv_percentile`, and `iv_zscore` are vectorized now, but
(O(n) iterations with per-window NumPy). Could move to Rust or vectorize. `iv_percentile`/`iv_zscore` still spend meaningful time in NumPy window
See also `docs/options-volatility.md`. materialization on very long series.
**Features** (`python/ferro_ta/features.py`): **Features** (`python/ferro_ta/features.py`):
- With `nan_policy="fill"` and no pandas, a Python loop fills NaN per column. - `nan_policy="fill"` is vectorized now.
- Indicators are run in a Python loop (one call per indicator); no bulk API. - `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`): **Signals** (`python/ferro_ta/signals.py`):
- `compose(..., method="rank")` uses a list comprehension over columns (one - `compose(..., method="rank")` now uses a one-call Rust rank-composition
Python round-trip per column). Could add a Rust batch rank for 2-D input. path, but its gains are moderate rather than dramatic. Keep measuring before
treating it as a major optimization lever.
**Other**: **Other**:
- **dsl.py**: Some code paths use Python loops over bars. - **dsl.py**: Some code paths use Python loops over bars.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "ferro-ta" 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" description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
+1
View File
@@ -525,6 +525,7 @@ from ferro_ta.data.batch import ( # noqa: F401, E402
batch_ema, batch_ema,
batch_rsi, batch_rsi,
batch_sma, batch_sma,
compute_many,
) )
from ferro_ta.data.chunked import ( # noqa: F401, E402 from ferro_ta.data.chunked import ( # noqa: F401, E402
chunk_apply, 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_ema as batch_ema
from ferro_ta.batch import batch_rsi as batch_rsi 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 batch_sma as batch_sma
from ferro_ta.batch import compute_many as compute_many
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Exception hierarchy (re-exported from ferro_ta.exceptions) # Exception hierarchy (re-exported from ferro_ta.exceptions)
+13 -8
View File
@@ -360,10 +360,10 @@ def backtest(
bar_returns[1:] = np.diff(c) / c[:-1] bar_returns[1:] = np.diff(c) / c[:-1]
strategy_returns = positions * bar_returns 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) # Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
if slippage_bps > 0: if slippage_bps > 0:
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
strategy_returns = strategy_returns.copy() strategy_returns = strategy_returns.copy()
strategy_returns[position_changed] -= slippage_bps / 10_000.0 strategy_returns[position_changed] -= slippage_bps / 10_000.0
@@ -371,13 +371,18 @@ def backtest(
if commission_per_trade <= 0: if commission_per_trade <= 0:
equity = np.cumprod(1.0 + strategy_returns) equity = np.cumprod(1.0 + strategy_returns)
else: else:
equity = np.empty(len(c), dtype=np.float64) gross_equity = np.cumprod(1.0 + strategy_returns)
equity[0] = 1.0 if np.any(gross_equity == 0.0):
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]]) equity = np.empty(len(c), dtype=np.float64)
for i in range(1, len(c)): equity[0] = 1.0
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]) for i in range(1, len(c)):
if position_changed[i]: equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
equity[i] -= commission_per_trade 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( return BacktestResult(
signals=signals, signals=signals,
+24 -59
View File
@@ -24,12 +24,23 @@ import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from ferro_ta._utils import _to_f64 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__ = [ __all__ = [
"feature_matrix", "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 # feature_matrix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -117,67 +128,23 @@ def feature_matrix(
n = len(close) n = len(close)
columns: dict[str, NDArray[np.float64]] = {} columns: dict[str, NDArray[np.float64]] = {}
# --- Indicators needing HLCV --- results = compute_many(
_multi_input = { indicators,
"ATR", close=close,
"NATR", high=high if high is not None else None,
"TRANGE", low=low if low is not None else None,
"ADX", volume=volume if volume is not None else None,
"ADXR", )
"PLUS_DI",
"MINUS_DI",
"PLUS_DM",
"MINUS_DM",
"DX",
"AROON",
"AROONOSC",
"CCI",
"MFI",
"STOCH",
"STOCHF",
"STOCHRSI",
"WILLR",
"AD",
"ADOSC",
"OBV",
"VWAP",
"DONCHIAN",
"ICHIMOKU",
}
def _call_indicator(name: str, kwargs: dict[str, Any]) -> Any: for spec, result in zip(indicators, results):
# 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:
if isinstance(spec, str): if isinstance(spec, str):
name = spec name = spec
kwargs: dict[str, Any] = {}
out_key: Optional[Any] = None out_key: Optional[Any] = None
elif len(spec) == 2: elif len(spec) == 2:
name, kwargs = spec # type: ignore[misc] name, _ = spec # type: ignore[misc]
out_key = None out_key = None
else: else:
name, kwargs, out_key = spec # type: ignore[misc] name, _, out_key = spec # type: ignore[misc]
result = _call_indicator(name, kwargs)
if isinstance(result, tuple): if isinstance(result, tuple):
if out_key is not None: if out_key is not None:
@@ -215,8 +182,6 @@ def feature_matrix(
mask &= ~np.isnan(arr) mask &= ~np.isnan(arr)
return {k: v[mask] for k, v in columns.items()} return {k: v[mask] for k, v in columns.items()}
elif nan_policy == "fill": elif nan_policy == "fill":
for k, arr in columns.items(): for key, arr in columns.items():
for i in range(1, len(arr)): columns[key] = _forward_fill_nan(arr)
if np.isnan(arr[i]):
arr[i] = arr[i - 1]
return columns return columns
+21 -20
View File
@@ -45,6 +45,7 @@ iv_zscore(iv_series, window)
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from numpy.typing import ArrayLike, NDArray from numpy.typing import ArrayLike, NDArray
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
@@ -103,15 +104,15 @@ def iv_rank(
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr) n = len(arr)
out = np.full(n, np.nan, dtype=np.float64) out = np.full(n, np.nan, dtype=np.float64)
if window > n:
return out
for i in range(window - 1, n): windows = sliding_window_view(arr, window_shape=window)
window_slice = arr[i - window + 1 : i + 1] lower = np.nanmin(windows, axis=1)
lo = float(np.nanmin(window_slice)) upper = np.nanmax(windows, axis=1)
hi = float(np.nanmax(window_slice)) current = arr[window - 1 :]
if hi == lo: spread = upper - lower
out[i] = 0.0 out[window - 1 :] = np.where(spread == 0.0, 0.0, (current - lower) / spread)
else:
out[i] = (arr[i] - lo) / (hi - lo)
return out return out
@@ -149,11 +150,12 @@ def iv_percentile(
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr) n = len(arr)
out = np.full(n, np.nan, dtype=np.float64) out = np.full(n, np.nan, dtype=np.float64)
if window > n:
return out
for i in range(window - 1, n): windows = sliding_window_view(arr, window_shape=window)
window_slice = arr[i - window + 1 : i + 1] current = arr[window - 1 :, None]
current = arr[i] out[window - 1 :] = np.sum(windows <= current, axis=1, dtype=np.int64) / window
out[i] = float(np.sum(window_slice <= current)) / window
return out return out
@@ -192,14 +194,13 @@ def iv_zscore(
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window) arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
n = len(arr) n = len(arr)
out = np.full(n, np.nan, dtype=np.float64) out = np.full(n, np.nan, dtype=np.float64)
if window > n:
return out
for i in range(window - 1, n): windows = sliding_window_view(arr, window_shape=window)
window_slice = arr[i - window + 1 : i + 1] mean = np.nanmean(windows, axis=1)
mu = float(np.nanmean(window_slice)) std = np.nanstd(windows, axis=1, ddof=0)
sigma = float(np.nanstd(window_slice, ddof=0)) current = arr[window - 1 :]
if sigma == 0.0: out[window - 1 :] = np.where(std == 0.0, np.nan, (current - mean) / std)
out[i] = np.nan
else:
out[i] = (arr[i] - mu) / sigma
return out return out
+2 -7
View File
@@ -33,6 +33,7 @@ import numpy as np
from numpy.typing import ArrayLike, NDArray 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 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 compose_weighted as _rust_compose_weighted
from ferro_ta._ferro_ta import rank_series as _rust_rank_series from ferro_ta._ferro_ta import rank_series as _rust_rank_series
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n from ferro_ta._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) w = np.full(n_sigs, 1.0 / n_sigs)
return _rust_compose_weighted(arr, w) return _rust_compose_weighted(arr, w)
elif method == "rank": elif method == "rank":
# Replace each column with its rank, then sum (ensure contiguous slices) return _rust_compose_rank(arr)
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)
else: else:
# weighted (default) # weighted (default)
if weights is None: if weights is None:
+153
View File
@@ -52,6 +52,13 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
batch_stoch as _rust_batch_stoch, 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.momentum import RSI
from ferro_ta.indicators.overlap import EMA, SMA from ferro_ta.indicators.overlap import EMA, SMA
@@ -60,8 +67,154 @@ __all__ = [
"batch_ema", "batch_ema",
"batch_rsi", "batch_rsi",
"batch_apply", "batch_apply",
"compute_many",
] ]
_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = {
"SMA": 30,
"EMA": 30,
"RSI": 14,
"STDDEV": 5,
"VAR": 5,
"LINEARREG": 14,
"LINEARREG_SLOPE": 14,
"LINEARREG_INTERCEPT": 14,
"LINEARREG_ANGLE": 14,
"TSF": 14,
}
_HLC_FASTPATH_DEFAULTS: dict[str, int] = {
"ATR": 14,
"NATR": 14,
"ADX": 14,
"ADXR": 14,
"CCI": 14,
"WILLR": 14,
}
def _normalize_indicator_spec(
spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object],
) -> tuple[str, dict[str, object], object | None]:
if isinstance(spec, str):
return spec, {}, None
if len(spec) == 2:
name, kwargs = spec
return name, kwargs, None
name, kwargs, out_key = spec
return name, kwargs, out_key
def _extract_timeperiod(
name: str, kwargs: dict[str, object], defaults: dict[str, int]
) -> int | None:
if name not in defaults:
return None
extra_keys = set(kwargs) - {"timeperiod"}
if extra_keys:
return None
raw_value = kwargs.get("timeperiod", defaults[name])
if not isinstance(raw_value, int):
return None
return raw_value
def compute_many(
indicators: list[str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]],
*,
close: ArrayLike,
high: ArrayLike | None = None,
low: ArrayLike | None = None,
volume: ArrayLike | None = None,
parallel: bool = True,
) -> list[object]:
"""Compute multiple indicators over the same arrays with grouped Rust calls.
Supported single-output indicators are grouped into one Rust boundary crossing
per input-shape family (`close` only or `high/low/close`). Unsupported specs
fall back to the regular registry path, preserving behavior.
"""
close_arr = np.ascontiguousarray(close, dtype=np.float64)
high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64)
low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64)
volume_arr = None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
normalized = [_normalize_indicator_spec(spec) for spec in indicators]
results: list[object | None] = [None] * len(normalized)
close_indices: list[int] = []
close_names: list[str] = []
close_periods: list[int] = []
hlc_indices: list[int] = []
hlc_names: list[str] = []
hlc_periods: list[int] = []
for idx, (name, kwargs, out_key) in enumerate(normalized):
if out_key is None:
close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS)
if close_period is not None:
close_indices.append(idx)
close_names.append(name)
close_periods.append(close_period)
continue
hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS)
if (
hlc_period is not None
and high_arr is not None
and low_arr is not None
):
hlc_indices.append(idx)
hlc_names.append(name)
hlc_periods.append(hlc_period)
continue
if close_names:
grouped = _rust_run_close_indicators(
close_arr, close_names, close_periods, parallel
)
for idx, value in zip(close_indices, grouped):
results[idx] = np.asarray(value, dtype=np.float64)
if hlc_names and high_arr is not None and low_arr is not None:
grouped = _rust_run_hlc_indicators(
high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel
)
for idx, value in zip(hlc_indices, grouped):
results[idx] = np.asarray(value, dtype=np.float64)
for idx, (name, kwargs, _) in enumerate(normalized):
if results[idx] is not None:
continue
try:
results[idx] = _registry_run(name, close_arr, **kwargs)
continue
except (TypeError, Exception):
pass
if high_arr is not None and low_arr is not None:
try:
results[idx] = _registry_run(name, high_arr, low_arr, close_arr, **kwargs)
continue
except Exception:
pass
if volume_arr is not None:
try:
results[idx] = _registry_run(
name, high_arr, low_arr, close_arr, volume_arr, **kwargs
)
continue
except Exception:
pass
raise ValueError(
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
)
return [result for result in results]
def batch_apply( def batch_apply(
data: ArrayLike, data: ArrayLike,
+412 -133
View File
@@ -9,11 +9,273 @@
//! the sequential path (`parallel = false`) may be faster due to thread-pool //! the sequential path (`parallel = false`) may be faster due to thread-pool
//! overhead. //! overhead.
use ndarray::Array2; use ndarray::{Array2, ArrayView2};
use numpy::{IntoPyArray, PyArray2, PyReadonlyArray2}; use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError; use pyo3::exceptions::PyValueError;
use pyo3::prelude::*; use pyo3::prelude::*;
use rayon::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 // batch_sma
@@ -43,34 +305,13 @@ pub fn batch_sma<'py>(
if timeperiod == 0 { if timeperiod == 0 {
return Err(PyValueError::new_err("timeperiod must be >= 1")); return Err(PyValueError::new_err("timeperiod must be >= 1"));
} }
let arr = data.as_array(); let (n_samples, n_series) = data.as_array().dim();
let (n_samples, n_series) = arr.dim();
log::debug!( log::debug!(
"batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" "batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
); );
Ok(run_unary_batch(py, data, parallel, |col| {
// Extract columns to owned Vecs so we can release the GIL for parallel work. ferro_ta_core::overlap::sma(col, timeperiod)
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))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -100,33 +341,13 @@ pub fn batch_ema<'py>(
if timeperiod == 0 { if timeperiod == 0 {
return Err(PyValueError::new_err("timeperiod must be >= 1")); return Err(PyValueError::new_err("timeperiod must be >= 1"));
} }
let arr = data.as_array(); let (n_samples, n_series) = data.as_array().dim();
let (n_samples, n_series) = arr.dim();
log::debug!( log::debug!(
"batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" "batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
); );
Ok(run_unary_batch(py, data, parallel, |col| {
let columns: Vec<Vec<f64>> = (0..n_series) ferro_ta_core::overlap::ema(col, timeperiod)
.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))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -157,18 +378,13 @@ pub fn batch_rsi<'py>(
if timeperiod == 0 { if timeperiod == 0 {
return Err(PyValueError::new_err("timeperiod must be >= 1")); return Err(PyValueError::new_err("timeperiod must be >= 1"));
} }
let arr = data.as_array(); let (n_samples, n_series) = data.as_array().dim();
let (n_samples, n_series) = arr.dim();
log::debug!( log::debug!(
"batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}" "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 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]; let mut col_result = vec![f64::NAN; n_samples];
if n_samples <= timeperiod { if n_samples <= timeperiod {
return col_result; return col_result;
@@ -208,23 +424,7 @@ pub fn batch_rsi<'py>(
col_result[i] = 100.0 - 100.0 / (1.0 + rs); col_result[i] = 100.0 - 100.0 / (1.0 + rs);
} }
col_result 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_l = low.as_array();
let arr_c = close.as_array(); let arr_c = close.as_array();
let (n_samples, n_series) = arr_h.dim(); 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) let high_by_series = transpose_to_series_major(arr_h);
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) let low_by_series = transpose_to_series_major(arr_l);
.collect(); let close_by_series = transpose_to_series_major(arr_c);
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 col_results: Vec<Vec<f64>> = py.allow_threads(|| { let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process_col = |j: usize| -> Vec<f64> { let process_col = |series_idx: usize| -> Vec<f64> {
ferro_ta_core::volatility::atr(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod) 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 { if parallel {
(0..n_series).into_par_iter().map(process_col).collect() (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() (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;
}
}
Ok(result.into_pyarray(py))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -303,23 +504,31 @@ pub fn batch_stoch<'py>(
let arr_l = low.as_array(); let arr_l = low.as_array();
let arr_c = close.as_array(); let arr_c = close.as_array();
let (n_samples, n_series) = arr_h.dim(); 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) let high_by_series = transpose_to_series_major(arr_h);
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) let low_by_series = transpose_to_series_major(arr_l);
.collect(); let close_by_series = transpose_to_series_major(arr_c);
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 col_results: Vec<(Vec<f64>, Vec<f64>)> = py.allow_threads(|| { 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( ferro_ta_core::momentum::stoch(
&cols_h[j], high_col,
&cols_l[j], low_col,
&cols_c[j], close_col,
fastk_period, fastk_period,
slowk_period, slowk_period,
slowd_period, slowd_period,
@@ -331,16 +540,7 @@ pub fn batch_stoch<'py>(
(0..n_series).map(process_col).collect() (0..n_series).map(process_col).collect()
} }
}); });
Ok(finish_pair_output(py, n_samples, n_series, col_results))
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)))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -364,20 +564,28 @@ pub fn batch_adx<'py>(
let arr_l = low.as_array(); let arr_l = low.as_array();
let arr_c = close.as_array(); let arr_c = close.as_array();
let (n_samples, n_series) = arr_h.dim(); 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) let high_by_series = transpose_to_series_major(arr_h);
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect()) let low_by_series = transpose_to_series_major(arr_l);
.collect(); let close_by_series = transpose_to_series_major(arr_c);
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 col_results: Vec<Vec<f64>> = py.allow_threads(|| { let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
let process_col = |j: usize| -> Vec<f64> { let process_col = |series_idx: usize| -> Vec<f64> {
ferro_ta_core::momentum::adx(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod) 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 { if parallel {
(0..n_series).into_par_iter().map(process_col).collect() (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() (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() { // grouped 1-D execution
for (i, &val) in col_result.iter().enumerate() { // ---------------------------------------------------------------------------
result[[i, j]] = val;
#[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_atr, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(batch_stoch, 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!(batch_adx, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(run_close_indicators, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(run_hlc_indicators, m)?)?;
Ok(()) Ok(())
} }
+63 -23
View File
@@ -4,10 +4,36 @@
//! - `top_n_indices` — indices of the N largest values in a 1-D array //! - `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 //! - `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::exceptions::PyValueError;
use pyo3::prelude::*; 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 // rank_series
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -33,30 +59,43 @@ pub fn rank_series<'py>(
if n == 0 { if n == 0 {
return Err(PyValueError::new_err("x must be non-empty")); return Err(PyValueError::new_err("x must be non-empty"));
} }
// Sort indices by value Ok(rank_values(xv).into_pyarray(py))
let mut order: Vec<usize> = (0..n).collect(); }
order.sort_by(|&a, &b| {
xv[a] // ---------------------------------------------------------------------------
.partial_cmp(&xv[b]) // compose_rank
.unwrap_or(std::cmp::Ordering::Equal) // ---------------------------------------------------------------------------
/// 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]; Ok(scores.into_pyarray(py))
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))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -122,6 +161,7 @@ pub fn bottom_n_indices<'py>(
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(rank_series, m)?)?; 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!(top_n_indices, m)?)?;
m.add_function(wrap_pyfunction!(bottom_n_indices, m)?)?; m.add_function(wrap_pyfunction!(bottom_n_indices, m)?)?;
Ok(()) Ok(())
+117 -33
View File
@@ -2,6 +2,45 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*; 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 in timeperiod..n {
let start = end - timeperiod;
let mut rx = vec![0.0_f64; timeperiod];
let mut ry = vec![0.0_f64; timeperiod];
for offset in 0..timeperiod {
let prev = start + offset;
let curr = prev + 1;
rx[offset] = price_return(x[curr], x[prev]);
ry[offset] = price_return(y[curr], y[prev]);
}
let mean_x = rx.iter().sum::<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;
result[end] = if var_x != 0.0 { cov / var_x } else { f64::NAN };
}
result
}
/// Beta: regression of *real1* daily returns on *real0* daily returns over a /// Beta: regression of *real1* daily returns on *real0* daily returns over a
/// rolling window of *timeperiod* return pairs. /// rolling window of *timeperiod* return pairs.
/// ///
@@ -24,39 +63,84 @@ pub fn beta<'py>(
let y = real1.as_slice()?; let y = real1.as_slice()?;
let n = x.len(); let n = x.len();
validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; 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 if x.iter().any(|value| !value.is_finite()) || y.iter().any(|value| !value.is_finite()) {
#[allow(clippy::needless_range_loop)] return Ok(beta_fallback(x, y, timeperiod).into_pyarray(py));
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 };
} }
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 in timeperiod..n {
result[end] = if invalid_pairs == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
} else {
f64::NAN
}
} else {
f64::NAN
};
if end + 1 < n {
let outgoing = end - timeperiod;
let incoming = end;
let outgoing_x = rx[outgoing];
let outgoing_y = ry[outgoing];
if outgoing_x.is_finite() && outgoing_y.is_finite() {
sum_rx -= outgoing_x;
sum_ry -= outgoing_y;
sum_rx2 -= outgoing_x * outgoing_x;
sum_rxry -= outgoing_x * outgoing_y;
} else {
invalid_pairs -= 1;
}
let incoming_x = rx[incoming];
let incoming_y = ry[incoming];
if incoming_x.is_finite() && incoming_y.is_finite() {
sum_rx += incoming_x;
sum_ry += incoming_y;
sum_rx2 += incoming_x * incoming_x;
sum_rxry += incoming_x * incoming_y;
} else {
invalid_pairs += 1;
}
}
}
Ok(result.into_pyarray(py)) 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; let intercept = (sum_y - slope * sum_x) / n;
(slope, intercept) (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 numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*; 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 in (timeperiod - 1)..n {
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;
result[end] = if denom != 0.0 { cov / denom } else { f64::NAN };
}
result
}
/// Pearson correlation coefficient between two series over the rolling window. /// Pearson correlation coefficient between two series over the rolling window.
#[pyfunction] #[pyfunction]
#[pyo3(signature = (real0, real1, timeperiod = 30))] #[pyo3(signature = (real0, real1, timeperiod = 30))]
@@ -16,21 +45,57 @@ pub fn correl<'py>(
let y = real1.as_slice()?; let y = real1.as_slice()?;
let n = x.len(); let n = x.len();
validation::validate_equal_length(&[(n, "real0"), (y.len(), "real1")])?; 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]; let mut result = vec![f64::NAN; n];
for i in (timeperiod - 1)..n { if n < timeperiod {
let wx = &x[(i + 1 - timeperiod)..=i]; return Ok(result.into_pyarray(py));
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 period = timeperiod as f64;
let cov: f64 = wx let mut sum_x = x[..timeperiod].iter().sum::<f64>();
.iter() let mut sum_y = y[..timeperiod].iter().sum::<f64>();
.zip(wy.iter()) let mut sum_x2 = x[..timeperiod]
.map(|(&xi, &yi)| (xi - mean_x) * (yi - mean_y)) .iter()
.sum::<f64>(); .map(|value| value * value)
let std_x: f64 = (wx.iter().map(|&xi| (xi - mean_x).powi(2)).sum::<f64>()).sqrt(); .sum::<f64>();
let std_y: f64 = (wy.iter().map(|&yi| (yi - mean_y).powi(2)).sum::<f64>()).sqrt(); let mut sum_y2 = y[..timeperiod]
let denom = std_x * std_y; .iter()
result[i] = if denom != 0.0 { cov / denom } else { f64::NAN }; .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 in (timeperiod - 1)..n {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN
};
if end + 1 < n {
let outgoing = end + 1 - timeperiod;
let incoming = end + 1;
let outgoing_x = x[outgoing];
let outgoing_y = y[outgoing];
let incoming_x = x[incoming];
let incoming_y = y[incoming];
sum_x += incoming_x - outgoing_x;
sum_y += incoming_y - outgoing_y;
sum_x2 += incoming_x * incoming_x - outgoing_x * outgoing_x;
sum_y2 += incoming_y * incoming_y - outgoing_y * outgoing_y;
sum_xy += incoming_x * incoming_y - outgoing_x * outgoing_y;
}
} }
Ok(result.into_pyarray(py)) 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 crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1}; use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*; use pyo3::prelude::*;
@@ -14,13 +14,10 @@ pub fn linearreg<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> { ) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?; let prices = close.as_slice()?;
let n = prices.len(); let last_x = (timeperiod - 1) as f64;
let mut result = vec![f64::NAN; n]; let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| {
for i in (timeperiod - 1)..n { intercept + slope * last_x
let window = &prices[(i + 1 - timeperiod)..=i]; });
let (slope, intercept) = linreg(window);
result[i] = intercept + slope * (timeperiod - 1) as f64;
}
Ok(result.into_pyarray(py)) Ok(result.into_pyarray(py))
} }
@@ -34,13 +31,7 @@ pub fn linearreg_slope<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> { ) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?; let prices = close.as_slice()?;
let n = prices.len(); let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope);
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;
}
Ok(result.into_pyarray(py)) Ok(result.into_pyarray(py))
} }
@@ -54,13 +45,7 @@ pub fn linearreg_intercept<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> { ) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?; let prices = close.as_slice()?;
let n = prices.len(); let result = rolling_linreg_apply(prices, timeperiod, |_, intercept| intercept);
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;
}
Ok(result.into_pyarray(py)) Ok(result.into_pyarray(py))
} }
@@ -74,13 +59,7 @@ pub fn linearreg_angle<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> { ) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?; let prices = close.as_slice()?;
let n = prices.len(); let result = rolling_linreg_apply(prices, timeperiod, |slope, _| slope.atan() * 180.0 / PI);
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;
}
Ok(result.into_pyarray(py)) Ok(result.into_pyarray(py))
} }
@@ -94,13 +73,9 @@ pub fn tsf<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> { ) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?; validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?; let prices = close.as_slice()?;
let n = prices.len(); let forecast_x = timeperiod as f64;
let mut result = vec![f64::NAN; n]; let result = rolling_linreg_apply(prices, timeperiod, |slope, intercept| {
for i in (timeperiod - 1)..n { intercept + slope * forecast_x
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;
}
Ok(result.into_pyarray(py)) Ok(result.into_pyarray(py))
} }
+1 -1
View File
@@ -2,7 +2,7 @@
//! Each function (or closely related group) lives in its own file. //! Each function (or closely related group) lives in its own file.
mod beta; mod beta;
mod common; pub(crate) mod common;
mod correl; mod correl;
mod linearreg; mod linearreg;
mod stddev; 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 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 # STDDEV
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -100,6 +162,11 @@ class TestLINEARREG:
def test_length(self): def test_length(self):
assert len(LINEARREG(_A, 14)) == N 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 # LINEARREG_SLOPE
@@ -179,6 +246,11 @@ class TestBETA:
valid = result[~np.isnan(result)] valid = result[~np.isnan(result)]
assert np.all(np.isfinite(valid)) 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 # CORREL
@@ -205,6 +277,11 @@ class TestCOREL:
def test_length(self): def test_length(self):
assert len(CORREL(_A, _B, 10)) == N 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 # TSF
@@ -226,3 +303,8 @@ class TestTSF:
def test_length(self): def test_length(self):
assert len(TSF(_A, 14)) == N 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)
+84
View File
@@ -323,6 +323,21 @@ class TestSignalComposition:
score = compose(sigs, method="rank") score = compose(sigs, method="rank")
assert score.shape == (30,) 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): def test_compose_equal_weights_default(self):
from ferro_ta.analysis.signals import compose from ferro_ta.analysis.signals import compose
@@ -574,6 +589,75 @@ class TestFeatureMatrix:
fm = feature_matrix(ohlcv, ["SMA"]) fm = feature_matrix(ohlcv, ["SMA"])
assert "SMA" in fm 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) # Viz (smoke tests)
+50 -1
View File
@@ -297,6 +297,40 @@ class TestBacktest:
result_no_slip.n_trades == 0 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 # Plugin / Registry
@@ -509,7 +543,13 @@ class TestChoppinessIndex:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from ferro_ta import EMA, RSI, SMA 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: class TestBatchSMA:
@@ -587,6 +627,15 @@ class TestBatchApply:
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3) 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 # Release playbook and version consistency
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
Generated
+1 -1
View File
@@ -609,7 +609,7 @@ wheels = [
[[package]] [[package]]
name = "ferro-ta" name = "ferro-ta"
version = "1.0.0" version = "1.0.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.0.0" version = "1.0.2"
edition = "2021" edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators" description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT" 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", "name": "ferro-ta-wasm",
"version": "1.0.1", "version": "1.0.2",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators", "description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "pkg/ferro_ta_wasm.js", "main": "pkg/ferro_ta_wasm.js",
"types": "pkg/ferro_ta_wasm.d.ts", "types": "pkg/ferro_ta_wasm.d.ts",
"files": ["pkg"], "files": ["pkg"],
"scripts": { "scripts": {
"build": "wasm-pack build --target nodejs --out-dir pkg", "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 })\"", "prepack": "npm run build && node -e \"require('fs').rmSync('pkg/.gitignore', { force: true })\"",
"test": "wasm-pack test --node" "test": "wasm-pack test --node"
}, },