Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d776b6f90 | |||
| 71b6343e92 | |||
| 53566b9d82 | |||
| ba77fbd418 |
@@ -41,10 +41,10 @@ jobs:
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
|
||||
- name: Run pyright on ferro_ta via uv
|
||||
run: uv run --with pyright pyright python/ferro_ta
|
||||
run: uv run --with pyright python -m pyright python/ferro_ta
|
||||
|
||||
test:
|
||||
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- name: Install maturin and test dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml mcp
|
||||
|
||||
- name: Build and install ferro_ta (dev mode)
|
||||
run: |
|
||||
@@ -73,6 +73,10 @@ jobs:
|
||||
- name: Run unit tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Check API manifest is current
|
||||
if: matrix.python-version == '3.12'
|
||||
run: python scripts/check_api_manifest.py
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
|
||||
@@ -35,6 +35,9 @@ jobs:
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Check API manifest is current
|
||||
run: python3 scripts/check_api_manifest.py
|
||||
|
||||
- name: Benchmark WASM package
|
||||
working-directory: wasm
|
||||
run: node bench.js --json ../wasm_benchmark.json
|
||||
|
||||
@@ -31,6 +31,9 @@ env/
|
||||
# WASM build output
|
||||
wasm/pkg/
|
||||
wasm/pkg-web/
|
||||
benchmark_vs_talib.json
|
||||
wasm_benchmark.json
|
||||
.wasm_benchmark.prepush.json
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
|
||||
+13
-2
@@ -1,12 +1,12 @@
|
||||
# Pre-commit hooks for ferro-ta
|
||||
# Install: pre-commit install
|
||||
# Install: pre-commit install --hook-type pre-commit --hook-type pre-push
|
||||
# Run: pre-commit run --all-files
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
rev: v0.15.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
@@ -18,6 +18,7 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
exclude: ^conda/meta\.yaml$
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
@@ -31,3 +32,13 @@ repos:
|
||||
# entry: mypy python/ferro_ta --ignore-missing-imports
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ci-basic-pre-push
|
||||
name: ci basic pre-push
|
||||
entry: scripts/pre_push_checks.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
|
||||
+38
-1
@@ -9,6 +9,42 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.6] — 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added a repo-managed pre-push gate that mirrors the core local CI and
|
||||
release checks, including version and changelog validation, Rust formatting
|
||||
and clippy, Python linting and type checks, tests, docs, and the WASM smoke
|
||||
suite.
|
||||
- Added generated API manifest tooling and CI coverage so Python and WASM
|
||||
export drift is detected before release candidates are pushed.
|
||||
- Expanded the Rust-backed implementation surface for analysis and data-heavy
|
||||
workflows, including backtest signal generation, portfolio loops, payoff and
|
||||
Greeks aggregation, chunked indicator execution, and related helper paths.
|
||||
- Expanded the WASM package surface with additional indicator exports such as
|
||||
`WMA`, `ADX`, and `MFI`, along with refreshed Node examples and conformance
|
||||
coverage against the Python package.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed benchmark wrappers, perf-contract artifacts, and benchmark
|
||||
comparison helpers so the checked-in performance evidence stays aligned with
|
||||
the current feature set.
|
||||
- Hardened Python CI and local tooling so they run the same typecheck and test
|
||||
entrypoints, including installing the optional MCP dependency needed by the
|
||||
MCP server tests.
|
||||
- Updated local pre-commit integration to match the current Ruff
|
||||
configuration and refreshed locked dependencies to pick up the audited
|
||||
PyJWT security fix.
|
||||
|
||||
### Fixed
|
||||
|
||||
- One-off benchmark output files produced in the repository root are now
|
||||
ignored so local benchmarking no longer dirties the repo by default.
|
||||
- Tightened API typing and MCP helper behavior so the stricter lint and
|
||||
typecheck pipeline passes consistently before release.
|
||||
|
||||
## [1.0.4] — 2026-03-24
|
||||
|
||||
### Added
|
||||
@@ -355,7 +391,8 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...HEAD
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.6...HEAD
|
||||
[1.0.6]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...v1.0.6
|
||||
[1.0.4]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.3...v1.0.4
|
||||
[1.0.3]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...v1.0.3
|
||||
[1.0.2]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...v1.0.2
|
||||
|
||||
@@ -36,6 +36,34 @@ uv run ruff check python/ tests/
|
||||
uv run mypy python/ferro_ta --ignore-missing-imports
|
||||
```
|
||||
|
||||
## Git hooks and pre-push checks
|
||||
|
||||
Install the repo-managed git hooks after syncing your environment:
|
||||
|
||||
```bash
|
||||
make hooks
|
||||
```
|
||||
|
||||
That installs both the existing `pre-commit` hook and a `pre-push` hook that
|
||||
runs the local CI gate before anything is pushed.
|
||||
|
||||
To run the same gate manually:
|
||||
|
||||
```bash
|
||||
make prepush
|
||||
```
|
||||
|
||||
To run only part of it while iterating:
|
||||
|
||||
```bash
|
||||
make prepush CHECKS="version changelog python_lint"
|
||||
```
|
||||
|
||||
The pre-push runner covers the basic required CI categories we can execute
|
||||
locally: version/changelog checks, Rust fmt/clippy/core checks, Python
|
||||
lint/typecheck/tests, docs, and WASM. It intentionally skips the multi-version
|
||||
matrix, audit, and benchmark-regression jobs.
|
||||
|
||||
## Alternative: set up with plain pip
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+2
-2
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"wide",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
edition = "2021"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
license = "MIT"
|
||||
@@ -30,7 +30,7 @@ ndarray = "0.16"
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.4" }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.6" }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ferro-ta development Makefile
|
||||
# Usage: make <target>
|
||||
|
||||
.PHONY: help dev build test lint typecheck fmt docs clean bench version
|
||||
.PHONY: help dev build test lint typecheck fmt docs clean bench version audit prepush hooks
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -17,12 +17,14 @@ help:
|
||||
@echo " make bench Run Rust criterion benchmarks (ferro_ta_core)"
|
||||
@echo " make version Bump tracked version strings (set VERSION=X.Y.Z)"
|
||||
@echo " make audit Run cargo-audit + pip-audit"
|
||||
@echo " make prepush Run the local pre-push CI gate (set CHECKS='version rust_fmt' to scope it)"
|
||||
@echo " make hooks Install pre-commit and pre-push git hooks"
|
||||
@echo " make clean Remove build artefacts"
|
||||
|
||||
dev:
|
||||
pip install uv
|
||||
uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \
|
||||
sphinx sphinx-rtd-theme ruff mypy pyright
|
||||
sphinx sphinx-rtd-theme ruff mypy pyright pre-commit
|
||||
|
||||
build:
|
||||
maturin develop --release
|
||||
@@ -57,6 +59,12 @@ audit:
|
||||
cargo audit
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
prepush:
|
||||
bash scripts/pre_push_checks.sh $(CHECKS)
|
||||
|
||||
hooks:
|
||||
uv run --with pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info
|
||||
|
||||
@@ -70,23 +70,23 @@ rustflags = ["-C", "target-cpu=native"]
|
||||
### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement)
|
||||
|
||||
#### SMA — O(n) running sum
|
||||
Current: recomputes each window.
|
||||
Current: recomputes each window.
|
||||
Target: single-pass running sum (already done in Rust — verify SIMD path is hit).
|
||||
|
||||
#### BBANDS — Welford's algorithm
|
||||
Current: compute mean, then variance in two passes.
|
||||
Current: compute mean, then variance in two passes.
|
||||
Target: Welford's online algorithm — single pass, better cache utilization.
|
||||
|
||||
#### ATR/ADX — Avoid redundant True Range calculations
|
||||
Current: ATR → ADX each compute TR independently.
|
||||
Current: ATR → ADX each compute TR independently.
|
||||
Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass.
|
||||
|
||||
#### MACD — Reuse EMA computations
|
||||
Current: Compute fast EMA and slow EMA separately.
|
||||
Current: Compute fast EMA and slow EMA separately.
|
||||
Target: Single function computes both EMAs in one pass.
|
||||
|
||||
#### Candlestick Patterns — Batch lookup table
|
||||
Current: Sequential condition checks per bar.
|
||||
Current: Sequential condition checks per bar.
|
||||
Target: Pre-compute body/shadow ratios, vectorized pattern matching.
|
||||
|
||||
### Phase 4: Streaming Precomputation (Target: 100x for incremental updates)
|
||||
|
||||
@@ -259,4 +259,3 @@ for the first `timeperiod - 1` bars.
|
||||
|
||||
> `ferro-ta` implements 100% of TA-Lib's function set. NaN values are placed
|
||||
> at the beginning of each output array for the warmup period.
|
||||
|
||||
|
||||
+18
-18
@@ -77,7 +77,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -117,12 +117,12 @@ app = FastAPI(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _nan_to_none(arr: np.ndarray) -> List[Optional[float]]:
|
||||
def _nan_to_none(arr: np.ndarray) -> list[float | None]:
|
||||
"""Convert numpy array to list, replacing NaN/Inf with None."""
|
||||
return [None if not math.isfinite(v) else float(v) for v in arr]
|
||||
|
||||
|
||||
def _validate_series(close: List[float]) -> np.ndarray:
|
||||
def _validate_series(close: list[float]) -> np.ndarray:
|
||||
if len(close) > MAX_SERIES_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -142,54 +142,54 @@ def _validate_series(close: List[float]) -> np.ndarray:
|
||||
|
||||
|
||||
class IndicatorRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=14, ge=1, description="Look-back period")
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class MACDRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
fastperiod: int = Field(default=12, ge=1)
|
||||
slowperiod: int = Field(default=26, ge=1)
|
||||
signalperiod: int = Field(default=9, ge=1)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BBANDSRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=5, ge=2)
|
||||
nbdevup: float = Field(default=2.0, gt=0)
|
||||
nbdevdn: float = Field(default=2.0, gt=0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
strategy: str = Field(default="rsi_30_70")
|
||||
commission_per_trade: float = Field(default=0.0, ge=0.0)
|
||||
slippage_bps: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
@@ -201,13 +201,13 @@ class BacktestRequest(BaseModel):
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> Dict[str, str]:
|
||||
def health() -> dict[str, str]:
|
||||
"""Readiness / liveness probe."""
|
||||
return {"status": "ok", "version": app.version}
|
||||
|
||||
|
||||
@app.post("/indicators/sma", summary="Simple Moving Average")
|
||||
def compute_sma(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_sma(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Simple Moving Average (SMA).
|
||||
|
||||
Returns ``result``: list of floats (null for warm-up bars).
|
||||
@@ -218,7 +218,7 @@ def compute_sma(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/ema", summary="Exponential Moving Average")
|
||||
def compute_ema(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_ema(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Exponential Moving Average (EMA)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
@@ -226,7 +226,7 @@ def compute_ema(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/rsi", summary="Relative Strength Index")
|
||||
def compute_rsi(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_rsi(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Relative Strength Index (RSI)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
@@ -234,7 +234,7 @@ def compute_rsi(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/macd", summary="MACD")
|
||||
def compute_macd(req: MACDRequest) -> Dict[str, Any]:
|
||||
def compute_macd(req: MACDRequest) -> dict[str, Any]:
|
||||
"""Compute MACD (line, signal, histogram).
|
||||
|
||||
Returns ``result`` with keys ``macd``, ``signal``, ``hist``.
|
||||
@@ -256,7 +256,7 @@ def compute_macd(req: MACDRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/bbands", summary="Bollinger Bands")
|
||||
def compute_bbands(req: BBANDSRequest) -> Dict[str, Any]:
|
||||
def compute_bbands(req: BBANDSRequest) -> dict[str, Any]:
|
||||
"""Compute Bollinger Bands (upper, middle, lower).
|
||||
|
||||
Returns ``result`` with keys ``upper``, ``middle``, ``lower``.
|
||||
@@ -278,7 +278,7 @@ def compute_bbands(req: BBANDSRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/backtest", summary="Vectorized backtest")
|
||||
def run_backtest(req: BacktestRequest) -> Dict[str, Any]:
|
||||
def run_backtest(req: BacktestRequest) -> dict[str, Any]:
|
||||
"""Run a vectorized backtest using a named strategy.
|
||||
|
||||
Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``.
|
||||
|
||||
@@ -68,4 +68,4 @@
|
||||
"speedup_vs_separate": 2.2811
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1159,4 +1159,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,4 +1379,4 @@
|
||||
"outcome": "ferro_ta_win"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,4 +160,4 @@
|
||||
"elapsed_ms": 0.0026
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,4 +59,4 @@
|
||||
"sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,4 +93,4 @@
|
||||
"share_of_suite_pct": 0.09
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,4 +282,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,4 @@
|
||||
"stream_over_batch_ratio": 121.7603
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +48,17 @@ def run_batch_benchmark(
|
||||
"SMA",
|
||||
lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True),
|
||||
lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=False),
|
||||
lambda: [ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series)],
|
||||
lambda: [
|
||||
ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series)
|
||||
],
|
||||
),
|
||||
(
|
||||
"RSI",
|
||||
lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=True),
|
||||
lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=False),
|
||||
lambda: [ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series)],
|
||||
lambda: [
|
||||
ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series)
|
||||
],
|
||||
),
|
||||
(
|
||||
"ATR",
|
||||
@@ -201,7 +205,9 @@ def main() -> int:
|
||||
if payload["grouped_results"]:
|
||||
print("\nGrouped Multi-Indicator Calls")
|
||||
print("-" * 64)
|
||||
print(f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}")
|
||||
print(
|
||||
f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}"
|
||||
)
|
||||
print("-" * 64)
|
||||
for row in payload["grouped_results"]:
|
||||
print(
|
||||
|
||||
@@ -26,9 +26,10 @@ import math
|
||||
import sys
|
||||
import time
|
||||
import tracemalloc
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -70,9 +70,7 @@ def run_simd_benchmark(
|
||||
for label, args in variants
|
||||
}
|
||||
|
||||
portable_rows = {
|
||||
row["name"]: row for row in reports["portable_release"]["results"]
|
||||
}
|
||||
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]] = []
|
||||
@@ -136,9 +134,7 @@ def main() -> int:
|
||||
window=args.window,
|
||||
)
|
||||
|
||||
print(
|
||||
f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}"
|
||||
)
|
||||
print(f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}")
|
||||
print("-" * 64)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
|
||||
@@ -57,7 +57,9 @@ def _stream_hlcv(
|
||||
) -> float:
|
||||
streamer = factory()
|
||||
last = np.nan
|
||||
for high_value, low_value, close_value, volume_value in zip(high, low, close, volume):
|
||||
for high_value, low_value, close_value, volume_value in zip(
|
||||
high, low, close, volume
|
||||
):
|
||||
last = streamer.update(
|
||||
float(high_value),
|
||||
float(low_value),
|
||||
@@ -154,7 +156,9 @@ def run_streaming_benchmark(
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Benchmark streaming indicator execution.")
|
||||
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")
|
||||
|
||||
@@ -309,9 +309,13 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
|
||||
|
||||
if not TALIB_AVAILABLE:
|
||||
print("Note: ta-lib not installed. Reporting ferro_ta timings only.")
|
||||
print("Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n")
|
||||
print(
|
||||
"Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n"
|
||||
)
|
||||
|
||||
print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup")
|
||||
print(
|
||||
f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup"
|
||||
)
|
||||
print(f"Sizes: {sizes}")
|
||||
print()
|
||||
|
||||
@@ -328,11 +332,15 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
|
||||
if size == 1_000_000 and name in SKIP_1M_FOR:
|
||||
continue
|
||||
|
||||
ft_samples_ms = _timed_runs_ms(ft_run, open_, high, low, close, volume, size)
|
||||
ft_samples_ms = _timed_runs_ms(
|
||||
ft_run, open_, high, low, close, volume, size
|
||||
)
|
||||
ft_stats = _summary_stats(ft_samples_ms)
|
||||
ft_median_ms = float(ft_stats["median_ms"])
|
||||
ft_m_bars_s = _throughput_m_bars_s(size, ft_median_ms)
|
||||
ft_peak_bytes = _python_peak_bytes(ft_run, open_, high, low, close, volume, size)
|
||||
ft_peak_bytes = _python_peak_bytes(
|
||||
ft_run, open_, high, low, close, volume, size
|
||||
)
|
||||
|
||||
row: dict[str, Any] = {
|
||||
"indicator": name,
|
||||
@@ -351,11 +359,15 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
|
||||
}
|
||||
|
||||
if TALIB_AVAILABLE:
|
||||
ta_samples_ms = _timed_runs_ms(ta_run, open_, high, low, close, volume, size)
|
||||
ta_samples_ms = _timed_runs_ms(
|
||||
ta_run, open_, high, low, close, volume, size
|
||||
)
|
||||
ta_stats = _summary_stats(ta_samples_ms)
|
||||
ta_median_ms = float(ta_stats["median_ms"])
|
||||
ta_m_bars_s = _throughput_m_bars_s(size, ta_median_ms)
|
||||
speedup = ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf")
|
||||
speedup = (
|
||||
ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf")
|
||||
)
|
||||
outcome = _outcome(speedup)
|
||||
ta_peak_bytes = _python_peak_bytes(
|
||||
ta_run, open_, high, low, close, volume, size
|
||||
|
||||
@@ -9,7 +9,9 @@ Reads results.json and prints a markdown table: all indicators × all libraries.
|
||||
Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark
|
||||
data show ERR (indicating the benchmark run was incomplete or failed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -21,9 +23,11 @@ if _root not in (Path(p).resolve() for p in sys.path):
|
||||
|
||||
from benchmarks.wrapper_registry import (
|
||||
INDICATOR_CATEGORIES,
|
||||
LIBRARY_NAMES as LIBS,
|
||||
is_supported,
|
||||
)
|
||||
from benchmarks.wrapper_registry import (
|
||||
LIBRARY_NAMES as LIBS,
|
||||
)
|
||||
|
||||
|
||||
def _all_indicators() -> list[str]:
|
||||
@@ -34,11 +38,17 @@ def _all_indicators() -> list[str]:
|
||||
def main():
|
||||
p = Path(__file__).parent / "results.json"
|
||||
if not p.exists():
|
||||
print("Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v", file=sys.stderr)
|
||||
print(
|
||||
"Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
raw = p.read_text().strip()
|
||||
if not raw:
|
||||
print("results.json is empty. Run the full benchmark suite first.", file=sys.stderr)
|
||||
print(
|
||||
"results.json is empty. Run the full benchmark suite first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
|
||||
@@ -88,7 +88,9 @@ def main() -> int:
|
||||
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not data.get("talib_available", False):
|
||||
print("ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy.")
|
||||
print(
|
||||
"ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy."
|
||||
)
|
||||
return 1
|
||||
|
||||
summary_by_size = {
|
||||
@@ -133,9 +135,7 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if rows < args.min_rows:
|
||||
failures.append(
|
||||
f"size={size} rows {rows} < min_rows {args.min_rows}"
|
||||
)
|
||||
failures.append(f"size={size} rows {rows} < min_rows {args.min_rows}")
|
||||
if med < median_floor.get(size, float("-inf")):
|
||||
failures.append(
|
||||
f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}"
|
||||
|
||||
@@ -50,11 +50,17 @@ def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray:
|
||||
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)],
|
||||
[
|
||||
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)],
|
||||
[
|
||||
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
|
||||
@@ -120,7 +126,12 @@ def build_hotspot_report(
|
||||
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)}
|
||||
ohlcv = {
|
||||
"close": close,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"volume": np.full(price_bars, 1000.0),
|
||||
}
|
||||
|
||||
rows = [
|
||||
(
|
||||
@@ -218,7 +229,9 @@ def build_hotspot_report(
|
||||
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)
|
||||
row["share_of_suite_pct"] = round(
|
||||
float(row["fast_ms"]) / total_fast_ms * 100.0, 2
|
||||
)
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
@@ -249,7 +262,9 @@ def main() -> int:
|
||||
window=args.window,
|
||||
)
|
||||
|
||||
print(f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}")
|
||||
print(
|
||||
f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}"
|
||||
)
|
||||
print("-" * 70)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
|
||||
@@ -16094,4 +16094,4 @@
|
||||
],
|
||||
"datetime": "2026-03-23T17:14:05.427766+00:00",
|
||||
"version": "5.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]:
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for entry in INDICATOR_SUITE:
|
||||
elapsed_ms = _time_min(lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds)
|
||||
elapsed_ms = _time_min(
|
||||
lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"name": entry["name"],
|
||||
@@ -192,10 +194,7 @@ def main() -> int:
|
||||
fixtures=[FIXTURE_PATH],
|
||||
extra={"output_dir": str(output_dir)},
|
||||
),
|
||||
"artifacts": {
|
||||
name: file_info(path)
|
||||
for name, path in artifacts.items()
|
||||
},
|
||||
"artifacts": {name: file_info(path) for name, path in artifacts.items()},
|
||||
}
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
+60
-53
@@ -5,65 +5,66 @@ For each indicator we compare ferro_ta output against every available reference
|
||||
Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed).
|
||||
We only compare the overlapping (valid) suffix of each output array.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import MEDIUM
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_NAMES,
|
||||
INDICATOR_CATEGORIES,
|
||||
CUMULATIVE_INDICATORS,
|
||||
BINARY_INDICATORS,
|
||||
CUMULATIVE_INDICATORS,
|
||||
INDICATOR_CATEGORIES,
|
||||
INDICATOR_NAMES,
|
||||
available_libraries,
|
||||
execute_indicator,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
# Reference = ferro_ta; compare against each library that has a non-empty result.
|
||||
REFERENCE_LIB = "ferro_ta"
|
||||
COMPARISON_LIBS = [l for l in available_libraries() if l != REFERENCE_LIB]
|
||||
COMPARISON_LIBS = [
|
||||
library for library in available_libraries() if library != REFERENCE_LIB
|
||||
]
|
||||
|
||||
# Per-indicator tolerances (rtol, atol)
|
||||
_TOLERANCES: dict[str, tuple[float, float]] = {
|
||||
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
|
||||
"NATR": (1e-3, 0.10),
|
||||
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
|
||||
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
|
||||
"NATR": (1e-3, 0.10),
|
||||
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
|
||||
"STDDEV": (1e-3, 0.20),
|
||||
"VAR": (1e-3, 0.50),
|
||||
"MACD": (1e-3, 1e-3), # double EMA seed
|
||||
"KAMA": (1e-3, 1e-3),
|
||||
"STOCH": (1e-3, 0.10), # smoothing method differences
|
||||
"SAR": (1e-3, 0.20),
|
||||
"ADOSC": (1e-3, 0.20),
|
||||
"ADX": (1e-3, 0.50), # Wilder's ADX
|
||||
"PLUS_DI":(1e-3, 0.50),
|
||||
"MINUS_DI":(1e-3, 0.50),
|
||||
"PPO": (1e-2, 1e-3),
|
||||
"CMO": (1e-3, 0.10),
|
||||
"TRIX": (1e-3, 1e-3),
|
||||
"CCI": (1e-3, 0.10),
|
||||
"VAR": (1e-3, 0.50),
|
||||
"MACD": (1e-3, 1.00), # seed differences across libraries
|
||||
"KAMA": (1e-3, 1e-3),
|
||||
"STOCH": (1e-3, 0.10), # smoothing method differences
|
||||
"SAR": (1e-3, 0.20),
|
||||
"ADOSC": (1e-3, 0.20),
|
||||
"ADX": (1e-3, 0.50), # Wilder's ADX
|
||||
"PLUS_DI": (1e-3, 0.50),
|
||||
"MINUS_DI": (1e-3, 0.50),
|
||||
"PPO": (1e-2, 1e-3),
|
||||
"CMO": (1e-3, 0.10),
|
||||
"TRIX": (1e-3, 0.05),
|
||||
"CCI": (1e-3, 0.10),
|
||||
"SUPERTREND": (1e-2, 0.50),
|
||||
"KELTNER_CHANNELS": (1e-2, 0.50),
|
||||
"DONCHIAN": (1e-4, 1e-4),
|
||||
"HT_DCPERIOD": (1e-2, 1.0),
|
||||
"VWAP": (1e-3, 0.10),
|
||||
"AROON": (1e-4, 1e-3),
|
||||
"HT_DCPERIOD": (1e-2, 2.0),
|
||||
"VWAP": (1e-3, 0.10),
|
||||
"AROON": (1e-4, 1e-3),
|
||||
"LINEARREG": (1e-4, 1e-4),
|
||||
"LINEARREG_SLOPE": (1e-4, 1e-4),
|
||||
"CORREL": (1e-4, 1e-3),
|
||||
"BETA": (1e-3, 1e-3),
|
||||
"TSF": (1e-4, 1e-4),
|
||||
"EMA": (1e-3, 0.30), # ta library uses different EMA seed
|
||||
"DEMA": (1e-3, 0.50),
|
||||
"TEMA": (1e-3, 0.50),
|
||||
"T3": (1e-3, 0.50),
|
||||
"HULL_MA":(1e-3, 0.10),
|
||||
"WMA": (1e-4, 1e-4),
|
||||
"TRIMA": (1e-4, 1e-4),
|
||||
"MACD": (1e-3, 1.00), # seed differences across libraries
|
||||
"TRIX": (1e-3, 0.05),
|
||||
"HT_DCPERIOD": (1e-2, 2.0),
|
||||
"BETA": (1e-3, 1e-3),
|
||||
"TSF": (1e-4, 1e-4),
|
||||
"EMA": (1e-3, 0.30), # ta library uses different EMA seed
|
||||
"DEMA": (1e-3, 0.50),
|
||||
"TEMA": (1e-3, 0.50),
|
||||
"T3": (1e-3, 0.50),
|
||||
"HULL_MA": (1e-3, 0.10),
|
||||
"WMA": (1e-4, 1e-4),
|
||||
"TRIMA": (1e-4, 1e-4),
|
||||
}
|
||||
|
||||
_DEFAULT_TOL = (1e-4, 1e-5)
|
||||
@@ -71,33 +72,33 @@ _DEFAULT_TOL = (1e-4, 1e-5)
|
||||
# Pairs that use correlation check (>=0.95) due to known algorithmic divergence
|
||||
# Format: (indicator, library) or just indicator (applies to all libs)
|
||||
_CORRELATION_PAIRS: set[tuple[str, str]] = {
|
||||
("PPO", "talib"), # different PPO formula normalization
|
||||
("PPO", "talib"), # different PPO formula normalization
|
||||
("PPO", "pandas_ta"),
|
||||
("PPO", "tulipy"),
|
||||
("STOCH", "ta"),
|
||||
("SUPERTREND", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "ta"),
|
||||
("EMA", "finta"), # finta EMA uses different initialization
|
||||
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
|
||||
("RSI", "ta"), # ta uses SMA warmup vs Wilder
|
||||
("RSI", "finta"), # same
|
||||
("EMA", "finta"), # finta EMA uses different initialization
|
||||
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
|
||||
("RSI", "ta"), # ta uses SMA warmup vs Wilder
|
||||
("RSI", "finta"), # same
|
||||
}
|
||||
|
||||
# Pairs that are skipped because they are structurally incompatible
|
||||
_SKIP_PAIRS: set[tuple[str, str]] = {
|
||||
("BBANDS", "finta"), # finta normalizes band differently
|
||||
("ATR", "finta"), # finta ATR uses simple TR not Wilder
|
||||
("STDDEV", "finta"), # finta uses population std
|
||||
("TRIMA", "finta"), # finta TRIMA uses different formula
|
||||
("PPO", "finta"), # finta PPO scaling incompatible
|
||||
("STOCH", "finta"), # finta STOCH formula differs
|
||||
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
|
||||
("BBANDS", "finta"), # finta normalizes band differently
|
||||
("ATR", "finta"), # finta ATR uses simple TR not Wilder
|
||||
("STDDEV", "finta"), # finta uses population std
|
||||
("TRIMA", "finta"), # finta TRIMA uses different formula
|
||||
("PPO", "finta"), # finta PPO scaling incompatible
|
||||
("STOCH", "finta"), # finta STOCH formula differs
|
||||
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
|
||||
("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges
|
||||
("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90
|
||||
("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90
|
||||
("CMO", "pandas_ta"),
|
||||
("CMO", "finta"),
|
||||
("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70
|
||||
("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70
|
||||
}
|
||||
|
||||
MIN_OVERLAP = 30 # minimum points to make comparison meaningful
|
||||
@@ -114,12 +115,14 @@ def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) ->
|
||||
c = cmp[-n:]
|
||||
if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS:
|
||||
# Use correlation check for structurally different algorithms
|
||||
corr = np.corrcoef(r, c)[0, 1] if not indicator in BINARY_INDICATORS else None
|
||||
corr = np.corrcoef(r, c)[0, 1] if indicator not in BINARY_INDICATORS else None
|
||||
if indicator in BINARY_INDICATORS:
|
||||
agree = np.mean(r == c)
|
||||
assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%"
|
||||
else:
|
||||
assert corr >= 0.90, f"Correlation {corr:.4f} < 0.90 (structural divergence)"
|
||||
assert corr >= 0.90, (
|
||||
f"Correlation {corr:.4f} < 0.90 (structural divergence)"
|
||||
)
|
||||
elif indicator in CUMULATIVE_INDICATORS:
|
||||
dr, dc = np.diff(r), np.diff(c)
|
||||
if len(dr) < 5 or len(dc) < 5:
|
||||
@@ -136,6 +139,7 @@ def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) ->
|
||||
|
||||
# ── dynamically generate one test per (indicator, library) pair ─────────────
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
@@ -172,6 +176,7 @@ class TestAccuracy:
|
||||
|
||||
# ── quick smoke tests that always run (no skip) ──────────────────────────────
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Sanity checks that ferro_ta returns non-empty finite arrays."""
|
||||
|
||||
@@ -182,7 +187,9 @@ class TestSmoke:
|
||||
|
||||
arr = execute_indicator("ferro_ta", indicator, MEDIUM)
|
||||
assert len(arr) > 0, f"ferro_ta {indicator} returned empty array"
|
||||
assert np.all(np.isfinite(arr)), f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}"
|
||||
assert np.all(np.isfinite(arr)), (
|
||||
f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items())
|
||||
def test_category_coverage(self, category, indicators):
|
||||
|
||||
+31
-18
@@ -6,31 +6,36 @@ Run: pytest benchmarks/test_speed.py --benchmark-only -v
|
||||
|
||||
Streaming benchmarks are in test_streaming_speed.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import LARGE
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_CATEGORIES,
|
||||
available_libraries,
|
||||
execute_indicator,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
BENCH_DATA = LARGE # 100k bars for main benchmarks
|
||||
BENCH_LIBS = available_libraries()
|
||||
BENCH_DATA = LARGE # 100k bars for main benchmarks
|
||||
BENCH_LIBS = available_libraries()
|
||||
|
||||
|
||||
def _make_bench(indicator: str, library: str):
|
||||
"""Return a benchmark function that runs indicator on library (uses BENCH_DATA)."""
|
||||
|
||||
def _fn():
|
||||
execute_indicator(library, indicator, BENCH_DATA)
|
||||
|
||||
_fn.__name__ = f"{library}_{indicator}"
|
||||
return _fn
|
||||
|
||||
|
||||
# ── Parametrize over all (indicator, library) combinations ───────────────────
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
@@ -53,20 +58,24 @@ class TestSpeed:
|
||||
|
||||
# ── Standalone head-to-head for the most important indicators ─────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator,libs", [
|
||||
("SMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("EMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("RSI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MACD", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("BBANDS",["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ATR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("CCI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("WILLR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("OBV", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ADX", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MFI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("STOCH", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator,libs",
|
||||
[
|
||||
("SMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("EMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("RSI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("MACD", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("BBANDS", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("ATR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("CCI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("WILLR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("OBV", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("ADX", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("MFI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("STOCH", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
],
|
||||
)
|
||||
def test_head_to_head(benchmark, indicator, libs):
|
||||
"""Benchmark ferro_ta vs all peers — for README table generation."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
@@ -77,7 +86,11 @@ def test_head_to_head(benchmark, indicator, libs):
|
||||
|
||||
# ── Large dataset benchmarks (100k bars) ─────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator", ["SMA","EMA","RSI","MACD","ATR","BBANDS","OBV","CCI","ADX","MFI"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator",
|
||||
["SMA", "EMA", "RSI", "MACD", "ATR", "BBANDS", "OBV", "CCI", "ADX", "MFI"],
|
||||
)
|
||||
def test_large_dataset(benchmark, indicator):
|
||||
"""Scaling benchmark at 100k bars for ferro_ta."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
|
||||
+2521
-616
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.0.4" %}
|
||||
{% set version = "1.0.6" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
|
||||
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.0.4"
|
||||
ferro_ta_core = "1.0.6"
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -1,7 +1,18 @@
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
These docs track package version ``1.0.4``.
|
||||
These docs track package version ``1.0.6``.
|
||||
|
||||
1.0.6 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- Added a repo-managed pre-push gate so the core Rust, Python, docs, and WASM
|
||||
checks can be run locally before release.
|
||||
- Expanded Rust-backed analysis/data helpers, broadened the WASM exports, and
|
||||
added cross-surface API manifest verification plus Node conformance checks.
|
||||
- Refreshed benchmark coverage and perf artifacts, aligned Python CI with the
|
||||
local tooling flow, and updated the locked security fixes needed for a clean
|
||||
release pass.
|
||||
|
||||
1.0.4 (2026-03-24)
|
||||
------------------
|
||||
|
||||
@@ -26,6 +26,28 @@ Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``.
|
||||
pytest tests/
|
||||
|
||||
|
||||
Git hooks and pre-push checks
|
||||
-----------------------------
|
||||
|
||||
Install the repository-managed hooks after setting up the environment:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
make hooks
|
||||
|
||||
Run the same push gate manually with:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
make prepush
|
||||
|
||||
You can scope it to selected checks while iterating:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
make prepush CHECKS="version changelog python_lint"
|
||||
|
||||
|
||||
Adding a new indicator
|
||||
-----------------------
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ For source builds, packaging details, and platform notes, see
|
||||
Release status
|
||||
--------------
|
||||
|
||||
These docs track package version ``1.0.4``.
|
||||
These docs track package version ``1.0.6``.
|
||||
|
||||
- Release notes by version: :doc:`changelog`
|
||||
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"\n",
|
||||
"import ferro_ta.config as config\n",
|
||||
"from ferro_ta import BBANDS, EMA, RSI, SMA\n",
|
||||
"import numpy as np\n",
|
||||
"from ferro_ta.backtest import backtest\n",
|
||||
"from ferro_ta.pipeline import Pipeline\n",
|
||||
"\n",
|
||||
"from ferro_ta import BBANDS, EMA, RSI, SMA\n",
|
||||
"\n",
|
||||
"# Synthetic data\n",
|
||||
"np.random.seed(42)\n",
|
||||
"n = 300\n",
|
||||
|
||||
@@ -207,9 +207,10 @@
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from ferro_ta import FerroTAValueError\n",
|
||||
"from ferro_ta.exceptions import check_timeperiod\n",
|
||||
"\n",
|
||||
"from ferro_ta import FerroTAValueError\n",
|
||||
"\n",
|
||||
"try:\n",
|
||||
" check_timeperiod(0)\n",
|
||||
"except FerroTAValueError as e:\n",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import numpy as np\n",
|
||||
"\n",
|
||||
"from ferro_ta.streaming import (\n",
|
||||
" StreamingATR,\n",
|
||||
" StreamingBBands,\n",
|
||||
|
||||
+55
-33
@@ -2,16 +2,38 @@
|
||||
"metadata": {
|
||||
"suite": "batch",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.877702+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"generated_at_utc": "2026-03-24T09:13:04.010216+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.4"
|
||||
},
|
||||
"dataset": {
|
||||
"n_samples": 20000,
|
||||
@@ -23,49 +45,49 @@
|
||||
"results": [
|
||||
{
|
||||
"indicator": "SMA",
|
||||
"parallel_ms": 1.9615,
|
||||
"sequential_ms": 2.0423,
|
||||
"loop_ms": 0.8865,
|
||||
"parallel_speedup_vs_loop": 0.452,
|
||||
"sequential_speedup_vs_loop": 0.4341
|
||||
"parallel_ms": 7.9306,
|
||||
"sequential_ms": 2.4832,
|
||||
"loop_ms": 1.0238,
|
||||
"parallel_speedup_vs_loop": 0.1291,
|
||||
"sequential_speedup_vs_loop": 0.4123
|
||||
},
|
||||
{
|
||||
"indicator": "RSI",
|
||||
"parallel_ms": 2.1731,
|
||||
"sequential_ms": 4.3225,
|
||||
"loop_ms": 3.2043,
|
||||
"parallel_speedup_vs_loop": 1.4745,
|
||||
"sequential_speedup_vs_loop": 0.7413
|
||||
"parallel_ms": 3.9307,
|
||||
"sequential_ms": 5.0938,
|
||||
"loop_ms": 3.6883,
|
||||
"parallel_speedup_vs_loop": 0.9383,
|
||||
"sequential_speedup_vs_loop": 0.7241
|
||||
},
|
||||
{
|
||||
"indicator": "ATR",
|
||||
"parallel_ms": 4.2249,
|
||||
"sequential_ms": 6.6175,
|
||||
"loop_ms": 4.0382,
|
||||
"parallel_speedup_vs_loop": 0.9558,
|
||||
"sequential_speedup_vs_loop": 0.6102
|
||||
"parallel_ms": 9.2546,
|
||||
"sequential_ms": 7.768,
|
||||
"loop_ms": 5.2669,
|
||||
"parallel_speedup_vs_loop": 0.5691,
|
||||
"sequential_speedup_vs_loop": 0.678
|
||||
},
|
||||
{
|
||||
"indicator": "ADX",
|
||||
"parallel_ms": 4.8674,
|
||||
"sequential_ms": 7.6402,
|
||||
"loop_ms": 5.1861,
|
||||
"parallel_speedup_vs_loop": 1.0655,
|
||||
"sequential_speedup_vs_loop": 0.6788
|
||||
"parallel_ms": 9.5489,
|
||||
"sequential_ms": 9.1403,
|
||||
"loop_ms": 7.1578,
|
||||
"parallel_speedup_vs_loop": 0.7496,
|
||||
"sequential_speedup_vs_loop": 0.7831
|
||||
}
|
||||
],
|
||||
"grouped_results": [
|
||||
{
|
||||
"case": "close_bundle_3",
|
||||
"grouped_ms": 0.1878,
|
||||
"separate_ms": 0.1719,
|
||||
"speedup_vs_separate": 0.9155
|
||||
"grouped_ms": 0.466,
|
||||
"separate_ms": 0.2003,
|
||||
"speedup_vs_separate": 0.4298
|
||||
},
|
||||
{
|
||||
"case": "hlc_bundle_3",
|
||||
"grouped_ms": 0.2958,
|
||||
"separate_ms": 0.4633,
|
||||
"speedup_vs_separate": 1.5666
|
||||
"grouped_ms": 1.2538,
|
||||
"separate_ms": 0.6999,
|
||||
"speedup_vs_separate": 0.5582
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,38 @@
|
||||
"metadata": {
|
||||
"suite": "indicator_latency",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.515962+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"generated_at_utc": "2026-03-24T09:13:02.973728+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.4"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
@@ -33,7 +55,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0202
|
||||
"elapsed_ms": 0.0233
|
||||
},
|
||||
{
|
||||
"name": "WILLR_14",
|
||||
@@ -41,13 +63,13 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0183
|
||||
"elapsed_ms": 0.0207
|
||||
},
|
||||
{
|
||||
"name": "STOCH",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0181
|
||||
"elapsed_ms": 0.0202
|
||||
},
|
||||
{
|
||||
"name": "ADX_14",
|
||||
@@ -55,7 +77,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0147
|
||||
"elapsed_ms": 0.0171
|
||||
},
|
||||
{
|
||||
"name": "CCI_14",
|
||||
@@ -63,13 +85,13 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0144
|
||||
"elapsed_ms": 0.0164
|
||||
},
|
||||
{
|
||||
"name": "MACD",
|
||||
"inputs": "close",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0132
|
||||
"elapsed_ms": 0.015
|
||||
},
|
||||
{
|
||||
"name": "ATR_14",
|
||||
@@ -77,7 +99,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0108
|
||||
"elapsed_ms": 0.0116
|
||||
},
|
||||
{
|
||||
"name": "RSI_14",
|
||||
@@ -85,7 +107,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0102
|
||||
"elapsed_ms": 0.011
|
||||
},
|
||||
{
|
||||
"name": "STDDEV_20",
|
||||
@@ -93,7 +115,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0086
|
||||
"elapsed_ms": 0.0103
|
||||
},
|
||||
{
|
||||
"name": "BETA_5",
|
||||
@@ -101,7 +123,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 5
|
||||
},
|
||||
"elapsed_ms": 0.008
|
||||
"elapsed_ms": 0.0091
|
||||
},
|
||||
{
|
||||
"name": "CORREL_30",
|
||||
@@ -109,15 +131,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 30
|
||||
},
|
||||
"elapsed_ms": 0.0068
|
||||
},
|
||||
{
|
||||
"name": "EMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0052
|
||||
"elapsed_ms": 0.0078
|
||||
},
|
||||
{
|
||||
"name": "BBANDS_20",
|
||||
@@ -125,7 +139,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.005
|
||||
"elapsed_ms": 0.0062
|
||||
},
|
||||
{
|
||||
"name": "TSF_14",
|
||||
@@ -133,7 +147,15 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0048
|
||||
"elapsed_ms": 0.0056
|
||||
},
|
||||
{
|
||||
"name": "EMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0055
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_14",
|
||||
@@ -141,7 +163,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0046
|
||||
"elapsed_ms": 0.0054
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_SLOPE_14",
|
||||
@@ -149,7 +171,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0045
|
||||
"elapsed_ms": 0.005
|
||||
},
|
||||
{
|
||||
"name": "SMA_20",
|
||||
@@ -157,7 +179,7 @@
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0027
|
||||
"elapsed_ms": 0.0032
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+37
-20
@@ -2,16 +2,38 @@
|
||||
"metadata": {
|
||||
"suite": "perf_contract",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:09:13.077731+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"generated_at_utc": "2026-03-24T09:13:08.787230+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.4"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
@@ -25,28 +47,23 @@
|
||||
"artifacts": {
|
||||
"indicator_latency": {
|
||||
"path": "perf-contract/indicator_latency.json",
|
||||
"size_bytes": 3212,
|
||||
"sha256": "1e7f844fe6eb467f07bbb1e4eb918c56861e8cf819ab4802b96e033c6d2570c4"
|
||||
"size_bytes": 4041,
|
||||
"sha256": "564027c7abed7ecd4ae2ac1720217d96e1e31807f8ac7d5c5393c8fd974f13ed"
|
||||
},
|
||||
"batch": {
|
||||
"path": "perf-contract/batch.json",
|
||||
"size_bytes": 1679,
|
||||
"sha256": "202d67b24a88184473f93cec9f866ca34ff60721068e15e7e5d23962f006d169"
|
||||
"size_bytes": 2507,
|
||||
"sha256": "c50f242a138a0c358a6fa86c420954b4b11a2200598ca2942bcd0fd2bc456fb2"
|
||||
},
|
||||
"streaming": {
|
||||
"path": "perf-contract/streaming.json",
|
||||
"size_bytes": 1939,
|
||||
"sha256": "04476c3d95a9e7d87e40331b28c5ccad9a7aa5abcfc47fb6ac7e929365f68554"
|
||||
"size_bytes": 2766,
|
||||
"sha256": "d271d3219ec098e443e84ef648ab4220cd2a38e6dc998bc6c9fc545d7fc77716"
|
||||
},
|
||||
"runtime_hotspots": {
|
||||
"path": "perf-contract/runtime_hotspots.json",
|
||||
"size_bytes": 2365,
|
||||
"sha256": "d37a58bc88def9f1525b9b0f64f0bd7b9c0df9ed4cae260c833697f6a1689ca8"
|
||||
},
|
||||
"simd": {
|
||||
"path": "perf-contract/simd.json",
|
||||
"size_bytes": 7670,
|
||||
"sha256": "3f9b341a8206698ed172650752d808621c9b5218f42a2373f0f4485496197b4c"
|
||||
"size_bytes": 3197,
|
||||
"sha256": "96eafc9a61ac410e47fcdfee6a9db1123cdee0ec05f49a85c631a3975063deed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,38 @@
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:34.436165+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"generated_at_utc": "2026-03-24T09:13:08.521805+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.4"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
@@ -23,74 +45,74 @@
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 28.857,
|
||||
"reference_ms": 897.0178,
|
||||
"speedup_vs_reference": 31.0849,
|
||||
"share_of_suite_pct": 70.0
|
||||
"fast_ms": 32.5847,
|
||||
"reference_ms": 990.4233,
|
||||
"speedup_vs_reference": 30.3954,
|
||||
"share_of_suite_pct": 69.27
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 10.8496,
|
||||
"reference_ms": 199.4376,
|
||||
"speedup_vs_reference": 18.3821,
|
||||
"share_of_suite_pct": 26.32
|
||||
"fast_ms": 12.2974,
|
||||
"reference_ms": 233.0671,
|
||||
"speedup_vs_reference": 18.9525,
|
||||
"share_of_suite_pct": 26.14
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 0.9124,
|
||||
"reference_ms": 79.6557,
|
||||
"speedup_vs_reference": 87.3019,
|
||||
"share_of_suite_pct": 2.21
|
||||
"fast_ms": 0.9511,
|
||||
"reference_ms": 90.5663,
|
||||
"speedup_vs_reference": 95.2202,
|
||||
"share_of_suite_pct": 2.02
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2411,
|
||||
"reference_ms": 0.2215,
|
||||
"speedup_vs_reference": 0.9186,
|
||||
"share_of_suite_pct": 0.58
|
||||
"fast_ms": 0.6619,
|
||||
"reference_ms": 0.6155,
|
||||
"speedup_vs_reference": 0.9299,
|
||||
"share_of_suite_pct": 1.41
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1563,
|
||||
"reference_ms": 0.1498,
|
||||
"speedup_vs_reference": 0.9587,
|
||||
"share_of_suite_pct": 0.38
|
||||
"fast_ms": 0.3175,
|
||||
"reference_ms": 0.2437,
|
||||
"speedup_vs_reference": 0.7675,
|
||||
"share_of_suite_pct": 0.67
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0694,
|
||||
"reference_ms": 159.2715,
|
||||
"speedup_vs_reference": 2294.4163,
|
||||
"share_of_suite_pct": 0.17
|
||||
"fast_ms": 0.0742,
|
||||
"reference_ms": 188.6048,
|
||||
"speedup_vs_reference": 2541.5695,
|
||||
"share_of_suite_pct": 0.16
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0555,
|
||||
"reference_ms": 158.2775,
|
||||
"speedup_vs_reference": 2851.8468,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0414,
|
||||
"reference_ms": 44.8111,
|
||||
"speedup_vs_reference": 1081.9491,
|
||||
"share_of_suite_pct": 0.1
|
||||
"fast_ms": 0.0638,
|
||||
"reference_ms": 215.973,
|
||||
"speedup_vs_reference": 3387.8115,
|
||||
"share_of_suite_pct": 0.14
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0413,
|
||||
"reference_ms": 46.3799,
|
||||
"speedup_vs_reference": 1122.1039,
|
||||
"share_of_suite_pct": 0.1
|
||||
"fast_ms": 0.0441,
|
||||
"reference_ms": 49.6108,
|
||||
"speedup_vs_reference": 1125.3954,
|
||||
"share_of_suite_pct": 0.09
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.044,
|
||||
"reference_ms": 57.9675,
|
||||
"speedup_vs_reference": 1318.7009,
|
||||
"share_of_suite_pct": 0.09
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,4 +282,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,38 @@
|
||||
"metadata": {
|
||||
"suite": "streaming",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.968886+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"generated_at_utc": "2026-03-24T09:13:04.351797+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.4"
|
||||
},
|
||||
"dataset": {
|
||||
"n_bars": 20000,
|
||||
@@ -22,52 +44,52 @@
|
||||
{
|
||||
"indicator": "StreamingSMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.8808,
|
||||
"batch_total_ms": 0.0153,
|
||||
"stream_ns_per_update": 44.04,
|
||||
"batch_ns_per_bar": 0.77,
|
||||
"updates_per_second": 22705779.59,
|
||||
"stream_over_batch_ratio": 57.4469
|
||||
"stream_total_ms": 0.9927,
|
||||
"batch_total_ms": 0.0166,
|
||||
"stream_ns_per_update": 49.63,
|
||||
"batch_ns_per_bar": 0.83,
|
||||
"updates_per_second": 20147763.43,
|
||||
"stream_over_batch_ratio": 59.7092
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingEMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.8611,
|
||||
"batch_total_ms": 0.0408,
|
||||
"stream_ns_per_update": 43.06,
|
||||
"batch_ns_per_bar": 2.04,
|
||||
"updates_per_second": 23225431.81,
|
||||
"stream_over_batch_ratio": 21.0884
|
||||
"stream_total_ms": 0.9459,
|
||||
"batch_total_ms": 0.0435,
|
||||
"stream_ns_per_update": 47.3,
|
||||
"batch_ns_per_bar": 2.18,
|
||||
"updates_per_second": 21143503.9,
|
||||
"stream_over_batch_ratio": 21.7247
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingRSI",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.9235,
|
||||
"batch_total_ms": 0.0932,
|
||||
"stream_ns_per_update": 46.17,
|
||||
"batch_ns_per_bar": 4.66,
|
||||
"updates_per_second": 21656740.75,
|
||||
"stream_over_batch_ratio": 9.9035
|
||||
"stream_total_ms": 1.0059,
|
||||
"batch_total_ms": 0.0991,
|
||||
"stream_ns_per_update": 50.3,
|
||||
"batch_ns_per_bar": 4.95,
|
||||
"updates_per_second": 19882356.06,
|
||||
"stream_over_batch_ratio": 10.1523
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingATR",
|
||||
"inputs": "hlc",
|
||||
"stream_total_ms": 2.0074,
|
||||
"batch_total_ms": 0.0935,
|
||||
"stream_ns_per_update": 100.37,
|
||||
"batch_ns_per_bar": 4.68,
|
||||
"updates_per_second": 9963056.99,
|
||||
"stream_over_batch_ratio": 21.4603
|
||||
"stream_total_ms": 2.1574,
|
||||
"batch_total_ms": 0.0992,
|
||||
"stream_ns_per_update": 107.87,
|
||||
"batch_ns_per_bar": 4.96,
|
||||
"updates_per_second": 9270525.53,
|
||||
"stream_over_batch_ratio": 21.746
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingVWAP",
|
||||
"inputs": "hlcv",
|
||||
"stream_total_ms": 2.6068,
|
||||
"batch_total_ms": 0.0223,
|
||||
"stream_ns_per_update": 130.34,
|
||||
"batch_ns_per_bar": 1.11,
|
||||
"updates_per_second": 7672265.38,
|
||||
"stream_over_batch_ratio": 116.9385
|
||||
"stream_total_ms": 2.6935,
|
||||
"batch_total_ms": 0.0252,
|
||||
"stream_ns_per_update": 134.68,
|
||||
"batch_ns_per_bar": 1.26,
|
||||
"updates_per_second": 7425170.07,
|
||||
"stream_over_batch_ratio": 106.8484
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -53,6 +53,7 @@ dev = [
|
||||
"hypothesis>=6.0",
|
||||
"pandas>=1.0",
|
||||
"polars>=0.19",
|
||||
"pre-commit>=3.0",
|
||||
"ruff>=0.3",
|
||||
"mypy>=1.0",
|
||||
"pyright>=1.1",
|
||||
@@ -103,6 +104,7 @@ ignore = ["E501", "UP006", "UP045", "UP007"]
|
||||
"tests/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
|
||||
"tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
|
||||
"tests/integration/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
|
||||
"benchmarks/*" = ["E402", "E741"]
|
||||
"python/ferro_ta/*.py" = ["N802"] # Public API: SMA, RSI, ATR, etc.
|
||||
"python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design
|
||||
"python/ferro_ta/__init__.pyi" = ["N802", "E402"]
|
||||
@@ -130,6 +132,7 @@ dev = [
|
||||
"hypothesis>=6.0",
|
||||
"pandas>=1.0",
|
||||
"polars>=0.19",
|
||||
"pre-commit>=3.0",
|
||||
"ruff>=0.3",
|
||||
"mypy>=1.0",
|
||||
"pyright>=1.1",
|
||||
|
||||
@@ -38,6 +38,9 @@ from typing import Any, Optional
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
extract_trades as _rust_extract_trades,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
monthly_contribution as _rust_monthly_contribution,
|
||||
)
|
||||
@@ -199,31 +202,10 @@ def from_backtest(result: Any) -> tuple[NDArray[np.float64], NDArray[np.float64]
|
||||
"""
|
||||
pos = np.asarray(result.positions, dtype=np.float64)
|
||||
ret = np.asarray(result.strategy_returns, dtype=np.float64)
|
||||
n = len(pos)
|
||||
|
||||
pnl_list: list[float] = []
|
||||
hold_list: list[float] = []
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
if pos[i] == 0.0:
|
||||
i += 1
|
||||
continue
|
||||
# Start of a trade
|
||||
j = i + 1
|
||||
while j < n and pos[j] == pos[i]:
|
||||
j += 1
|
||||
# Trade from i to j-1
|
||||
trade_pnl = float(np.sum(ret[i:j]))
|
||||
pnl_list.append(trade_pnl)
|
||||
hold_list.append(float(j - i))
|
||||
i = j
|
||||
|
||||
if not pnl_list:
|
||||
return np.empty(0, dtype=np.float64), np.empty(0, dtype=np.float64)
|
||||
pnl, hold = _rust_extract_trades(pos, ret)
|
||||
return (
|
||||
np.array(pnl_list, dtype=np.float64),
|
||||
np.array(hold_list, dtype=np.float64),
|
||||
np.asarray(pnl, dtype=np.float64),
|
||||
np.asarray(hold, dtype=np.float64),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ from typing import Optional, Union
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import backtest_core as _rust_backtest_core
|
||||
from ferro_ta._ferro_ta import macd_crossover_signals as _rust_macd_crossover_signals
|
||||
from ferro_ta._ferro_ta import rsi_threshold_signals as _rust_rsi_threshold_signals
|
||||
from ferro_ta._ferro_ta import sma_crossover_signals as _rust_sma_crossover_signals
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -149,16 +153,16 @@ def rsi_strategy(
|
||||
overbought : float
|
||||
RSI level above which a short (-1) signal is generated (default 70).
|
||||
"""
|
||||
from ferro_ta import RSI # local import to avoid circular dep
|
||||
|
||||
if timeperiod < 1:
|
||||
raise FerroTAValueError(f"timeperiod must be >= 1, got {timeperiod}")
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
rsi = np.asarray(RSI(c, timeperiod=timeperiod), dtype=np.float64)
|
||||
signals = np.where(rsi <= oversold, 1.0, np.where(rsi >= overbought, -1.0, 0.0))
|
||||
signals[np.isnan(rsi)] = np.nan
|
||||
return signals
|
||||
return np.asarray(
|
||||
_rust_rsi_threshold_signals(
|
||||
c, int(timeperiod), float(oversold), float(overbought)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def sma_crossover_strategy(
|
||||
@@ -183,8 +187,6 @@ def sma_crossover_strategy(
|
||||
slow : int
|
||||
Slow SMA period (default 30).
|
||||
"""
|
||||
from ferro_ta import SMA # local import
|
||||
|
||||
if fast < 1:
|
||||
raise FerroTAValueError(f"fast must be >= 1, got {fast}")
|
||||
if slow < 1:
|
||||
@@ -193,13 +195,10 @@ def sma_crossover_strategy(
|
||||
raise FerroTAValueError(f"fast ({fast}) must be less than slow ({slow})")
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
sma_fast = np.asarray(SMA(c, timeperiod=fast), dtype=np.float64)
|
||||
sma_slow = np.asarray(SMA(c, timeperiod=slow), dtype=np.float64)
|
||||
signals = np.where(sma_fast > sma_slow, 1.0, -1.0).astype(np.float64)
|
||||
# Warm-up: NaN where either MA is NaN
|
||||
warmup = np.isnan(sma_fast) | np.isnan(sma_slow)
|
||||
signals[warmup] = np.nan
|
||||
return signals
|
||||
return np.asarray(
|
||||
_rust_sma_crossover_signals(c, int(fast), int(slow)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def macd_crossover_strategy(
|
||||
@@ -227,8 +226,6 @@ def macd_crossover_strategy(
|
||||
signalperiod : int
|
||||
Signal line EMA period (default 9).
|
||||
"""
|
||||
from ferro_ta import MACD # local import
|
||||
|
||||
if fastperiod < 1 or slowperiod < 1 or signalperiod < 1:
|
||||
raise FerroTAValueError("MACD periods must be >= 1")
|
||||
if fastperiod >= slowperiod:
|
||||
@@ -237,15 +234,12 @@ def macd_crossover_strategy(
|
||||
)
|
||||
|
||||
c = np.asarray(close, dtype=np.float64)
|
||||
macd_line, signal_line, _ = MACD(
|
||||
c, fastperiod=fastperiod, slowperiod=slowperiod, signalperiod=signalperiod
|
||||
return np.asarray(
|
||||
_rust_macd_crossover_signals(
|
||||
c, int(fastperiod), int(slowperiod), int(signalperiod)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
macd_line = np.asarray(macd_line, dtype=np.float64)
|
||||
signal_line = np.asarray(signal_line, dtype=np.float64)
|
||||
signals = np.where(macd_line > signal_line, 1.0, -1.0).astype(np.float64)
|
||||
warmup = np.isnan(macd_line) | np.isnan(signal_line)
|
||||
signals[warmup] = np.nan
|
||||
return signals
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -342,52 +336,14 @@ def backtest(
|
||||
# Compute signals
|
||||
# ------------------------------------------------------------------
|
||||
signals = np.asarray(strategy_fn(c, **strategy_kwargs), dtype=np.float64)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Positions: lag signals by 1 bar to avoid look-ahead bias
|
||||
# ------------------------------------------------------------------
|
||||
positions = np.empty_like(signals)
|
||||
positions[0] = 0.0
|
||||
positions[1:] = signals[:-1]
|
||||
# Replace NaN in positions with 0 (flat)
|
||||
positions = np.nan_to_num(positions, nan=0.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Returns
|
||||
# ------------------------------------------------------------------
|
||||
bar_returns: np.ndarray = np.empty(len(c), dtype=np.float64)
|
||||
bar_returns[0] = 0.0
|
||||
bar_returns[1:] = np.diff(c) / c[:-1]
|
||||
|
||||
strategy_returns = positions * bar_returns
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
|
||||
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
|
||||
if slippage_bps > 0:
|
||||
strategy_returns = strategy_returns.copy()
|
||||
strategy_returns[position_changed] -= slippage_bps / 10_000.0
|
||||
|
||||
# Cumulative equity: with optional commission per trade
|
||||
if commission_per_trade <= 0:
|
||||
equity = np.cumprod(1.0 + strategy_returns)
|
||||
else:
|
||||
gross_equity = np.cumprod(1.0 + strategy_returns)
|
||||
if np.any(gross_equity == 0.0):
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
else:
|
||||
commissions = position_changed.astype(np.float64) * commission_per_trade
|
||||
discounted_commissions = np.cumsum(commissions / gross_equity)
|
||||
equity = gross_equity * (1.0 - discounted_commissions)
|
||||
positions, bar_returns, strategy_returns, equity = _rust_backtest_core(
|
||||
c, signals, float(commission_per_trade), float(slippage_bps)
|
||||
)
|
||||
|
||||
return BacktestResult(
|
||||
signals=signals,
|
||||
positions=positions,
|
||||
bar_returns=bar_returns,
|
||||
strategy_returns=strategy_returns,
|
||||
positions=np.asarray(positions, dtype=np.float64),
|
||||
bar_returns=np.asarray(bar_returns, dtype=np.float64),
|
||||
strategy_returns=np.asarray(strategy_returns, dtype=np.float64),
|
||||
equity=np.asarray(equity, dtype=np.float64),
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ from __future__ import annotations
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import ratio as _rust_ratio
|
||||
from ferro_ta._ferro_ta import relative_strength as _rust_rel_strength
|
||||
from ferro_ta._ferro_ta import rolling_beta as _rust_rolling_beta
|
||||
from ferro_ta._ferro_ta import spread as _rust_spread
|
||||
@@ -162,11 +163,7 @@ def ratio(
|
||||
>>> list(ratio(a, b))
|
||||
[2.0, 3.0, 3.0]
|
||||
"""
|
||||
av = _to_f64(a)
|
||||
bv = _to_f64(b)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
result = np.where(bv == 0, np.nan, av / bv)
|
||||
return result
|
||||
return _rust_ratio(_to_f64(a), _to_f64(b))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,10 +11,16 @@ from typing import Any
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
|
||||
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
|
||||
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
|
||||
from ferro_ta.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options import greeks as option_greeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
_normalize_rust_error,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PayoffLeg",
|
||||
@@ -79,14 +85,23 @@ def option_leg_payoff(
|
||||
) -> NDArray[np.float64]:
|
||||
"""Expiry payoff for a single option leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
if option_type == "call":
|
||||
intrinsic = np.maximum(grid - float(strike), 0.0)
|
||||
elif option_type == "put":
|
||||
intrinsic = np.maximum(float(strike) - grid, 0.0)
|
||||
else:
|
||||
_side_sign(side)
|
||||
if option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return sign * (intrinsic - float(premium))
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([0], dtype=np.int64), # option
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([1 if option_type == "call" else -1], dtype=np.int64),
|
||||
np.array([float(strike)], dtype=np.float64),
|
||||
np.array([float(premium)], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def futures_leg_payoff(
|
||||
@@ -99,8 +114,21 @@ def futures_leg_payoff(
|
||||
) -> NDArray[np.float64]:
|
||||
"""P/L profile for a futures leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
return sign * (grid - float(entry_price))
|
||||
_side_sign(side)
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_dense(
|
||||
grid,
|
||||
np.array([1], dtype=np.int64), # future
|
||||
np.array([1 if side == "long" else -1], dtype=np.int64),
|
||||
np.array([-1], dtype=np.int64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([0.0], dtype=np.float64),
|
||||
np.array([float(entry_price)], dtype=np.float64),
|
||||
np.array([float(quantity)], dtype=np.float64),
|
||||
np.array([float(multiplier)], dtype=np.float64),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
@@ -141,31 +169,15 @@ def strategy_payoff(
|
||||
"""Aggregate expiry payoff across option and futures legs."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
total = np.zeros_like(grid)
|
||||
for leg in normalized:
|
||||
if leg.instrument == "option":
|
||||
if leg.strike is None:
|
||||
raise FerroTAValueError("Option payoff legs require strike.")
|
||||
total += option_leg_payoff(
|
||||
grid,
|
||||
strike=float(leg.strike),
|
||||
premium=float(leg.premium),
|
||||
option_type=str(leg.option_type),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
else:
|
||||
if leg.entry_price is None:
|
||||
raise FerroTAValueError("Futures payoff legs require entry_price.")
|
||||
total += futures_leg_payoff(
|
||||
grid,
|
||||
entry_price=float(leg.entry_price),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
return total
|
||||
if len(normalized) == 0:
|
||||
return np.zeros_like(grid)
|
||||
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_strategy_payoff_legs(grid, normalized), dtype=np.float64
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def aggregate_greeks(
|
||||
@@ -176,42 +188,20 @@ def aggregate_greeks(
|
||||
) -> OptionGreeks:
|
||||
"""Aggregate Greeks across option and futures legs."""
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
totals = {
|
||||
"delta": 0.0,
|
||||
"gamma": 0.0,
|
||||
"vega": 0.0,
|
||||
"theta": 0.0,
|
||||
"rho": 0.0,
|
||||
}
|
||||
for leg in normalized:
|
||||
leg_sign = _side_sign(leg.side) * float(leg.quantity) * float(leg.multiplier)
|
||||
if leg.instrument == "future":
|
||||
totals["delta"] += leg_sign
|
||||
continue
|
||||
if leg.strike is None or leg.volatility is None or leg.time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation."
|
||||
)
|
||||
leg_greeks = option_greeks(
|
||||
float(spot),
|
||||
float(leg.strike),
|
||||
float(leg.rate),
|
||||
float(leg.time_to_expiry),
|
||||
float(leg.volatility),
|
||||
option_type=str(leg.option_type),
|
||||
model="bsm",
|
||||
carry=float(leg.carry),
|
||||
if len(normalized) == 0:
|
||||
return OptionGreeks(0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
try:
|
||||
delta, gamma, vega, theta, rho = _rust_aggregate_greeks_legs(
|
||||
float(spot), normalized
|
||||
)
|
||||
totals["delta"] += leg_sign * float(leg_greeks.delta)
|
||||
totals["gamma"] += leg_sign * float(leg_greeks.gamma)
|
||||
totals["vega"] += leg_sign * float(leg_greeks.vega)
|
||||
totals["theta"] += leg_sign * float(leg_greeks.theta)
|
||||
totals["rho"] += leg_sign * float(leg_greeks.rho)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
return OptionGreeks(
|
||||
totals["delta"],
|
||||
totals["gamma"],
|
||||
totals["vega"],
|
||||
totals["theta"],
|
||||
totals["rho"],
|
||||
float(delta),
|
||||
float(gamma),
|
||||
float(vega),
|
||||
float(theta),
|
||||
float(rho),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import Any, Optional, Union
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import forward_fill_nan as _rust_forward_fill_nan
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
@@ -32,13 +33,9 @@ __all__ = [
|
||||
|
||||
|
||||
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]
|
||||
return np.asarray(
|
||||
_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,16 +6,16 @@ This module provides a 2-D batch API that accepts a 2-D numpy array
|
||||
a 2-D output array of the same shape.
|
||||
|
||||
For the most common indicators — SMA, EMA, RSI — the 2-D path is handled
|
||||
entirely in Rust (a single GIL release for all columns). The generic
|
||||
``batch_apply`` is available for other indicators that do not have a Rust
|
||||
batch implementation.
|
||||
entirely in Rust (a single GIL release for all columns). ``batch_apply``
|
||||
also dispatches these indicators to Rust when possible; other indicators
|
||||
use the generic Python fallback path.
|
||||
|
||||
Functions
|
||||
---------
|
||||
batch_sma — SMA on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_ema — EMA on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_rsi — RSI on every column of a 2-D array (Rust fast path for 2-D)
|
||||
batch_apply — Generic batch wrapper (Python loop) for any arbitrary indicator
|
||||
batch_apply — Generic batch wrapper with Rust fast-path for SMA/EMA/RSI
|
||||
|
||||
Usage
|
||||
-----
|
||||
@@ -92,6 +92,27 @@ _HLC_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"WILLR": 14,
|
||||
}
|
||||
|
||||
_BATCH_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_batch_fastpath(
|
||||
fn: Callable[..., np.ndarray],
|
||||
kwargs: dict[str, object],
|
||||
) -> tuple[str, int] | None:
|
||||
name = getattr(fn, "__name__", "").upper()
|
||||
if name not in _BATCH_FASTPATH_DEFAULTS:
|
||||
return None
|
||||
if set(kwargs) - {"timeperiod"}:
|
||||
return None
|
||||
raw = kwargs.get("timeperiod", _BATCH_FASTPATH_DEFAULTS[name])
|
||||
if not isinstance(raw, int):
|
||||
return None
|
||||
return name, int(raw)
|
||||
|
||||
|
||||
def _normalize_indicator_spec(
|
||||
spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object],
|
||||
@@ -225,11 +246,9 @@ def batch_apply(
|
||||
) -> np.ndarray:
|
||||
"""Apply any single-series indicator *fn* to every column of *data*.
|
||||
|
||||
This is the generic fallback batch executor — it calls *fn* once per
|
||||
column in a Python loop. For the common indicators SMA, EMA, and RSI
|
||||
prefer the dedicated :func:`batch_sma`, :func:`batch_ema`, and
|
||||
:func:`batch_rsi` functions, which use a Rust-side loop and avoid
|
||||
per-column Python round-trips.
|
||||
For recognized close-only indicators (SMA/EMA/RSI with default or
|
||||
``timeperiod`` argument only), this function dispatches to the Rust
|
||||
batch kernels. Otherwise it falls back to a Python per-column loop.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -265,6 +284,16 @@ def batch_apply(
|
||||
if arr.ndim != 2:
|
||||
raise ValueError(f"batch_apply expects 1-D or 2-D input; got {arr.ndim}-D")
|
||||
|
||||
fastpath = _resolve_batch_fastpath(fn, kwargs)
|
||||
if fastpath is not None:
|
||||
indicator, timeperiod = fastpath
|
||||
contiguous = np.ascontiguousarray(arr)
|
||||
if indicator == "SMA":
|
||||
return np.asarray(_rust_batch_sma(contiguous, timeperiod, True))
|
||||
if indicator == "EMA":
|
||||
return np.asarray(_rust_batch_ema(contiguous, timeperiod, True))
|
||||
return np.asarray(_rust_batch_rsi(contiguous, timeperiod, True))
|
||||
|
||||
n_samples, n_series = arr.shape
|
||||
result = np.empty((n_samples, n_series), dtype=np.float64)
|
||||
for j in range(n_series):
|
||||
|
||||
@@ -27,6 +27,7 @@ Rust backend
|
||||
ferro_ta._ferro_ta.make_chunk_ranges
|
||||
ferro_ta._ferro_ta.trim_overlap
|
||||
ferro_ta._ferro_ta.stitch_chunks
|
||||
ferro_ta._ferro_ta.chunk_apply_close_indicator
|
||||
|
||||
Notes
|
||||
-----
|
||||
@@ -49,6 +50,9 @@ from typing import Any
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
chunk_apply_close_indicator as _rust_chunk_apply_close_indicator,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
make_chunk_ranges as _rust_make_chunk_ranges,
|
||||
)
|
||||
@@ -67,6 +71,26 @@ __all__ = [
|
||||
"stitch_chunks",
|
||||
]
|
||||
|
||||
_FASTPATH_DEFAULT_PERIODS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_chunk_fastpath(
|
||||
fn: Callable[..., Any], fn_kwargs: dict[str, Any]
|
||||
) -> tuple[str, int] | None:
|
||||
name = getattr(fn, "__name__", "").upper()
|
||||
if name not in _FASTPATH_DEFAULT_PERIODS:
|
||||
return None
|
||||
if set(fn_kwargs) - {"timeperiod"}:
|
||||
return None
|
||||
raw = fn_kwargs.get("timeperiod", _FASTPATH_DEFAULT_PERIODS[name])
|
||||
if not isinstance(raw, int):
|
||||
return None
|
||||
return name, int(raw)
|
||||
|
||||
|
||||
def make_chunk_ranges(
|
||||
n: int,
|
||||
@@ -190,6 +214,20 @@ def chunk_apply(
|
||||
if n == 0:
|
||||
return np.empty(0, dtype=np.float64)
|
||||
|
||||
fastpath = _resolve_chunk_fastpath(fn, fn_kwargs)
|
||||
if fastpath is not None:
|
||||
indicator, timeperiod = fastpath
|
||||
return np.asarray(
|
||||
_rust_chunk_apply_close_indicator(
|
||||
np.ascontiguousarray(s),
|
||||
indicator,
|
||||
int(timeperiod),
|
||||
int(chunk_size),
|
||||
int(overlap),
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
ranges = make_chunk_ranges(n, chunk_size, overlap)
|
||||
if len(ranges) == 0:
|
||||
result = fn(s, **fn_kwargs)
|
||||
|
||||
@@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping
|
||||
from datetime import date, datetime, time
|
||||
from functools import lru_cache
|
||||
from itertools import count
|
||||
from typing import Any, get_args, get_origin, get_type_hints
|
||||
from typing import Any, cast, get_args, get_origin, get_type_hints
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -156,38 +156,42 @@ def _object_snapshot(value: Any) -> Any:
|
||||
if isinstance(value, enum.Enum):
|
||||
return value.value
|
||||
|
||||
if dataclasses.is_dataclass(value):
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
return _normalise_json(dataclasses.asdict(value), store_objects=False)
|
||||
|
||||
if hasattr(value, "to_dict") and callable(value.to_dict):
|
||||
dynamic_value = cast(Any, value)
|
||||
|
||||
if hasattr(dynamic_value, "to_dict") and callable(dynamic_value.to_dict):
|
||||
try:
|
||||
return _normalise_json(value.to_dict(), store_objects=False)
|
||||
return _normalise_json(dynamic_value.to_dict(), store_objects=False)
|
||||
except TypeError:
|
||||
if value.__class__.__module__.startswith("pandas"):
|
||||
return _normalise_json(value.to_dict(orient="list"), store_objects=False)
|
||||
if value.__class__.__module__.startswith("polars"):
|
||||
if dynamic_value.__class__.__module__.startswith("pandas"):
|
||||
return _normalise_json(
|
||||
value.to_dict(as_series=False), store_objects=False
|
||||
dynamic_value.to_dict(orient="list"), store_objects=False
|
||||
)
|
||||
if dynamic_value.__class__.__module__.startswith("polars"):
|
||||
return _normalise_json(
|
||||
dynamic_value.to_dict(as_series=False), store_objects=False
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if hasattr(value, "__dict__"):
|
||||
if hasattr(dynamic_value, "__dict__"):
|
||||
fields = {
|
||||
key: val
|
||||
for key, val in vars(value).items()
|
||||
for key, val in vars(dynamic_value).items()
|
||||
if not key.startswith("_") and not callable(val)
|
||||
}
|
||||
if fields:
|
||||
return _normalise_json(fields, store_objects=False)
|
||||
|
||||
slots = getattr(type(value), "__slots__", ())
|
||||
slots = getattr(type(dynamic_value), "__slots__", ())
|
||||
if slots:
|
||||
fields = {}
|
||||
for slot in slots:
|
||||
if slot.startswith("_") or not hasattr(value, slot):
|
||||
if slot.startswith("_") or not hasattr(dynamic_value, slot):
|
||||
continue
|
||||
slot_value = getattr(value, slot)
|
||||
slot_value = getattr(dynamic_value, slot)
|
||||
if callable(slot_value):
|
||||
continue
|
||||
fields[slot] = slot_value
|
||||
@@ -215,7 +219,10 @@ def _normalise_json(value: Any, *, store_objects: bool = True) -> Any:
|
||||
return _normalise_json(value.value, store_objects=store_objects)
|
||||
|
||||
if isinstance(value, np.ndarray):
|
||||
return [_normalise_json(item, store_objects=store_objects) for item in value.tolist()]
|
||||
return [
|
||||
_normalise_json(item, store_objects=store_objects)
|
||||
for item in value.tolist()
|
||||
]
|
||||
|
||||
if isinstance(value, np.generic):
|
||||
return _normalise_json(value.item(), store_objects=store_objects)
|
||||
@@ -408,7 +415,9 @@ def _annotation_includes_custom_class(annotation: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _schema_and_py_type(annotation: Any, *, param_name: str) -> tuple[dict[str, Any], Any]:
|
||||
def _schema_and_py_type(
|
||||
annotation: Any, *, param_name: str
|
||||
) -> tuple[dict[str, Any], Any]:
|
||||
"""Map Python annotations to JSON Schema and wrapper annotations."""
|
||||
enum_type = _is_enum_annotation(annotation)
|
||||
if enum_type is not None:
|
||||
@@ -437,18 +446,25 @@ def _schema_and_py_type(annotation: Any, *, param_name: str) -> tuple[dict[str,
|
||||
if "scalarorarray" in lower:
|
||||
return {
|
||||
"type": _JSON_ANY_TYPE,
|
||||
"description": f"Parameter `{param_name}`. { _REFERENCE_HELP }",
|
||||
"description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
|
||||
}, Any
|
||||
if "ndarray" in lower or "arraylike" in lower:
|
||||
return {"type": "array", "items": {}}, list[Any]
|
||||
return {"type": "number"}, float
|
||||
if "list" in lower or "tuple" in lower or "sequence" in lower or "iterable" in lower:
|
||||
if (
|
||||
"list" in lower
|
||||
or "tuple" in lower
|
||||
or "sequence" in lower
|
||||
or "iterable" in lower
|
||||
):
|
||||
return {"type": "array", "items": {}}, list[Any]
|
||||
if "dict" in lower or "mapping" in lower:
|
||||
return {"type": "object"}, dict[str, Any]
|
||||
if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower:
|
||||
return {"type": "string"}, str
|
||||
if _annotation_includes_callable(annotation) or _annotation_includes_custom_class(annotation):
|
||||
if _annotation_includes_callable(annotation) or _annotation_includes_custom_class(
|
||||
annotation
|
||||
):
|
||||
return {
|
||||
"type": _JSON_ANY_TYPE,
|
||||
"description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
|
||||
@@ -639,7 +655,10 @@ def _invoke_target(
|
||||
if not isinstance(extra_kwargs, dict):
|
||||
raise TypeError("kwargs must be a JSON object")
|
||||
keyword_args.update(
|
||||
{str(key): _decode_value(item, Any) for key, item in extra_kwargs.items()}
|
||||
{
|
||||
str(key): _decode_value(item, Any)
|
||||
for key, item in extra_kwargs.items()
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -689,8 +708,7 @@ def _describe_instance_payload(identifier: str) -> dict[str, Any]:
|
||||
def _list_instances_payload() -> list[dict[str, Any]]:
|
||||
"""Return current stored-object metadata."""
|
||||
return [
|
||||
_describe_instance_payload(identifier)
|
||||
for identifier in sorted(_INSTANCE_STORE)
|
||||
_describe_instance_payload(identifier) for identifier in sorted(_INSTANCE_STORE)
|
||||
]
|
||||
|
||||
|
||||
@@ -719,7 +737,9 @@ def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
|
||||
or f"Construct a {item['name']} enum member. Returns a stored instance reference."
|
||||
)
|
||||
|
||||
def invoke(arguments: dict[str, Any], *, enum_type: type[enum.Enum] = target) -> Any:
|
||||
def invoke_enum(
|
||||
arguments: dict[str, Any], *, enum_type: type[enum.Enum] = target
|
||||
) -> Any:
|
||||
if "value" not in arguments:
|
||||
raise KeyError("Missing required argument: value")
|
||||
member = _coerce_enum(arguments["value"], enum_type)
|
||||
@@ -730,7 +750,7 @@ def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
wrapper_signature=wrapper_signature,
|
||||
invoke=invoke,
|
||||
invoke=invoke_enum,
|
||||
)
|
||||
|
||||
signature = inspect.signature(target)
|
||||
@@ -746,7 +766,7 @@ def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
|
||||
or f"Construct a {item['name']} instance. Returns a stored instance reference."
|
||||
)
|
||||
|
||||
def invoke(
|
||||
def invoke_target(
|
||||
arguments: dict[str, Any],
|
||||
*,
|
||||
raw_target: Any = target,
|
||||
@@ -770,7 +790,7 @@ def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
wrapper_signature=wrapper_signature,
|
||||
invoke=invoke,
|
||||
invoke=invoke_target,
|
||||
)
|
||||
|
||||
|
||||
@@ -1136,7 +1156,9 @@ def _instance_management_specs() -> list[_ToolSpec]:
|
||||
raise ValueError("Only public methods can be called")
|
||||
method = getattr(value, method_name)
|
||||
if not callable(method):
|
||||
raise TypeError(f"{method_name!r} is not callable on {arguments['instance_id']!r}")
|
||||
raise TypeError(
|
||||
f"{method_name!r} is not callable on {arguments['instance_id']!r}"
|
||||
)
|
||||
args = [_decode_value(item, Any) for item in (arguments.get("args") or [])]
|
||||
kwargs = {
|
||||
str(key): _decode_value(item, Any)
|
||||
@@ -1247,7 +1269,7 @@ def _make_fastmcp_wrapper(spec: _ToolSpec) -> Callable[..., Any]:
|
||||
|
||||
wrapper.__name__ = f"tool_{_slugify(spec.name).replace('-', '_')}"
|
||||
wrapper.__doc__ = spec.description
|
||||
wrapper.__signature__ = spec.wrapper_signature
|
||||
setattr(wrapper, "__signature__", spec.wrapper_signature)
|
||||
wrapper.__annotations__ = {
|
||||
parameter.name: (
|
||||
Any if parameter.annotation is inspect._empty else parameter.annotation
|
||||
@@ -1311,7 +1333,9 @@ def create_server() -> Any:
|
||||
)
|
||||
|
||||
for spec in _tool_catalog().values():
|
||||
app.add_tool(_make_fastmcp_wrapper(spec), name=spec.name, description=spec.description)
|
||||
app.add_tool(
|
||||
_make_fastmcp_wrapper(spec), name=spec.name, description=spec.description
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a cross-surface API manifest for ferro-ta.
|
||||
|
||||
The generated manifest summarizes:
|
||||
- Python indicator/method exposure (from ferro_ta.tools.api_info)
|
||||
- Core Rust crate public functions (ferro_ta_core)
|
||||
- WASM/Node exported functions (from wasm pkg d.ts)
|
||||
|
||||
Output is written to `docs/api_manifest.json`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import datetime as _dt
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_api_info_module(root: Path, module_path: Path):
|
||||
python_root = str(root / "python")
|
||||
if python_root not in sys.path:
|
||||
sys.path.insert(0, python_root)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"ferro_ta_tools_api_info", module_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load module spec from {module_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[assignment]
|
||||
return module
|
||||
|
||||
|
||||
def _module_file(root: Path, module_name: str) -> Path | None:
|
||||
module_rel = module_name.replace(".", "/")
|
||||
file_path = root / "python" / f"{module_rel}.py"
|
||||
if file_path.exists():
|
||||
return file_path
|
||||
init_path = root / "python" / module_rel / "__init__.py"
|
||||
if init_path.exists():
|
||||
return init_path
|
||||
return None
|
||||
|
||||
|
||||
def _extract_dunder_all(file_path: Path) -> list[str]:
|
||||
try:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(file_path))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
exports: list[str] = []
|
||||
for node in tree.body:
|
||||
value_node = None
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
value_node = node.value
|
||||
break
|
||||
elif isinstance(node, ast.AnnAssign):
|
||||
target = node.target
|
||||
if isinstance(target, ast.Name) and target.id == "__all__":
|
||||
value_node = node.value
|
||||
if value_node is None:
|
||||
continue
|
||||
try:
|
||||
value = ast.literal_eval(value_node)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
exports = [value]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
exports = [item for item in value if isinstance(item, str)]
|
||||
return exports
|
||||
|
||||
|
||||
def _module_exports(root: Path, module_name: str) -> list[str]:
|
||||
file_path = _module_file(root, module_name)
|
||||
if file_path is None:
|
||||
return []
|
||||
return _extract_dunder_all(file_path)
|
||||
|
||||
|
||||
def _extract_python_api(root: Path) -> dict[str, Any]:
|
||||
module_path = root / "python" / "ferro_ta" / "tools" / "api_info.py"
|
||||
api_info_module = _load_api_info_module(root, module_path)
|
||||
|
||||
category_modules = dict(getattr(api_info_module, "_CATEGORY_MODULES", {}))
|
||||
method_modules = dict(getattr(api_info_module, "_METHOD_MODULES", {}))
|
||||
|
||||
indicators: list[dict[str, Any]] = []
|
||||
seen_indicators: set[str] = set()
|
||||
for category, module_name in category_modules.items():
|
||||
for name in _module_exports(root, module_name):
|
||||
if name in seen_indicators:
|
||||
continue
|
||||
seen_indicators.add(name)
|
||||
indicators.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"module": module_name,
|
||||
"doc": "",
|
||||
"params": [],
|
||||
}
|
||||
)
|
||||
|
||||
methods: list[dict[str, Any]] = []
|
||||
seen_methods: set[tuple[str, str]] = set()
|
||||
for category, module_name in method_modules.items():
|
||||
for name in _module_exports(root, module_name):
|
||||
key = (module_name, name)
|
||||
if key in seen_methods:
|
||||
continue
|
||||
seen_methods.add(key)
|
||||
methods.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": category,
|
||||
"module": module_name,
|
||||
"doc": "",
|
||||
"params": [],
|
||||
}
|
||||
)
|
||||
|
||||
indicators.sort(key=lambda entry: entry["name"])
|
||||
methods.sort(key=lambda entry: (entry["category"], entry["name"]))
|
||||
|
||||
categories = sorted({entry["category"] for entry in indicators})
|
||||
|
||||
if not indicators:
|
||||
raise RuntimeError(
|
||||
"No Python indicators discovered from source exports. "
|
||||
"Check `python/ferro_ta/tools/api_info.py` mappings and module __all__ declarations."
|
||||
)
|
||||
|
||||
return {
|
||||
"indicator_count": len(indicators),
|
||||
"method_count": len(methods),
|
||||
"categories": categories,
|
||||
"indicators": indicators,
|
||||
"methods": methods,
|
||||
}
|
||||
|
||||
|
||||
def _extract_core_exports(root: Path) -> list[dict[str, str]]:
|
||||
core_src = root / "crates" / "ferro_ta_core" / "src"
|
||||
entries: list[dict[str, str]] = []
|
||||
|
||||
for rs_file in sorted(core_src.rglob("*.rs")):
|
||||
rel = rs_file.relative_to(core_src).as_posix()
|
||||
module = rel[:-3].replace("/", ".")
|
||||
text = rs_file.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"(?m)^\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(", text):
|
||||
entries.append(
|
||||
{
|
||||
"module": module,
|
||||
"function": match.group(1),
|
||||
"file": rel,
|
||||
}
|
||||
)
|
||||
|
||||
entries.sort(key=lambda item: (item["module"], item["function"]))
|
||||
return entries
|
||||
|
||||
|
||||
def _extract_wasm_exports(root: Path) -> list[str]:
|
||||
exports: set[str] = set()
|
||||
|
||||
# Source exports are the canonical declaration of the WASM/Node API and
|
||||
# avoid drift when a stale wasm/pkg folder is present locally.
|
||||
wasm_lib = root / "wasm" / "src" / "lib.rs"
|
||||
if wasm_lib.exists():
|
||||
text = wasm_lib.read_text(encoding="utf-8")
|
||||
for match in re.finditer(
|
||||
r"(?ms)#\s*\[wasm_bindgen(?:\([^\)]*\))?\]\s*pub\s+fn\s+([A-Za-z0-9_]+)\s*\(",
|
||||
text,
|
||||
):
|
||||
exports.add(match.group(1))
|
||||
if exports:
|
||||
return sorted(exports)
|
||||
|
||||
# Fallback to generated declarations if source parsing did not find exports.
|
||||
dts_path = root / "wasm" / "pkg" / "ferro_ta_wasm.d.ts"
|
||||
if dts_path.exists():
|
||||
for line in dts_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("export function "):
|
||||
name = line[len("export function ") :].split("(")[0].strip()
|
||||
if name:
|
||||
exports.add(name)
|
||||
|
||||
return sorted(exports)
|
||||
|
||||
|
||||
def _safe_git_head(root: Path) -> str | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
value = completed.stdout.strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def build_manifest(
|
||||
root: Path, include_runtime_metadata: bool = False
|
||||
) -> dict[str, Any]:
|
||||
python_api = _extract_python_api(root)
|
||||
rust_core = _extract_core_exports(root)
|
||||
wasm_exports = _extract_wasm_exports(root)
|
||||
|
||||
python_indicator_names = {entry["name"] for entry in python_api["indicators"]}
|
||||
python_indicator_names_lc = {name.lower() for name in python_indicator_names}
|
||||
wasm_set = set(wasm_exports)
|
||||
wasm_set_lc = {name.lower() for name in wasm_set}
|
||||
common_with_wasm = sorted(python_indicator_names_lc.intersection(wasm_set_lc))
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"surfaces": {
|
||||
"python": python_api,
|
||||
"rust_core": {
|
||||
"public_function_count": len(rust_core),
|
||||
"functions": rust_core,
|
||||
},
|
||||
"wasm_node": {
|
||||
"export_count": len(wasm_exports),
|
||||
"exports": wasm_exports,
|
||||
},
|
||||
},
|
||||
"parity_summary": {
|
||||
"python_indicator_count": len(python_indicator_names_lc),
|
||||
"wasm_export_count": len(wasm_set),
|
||||
"common_python_wasm_count": len(common_with_wasm),
|
||||
"common_python_wasm": common_with_wasm,
|
||||
"python_only_vs_wasm": sorted(python_indicator_names_lc - wasm_set_lc),
|
||||
"wasm_only_vs_python": sorted(wasm_set_lc - python_indicator_names_lc),
|
||||
},
|
||||
}
|
||||
|
||||
if include_runtime_metadata:
|
||||
manifest["generated_at_utc"] = _dt.datetime.now(tz=_dt.UTC).isoformat()
|
||||
manifest["git_head"] = _safe_git_head(root)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Build cross-surface API manifest")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("docs/api_manifest.json"),
|
||||
help="Output JSON path relative to repo root (default: docs/api_manifest.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-runtime-metadata",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Include non-deterministic metadata fields (timestamp, git head). "
|
||||
"Disabled by default to keep manifest reproducible for CI checks."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
root = _repo_root()
|
||||
output_path = (root / args.output).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = build_manifest(
|
||||
root, include_runtime_metadata=args.include_runtime_metadata
|
||||
)
|
||||
output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote API manifest to {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,6 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check that docs/api_manifest.json is up-to-date.
|
||||
|
||||
This script regenerates the deterministic manifest in-memory and compares it to
|
||||
the committed file. It exits non-zero if drift is detected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
python_root = str(root / "python")
|
||||
if python_root not in sys.path:
|
||||
sys.path.insert(0, python_root)
|
||||
scripts_root = str(root / "scripts")
|
||||
if scripts_root not in sys.path:
|
||||
sys.path.insert(0, scripts_root)
|
||||
|
||||
from build_api_manifest import build_manifest
|
||||
|
||||
manifest_path = root / "docs" / "api_manifest.json"
|
||||
|
||||
if not manifest_path.exists():
|
||||
print(
|
||||
"docs/api_manifest.json is missing. Run:\n"
|
||||
" python scripts/build_api_manifest.py --output docs/api_manifest.json"
|
||||
)
|
||||
return 1
|
||||
|
||||
expected = build_manifest(root, include_runtime_metadata=False)
|
||||
actual = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
if actual != expected:
|
||||
print(
|
||||
"docs/api_manifest.json is out of date.\n"
|
||||
"Run:\n"
|
||||
" python scripts/build_api_manifest.py --output docs/api_manifest.json\n"
|
||||
"and commit the updated file."
|
||||
)
|
||||
return 1
|
||||
|
||||
print("docs/api_manifest.json is up to date.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
AVAILABLE_CHECKS=(
|
||||
version
|
||||
changelog
|
||||
rust_fmt
|
||||
rust_clippy
|
||||
rust_core
|
||||
rust_bench
|
||||
python_lint
|
||||
python_typecheck
|
||||
python_test
|
||||
docs
|
||||
wasm
|
||||
manifest
|
||||
)
|
||||
|
||||
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
|
||||
|
||||
python_env_ready=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/pre_push_checks.sh
|
||||
scripts/pre_push_checks.sh <check> [<check> ...]
|
||||
scripts/pre_push_checks.sh --list
|
||||
|
||||
Runs the repo's basic local CI gate before push. By default it covers:
|
||||
version changelog rust_fmt rust_clippy rust_core rust_bench
|
||||
python_lint python_typecheck python_test docs wasm manifest
|
||||
|
||||
Notes:
|
||||
- This mirrors the required CI categories we can run locally.
|
||||
- It intentionally skips the multi-Python test matrix, audit jobs, perf smoke,
|
||||
and benchmark-regression jobs.
|
||||
EOF
|
||||
}
|
||||
|
||||
list_checks() {
|
||||
printf '%s\n' "${AVAILABLE_CHECKS[@]}"
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
local command_name="$1"
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "Missing required command: $command_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
printf ' +'
|
||||
printf ' %q' "$@"
|
||||
printf '\n'
|
||||
"$@"
|
||||
}
|
||||
|
||||
ensure_python_env() {
|
||||
if [[ "$python_env_ready" -eq 1 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
need_cmd uv
|
||||
run_cmd uv sync --extra dev --extra docs --extra mcp
|
||||
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
|
||||
python_env_ready=1
|
||||
}
|
||||
|
||||
run_version() {
|
||||
need_cmd python3
|
||||
run_cmd python3 scripts/bump_version.py --check
|
||||
}
|
||||
|
||||
run_changelog() {
|
||||
need_cmd python3
|
||||
run_cmd python3 scripts/check_changelog.py
|
||||
}
|
||||
|
||||
run_rust_fmt() {
|
||||
need_cmd cargo
|
||||
run_cmd cargo fmt --all -- --check
|
||||
}
|
||||
|
||||
run_rust_clippy() {
|
||||
need_cmd cargo
|
||||
run_cmd cargo clippy --release -- -D warnings
|
||||
}
|
||||
|
||||
run_rust_core() {
|
||||
need_cmd cargo
|
||||
run_cmd cargo build -p ferro_ta_core
|
||||
run_cmd cargo test -p ferro_ta_core
|
||||
}
|
||||
|
||||
run_rust_bench() {
|
||||
need_cmd cargo
|
||||
run_cmd cargo bench -p ferro_ta_core --no-run
|
||||
}
|
||||
|
||||
run_python_lint() {
|
||||
need_cmd uv
|
||||
run_cmd uv run --with ruff ruff check python/ tests/
|
||||
run_cmd uv run --with ruff ruff format --check python/ tests/
|
||||
}
|
||||
|
||||
run_python_typecheck() {
|
||||
need_cmd uv
|
||||
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
run_cmd uv run --with pyright python -m pyright python/ferro_ta
|
||||
}
|
||||
|
||||
run_python_test() {
|
||||
ensure_python_env
|
||||
run_cmd uv run --extra dev --extra mcp --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
|
||||
}
|
||||
|
||||
run_docs() {
|
||||
ensure_python_env
|
||||
run_cmd uv run --extra docs python -m sphinx -b html docs docs/_build -W --keep-going
|
||||
}
|
||||
|
||||
run_wasm() {
|
||||
need_cmd node
|
||||
need_cmd wasm-pack
|
||||
local benchmark_json="../.wasm_benchmark.prepush.json"
|
||||
(
|
||||
cd wasm
|
||||
trap 'rm -f "$benchmark_json"' EXIT
|
||||
run_cmd wasm-pack test --node
|
||||
run_cmd wasm-pack build --target nodejs --out-dir pkg
|
||||
run_cmd node bench.js --json "$benchmark_json"
|
||||
)
|
||||
}
|
||||
|
||||
run_manifest() {
|
||||
need_cmd python3
|
||||
run_cmd python3 scripts/check_api_manifest.py
|
||||
}
|
||||
|
||||
run_check() {
|
||||
local check_name="$1"
|
||||
case "$check_name" in
|
||||
version) run_version ;;
|
||||
changelog) run_changelog ;;
|
||||
rust_fmt) run_rust_fmt ;;
|
||||
rust_clippy) run_rust_clippy ;;
|
||||
rust_core) run_rust_core ;;
|
||||
rust_bench) run_rust_bench ;;
|
||||
python_lint) run_python_lint ;;
|
||||
python_typecheck) run_python_typecheck ;;
|
||||
python_test) run_python_test ;;
|
||||
docs) run_docs ;;
|
||||
wasm) run_wasm ;;
|
||||
manifest) run_manifest ;;
|
||||
*)
|
||||
echo "Unknown check: $check_name" >&2
|
||||
echo "Use --list to see supported checks." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "--list" ]]; then
|
||||
list_checks
|
||||
exit 0
|
||||
fi
|
||||
|
||||
selected_checks=()
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
selected_checks=("$@")
|
||||
else
|
||||
selected_checks=("${DEFAULT_CHECKS[@]}")
|
||||
fi
|
||||
|
||||
total_checks="${#selected_checks[@]}"
|
||||
index=0
|
||||
for check_name in "${selected_checks[@]}"; do
|
||||
index=$((index + 1))
|
||||
printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name"
|
||||
run_check "$check_name"
|
||||
done
|
||||
|
||||
printf '\nAll selected pre-push checks passed.\n'
|
||||
@@ -191,6 +191,54 @@ pub fn signal_attribution<'py>(
|
||||
Ok((labels.into_pyarray(py), contributions.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_trades
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract trade-level pnl and hold durations from positions and strategy returns.
|
||||
///
|
||||
/// A trade is a maximal contiguous run of non-zero position values.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn extract_trades<'py>(
|
||||
py: Python<'py>,
|
||||
positions: PyReadonlyArray1<'py, f64>,
|
||||
strategy_returns: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
|
||||
let pos = positions.as_slice()?;
|
||||
let ret = strategy_returns.as_slice()?;
|
||||
let n = pos.len();
|
||||
if n != ret.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"positions and strategy_returns must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut pnl = Vec::<f64>::new();
|
||||
let mut hold = Vec::<f64>::new();
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
if pos[i] == 0.0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < n && pos[j] == pos[i] {
|
||||
j += 1;
|
||||
}
|
||||
let mut trade_pnl = 0.0_f64;
|
||||
for v in ret.iter().take(j).skip(i) {
|
||||
trade_pnl += *v;
|
||||
}
|
||||
pnl.push(trade_pnl);
|
||||
hold.push((j - i) as f64);
|
||||
i = j;
|
||||
}
|
||||
|
||||
Ok((pnl.into_pyarray(py), hold.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,5 +247,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trade_stats, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(signal_attribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(extract_trades, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Rust-backed strategy signal generation and backtest core.
|
||||
//!
|
||||
//! These functions move the hot loops from Python into Rust while preserving
|
||||
//! the public Python behavior.
|
||||
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
fn nan_to_num_with_numpy_defaults(v: f64) -> f64 {
|
||||
if v.is_nan() {
|
||||
0.0
|
||||
} else if v.is_infinite() {
|
||||
if v.is_sign_positive() {
|
||||
f64::MAX
|
||||
} else {
|
||||
-f64::MAX
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strategy signal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// RSI threshold strategy:
|
||||
/// +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
|
||||
/// Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))]
|
||||
pub fn rsi_threshold_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
timeperiod: usize,
|
||||
oversold: f64,
|
||||
overbought: f64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
|
||||
let prices = close.as_slice()?;
|
||||
let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod);
|
||||
let out: Vec<f64> = rsi
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
if v.is_nan() {
|
||||
f64::NAN
|
||||
} else if v <= oversold {
|
||||
1.0
|
||||
} else if v >= overbought {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// SMA crossover strategy:
|
||||
/// +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, fast = 10, slow = 30))]
|
||||
pub fn sma_crossover_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fast: usize,
|
||||
slow: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(fast, "fast", 1)?;
|
||||
validation::validate_timeperiod(slow, "slow", 1)?;
|
||||
if fast >= slow {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"fast ({fast}) must be less than slow ({slow})"
|
||||
)));
|
||||
}
|
||||
let prices = close.as_slice()?;
|
||||
let sma_fast = ferro_ta_core::overlap::sma(prices, fast);
|
||||
let sma_slow = ferro_ta_core::overlap::sma(prices, slow);
|
||||
let out: Vec<f64> = sma_fast
|
||||
.iter()
|
||||
.zip(sma_slow.iter())
|
||||
.map(|(&f, &s)| {
|
||||
if f.is_nan() || s.is_nan() {
|
||||
f64::NAN
|
||||
} else if f > s {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// MACD crossover strategy:
|
||||
/// +1 when MACD line > signal line, -1 otherwise. Warm-up bars are NaN.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))]
|
||||
pub fn macd_crossover_signals<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
validation::validate_timeperiod(fastperiod, "fastperiod", 1)?;
|
||||
validation::validate_timeperiod(slowperiod, "slowperiod", 1)?;
|
||||
validation::validate_timeperiod(signalperiod, "signalperiod", 1)?;
|
||||
if fastperiod >= slowperiod {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
|
||||
)));
|
||||
}
|
||||
|
||||
let prices = close.as_slice()?;
|
||||
let (macd_line, signal_line, _) =
|
||||
ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod);
|
||||
let out: Vec<f64> = macd_line
|
||||
.iter()
|
||||
.zip(signal_line.iter())
|
||||
.map(|(&m, &s)| {
|
||||
if m.is_nan() || s.is_nan() {
|
||||
f64::NAN
|
||||
} else if m > s {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backtest core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Backtest core loop over close prices and strategy signals.
|
||||
///
|
||||
/// Returns `(positions, bar_returns, strategy_returns, equity)`.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn backtest_core<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
signals: PyReadonlyArray1<'py, f64>,
|
||||
commission_per_trade: f64,
|
||||
slippage_bps: f64,
|
||||
) -> PyResult<(
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
)> {
|
||||
let c = close.as_slice()?;
|
||||
let s = signals.as_slice()?;
|
||||
let n = c.len();
|
||||
validation::validate_equal_length(&[(n, "close"), (s.len(), "signals")])?;
|
||||
|
||||
let mut positions = vec![0.0_f64; n];
|
||||
if n > 1 {
|
||||
for i in 1..n {
|
||||
positions[i] = nan_to_num_with_numpy_defaults(s[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut bar_returns = vec![0.0_f64; n];
|
||||
for i in 1..n {
|
||||
bar_returns[i] = (c[i] - c[i - 1]) / c[i - 1];
|
||||
}
|
||||
|
||||
let mut strategy_returns = vec![0.0_f64; n];
|
||||
for i in 0..n {
|
||||
strategy_returns[i] = positions[i] * bar_returns[i];
|
||||
}
|
||||
|
||||
let mut position_changed = vec![false; n];
|
||||
for i in 1..n {
|
||||
position_changed[i] = positions[i] != positions[i - 1];
|
||||
}
|
||||
|
||||
if slippage_bps > 0.0 {
|
||||
let slip = slippage_bps / 10_000.0;
|
||||
for i in 0..n {
|
||||
if position_changed[i] {
|
||||
strategy_returns[i] -= slip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut equity = vec![1.0_f64; n];
|
||||
if n > 0 {
|
||||
if commission_per_trade <= 0.0 {
|
||||
let mut gross = 1.0_f64;
|
||||
for i in 0..n {
|
||||
gross *= 1.0 + strategy_returns[i];
|
||||
equity[i] = gross;
|
||||
}
|
||||
} else {
|
||||
let mut gross_equity = vec![1.0_f64; n];
|
||||
let mut gross = 1.0_f64;
|
||||
for i in 0..n {
|
||||
gross *= 1.0 + strategy_returns[i];
|
||||
gross_equity[i] = gross;
|
||||
}
|
||||
|
||||
if gross_equity.contains(&0.0) {
|
||||
equity[0] = 1.0;
|
||||
for i in 1..n {
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
|
||||
if position_changed[i] {
|
||||
equity[i] -= commission_per_trade;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut discounted_commissions = 0.0_f64;
|
||||
for i in 0..n {
|
||||
if position_changed[i] {
|
||||
discounted_commissions += commission_per_trade / gross_equity[i];
|
||||
}
|
||||
equity[i] = gross_equity[i] * (1.0 - discounted_commissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
positions.into_pyarray(py),
|
||||
bar_returns.into_pyarray(py),
|
||||
strategy_returns.into_pyarray(py),
|
||||
equity.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(backtest_core, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
+130
-5
@@ -8,11 +8,15 @@
|
||||
//!
|
||||
//! Functions
|
||||
//! ---------
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from an array
|
||||
//! (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series given chunk
|
||||
//! size and overlap, for use by the Python caller.
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from
|
||||
//! an array (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one
|
||||
//! array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series
|
||||
//! given chunk size and overlap, for use by the Python caller.
|
||||
//! - `chunk_apply_close_indicator`— run chunked close-only indicators fully in
|
||||
//! Rust (SMA/EMA/RSI).
|
||||
//! - `forward_fill_nan` — forward-fill NaN values in a 1-D array.
|
||||
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
@@ -132,6 +136,125 @@ pub fn make_chunk_ranges<'py>(
|
||||
Ok(ranges.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chunk_apply_close_indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn compute_close_indicator(
|
||||
indicator: &str,
|
||||
series: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> PyResult<Vec<f64>> {
|
||||
match indicator {
|
||||
"SMA" => Ok(ferro_ta_core::overlap::sma(series, timeperiod)),
|
||||
"EMA" => Ok(ferro_ta_core::overlap::ema(series, timeperiod)),
|
||||
"RSI" => Ok(ferro_ta_core::momentum::rsi(series, timeperiod)),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"chunk_apply_close_indicator does not support indicator '{indicator}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run chunked execution for close-only indicators in Rust.
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// series : 1-D float64 array
|
||||
/// indicator : one of {"SMA", "EMA", "RSI"}
|
||||
/// timeperiod : indicator period (>= 1)
|
||||
/// chunk_size : output bars per chunk (>= 1)
|
||||
/// overlap : warm-up bars prepended to each chunk
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D float64 array with the same length as `series`.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))]
|
||||
pub fn chunk_apply_close_indicator<'py>(
|
||||
py: Python<'py>,
|
||||
series: PyReadonlyArray1<'py, f64>,
|
||||
indicator: &str,
|
||||
timeperiod: usize,
|
||||
chunk_size: usize,
|
||||
overlap: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
if chunk_size == 0 {
|
||||
return Err(PyValueError::new_err("chunk_size must be >= 1"));
|
||||
}
|
||||
|
||||
let values = series.as_slice()?;
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::<f64>::new().into_pyarray(py));
|
||||
}
|
||||
|
||||
let name = indicator.to_ascii_uppercase();
|
||||
let n = values.len();
|
||||
let mut stitched: Vec<f64> = Vec::with_capacity(n);
|
||||
let mut start = 0usize;
|
||||
let mut chunk_index = 0usize;
|
||||
|
||||
loop {
|
||||
let end = (start + chunk_size + overlap).min(n);
|
||||
let chunk = &values[start..end];
|
||||
let out = compute_close_indicator(name.as_str(), chunk, timeperiod)?;
|
||||
|
||||
let discard = if chunk_index == 0 { 0 } else { overlap };
|
||||
if discard > out.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"overlap ({discard}) must be <= chunk output length ({})",
|
||||
out.len()
|
||||
)));
|
||||
}
|
||||
stitched.extend_from_slice(&out[discard..]);
|
||||
|
||||
if end >= n {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(overlap);
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
if stitched.len() != n {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"internal chunk stitching error: expected output length {n}, got {}",
|
||||
stitched.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(stitched.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward_fill_nan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Forward-fill NaN values in a 1-D array.
|
||||
///
|
||||
/// Leading NaN values are preserved until the first non-NaN value appears.
|
||||
#[pyfunction]
|
||||
pub fn forward_fill_nan<'py>(
|
||||
py: Python<'py>,
|
||||
values: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let input = values.as_slice()?;
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut last = f64::NAN;
|
||||
|
||||
for &value in input {
|
||||
if value.is_nan() {
|
||||
out.push(last);
|
||||
} else {
|
||||
last = value;
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,5 +263,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trim_overlap, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(make_chunk_ranges, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(chunk_apply_close_indicator, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(forward_fill_nan, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod aggregation;
|
||||
pub mod alerts;
|
||||
pub mod attribution;
|
||||
pub mod backtest;
|
||||
pub mod batch;
|
||||
pub mod chunked;
|
||||
pub mod crypto;
|
||||
@@ -70,5 +71,6 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
chunked::register(m)?;
|
||||
regime::register(m)?;
|
||||
attribution::register(m)?;
|
||||
backtest::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod chain;
|
||||
mod greeks;
|
||||
mod iv;
|
||||
mod payoff;
|
||||
mod pricing;
|
||||
mod surface;
|
||||
|
||||
@@ -63,5 +64,21 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_legs,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_legs,
|
||||
m
|
||||
)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyAny, PyTuple};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Instrument {
|
||||
Option,
|
||||
Future,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Side {
|
||||
Long,
|
||||
Short,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OptionType {
|
||||
Call,
|
||||
Put,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn sign(self) -> f64 {
|
||||
match self {
|
||||
Side::Long => 1.0,
|
||||
Side::Short => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument(v: i64) -> PyResult<Instrument> {
|
||||
match v {
|
||||
0 => Ok(Instrument::Option),
|
||||
1 => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 0 (option) or 1 (future)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side(v: i64) -> PyResult<Side> {
|
||||
match v {
|
||||
1 => Ok(Side::Long),
|
||||
-1 => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 1 (long) or -1 (short)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type(v: i64) -> PyResult<OptionType> {
|
||||
match v {
|
||||
1 => Ok(OptionType::Call),
|
||||
-1 => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"option_type must be 1 (call) or -1 (put)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument_label(v: &str) -> PyResult<Instrument> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"option" => Ok(Instrument::Option),
|
||||
"future" => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 'option' or 'future'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side_label(v: &str) -> PyResult<Side> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"long" => Ok(Side::Long),
|
||||
"short" => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 'long' or 'short'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type_label(v: &str) -> PyResult<OptionType> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"call" => Ok(OptionType::Call),
|
||||
"put" => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err("option_type must be 'call' or 'put'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn leg_attr_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<String> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<String>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<f64> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<f64>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<String>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<f64>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<f64>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff over a spot grid.
|
||||
///
|
||||
/// Encoded arrays (same length = n_legs):
|
||||
/// - `instruments`: 0=option, 1=future
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures)
|
||||
/// - `strikes`: strike for options, ignored for futures
|
||||
/// - `premiums`: premium for options, ignored for futures
|
||||
/// - `entry_prices`: entry price for futures, ignored for options
|
||||
/// - `quantities`, `multipliers`: applied to both instruments
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_payoff_dense<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
instruments: PyReadonlyArray1<'py, i64>,
|
||||
sides: PyReadonlyArray1<'py, i64>,
|
||||
option_types: PyReadonlyArray1<'py, i64>,
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
premiums: PyReadonlyArray1<'py, f64>,
|
||||
entry_prices: PyReadonlyArray1<'py, f64>,
|
||||
quantities: PyReadonlyArray1<'py, f64>,
|
||||
multipliers: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let premium = premiums.as_slice()?;
|
||||
let entry = entry_prices.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| premium.len() != n_legs
|
||||
|| entry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[leg_idx])?;
|
||||
let side_sign = parse_side(side[leg_idx])?.sign();
|
||||
let leg_scale = side_sign * qty[leg_idx] * mult[leg_idx];
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype = parse_option_type(opt_t[leg_idx])?;
|
||||
let k = strike[leg_idx];
|
||||
let p = premium[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - k).max(0.0),
|
||||
OptionType::Put => (k - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let e = entry[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff from Python leg objects.
|
||||
///
|
||||
/// `legs` is expected to be a sequence of `PayoffLeg`-like objects
|
||||
/// with attributes used by `ferro_ta.analysis.derivatives_payoff`.
|
||||
#[pyfunction]
|
||||
pub fn strategy_payoff_legs<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
legs: Bound<'py, PyTuple>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Option payoff legs require option_type.")
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?
|
||||
.ok_or_else(|| PyValueError::new_err("Option payoff legs require strike."))?;
|
||||
let premium = leg_attr_f64(&leg, "premium")?;
|
||||
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - strike).max(0.0),
|
||||
OptionType::Put => (strike - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - premium);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Futures payoff legs require entry_price.")
|
||||
})?;
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - entry_price);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks over multiple legs.
|
||||
///
|
||||
/// Encodings match `strategy_payoff_dense`.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: PyReadonlyArray1<'_, i64>,
|
||||
sides: PyReadonlyArray1<'_, i64>,
|
||||
option_types: PyReadonlyArray1<'_, i64>,
|
||||
strikes: PyReadonlyArray1<'_, f64>,
|
||||
volatilities: PyReadonlyArray1<'_, f64>,
|
||||
time_to_expiries: PyReadonlyArray1<'_, f64>,
|
||||
rates: PyReadonlyArray1<'_, f64>,
|
||||
carries: PyReadonlyArray1<'_, f64>,
|
||||
quantities: PyReadonlyArray1<'_, f64>,
|
||||
multipliers: PyReadonlyArray1<'_, f64>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let vol = volatilities.as_slice()?;
|
||||
let tte = time_to_expiries.as_slice()?;
|
||||
let rate = rates.as_slice()?;
|
||||
let carry = carries.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| vol.len() != n_legs
|
||||
|| tte.len() != n_legs
|
||||
|| rate.len() != n_legs
|
||||
|| carry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for i in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[i])?;
|
||||
let side_sign = parse_side(side[i])?.sign();
|
||||
let leg_scale = side_sign * qty[i] * mult[i];
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
if vol[i].is_nan() || tte[i].is_nan() {
|
||||
return Err(PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
));
|
||||
}
|
||||
let kind = match parse_option_type(opt_t[i])? {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike: strike[i],
|
||||
rate: rate[i],
|
||||
carry: carry[i],
|
||||
time_to_expiry: tte[i],
|
||||
kind,
|
||||
},
|
||||
volatility: vol[i],
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks from Python leg objects.
|
||||
#[pyfunction]
|
||||
pub fn aggregate_greeks_legs(
|
||||
spot: f64,
|
||||
legs: Bound<'_, PyTuple>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require option_type for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let volatility = leg_attr_optional_f64(&leg, "volatility")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let time_to_expiry =
|
||||
leg_attr_optional_f64(&leg, "time_to_expiry")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let rate = leg_attr_f64(&leg, "rate")?;
|
||||
let carry = leg_attr_f64(&leg, "carry")?;
|
||||
|
||||
let kind = match otype {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
volatility,
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
@@ -350,6 +350,35 @@ pub fn spread<'py>(
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ratio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the ratio between two series: A / B.
|
||||
///
|
||||
/// Where B is 0, returns NaN.
|
||||
#[pyfunction]
|
||||
pub fn ratio<'py>(
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let av = a.as_slice()?;
|
||||
let bv = b.as_slice()?;
|
||||
let n = av.len();
|
||||
if n == 0 || bv.len() != n {
|
||||
return Err(PyValueError::new_err(
|
||||
"a and b must be non-empty and equal length",
|
||||
));
|
||||
}
|
||||
let result: Vec<f64> = av
|
||||
.iter()
|
||||
.zip(bv.iter())
|
||||
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
|
||||
.collect();
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zscore_series
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -449,6 +478,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(correlation_matrix, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(relative_strength, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(spread, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(ratio, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(zscore_series, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(compose_weighted, m)?)?;
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(ROOT / "python") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "python"))
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from build_api_manifest import build_manifest
|
||||
|
||||
|
||||
def test_api_manifest_is_deterministic_and_current() -> None:
|
||||
manifest_path = ROOT / "docs" / "api_manifest.json"
|
||||
assert manifest_path.exists(), "docs/api_manifest.json is missing"
|
||||
|
||||
expected = build_manifest(ROOT, include_runtime_metadata=False)
|
||||
actual = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert actual == expected
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import ferro_ta
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
WASM_DIR = ROOT / "wasm"
|
||||
PKG_JS = WASM_DIR / "pkg" / "ferro_ta_wasm.js"
|
||||
SCRIPT = WASM_DIR / "conformance_node.js"
|
||||
|
||||
|
||||
def _write_node_conformance_script(path: Path) -> None:
|
||||
path.write_text(
|
||||
"""
|
||||
const wasm = require("./pkg/ferro_ta_wasm.js");
|
||||
|
||||
function toArray(x) {
|
||||
return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v)));
|
||||
}
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.1, 45.42, 45.84, 46.08, 45.89, 46.03, 46.21, 46.02, 45.78]);
|
||||
const high = new Float64Array([44.71, 44.5, 44.6, 44.09, 44.79, 45.2, 45.44, 45.73, 46.01, 46.44, 46.21, 46.39, 46.53, 46.3, 46.12]);
|
||||
const low = new Float64Array([43.9, 43.8, 43.9, 43.2, 43.9, 44.2, 44.6, 44.8, 45.2, 45.5, 45.4, 45.5, 45.7, 45.6, 45.4]);
|
||||
const volume = new Float64Array([1200, 1320, 1250, 1460, 1500, 1670, 1720, 1810, 1900, 2020, 1980, 2100, 2170, 2140, 2080]);
|
||||
|
||||
const payload = {
|
||||
sma: toArray(wasm.sma(close, 5)),
|
||||
ema: toArray(wasm.ema(close, 5)),
|
||||
wma: toArray(wasm.wma(close, 5)),
|
||||
rsi: toArray(wasm.rsi(close, 5)),
|
||||
adx: toArray(wasm.adx(high, low, close, 5)),
|
||||
mfi: toArray(wasm.mfi(high, low, close, volume, 5)),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(payload));
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _run_node_conformance() -> dict[str, list[float | None]]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node is required for wasm/node conformance test")
|
||||
if not PKG_JS.exists():
|
||||
pytest.skip(
|
||||
"wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`"
|
||||
)
|
||||
|
||||
_write_node_conformance_script(SCRIPT)
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["node", str(SCRIPT)],
|
||||
cwd=WASM_DIR,
|
||||
text=True,
|
||||
)
|
||||
finally:
|
||||
if SCRIPT.exists():
|
||||
SCRIPT.unlink()
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def _to_jsonable(arr: np.ndarray) -> list[float | None]:
|
||||
vals = np.asarray(arr, dtype=np.float64)
|
||||
return [None if np.isnan(x) else float(x) for x in vals]
|
||||
|
||||
|
||||
def _assert_close_with_null_nan(
|
||||
actual: list[float | None],
|
||||
expected: list[float | None],
|
||||
*,
|
||||
atol: float,
|
||||
) -> None:
|
||||
assert len(actual) == len(expected)
|
||||
a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64)
|
||||
e = np.array(
|
||||
[np.nan if v is None else float(v) for v in expected], dtype=np.float64
|
||||
)
|
||||
np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True)
|
||||
|
||||
|
||||
def test_wasm_node_matches_python_core_indicators() -> None:
|
||||
close = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.42,
|
||||
45.84,
|
||||
46.08,
|
||||
45.89,
|
||||
46.03,
|
||||
46.21,
|
||||
46.02,
|
||||
45.78,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
high = np.array(
|
||||
[
|
||||
44.71,
|
||||
44.50,
|
||||
44.60,
|
||||
44.09,
|
||||
44.79,
|
||||
45.20,
|
||||
45.44,
|
||||
45.73,
|
||||
46.01,
|
||||
46.44,
|
||||
46.21,
|
||||
46.39,
|
||||
46.53,
|
||||
46.30,
|
||||
46.12,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
low = np.array(
|
||||
[
|
||||
43.90,
|
||||
43.80,
|
||||
43.90,
|
||||
43.20,
|
||||
43.90,
|
||||
44.20,
|
||||
44.60,
|
||||
44.80,
|
||||
45.20,
|
||||
45.50,
|
||||
45.40,
|
||||
45.50,
|
||||
45.70,
|
||||
45.60,
|
||||
45.40,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
volume = np.array(
|
||||
[
|
||||
1200.0,
|
||||
1320.0,
|
||||
1250.0,
|
||||
1460.0,
|
||||
1500.0,
|
||||
1670.0,
|
||||
1720.0,
|
||||
1810.0,
|
||||
1900.0,
|
||||
2020.0,
|
||||
1980.0,
|
||||
2100.0,
|
||||
2170.0,
|
||||
2140.0,
|
||||
2080.0,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
node_payload = _run_node_conformance()
|
||||
|
||||
py_expected = {
|
||||
"sma": _to_jsonable(ferro_ta.SMA(close, 5)),
|
||||
"ema": _to_jsonable(ferro_ta.EMA(close, 5)),
|
||||
"wma": _to_jsonable(ferro_ta.WMA(close, 5)),
|
||||
"rsi": _to_jsonable(ferro_ta.RSI(close, 5)),
|
||||
"adx": _to_jsonable(ferro_ta.ADX(high, low, close, 5)),
|
||||
"mfi": _to_jsonable(ferro_ta.MFI(high, low, close, volume, 5)),
|
||||
}
|
||||
|
||||
for name, expected in py_expected.items():
|
||||
assert name in node_payload
|
||||
_assert_close_with_null_nan(node_payload[name], expected, atol=1e-9)
|
||||
@@ -626,6 +626,13 @@ class TestBatchApply:
|
||||
with pytest.raises(ValueError, match="1-D or 2-D"):
|
||||
batch_apply(np.zeros((5, 5, 5)), SMA, timeperiod=3)
|
||||
|
||||
def test_sma_fastpath_matches_batch_sma(self):
|
||||
from ferro_ta.data.batch import batch_sma
|
||||
|
||||
fast = batch_apply(self.C2D, SMA, timeperiod=10)
|
||||
direct = batch_sma(self.C2D, timeperiod=10)
|
||||
assert np.allclose(fast, direct, equal_nan=True)
|
||||
|
||||
|
||||
class TestBatchShapeValidation:
|
||||
def test_batch_atr_shape_mismatch_raises(self):
|
||||
|
||||
@@ -1403,9 +1403,9 @@ class TestMCPCallTool:
|
||||
instance_id = created["instance_id"]
|
||||
|
||||
described = json.loads(
|
||||
handle_call_tool(
|
||||
"describe_instance", {"instance_id": instance_id}
|
||||
)["content"][0]["text"]
|
||||
handle_call_tool("describe_instance", {"instance_id": instance_id})[
|
||||
"content"
|
||||
][0]["text"]
|
||||
)
|
||||
method_names = [item["name"] for item in described["methods"]]
|
||||
assert "aggregate" in method_names
|
||||
@@ -1429,9 +1429,9 @@ class TestMCPCallTool:
|
||||
assert "close" in aggregated
|
||||
|
||||
deleted = json.loads(
|
||||
handle_call_tool(
|
||||
"delete_instance", {"instance_id": instance_id}
|
||||
)["content"][0]["text"]
|
||||
handle_call_tool("delete_instance", {"instance_id": instance_id})[
|
||||
"content"
|
||||
][0]["text"]
|
||||
)
|
||||
assert deleted["deleted"] is True
|
||||
|
||||
@@ -1441,9 +1441,9 @@ class TestMCPCallTool:
|
||||
from ferro_ta.mcp import handle_call_tool
|
||||
|
||||
wrapped = json.loads(
|
||||
handle_call_tool(
|
||||
"traced", {"func": {"callable": "SMA"}}
|
||||
)["content"][0]["text"]
|
||||
handle_call_tool("traced", {"func": {"callable": "SMA"}})["content"][0][
|
||||
"text"
|
||||
]
|
||||
)
|
||||
instance_id = wrapped["instance_id"]
|
||||
|
||||
|
||||
@@ -180,6 +180,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.5"
|
||||
@@ -595,6 +604,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.21.2"
|
||||
@@ -657,7 +675,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro-ta"
|
||||
version = "1.0.3"
|
||||
version = "1.0.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
@@ -693,6 +711,7 @@ dev = [
|
||||
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.14'" },
|
||||
{ name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
||||
{ name = "polars" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -731,6 +750,7 @@ dev = [
|
||||
{ name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
||||
{ name = "pandas-ta", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "polars" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pyyaml" },
|
||||
@@ -757,6 +777,7 @@ requires-dist = [
|
||||
{ name = "polars", marker = "extra == 'all'", specifier = ">=0.19" },
|
||||
{ name = "polars", marker = "extra == 'dev'", specifier = ">=0.19" },
|
||||
{ name = "polars", marker = "extra == 'polars'", specifier = ">=0.19" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.0" },
|
||||
{ name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" },
|
||||
{ name = "pytest", marker = "extra == 'all'", specifier = ">=7.0" },
|
||||
{ name = "pytest", marker = "extra == 'benchmark'", specifier = ">=7.0" },
|
||||
@@ -783,6 +804,7 @@ dev = [
|
||||
{ name = "pandas", specifier = ">=1.0" },
|
||||
{ name = "pandas-ta", marker = "python_full_version >= '3.12'", specifier = ">=0.3" },
|
||||
{ name = "polars", specifier = ">=0.19" },
|
||||
{ name = "pre-commit", specifier = ">=3.0" },
|
||||
{ name = "pyright", specifier = ">=1.1" },
|
||||
{ name = "pytest", specifier = ">=7.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0" },
|
||||
@@ -924,6 +946,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -2046,6 +2077,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -2083,6 +2123,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-cpuinfo"
|
||||
version = "9.0.0"
|
||||
@@ -2259,11 +2315,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.11.0"
|
||||
version = "2.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2345,6 +2404,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.2"
|
||||
@@ -3162,6 +3234,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "21.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "python-discovery" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
|
||||
Generated
+6
-1
@@ -47,10 +47,15 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.6"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.0.0"
|
||||
version = "1.0.6"
|
||||
dependencies = [
|
||||
"ferro_ta_core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.0.4"
|
||||
version = "1.0.6"
|
||||
edition = "2021"
|
||||
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
|
||||
license = "MIT"
|
||||
@@ -13,6 +13,7 @@ crate-type = ["cdylib", "rlib"]
|
||||
[dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
ferro_ta_core = { path = "../crates/ferro_ta_core", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
+20
-5
@@ -11,7 +11,7 @@ npm install ferro-ta-wasm
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { sma, ema, rsi, bbands, atr, obv, macd } = require('ferro-ta-wasm');
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('ferro-ta-wasm');
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
|
||||
const smaOut = sma(close, 3);
|
||||
@@ -28,20 +28,23 @@ console.log('SMA:', Array.from(smaOut));
|
||||
|------------|---------------|----------------------------------------------------|---------|
|
||||
| Overlap | `sma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `ema` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `wma` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Overlap | `bbands` | `close, timeperiod, nbdevup, nbdevdn` | `Array[upper, middle, lower]` |
|
||||
| Momentum | `rsi` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `adx` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Momentum | `macd` | `close, fastperiod, slowperiod, signalperiod` | `Array[macd, signal, hist]` |
|
||||
| Momentum | `mom` | `close: Float64Array, timeperiod: number` | `Float64Array` |
|
||||
| Momentum | `stochf` | `high, low, close, fastk_period, fastd_period` | `Array[fastk, fastd]` |
|
||||
| Volatility | `atr` | `high, low, close: Float64Array, timeperiod` | `Float64Array` |
|
||||
| Volume | `obv` | `close: Float64Array, volume: Float64Array` | `Float64Array` |
|
||||
| Volume | `mfi` | `high, low, close, volume: Float64Array, timeperiod` | `Float64Array` |
|
||||
|
||||
### Adding more indicators
|
||||
|
||||
All implementations are self-contained in `src/lib.rs` — no external crate dependency needed.
|
||||
WASM exports live in `src/lib.rs` and can either implement logic directly or delegate to `ferro_ta_core`.
|
||||
To add a new indicator:
|
||||
|
||||
1. Implement the algorithm in a `#[wasm_bindgen]` function in `src/lib.rs`.
|
||||
1. Add a `#[wasm_bindgen]` export in `src/lib.rs` (prefer delegating to `ferro_ta_core` where possible).
|
||||
2. Add at least two `#[wasm_bindgen_test]` tests covering output length and a known value.
|
||||
3. Update this README table.
|
||||
4. Run `wasm-pack test --node` to verify.
|
||||
@@ -79,7 +82,7 @@ wasm-pack build --target web --out-dir pkg-web
|
||||
## Usage (Node.js)
|
||||
|
||||
```javascript
|
||||
const { sma, ema, rsi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js');
|
||||
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js');
|
||||
|
||||
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
|
||||
|
||||
@@ -91,6 +94,10 @@ console.log('SMA:', Array.from(smaOut)); // [ NaN, NaN, 44.193, ... ]
|
||||
const rsiOut = rsi(close, 5);
|
||||
console.log('RSI:', Array.from(rsiOut));
|
||||
|
||||
// WMA (period 5)
|
||||
const wmaOut = wma(close, 5);
|
||||
console.log('WMA:', Array.from(wmaOut));
|
||||
|
||||
// Bollinger Bands (period 5, ±2σ) — returns [upper, middle, lower]
|
||||
const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0);
|
||||
console.log('BBANDS upper:', Array.from(upper));
|
||||
@@ -107,10 +114,18 @@ const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]);
|
||||
const atrOut = atr(high, low, close, 3);
|
||||
console.log('ATR:', Array.from(atrOut));
|
||||
|
||||
// ADX (period 3)
|
||||
const adxOut = adx(high, low, close, 3);
|
||||
console.log('ADX:', Array.from(adxOut));
|
||||
|
||||
// OBV
|
||||
const volume = new Float64Array([1000, 1200, 900, 1500, 800, 600, 700]);
|
||||
const obvOut = obv(close, volume);
|
||||
console.log('OBV:', Array.from(obvOut));
|
||||
|
||||
// MFI (period 3)
|
||||
const mfiOut = mfi(high, low, close, volume, 3);
|
||||
console.log('MFI:', Array.from(mfiOut));
|
||||
```
|
||||
|
||||
## Usage (Browser)
|
||||
@@ -150,7 +165,7 @@ from source:
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only 9 indicators are currently exposed (SMA, EMA, BBANDS, RSI, MACD, MOM, STOCHF, ATR, OBV).
|
||||
- Only 12 indicators are currently exposed (SMA, EMA, WMA, BBANDS, RSI, ADX, MACD, MOM, STOCHF, ATR, OBV, MFI).
|
||||
Additional indicators will be added following the same pattern in `src/lib.rs`.
|
||||
- Large arrays (> 10M bars) may be slow due to JS↔WASM memory copies. For high-throughput
|
||||
use cases prefer the Python (PyO3) binding.
|
||||
|
||||
+7
-2
@@ -23,14 +23,16 @@ function makeSeries(length) {
|
||||
const close = new Float64Array(length);
|
||||
const high = new Float64Array(length);
|
||||
const low = new Float64Array(length);
|
||||
const volume = new Float64Array(length);
|
||||
let value = 100.0;
|
||||
for (let idx = 0; idx < length; idx += 1) {
|
||||
value += Math.sin(idx / 13.0) * 0.35 + Math.cos(idx / 29.0) * 0.18;
|
||||
close[idx] = value;
|
||||
high[idx] = value + 1.25;
|
||||
low[idx] = value - 1.10;
|
||||
volume[idx] = 1000.0 + Math.abs(Math.sin(idx / 7.0) * 300.0) + (idx % 100);
|
||||
}
|
||||
return { close, high, low };
|
||||
return { close, high, low, volume };
|
||||
}
|
||||
|
||||
function timeMin(fn, rounds = 7) {
|
||||
@@ -45,11 +47,14 @@ function timeMin(fn, rounds = 7) {
|
||||
}
|
||||
|
||||
function runBenchmark({ bars }) {
|
||||
const { close, high, low } = makeSeries(bars);
|
||||
const { close, high, low, volume } = makeSeries(bars);
|
||||
const cases = [
|
||||
["SMA", () => wasm.sma(close, 20)],
|
||||
["EMA", () => wasm.ema(close, 20)],
|
||||
["WMA", () => wasm.wma(close, 20)],
|
||||
["RSI", () => wasm.rsi(close, 14)],
|
||||
["ADX", () => wasm.adx(high, low, close, 14)],
|
||||
["MFI", () => wasm.mfi(high, low, close, volume, 14)],
|
||||
["ATR", () => wasm.atr(high, low, close, 14)],
|
||||
["BBANDS", () => wasm.bbands(close, 20, 2.0, 2.0)],
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ferro-ta-wasm",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.6",
|
||||
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
|
||||
"main": "pkg/ferro_ta_wasm.js",
|
||||
"types": "pkg/ferro_ta_wasm.d.ts",
|
||||
|
||||
+158
@@ -10,6 +10,7 @@ and `MACD`).
|
||||
## Overlap Studies
|
||||
- [`sma`] — Simple Moving Average
|
||||
- [`ema`] — Exponential Moving Average
|
||||
- [`wma`] — Weighted Moving Average
|
||||
- [`bbands`] — Bollinger Bands (returns `[upper, middle, lower]`)
|
||||
|
||||
## Momentum Indicators
|
||||
@@ -17,12 +18,14 @@ and `MACD`).
|
||||
- [`macd`] — Moving Average Convergence/Divergence (returns `[macd, signal, hist]`)
|
||||
- [`mom`] — Momentum (close[i] - close[i-period])
|
||||
- [`stochf`] — Fast Stochastic (returns `[fastk, fastd]`)
|
||||
- [`adx`] — Average Directional Movement Index
|
||||
|
||||
## Volatility Indicators
|
||||
- [`atr`] — Average True Range (Wilder smoothing)
|
||||
|
||||
## Volume Indicators
|
||||
- [`obv`] — On-Balance Volume
|
||||
- [`mfi`] — Money Flow Index
|
||||
*/
|
||||
|
||||
use js_sys::{Array, Float64Array};
|
||||
@@ -325,6 +328,24 @@ pub fn obv(close: &Float64Array, volume: &Float64Array) -> Float64Array {
|
||||
from_vec(result)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WMA — Weighted Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Weighted Moving Average.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `timeperiod` – look-back window (default 30, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array` with the first `timeperiod - 1` values set to `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn wma(close: &Float64Array, timeperiod: usize) -> Float64Array {
|
||||
let prices = to_vec(close);
|
||||
from_vec(ferro_ta_core::overlap::wma(&prices, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MOM — Momentum
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -433,6 +454,70 @@ pub fn stochf(
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADX — Average Directional Movement Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Average Directional Movement Index (Wilder smoothing).
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `high` – `Float64Array` of high prices.
|
||||
/// - `low` – `Float64Array` of low prices.
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `timeperiod` – look-back period (default 14, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array`; warm-up values are `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn adx(
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
timeperiod: usize,
|
||||
) -> Float64Array {
|
||||
let h = to_vec(high);
|
||||
let l = to_vec(low);
|
||||
let c = to_vec(close);
|
||||
if h.len() != l.len() || h.len() != c.len() {
|
||||
return from_vec(vec![f64::NAN; c.len()]);
|
||||
}
|
||||
from_vec(ferro_ta_core::momentum::adx(&h, &l, &c, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MFI — Money Flow Index
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Money Flow Index.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `high` – `Float64Array` of high prices.
|
||||
/// - `low` – `Float64Array` of low prices.
|
||||
/// - `close` – `Float64Array` of close prices.
|
||||
/// - `volume` – `Float64Array` of volume values.
|
||||
/// - `timeperiod` – look-back period (default 14, minimum 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// `Float64Array`; warm-up values are `NaN`.
|
||||
#[wasm_bindgen]
|
||||
pub fn mfi(
|
||||
high: &Float64Array,
|
||||
low: &Float64Array,
|
||||
close: &Float64Array,
|
||||
volume: &Float64Array,
|
||||
timeperiod: usize,
|
||||
) -> Float64Array {
|
||||
let h = to_vec(high);
|
||||
let l = to_vec(low);
|
||||
let c = to_vec(close);
|
||||
let v = to_vec(volume);
|
||||
let n = c.len();
|
||||
if h.len() != n || l.len() != n || v.len() != n {
|
||||
return from_vec(vec![f64::NAN; n]);
|
||||
}
|
||||
from_vec(ferro_ta_core::volume::mfi(&h, &l, &c, &v, timeperiod))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MACD — Moving Average Convergence/Divergence
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -861,4 +946,77 @@ mod tests {
|
||||
assert!(v >= 0.0 && v <= 100.0, "fastk value {v} out of [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// WMA tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wma_output_length() {
|
||||
let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let out = wma(&close, 3);
|
||||
assert_eq!(out.length(), 5);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wma_known_value() {
|
||||
// WMA(3) at index 2 = (1*1 + 2*2 + 3*3) / 6 = 14/6
|
||||
let close = make_arr(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
let out = wma(&close, 3);
|
||||
let mut vals = vec![0.0f64; 5];
|
||||
out.copy_to(&mut vals);
|
||||
assert!(vals[0].is_nan());
|
||||
assert!(vals[1].is_nan());
|
||||
assert!((vals[2] - (14.0 / 6.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ADX tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_adx_output_length() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]);
|
||||
let out = adx(&h, &l, &c, 3);
|
||||
assert_eq!(out.length(), 8);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_adx_values_in_range() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 13.0, 13.5, 14.0, 14.5, 15.0]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 11.5, 12.0, 12.5, 13.0, 13.5]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 12.0, 13.0, 13.5, 14.0, 14.5]);
|
||||
let out = adx(&h, &l, &c, 3);
|
||||
for v in get_finite(&out) {
|
||||
assert!((0.0..=100.0).contains(&v), "ADX out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MFI tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mfi_output_length() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]);
|
||||
let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]);
|
||||
let out = mfi(&h, &l, &c, &v, 3);
|
||||
assert_eq!(out.length(), 7);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mfi_values_in_range() {
|
||||
let h = make_arr(&[10.0, 11.0, 12.0, 11.5, 12.5, 13.0, 13.5]);
|
||||
let l = make_arr(&[9.0, 9.5, 10.5, 10.0, 11.0, 11.5, 12.0]);
|
||||
let c = make_arr(&[9.5, 10.5, 11.5, 11.0, 12.0, 12.5, 13.0]);
|
||||
let v = make_arr(&[100.0, 110.0, 120.0, 130.0, 125.0, 140.0, 150.0]);
|
||||
let out = mfi(&h, &l, &c, &v, 3);
|
||||
for val in get_finite(&out) {
|
||||
assert!((0.0..=100.0).contains(&val), "MFI out of range: {val}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user