feat: refresh benchmark coverage and harden CI tooling

Refresh the benchmark and performance surface across the repo. This updates the benchmark wrappers and helper scripts, regenerates the checked-in benchmark and perf-contract artifacts, and folds in the related roadmap, compatibility, and example notebook changes that belong with this performance-focused pass.

Harden the Python CI and local pre-push flow so the same checks pass reliably in both places. The workflow and pre-push script now use module-safe uv typecheck invocations, the Python test environment installs the optional MCP dependency needed by the MCP server tests, and one-off root benchmark outputs are ignored to keep the repo clean.

Align local tooling with the current project configuration by updating the Ruff pre-commit hook, tightening the API typing and MCP server helpers, and refreshing the lockfile to pick up the audited PyJWT fix while preserving the rest of the staged source changes.
This commit is contained in:
Pratik Bhadane
2026-03-24 14:52:20 +05:30
parent 53566b9d82
commit 71b6343e92
48 changed files with 3107 additions and 988 deletions
+3 -3
View File
@@ -41,10 +41,10 @@ jobs:
run: pip install uv run: pip install uv
- name: Run mypy on ferro_ta via 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 - 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: test:
name: Test (ubuntu-latest / Python ${{ matrix.python-version }}) name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
@@ -63,7 +63,7 @@ jobs:
- name: Install maturin and test dependencies - name: Install maturin and test dependencies
run: | 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) - name: Build and install ferro_ta (dev mode)
run: | run: |
+3
View File
@@ -31,6 +31,9 @@ env/
# WASM build output # WASM build output
wasm/pkg/ wasm/pkg/
wasm/pkg-web/ wasm/pkg-web/
benchmark_vs_talib.json
wasm_benchmark.json
.wasm_benchmark.prepush.json
.coverage .coverage
.coverage.* .coverage.*
coverage.xml coverage.xml
+2 -1
View File
@@ -6,7 +6,7 @@ default_language_version:
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0 rev: v0.15.7
hooks: hooks:
- id: ruff - id: ruff
args: [--fix] args: [--fix]
@@ -18,6 +18,7 @@ repos:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
exclude: ^conda/meta\.yaml$
- id: check-added-large-files - id: check-added-large-files
args: [--maxkb=1000] args: [--maxkb=1000]
- id: check-merge-conflict - id: check-merge-conflict
+5 -5
View File
@@ -70,23 +70,23 @@ rustflags = ["-C", "target-cpu=native"]
### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement) ### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement)
#### SMA — O(n) running sum #### 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). Target: single-pass running sum (already done in Rust — verify SIMD path is hit).
#### BBANDS — Welford's algorithm #### 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. Target: Welford's online algorithm — single pass, better cache utilization.
#### ATR/ADX — Avoid redundant True Range calculations #### 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. Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass.
#### MACD — Reuse EMA computations #### 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. Target: Single function computes both EMAs in one pass.
#### Candlestick Patterns — Batch lookup table #### 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. Target: Pre-compute body/shadow ratios, vectorized pattern matching.
### Phase 4: Streaming Precomputation (Target: 100x for incremental updates) ### Phase 4: Streaming Precomputation (Target: 100x for incremental updates)
-1
View File
@@ -259,4 +259,3 @@ for the first `timeperiod - 1` bars.
> `ferro-ta` implements 100% of TA-Lib's function set. NaN values are placed > `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. > at the beginning of each output array for the warmup period.
+18 -18
View File
@@ -77,7 +77,7 @@ from __future__ import annotations
import math import math
import os import os
from typing import Any, Dict, List, Optional from typing import Any
import numpy as np 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.""" """Convert numpy array to list, replacing NaN/Inf with None."""
return [None if not math.isfinite(v) else float(v) for v in arr] 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: if len(close) > MAX_SERIES_LENGTH:
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
@@ -142,54 +142,54 @@ def _validate_series(close: List[float]) -> np.ndarray:
class IndicatorRequest(BaseModel): 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") timeperiod: int = Field(default=14, ge=1, description="Look-back period")
@field_validator("close") @field_validator("close")
@classmethod @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): if not all(math.isfinite(x) for x in v):
raise ValueError("close series must contain only finite values") raise ValueError("close series must contain only finite values")
return v return v
class MACDRequest(BaseModel): 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) fastperiod: int = Field(default=12, ge=1)
slowperiod: int = Field(default=26, ge=1) slowperiod: int = Field(default=26, ge=1)
signalperiod: int = Field(default=9, ge=1) signalperiod: int = Field(default=9, ge=1)
@field_validator("close") @field_validator("close")
@classmethod @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): if not all(math.isfinite(x) for x in v):
raise ValueError("close series must contain only finite values") raise ValueError("close series must contain only finite values")
return v return v
class BBANDSRequest(BaseModel): 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) timeperiod: int = Field(default=5, ge=2)
nbdevup: float = Field(default=2.0, gt=0) nbdevup: float = Field(default=2.0, gt=0)
nbdevdn: float = Field(default=2.0, gt=0) nbdevdn: float = Field(default=2.0, gt=0)
@field_validator("close") @field_validator("close")
@classmethod @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): if not all(math.isfinite(x) for x in v):
raise ValueError("close series must contain only finite values") raise ValueError("close series must contain only finite values")
return v return v
class BacktestRequest(BaseModel): 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") strategy: str = Field(default="rsi_30_70")
commission_per_trade: float = Field(default=0.0, ge=0.0) commission_per_trade: float = Field(default=0.0, ge=0.0)
slippage_bps: float = Field(default=0.0, ge=0.0) slippage_bps: float = Field(default=0.0, ge=0.0)
@field_validator("close") @field_validator("close")
@classmethod @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): if not all(math.isfinite(x) for x in v):
raise ValueError("close series must contain only finite values") raise ValueError("close series must contain only finite values")
return v return v
@@ -201,13 +201,13 @@ class BacktestRequest(BaseModel):
@app.get("/health", summary="Health check") @app.get("/health", summary="Health check")
def health() -> Dict[str, str]: def health() -> dict[str, str]:
"""Readiness / liveness probe.""" """Readiness / liveness probe."""
return {"status": "ok", "version": app.version} return {"status": "ok", "version": app.version}
@app.post("/indicators/sma", summary="Simple Moving Average") @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). """Compute Simple Moving Average (SMA).
Returns ``result``: list of floats (null for warm-up bars). 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") @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).""" """Compute Exponential Moving Average (EMA)."""
c = _validate_series(req.close) c = _validate_series(req.close)
out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64) 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") @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).""" """Compute Relative Strength Index (RSI)."""
c = _validate_series(req.close) c = _validate_series(req.close)
out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64) 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") @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). """Compute MACD (line, signal, histogram).
Returns ``result`` with keys ``macd``, ``signal``, ``hist``. 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") @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). """Compute Bollinger Bands (upper, middle, lower).
Returns ``result`` with keys ``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") @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. """Run a vectorized backtest using a named strategy.
Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``. Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``.
+1 -1
View File
@@ -68,4 +68,4 @@
"speedup_vs_separate": 2.2811 "speedup_vs_separate": 2.2811
} }
] ]
} }
@@ -1159,4 +1159,4 @@
} }
] ]
} }
} }
@@ -1379,4 +1379,4 @@
"outcome": "ferro_ta_win" "outcome": "ferro_ta_win"
} }
] ]
} }
@@ -160,4 +160,4 @@
"elapsed_ms": 0.0026 "elapsed_ms": 0.0026
} }
] ]
} }
+1 -1
View File
@@ -59,4 +59,4 @@
"sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7" "sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7"
} }
} }
} }
@@ -93,4 +93,4 @@
"share_of_suite_pct": 0.09 "share_of_suite_pct": 0.09
} }
] ]
} }
+1 -1
View File
@@ -282,4 +282,4 @@
] ]
} }
} }
} }
+1 -1
View File
@@ -70,4 +70,4 @@
"stream_over_batch_ratio": 121.7603 "stream_over_batch_ratio": 121.7603
} }
] ]
} }
+9 -3
View File
@@ -48,13 +48,17 @@ def run_batch_benchmark(
"SMA", "SMA",
lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True), 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.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", "RSI",
lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=True), 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.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", "ATR",
@@ -201,7 +205,9 @@ def main() -> int:
if payload["grouped_results"]: if payload["grouped_results"]:
print("\nGrouped Multi-Indicator Calls") print("\nGrouped Multi-Indicator Calls")
print("-" * 64) 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) print("-" * 64)
for row in payload["grouped_results"]: for row in payload["grouped_results"]:
print( print(
+2 -1
View File
@@ -26,9 +26,10 @@ import math
import sys import sys
import time import time
import tracemalloc import tracemalloc
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any
import numpy as np import numpy as np
+2 -6
View File
@@ -70,9 +70,7 @@ def run_simd_benchmark(
for label, args in variants for label, args in variants
} }
portable_rows = { portable_rows = {row["name"]: row for row in reports["portable_release"]["results"]}
row["name"]: row for row in reports["portable_release"]["results"]
}
simd_rows = {row["name"]: row for row in reports["simd_release"]["results"]} simd_rows = {row["name"]: row for row in reports["simd_release"]["results"]}
comparison: list[dict[str, Any]] = [] comparison: list[dict[str, Any]] = []
@@ -136,9 +134,7 @@ def main() -> int:
window=args.window, window=args.window,
) )
print( print(f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}")
f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}"
)
print("-" * 64) print("-" * 64)
for row in payload["results"]: for row in payload["results"]:
print( print(
+6 -2
View File
@@ -57,7 +57,9 @@ def _stream_hlcv(
) -> float: ) -> float:
streamer = factory() streamer = factory()
last = np.nan 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( last = streamer.update(
float(high_value), float(high_value),
float(low_value), float(low_value),
@@ -154,7 +156,9 @@ def run_streaming_benchmark(
def main() -> int: 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("--bars", type=int, default=100_000)
parser.add_argument("--seed", type=int, default=2026) parser.add_argument("--seed", type=int, default=2026)
parser.add_argument("--json", dest="json_path") parser.add_argument("--json", dest="json_path")
+18 -6
View File
@@ -309,9 +309,13 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
if not TALIB_AVAILABLE: if not TALIB_AVAILABLE:
print("Note: ta-lib not installed. Reporting ferro_ta timings only.") 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(f"Sizes: {sizes}")
print() 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: if size == 1_000_000 and name in SKIP_1M_FOR:
continue 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_stats = _summary_stats(ft_samples_ms)
ft_median_ms = float(ft_stats["median_ms"]) ft_median_ms = float(ft_stats["median_ms"])
ft_m_bars_s = _throughput_m_bars_s(size, ft_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] = { row: dict[str, Any] = {
"indicator": name, "indicator": name,
@@ -351,11 +359,15 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
} }
if TALIB_AVAILABLE: 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_stats = _summary_stats(ta_samples_ms)
ta_median_ms = float(ta_stats["median_ms"]) ta_median_ms = float(ta_stats["median_ms"])
ta_m_bars_s = _throughput_m_bars_s(size, ta_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) outcome = _outcome(speedup)
ta_peak_bytes = _python_peak_bytes( ta_peak_bytes = _python_peak_bytes(
ta_run, open_, high, low, close, volume, size ta_run, open_, high, low, close, volume, size
+13 -3
View File
@@ -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 Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark
data show ERR (indicating the benchmark run was incomplete or failed). data show ERR (indicating the benchmark run was incomplete or failed).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import sys import sys
from pathlib import Path 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 ( from benchmarks.wrapper_registry import (
INDICATOR_CATEGORIES, INDICATOR_CATEGORIES,
LIBRARY_NAMES as LIBS,
is_supported, is_supported,
) )
from benchmarks.wrapper_registry import (
LIBRARY_NAMES as LIBS,
)
def _all_indicators() -> list[str]: def _all_indicators() -> list[str]:
@@ -34,11 +38,17 @@ def _all_indicators() -> list[str]:
def main(): def main():
p = Path(__file__).parent / "results.json" p = Path(__file__).parent / "results.json"
if not p.exists(): 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) sys.exit(1)
raw = p.read_text().strip() raw = p.read_text().strip()
if not raw: 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) sys.exit(1)
try: try:
data = json.loads(raw) data = json.loads(raw)
+4 -4
View File
@@ -88,7 +88,9 @@ def main() -> int:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
if not data.get("talib_available", False): 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 return 1
summary_by_size = { summary_by_size = {
@@ -133,9 +135,7 @@ def main() -> int:
) )
if rows < args.min_rows: if rows < args.min_rows:
failures.append( failures.append(f"size={size} rows {rows} < min_rows {args.min_rows}")
f"size={size} rows {rows} < min_rows {args.min_rows}"
)
if med < median_floor.get(size, float("-inf")): if med < median_floor.get(size, float("-inf")):
failures.append( failures.append(
f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}" f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}"
+20 -5
View File
@@ -50,11 +50,17 @@ def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray:
for end in range(window, len(x)): for end in range(window, len(x)):
start = end - window start = end - window
rx = np.array( 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, dtype=np.float64,
) )
ry = np.array( 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, dtype=np.float64,
) )
mean_x = float(np.sum(rx)) / window 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) high = close + rng.uniform(0.1, 2.0, price_bars)
low = 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) 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 = [ rows = [
( (
@@ -218,7 +229,9 @@ def build_hotspot_report(
results.sort(key=lambda row: row["fast_ms"], reverse=True) 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 total_fast_ms = sum(float(row["fast_ms"]) for row in results) or 1.0
for row in results: 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 { return {
"metadata": benchmark_metadata( "metadata": benchmark_metadata(
@@ -249,7 +262,9 @@ def main() -> int:
window=args.window, 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) print("-" * 70)
for row in payload["results"]: for row in payload["results"]:
print( print(
+1 -1
View File
@@ -16094,4 +16094,4 @@
], ],
"datetime": "2026-03-23T17:14:05.427766+00:00", "datetime": "2026-03-23T17:14:05.427766+00:00",
"version": "5.2.3" "version": "5.2.3"
} }
+4 -5
View File
@@ -52,7 +52,9 @@ def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for entry in INDICATOR_SUITE: 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( rows.append(
{ {
"name": entry["name"], "name": entry["name"],
@@ -192,10 +194,7 @@ def main() -> int:
fixtures=[FIXTURE_PATH], fixtures=[FIXTURE_PATH],
extra={"output_dir": str(output_dir)}, extra={"output_dir": str(output_dir)},
), ),
"artifacts": { "artifacts": {name: file_info(path) for name, path in artifacts.items()},
name: file_info(path)
for name, path in artifacts.items()
},
} }
manifest_path = output_dir / "manifest.json" manifest_path = output_dir / "manifest.json"
_write_json(manifest_path, manifest) _write_json(manifest_path, manifest)
+60 -53
View File
@@ -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). Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed).
We only compare the overlapping (valid) suffix of each output array. We only compare the overlapping (valid) suffix of each output array.
""" """
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pytest import pytest
from benchmarks.data_generator import MEDIUM from benchmarks.data_generator import MEDIUM
from benchmarks.wrapper_registry import ( from benchmarks.wrapper_registry import (
execute_indicator,
INDICATOR_NAMES,
INDICATOR_CATEGORIES,
CUMULATIVE_INDICATORS,
BINARY_INDICATORS, BINARY_INDICATORS,
CUMULATIVE_INDICATORS,
INDICATOR_CATEGORIES,
INDICATOR_NAMES,
available_libraries, available_libraries,
execute_indicator,
is_supported, is_supported,
) )
# Reference = ferro_ta; compare against each library that has a non-empty result. # Reference = ferro_ta; compare against each library that has a non-empty result.
REFERENCE_LIB = "ferro_ta" 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) # Per-indicator tolerances (rtol, atol)
_TOLERANCES: dict[str, tuple[float, float]] = { _TOLERANCES: dict[str, tuple[float, float]] = {
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs "ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
"NATR": (1e-3, 0.10), "NATR": (1e-3, 0.10),
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1 "BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
"STDDEV": (1e-3, 0.20), "STDDEV": (1e-3, 0.20),
"VAR": (1e-3, 0.50), "VAR": (1e-3, 0.50),
"MACD": (1e-3, 1e-3), # double EMA seed "MACD": (1e-3, 1.00), # seed differences across libraries
"KAMA": (1e-3, 1e-3), "KAMA": (1e-3, 1e-3),
"STOCH": (1e-3, 0.10), # smoothing method differences "STOCH": (1e-3, 0.10), # smoothing method differences
"SAR": (1e-3, 0.20), "SAR": (1e-3, 0.20),
"ADOSC": (1e-3, 0.20), "ADOSC": (1e-3, 0.20),
"ADX": (1e-3, 0.50), # Wilder's ADX "ADX": (1e-3, 0.50), # Wilder's ADX
"PLUS_DI":(1e-3, 0.50), "PLUS_DI": (1e-3, 0.50),
"MINUS_DI":(1e-3, 0.50), "MINUS_DI": (1e-3, 0.50),
"PPO": (1e-2, 1e-3), "PPO": (1e-2, 1e-3),
"CMO": (1e-3, 0.10), "CMO": (1e-3, 0.10),
"TRIX": (1e-3, 1e-3), "TRIX": (1e-3, 0.05),
"CCI": (1e-3, 0.10), "CCI": (1e-3, 0.10),
"SUPERTREND": (1e-2, 0.50), "SUPERTREND": (1e-2, 0.50),
"KELTNER_CHANNELS": (1e-2, 0.50), "KELTNER_CHANNELS": (1e-2, 0.50),
"DONCHIAN": (1e-4, 1e-4), "DONCHIAN": (1e-4, 1e-4),
"HT_DCPERIOD": (1e-2, 1.0), "HT_DCPERIOD": (1e-2, 2.0),
"VWAP": (1e-3, 0.10), "VWAP": (1e-3, 0.10),
"AROON": (1e-4, 1e-3), "AROON": (1e-4, 1e-3),
"LINEARREG": (1e-4, 1e-4), "LINEARREG": (1e-4, 1e-4),
"LINEARREG_SLOPE": (1e-4, 1e-4), "LINEARREG_SLOPE": (1e-4, 1e-4),
"CORREL": (1e-4, 1e-3), "CORREL": (1e-4, 1e-3),
"BETA": (1e-3, 1e-3), "BETA": (1e-3, 1e-3),
"TSF": (1e-4, 1e-4), "TSF": (1e-4, 1e-4),
"EMA": (1e-3, 0.30), # ta library uses different EMA seed "EMA": (1e-3, 0.30), # ta library uses different EMA seed
"DEMA": (1e-3, 0.50), "DEMA": (1e-3, 0.50),
"TEMA": (1e-3, 0.50), "TEMA": (1e-3, 0.50),
"T3": (1e-3, 0.50), "T3": (1e-3, 0.50),
"HULL_MA":(1e-3, 0.10), "HULL_MA": (1e-3, 0.10),
"WMA": (1e-4, 1e-4), "WMA": (1e-4, 1e-4),
"TRIMA": (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),
} }
_DEFAULT_TOL = (1e-4, 1e-5) _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 # Pairs that use correlation check (>=0.95) due to known algorithmic divergence
# Format: (indicator, library) or just indicator (applies to all libs) # Format: (indicator, library) or just indicator (applies to all libs)
_CORRELATION_PAIRS: set[tuple[str, str]] = { _CORRELATION_PAIRS: set[tuple[str, str]] = {
("PPO", "talib"), # different PPO formula normalization ("PPO", "talib"), # different PPO formula normalization
("PPO", "pandas_ta"), ("PPO", "pandas_ta"),
("PPO", "tulipy"), ("PPO", "tulipy"),
("STOCH", "ta"), ("STOCH", "ta"),
("SUPERTREND", "pandas_ta"), ("SUPERTREND", "pandas_ta"),
("KELTNER_CHANNELS", "pandas_ta"), ("KELTNER_CHANNELS", "pandas_ta"),
("KELTNER_CHANNELS", "ta"), ("KELTNER_CHANNELS", "ta"),
("EMA", "finta"), # finta EMA uses different initialization ("EMA", "finta"), # finta EMA uses different initialization
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed ("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
("RSI", "ta"), # ta uses SMA warmup vs Wilder ("RSI", "ta"), # ta uses SMA warmup vs Wilder
("RSI", "finta"), # same ("RSI", "finta"), # same
} }
# Pairs that are skipped because they are structurally incompatible # Pairs that are skipped because they are structurally incompatible
_SKIP_PAIRS: set[tuple[str, str]] = { _SKIP_PAIRS: set[tuple[str, str]] = {
("BBANDS", "finta"), # finta normalizes band differently ("BBANDS", "finta"), # finta normalizes band differently
("ATR", "finta"), # finta ATR uses simple TR not Wilder ("ATR", "finta"), # finta ATR uses simple TR not Wilder
("STDDEV", "finta"), # finta uses population std ("STDDEV", "finta"), # finta uses population std
("TRIMA", "finta"), # finta TRIMA uses different formula ("TRIMA", "finta"), # finta TRIMA uses different formula
("PPO", "finta"), # finta PPO scaling incompatible ("PPO", "finta"), # finta PPO scaling incompatible
("STOCH", "finta"), # finta STOCH formula differs ("STOCH", "finta"), # finta STOCH formula differs
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start ("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges ("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", "pandas_ta"),
("CMO", "finta"), ("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 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:] c = cmp[-n:]
if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS: if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS:
# Use correlation check for structurally different algorithms # 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: if indicator in BINARY_INDICATORS:
agree = np.mean(r == c) agree = np.mean(r == c)
assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%" assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%"
else: 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: elif indicator in CUMULATIVE_INDICATORS:
dr, dc = np.diff(r), np.diff(c) dr, dc = np.diff(r), np.diff(c)
if len(dr) < 5 or len(dc) < 5: 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 ───────────── # ── dynamically generate one test per (indicator, library) pair ─────────────
def pytest_generate_tests(metafunc): def pytest_generate_tests(metafunc):
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
params = [] params = []
@@ -172,6 +176,7 @@ class TestAccuracy:
# ── quick smoke tests that always run (no skip) ────────────────────────────── # ── quick smoke tests that always run (no skip) ──────────────────────────────
class TestSmoke: class TestSmoke:
"""Sanity checks that ferro_ta returns non-empty finite arrays.""" """Sanity checks that ferro_ta returns non-empty finite arrays."""
@@ -182,7 +187,9 @@ class TestSmoke:
arr = execute_indicator("ferro_ta", indicator, MEDIUM) arr = execute_indicator("ferro_ta", indicator, MEDIUM)
assert len(arr) > 0, f"ferro_ta {indicator} returned empty array" 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()) @pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items())
def test_category_coverage(self, category, indicators): def test_category_coverage(self, category, indicators):
+31 -18
View File
@@ -6,31 +6,36 @@ Run: pytest benchmarks/test_speed.py --benchmark-only -v
Streaming benchmarks are in test_streaming_speed.py Streaming benchmarks are in test_streaming_speed.py
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
from benchmarks.data_generator import LARGE from benchmarks.data_generator import LARGE
from benchmarks.wrapper_registry import ( from benchmarks.wrapper_registry import (
execute_indicator,
INDICATOR_CATEGORIES, INDICATOR_CATEGORIES,
available_libraries, available_libraries,
execute_indicator,
is_supported, is_supported,
) )
BENCH_DATA = LARGE # 100k bars for main benchmarks BENCH_DATA = LARGE # 100k bars for main benchmarks
BENCH_LIBS = available_libraries() BENCH_LIBS = available_libraries()
def _make_bench(indicator: str, library: str): def _make_bench(indicator: str, library: str):
"""Return a benchmark function that runs indicator on library (uses BENCH_DATA).""" """Return a benchmark function that runs indicator on library (uses BENCH_DATA)."""
def _fn(): def _fn():
execute_indicator(library, indicator, BENCH_DATA) execute_indicator(library, indicator, BENCH_DATA)
_fn.__name__ = f"{library}_{indicator}" _fn.__name__ = f"{library}_{indicator}"
return _fn return _fn
# ── Parametrize over all (indicator, library) combinations ─────────────────── # ── Parametrize over all (indicator, library) combinations ───────────────────
def pytest_generate_tests(metafunc): def pytest_generate_tests(metafunc):
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames: if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
params = [] params = []
@@ -53,20 +58,24 @@ class TestSpeed:
# ── Standalone head-to-head for the most important indicators ───────────────── # ── Standalone head-to-head for the most important indicators ─────────────────
@pytest.mark.parametrize("indicator,libs", [
("SMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), @pytest.mark.parametrize(
("EMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), "indicator,libs",
("RSI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), [
("MACD", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("SMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("BBANDS",["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("EMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("ATR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("RSI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("CCI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("MACD", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("WILLR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("BBANDS", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("OBV", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("ATR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("ADX", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("CCI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("MFI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]), ("WILLR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
("STOCH", ["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): def test_head_to_head(benchmark, indicator, libs):
"""Benchmark ferro_ta vs all peers — for README table generation.""" """Benchmark ferro_ta vs all peers — for README table generation."""
if not is_supported("ferro_ta", indicator): if not is_supported("ferro_ta", indicator):
@@ -77,7 +86,11 @@ def test_head_to_head(benchmark, indicator, libs):
# ── Large dataset benchmarks (100k bars) ───────────────────────────────────── # ── 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): def test_large_dataset(benchmark, indicator):
"""Scaling benchmark at 100k bars for ferro_ta.""" """Scaling benchmark at 100k bars for ferro_ta."""
if not is_supported("ferro_ta", indicator): if not is_supported("ferro_ta", indicator):
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -21,13 +21,13 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"import numpy as np\n",
"\n",
"import ferro_ta.config as config\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.backtest import backtest\n",
"from ferro_ta.pipeline import Pipeline\n", "from ferro_ta.pipeline import Pipeline\n",
"\n", "\n",
"from ferro_ta import BBANDS, EMA, RSI, SMA\n",
"\n",
"# Synthetic data\n", "# Synthetic data\n",
"np.random.seed(42)\n", "np.random.seed(42)\n",
"n = 300\n", "n = 300\n",
+2 -1
View File
@@ -207,9 +207,10 @@
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"from ferro_ta import FerroTAValueError\n",
"from ferro_ta.exceptions import check_timeperiod\n", "from ferro_ta.exceptions import check_timeperiod\n",
"\n", "\n",
"from ferro_ta import FerroTAValueError\n",
"\n",
"try:\n", "try:\n",
" check_timeperiod(0)\n", " check_timeperiod(0)\n",
"except FerroTAValueError as e:\n", "except FerroTAValueError as e:\n",
-1
View File
@@ -22,7 +22,6 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"import numpy as np\n", "import numpy as np\n",
"\n",
"from ferro_ta.streaming import (\n", "from ferro_ta.streaming import (\n",
" StreamingATR,\n", " StreamingATR,\n",
" StreamingBBands,\n", " StreamingBBands,\n",
+55 -33
View File
@@ -2,16 +2,38 @@
"metadata": { "metadata": {
"suite": "batch", "suite": "batch",
"runtime": { "runtime": {
"generated_at_utc": "2026-03-23T21:08:30.877702+00:00", "generated_at_utc": "2026-03-24T09:13:04.010216+00:00",
"python_version": "3.12.11", "python_version": "3.13.5",
"platform": "macOS-26.3.1-arm64-arm-64bit", "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", "machine": "arm64",
"processor": "arm" "processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
}, },
"git": { "git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4", "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
"dirty": true, "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": { "dataset": {
"n_samples": 20000, "n_samples": 20000,
@@ -23,49 +45,49 @@
"results": [ "results": [
{ {
"indicator": "SMA", "indicator": "SMA",
"parallel_ms": 1.9615, "parallel_ms": 7.9306,
"sequential_ms": 2.0423, "sequential_ms": 2.4832,
"loop_ms": 0.8865, "loop_ms": 1.0238,
"parallel_speedup_vs_loop": 0.452, "parallel_speedup_vs_loop": 0.1291,
"sequential_speedup_vs_loop": 0.4341 "sequential_speedup_vs_loop": 0.4123
}, },
{ {
"indicator": "RSI", "indicator": "RSI",
"parallel_ms": 2.1731, "parallel_ms": 3.9307,
"sequential_ms": 4.3225, "sequential_ms": 5.0938,
"loop_ms": 3.2043, "loop_ms": 3.6883,
"parallel_speedup_vs_loop": 1.4745, "parallel_speedup_vs_loop": 0.9383,
"sequential_speedup_vs_loop": 0.7413 "sequential_speedup_vs_loop": 0.7241
}, },
{ {
"indicator": "ATR", "indicator": "ATR",
"parallel_ms": 4.2249, "parallel_ms": 9.2546,
"sequential_ms": 6.6175, "sequential_ms": 7.768,
"loop_ms": 4.0382, "loop_ms": 5.2669,
"parallel_speedup_vs_loop": 0.9558, "parallel_speedup_vs_loop": 0.5691,
"sequential_speedup_vs_loop": 0.6102 "sequential_speedup_vs_loop": 0.678
}, },
{ {
"indicator": "ADX", "indicator": "ADX",
"parallel_ms": 4.8674, "parallel_ms": 9.5489,
"sequential_ms": 7.6402, "sequential_ms": 9.1403,
"loop_ms": 5.1861, "loop_ms": 7.1578,
"parallel_speedup_vs_loop": 1.0655, "parallel_speedup_vs_loop": 0.7496,
"sequential_speedup_vs_loop": 0.6788 "sequential_speedup_vs_loop": 0.7831
} }
], ],
"grouped_results": [ "grouped_results": [
{ {
"case": "close_bundle_3", "case": "close_bundle_3",
"grouped_ms": 0.1878, "grouped_ms": 0.466,
"separate_ms": 0.1719, "separate_ms": 0.2003,
"speedup_vs_separate": 0.9155 "speedup_vs_separate": 0.4298
}, },
{ {
"case": "hlc_bundle_3", "case": "hlc_bundle_3",
"grouped_ms": 0.2958, "grouped_ms": 1.2538,
"separate_ms": 0.4633, "separate_ms": 0.6999,
"speedup_vs_separate": 1.5666 "speedup_vs_separate": 0.5582
} }
] ]
} }
+53 -31
View File
@@ -2,16 +2,38 @@
"metadata": { "metadata": {
"suite": "indicator_latency", "suite": "indicator_latency",
"runtime": { "runtime": {
"generated_at_utc": "2026-03-23T21:08:30.515962+00:00", "generated_at_utc": "2026-03-24T09:13:02.973728+00:00",
"python_version": "3.12.11", "python_version": "3.13.5",
"platform": "macOS-26.3.1-arm64-arm-64bit", "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", "machine": "arm64",
"processor": "arm" "processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
}, },
"git": { "git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4", "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
"dirty": true, "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": [ "fixtures": [
{ {
@@ -33,7 +55,7 @@
"kwargs": { "kwargs": {
"timeperiod": 20 "timeperiod": 20
}, },
"elapsed_ms": 0.0202 "elapsed_ms": 0.0233
}, },
{ {
"name": "WILLR_14", "name": "WILLR_14",
@@ -41,13 +63,13 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0183 "elapsed_ms": 0.0207
}, },
{ {
"name": "STOCH", "name": "STOCH",
"inputs": "hlc", "inputs": "hlc",
"kwargs": {}, "kwargs": {},
"elapsed_ms": 0.0181 "elapsed_ms": 0.0202
}, },
{ {
"name": "ADX_14", "name": "ADX_14",
@@ -55,7 +77,7 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0147 "elapsed_ms": 0.0171
}, },
{ {
"name": "CCI_14", "name": "CCI_14",
@@ -63,13 +85,13 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0144 "elapsed_ms": 0.0164
}, },
{ {
"name": "MACD", "name": "MACD",
"inputs": "close", "inputs": "close",
"kwargs": {}, "kwargs": {},
"elapsed_ms": 0.0132 "elapsed_ms": 0.015
}, },
{ {
"name": "ATR_14", "name": "ATR_14",
@@ -77,7 +99,7 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0108 "elapsed_ms": 0.0116
}, },
{ {
"name": "RSI_14", "name": "RSI_14",
@@ -85,7 +107,7 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0102 "elapsed_ms": 0.011
}, },
{ {
"name": "STDDEV_20", "name": "STDDEV_20",
@@ -93,7 +115,7 @@
"kwargs": { "kwargs": {
"timeperiod": 20 "timeperiod": 20
}, },
"elapsed_ms": 0.0086 "elapsed_ms": 0.0103
}, },
{ {
"name": "BETA_5", "name": "BETA_5",
@@ -101,7 +123,7 @@
"kwargs": { "kwargs": {
"timeperiod": 5 "timeperiod": 5
}, },
"elapsed_ms": 0.008 "elapsed_ms": 0.0091
}, },
{ {
"name": "CORREL_30", "name": "CORREL_30",
@@ -109,15 +131,7 @@
"kwargs": { "kwargs": {
"timeperiod": 30 "timeperiod": 30
}, },
"elapsed_ms": 0.0068 "elapsed_ms": 0.0078
},
{
"name": "EMA_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.0052
}, },
{ {
"name": "BBANDS_20", "name": "BBANDS_20",
@@ -125,7 +139,7 @@
"kwargs": { "kwargs": {
"timeperiod": 20 "timeperiod": 20
}, },
"elapsed_ms": 0.005 "elapsed_ms": 0.0062
}, },
{ {
"name": "TSF_14", "name": "TSF_14",
@@ -133,7 +147,15 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "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", "name": "LINEARREG_14",
@@ -141,7 +163,7 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0046 "elapsed_ms": 0.0054
}, },
{ {
"name": "LINEARREG_SLOPE_14", "name": "LINEARREG_SLOPE_14",
@@ -149,7 +171,7 @@
"kwargs": { "kwargs": {
"timeperiod": 14 "timeperiod": 14
}, },
"elapsed_ms": 0.0045 "elapsed_ms": 0.005
}, },
{ {
"name": "SMA_20", "name": "SMA_20",
@@ -157,7 +179,7 @@
"kwargs": { "kwargs": {
"timeperiod": 20 "timeperiod": 20
}, },
"elapsed_ms": 0.0027 "elapsed_ms": 0.0032
} }
] ]
} }
+37 -20
View File
@@ -2,16 +2,38 @@
"metadata": { "metadata": {
"suite": "perf_contract", "suite": "perf_contract",
"runtime": { "runtime": {
"generated_at_utc": "2026-03-23T21:09:13.077731+00:00", "generated_at_utc": "2026-03-24T09:13:08.787230+00:00",
"python_version": "3.12.11", "python_version": "3.13.5",
"platform": "macOS-26.3.1-arm64-arm-64bit", "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", "machine": "arm64",
"processor": "arm" "processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
}, },
"git": { "git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4", "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
"dirty": true, "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": [ "fixtures": [
{ {
@@ -25,28 +47,23 @@
"artifacts": { "artifacts": {
"indicator_latency": { "indicator_latency": {
"path": "perf-contract/indicator_latency.json", "path": "perf-contract/indicator_latency.json",
"size_bytes": 3212, "size_bytes": 4041,
"sha256": "1e7f844fe6eb467f07bbb1e4eb918c56861e8cf819ab4802b96e033c6d2570c4" "sha256": "564027c7abed7ecd4ae2ac1720217d96e1e31807f8ac7d5c5393c8fd974f13ed"
}, },
"batch": { "batch": {
"path": "perf-contract/batch.json", "path": "perf-contract/batch.json",
"size_bytes": 1679, "size_bytes": 2507,
"sha256": "202d67b24a88184473f93cec9f866ca34ff60721068e15e7e5d23962f006d169" "sha256": "c50f242a138a0c358a6fa86c420954b4b11a2200598ca2942bcd0fd2bc456fb2"
}, },
"streaming": { "streaming": {
"path": "perf-contract/streaming.json", "path": "perf-contract/streaming.json",
"size_bytes": 1939, "size_bytes": 2766,
"sha256": "04476c3d95a9e7d87e40331b28c5ccad9a7aa5abcfc47fb6ac7e929365f68554" "sha256": "d271d3219ec098e443e84ef648ab4220cd2a38e6dc998bc6c9fc545d7fc77716"
}, },
"runtime_hotspots": { "runtime_hotspots": {
"path": "perf-contract/runtime_hotspots.json", "path": "perf-contract/runtime_hotspots.json",
"size_bytes": 2365, "size_bytes": 3197,
"sha256": "d37a58bc88def9f1525b9b0f64f0bd7b9c0df9ed4cae260c833697f6a1689ca8" "sha256": "96eafc9a61ac410e47fcdfee6a9db1123cdee0ec05f49a85c631a3975063deed"
},
"simd": {
"path": "perf-contract/simd.json",
"size_bytes": 7670,
"sha256": "3f9b341a8206698ed172650752d808621c9b5218f42a2373f0f4485496197b4c"
} }
} }
} }
+69 -47
View File
@@ -2,16 +2,38 @@
"metadata": { "metadata": {
"suite": "runtime_hotspots", "suite": "runtime_hotspots",
"runtime": { "runtime": {
"generated_at_utc": "2026-03-23T21:08:34.436165+00:00", "generated_at_utc": "2026-03-24T09:13:08.521805+00:00",
"python_version": "3.12.11", "python_version": "3.13.5",
"platform": "macOS-26.3.1-arm64-arm-64bit", "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", "machine": "arm64",
"processor": "arm" "processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
}, },
"git": { "git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4", "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
"dirty": true, "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": { "dataset": {
"price_bars": 20000, "price_bars": 20000,
@@ -23,74 +45,74 @@
{ {
"category": "python_analysis", "category": "python_analysis",
"name": "iv_zscore", "name": "iv_zscore",
"fast_ms": 28.857, "fast_ms": 32.5847,
"reference_ms": 897.0178, "reference_ms": 990.4233,
"speedup_vs_reference": 31.0849, "speedup_vs_reference": 30.3954,
"share_of_suite_pct": 70.0 "share_of_suite_pct": 69.27
}, },
{ {
"category": "python_analysis", "category": "python_analysis",
"name": "iv_rank", "name": "iv_rank",
"fast_ms": 10.8496, "fast_ms": 12.2974,
"reference_ms": 199.4376, "reference_ms": 233.0671,
"speedup_vs_reference": 18.3821, "speedup_vs_reference": 18.9525,
"share_of_suite_pct": 26.32 "share_of_suite_pct": 26.14
}, },
{ {
"category": "python_analysis", "category": "python_analysis",
"name": "iv_percentile", "name": "iv_percentile",
"fast_ms": 0.9124, "fast_ms": 0.9511,
"reference_ms": 79.6557, "reference_ms": 90.5663,
"speedup_vs_reference": 87.3019, "speedup_vs_reference": 95.2202,
"share_of_suite_pct": 2.21 "share_of_suite_pct": 2.02
}, },
{ {
"category": "ffi_grouping", "category": "ffi_grouping",
"name": "feature_matrix", "name": "feature_matrix",
"fast_ms": 0.2411, "fast_ms": 0.6619,
"reference_ms": 0.2215, "reference_ms": 0.6155,
"speedup_vs_reference": 0.9186, "speedup_vs_reference": 0.9299,
"share_of_suite_pct": 0.58 "share_of_suite_pct": 1.41
}, },
{ {
"category": "ffi_grouping", "category": "ffi_grouping",
"name": "compute_many_close", "name": "compute_many_close",
"fast_ms": 0.1563, "fast_ms": 0.3175,
"reference_ms": 0.1498, "reference_ms": 0.2437,
"speedup_vs_reference": 0.9587, "speedup_vs_reference": 0.7675,
"share_of_suite_pct": 0.38 "share_of_suite_pct": 0.67
}, },
{ {
"category": "rust_kernel", "category": "rust_kernel",
"name": "BETA", "name": "BETA",
"fast_ms": 0.0694, "fast_ms": 0.0742,
"reference_ms": 159.2715, "reference_ms": 188.6048,
"speedup_vs_reference": 2294.4163, "speedup_vs_reference": 2541.5695,
"share_of_suite_pct": 0.17 "share_of_suite_pct": 0.16
}, },
{ {
"category": "rust_kernel", "category": "rust_kernel",
"name": "CORREL", "name": "CORREL",
"fast_ms": 0.0555, "fast_ms": 0.0638,
"reference_ms": 158.2775, "reference_ms": 215.973,
"speedup_vs_reference": 2851.8468, "speedup_vs_reference": 3387.8115,
"share_of_suite_pct": 0.13 "share_of_suite_pct": 0.14
},
{
"category": "rust_kernel",
"name": "LINEARREG",
"fast_ms": 0.0414,
"reference_ms": 44.8111,
"speedup_vs_reference": 1081.9491,
"share_of_suite_pct": 0.1
}, },
{ {
"category": "rust_kernel", "category": "rust_kernel",
"name": "TSF", "name": "TSF",
"fast_ms": 0.0413, "fast_ms": 0.0441,
"reference_ms": 46.3799, "reference_ms": 49.6108,
"speedup_vs_reference": 1122.1039, "speedup_vs_reference": 1125.3954,
"share_of_suite_pct": 0.1 "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
} }
] ]
} }
+1 -1
View File
@@ -282,4 +282,4 @@
] ]
} }
} }
} }
+59 -37
View File
@@ -2,16 +2,38 @@
"metadata": { "metadata": {
"suite": "streaming", "suite": "streaming",
"runtime": { "runtime": {
"generated_at_utc": "2026-03-23T21:08:30.968886+00:00", "generated_at_utc": "2026-03-24T09:13:04.351797+00:00",
"python_version": "3.12.11", "python_version": "3.13.5",
"platform": "macOS-26.3.1-arm64-arm-64bit", "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", "machine": "arm64",
"processor": "arm" "processor": "arm",
"cpu_model": "Apple M3 Max",
"cpu_count_logical": 14,
"total_memory_bytes": 38654705664
}, },
"git": { "git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4", "commit": "53566b9d82898fa4f95c5190156c969a0bd8e8e3",
"dirty": true, "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": { "dataset": {
"n_bars": 20000, "n_bars": 20000,
@@ -22,52 +44,52 @@
{ {
"indicator": "StreamingSMA", "indicator": "StreamingSMA",
"inputs": "close", "inputs": "close",
"stream_total_ms": 0.8808, "stream_total_ms": 0.9927,
"batch_total_ms": 0.0153, "batch_total_ms": 0.0166,
"stream_ns_per_update": 44.04, "stream_ns_per_update": 49.63,
"batch_ns_per_bar": 0.77, "batch_ns_per_bar": 0.83,
"updates_per_second": 22705779.59, "updates_per_second": 20147763.43,
"stream_over_batch_ratio": 57.4469 "stream_over_batch_ratio": 59.7092
}, },
{ {
"indicator": "StreamingEMA", "indicator": "StreamingEMA",
"inputs": "close", "inputs": "close",
"stream_total_ms": 0.8611, "stream_total_ms": 0.9459,
"batch_total_ms": 0.0408, "batch_total_ms": 0.0435,
"stream_ns_per_update": 43.06, "stream_ns_per_update": 47.3,
"batch_ns_per_bar": 2.04, "batch_ns_per_bar": 2.18,
"updates_per_second": 23225431.81, "updates_per_second": 21143503.9,
"stream_over_batch_ratio": 21.0884 "stream_over_batch_ratio": 21.7247
}, },
{ {
"indicator": "StreamingRSI", "indicator": "StreamingRSI",
"inputs": "close", "inputs": "close",
"stream_total_ms": 0.9235, "stream_total_ms": 1.0059,
"batch_total_ms": 0.0932, "batch_total_ms": 0.0991,
"stream_ns_per_update": 46.17, "stream_ns_per_update": 50.3,
"batch_ns_per_bar": 4.66, "batch_ns_per_bar": 4.95,
"updates_per_second": 21656740.75, "updates_per_second": 19882356.06,
"stream_over_batch_ratio": 9.9035 "stream_over_batch_ratio": 10.1523
}, },
{ {
"indicator": "StreamingATR", "indicator": "StreamingATR",
"inputs": "hlc", "inputs": "hlc",
"stream_total_ms": 2.0074, "stream_total_ms": 2.1574,
"batch_total_ms": 0.0935, "batch_total_ms": 0.0992,
"stream_ns_per_update": 100.37, "stream_ns_per_update": 107.87,
"batch_ns_per_bar": 4.68, "batch_ns_per_bar": 4.96,
"updates_per_second": 9963056.99, "updates_per_second": 9270525.53,
"stream_over_batch_ratio": 21.4603 "stream_over_batch_ratio": 21.746
}, },
{ {
"indicator": "StreamingVWAP", "indicator": "StreamingVWAP",
"inputs": "hlcv", "inputs": "hlcv",
"stream_total_ms": 2.6068, "stream_total_ms": 2.6935,
"batch_total_ms": 0.0223, "batch_total_ms": 0.0252,
"stream_ns_per_update": 130.34, "stream_ns_per_update": 134.68,
"batch_ns_per_bar": 1.11, "batch_ns_per_bar": 1.26,
"updates_per_second": 7672265.38, "updates_per_second": 7425170.07,
"stream_over_batch_ratio": 116.9385 "stream_over_batch_ratio": 106.8484
} }
] ]
} }
+1
View File
@@ -104,6 +104,7 @@ ignore = ["E501", "UP006", "UP045", "UP007"]
"tests/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] "tests/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
"tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"] "tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
"tests/integration/*" = ["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/*.py" = ["N802"] # Public API: SMA, RSI, ATR, etc.
"python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design "python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design
"python/ferro_ta/__init__.pyi" = ["N802", "E402"] "python/ferro_ta/__init__.pyi" = ["N802", "E402"]
+3 -1
View File
@@ -158,7 +158,9 @@ def rsi_strategy(
c = np.asarray(close, dtype=np.float64) c = np.asarray(close, dtype=np.float64)
return np.asarray( return np.asarray(
_rust_rsi_threshold_signals(c, int(timeperiod), float(oversold), float(overbought)), _rust_rsi_threshold_signals(
c, int(timeperiod), float(oversold), float(overbought)
),
dtype=np.float64, dtype=np.float64,
) )
@@ -173,7 +173,9 @@ def strategy_payoff(
return np.zeros_like(grid) return np.zeros_like(grid)
try: try:
return np.asarray(_rust_strategy_payoff_legs(grid, normalized), dtype=np.float64) return np.asarray(
_rust_strategy_payoff_legs(grid, normalized), dtype=np.float64
)
except ValueError as err: except ValueError as err:
_normalize_rust_error(err) _normalize_rust_error(err)
+3 -1
View File
@@ -33,7 +33,9 @@ __all__ = [
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]: def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
return np.asarray(_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))) return np.asarray(
_rust_forward_fill_nan(np.ascontiguousarray(arr, dtype=np.float64))
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+52 -28
View File
@@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping
from datetime import date, datetime, time from datetime import date, datetime, time
from functools import lru_cache from functools import lru_cache
from itertools import count 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 import numpy as np
@@ -156,38 +156,42 @@ def _object_snapshot(value: Any) -> Any:
if isinstance(value, enum.Enum): if isinstance(value, enum.Enum):
return value.value 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) 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: try:
return _normalise_json(value.to_dict(), store_objects=False) return _normalise_json(dynamic_value.to_dict(), store_objects=False)
except TypeError: except TypeError:
if value.__class__.__module__.startswith("pandas"): if dynamic_value.__class__.__module__.startswith("pandas"):
return _normalise_json(value.to_dict(orient="list"), store_objects=False)
if value.__class__.__module__.startswith("polars"):
return _normalise_json( 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: except Exception:
return None return None
if hasattr(value, "__dict__"): if hasattr(dynamic_value, "__dict__"):
fields = { fields = {
key: val 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 not key.startswith("_") and not callable(val)
} }
if fields: if fields:
return _normalise_json(fields, store_objects=False) return _normalise_json(fields, store_objects=False)
slots = getattr(type(value), "__slots__", ()) slots = getattr(type(dynamic_value), "__slots__", ())
if slots: if slots:
fields = {} fields = {}
for slot in slots: for slot in slots:
if slot.startswith("_") or not hasattr(value, slot): if slot.startswith("_") or not hasattr(dynamic_value, slot):
continue continue
slot_value = getattr(value, slot) slot_value = getattr(dynamic_value, slot)
if callable(slot_value): if callable(slot_value):
continue continue
fields[slot] = slot_value 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) return _normalise_json(value.value, store_objects=store_objects)
if isinstance(value, np.ndarray): 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): if isinstance(value, np.generic):
return _normalise_json(value.item(), store_objects=store_objects) return _normalise_json(value.item(), store_objects=store_objects)
@@ -408,7 +415,9 @@ def _annotation_includes_custom_class(annotation: Any) -> bool:
return False 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.""" """Map Python annotations to JSON Schema and wrapper annotations."""
enum_type = _is_enum_annotation(annotation) enum_type = _is_enum_annotation(annotation)
if enum_type is not None: 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: if "scalarorarray" in lower:
return { return {
"type": _JSON_ANY_TYPE, "type": _JSON_ANY_TYPE,
"description": f"Parameter `{param_name}`. { _REFERENCE_HELP }", "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
}, Any }, Any
if "ndarray" in lower or "arraylike" in lower: if "ndarray" in lower or "arraylike" in lower:
return {"type": "array", "items": {}}, list[Any] return {"type": "array", "items": {}}, list[Any]
return {"type": "number"}, float 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] return {"type": "array", "items": {}}, list[Any]
if "dict" in lower or "mapping" in lower: if "dict" in lower or "mapping" in lower:
return {"type": "object"}, dict[str, Any] return {"type": "object"}, dict[str, Any]
if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower: if "str" in lower or "date" in lower or "datetime" in lower or "time" in lower:
return {"type": "string"}, str 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 { return {
"type": _JSON_ANY_TYPE, "type": _JSON_ANY_TYPE,
"description": f"Parameter `{param_name}`. {_REFERENCE_HELP}", "description": f"Parameter `{param_name}`. {_REFERENCE_HELP}",
@@ -639,7 +655,10 @@ def _invoke_target(
if not isinstance(extra_kwargs, dict): if not isinstance(extra_kwargs, dict):
raise TypeError("kwargs must be a JSON object") raise TypeError("kwargs must be a JSON object")
keyword_args.update( 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 continue
@@ -689,8 +708,7 @@ def _describe_instance_payload(identifier: str) -> dict[str, Any]:
def _list_instances_payload() -> list[dict[str, Any]]: def _list_instances_payload() -> list[dict[str, Any]]:
"""Return current stored-object metadata.""" """Return current stored-object metadata."""
return [ return [
_describe_instance_payload(identifier) _describe_instance_payload(identifier) for identifier in sorted(_INSTANCE_STORE)
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." 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: if "value" not in arguments:
raise KeyError("Missing required argument: value") raise KeyError("Missing required argument: value")
member = _coerce_enum(arguments["value"], enum_type) 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, description=description,
input_schema=input_schema, input_schema=input_schema,
wrapper_signature=wrapper_signature, wrapper_signature=wrapper_signature,
invoke=invoke, invoke=invoke_enum,
) )
signature = inspect.signature(target) 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." or f"Construct a {item['name']} instance. Returns a stored instance reference."
) )
def invoke( def invoke_target(
arguments: dict[str, Any], arguments: dict[str, Any],
*, *,
raw_target: Any = target, raw_target: Any = target,
@@ -770,7 +790,7 @@ def _build_public_tool_spec(item: dict[str, str], target: Any) -> _ToolSpec:
description=description, description=description,
input_schema=input_schema, input_schema=input_schema,
wrapper_signature=wrapper_signature, 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") raise ValueError("Only public methods can be called")
method = getattr(value, method_name) method = getattr(value, method_name)
if not callable(method): 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 [])] args = [_decode_value(item, Any) for item in (arguments.get("args") or [])]
kwargs = { kwargs = {
str(key): _decode_value(item, Any) 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.__name__ = f"tool_{_slugify(spec.name).replace('-', '_')}"
wrapper.__doc__ = spec.description wrapper.__doc__ = spec.description
wrapper.__signature__ = spec.wrapper_signature setattr(wrapper, "__signature__", spec.wrapper_signature)
wrapper.__annotations__ = { wrapper.__annotations__ = {
parameter.name: ( parameter.name: (
Any if parameter.annotation is inspect._empty else parameter.annotation Any if parameter.annotation is inspect._empty else parameter.annotation
@@ -1311,7 +1333,9 @@ def create_server() -> Any:
) )
for spec in _tool_catalog().values(): 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 return app
+9 -3
View File
@@ -32,7 +32,9 @@ def _load_api_info_module(root: Path, module_path: Path):
python_root = str(root / "python") python_root = str(root / "python")
if python_root not in sys.path: if python_root not in sys.path:
sys.path.insert(0, python_root) sys.path.insert(0, python_root)
spec = importlib.util.spec_from_file_location("ferro_ta_tools_api_info", module_path) spec = importlib.util.spec_from_file_location(
"ferro_ta_tools_api_info", module_path
)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load module spec from {module_path}") raise RuntimeError(f"Could not load module spec from {module_path}")
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
@@ -217,7 +219,9 @@ def _safe_git_head(root: Path) -> str | None:
return value or None return value or None
def build_manifest(root: Path, include_runtime_metadata: bool = False) -> dict[str, Any]: def build_manifest(
root: Path, include_runtime_metadata: bool = False
) -> dict[str, Any]:
python_api = _extract_python_api(root) python_api = _extract_python_api(root)
rust_core = _extract_core_exports(root) rust_core = _extract_core_exports(root)
wasm_exports = _extract_wasm_exports(root) wasm_exports = _extract_wasm_exports(root)
@@ -279,7 +283,9 @@ def main() -> None:
output_path = (root / args.output).resolve() output_path = (root / args.output).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True) output_path.parent.mkdir(parents=True, exist_ok=True)
manifest = build_manifest(root, include_runtime_metadata=args.include_runtime_metadata) manifest = build_manifest(
root, include_runtime_metadata=args.include_runtime_metadata
)
output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") output_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
print(f"Wrote API manifest to {output_path}") print(f"Wrote API manifest to {output_path}")
-1
View File
@@ -15,7 +15,6 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
+5 -5
View File
@@ -66,8 +66,8 @@ ensure_python_env() {
fi fi
need_cmd uv need_cmd uv
run_cmd uv sync --extra dev --extra docs run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs maturin develop --release run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
python_env_ready=1 python_env_ready=1
} }
@@ -110,13 +110,13 @@ run_python_lint() {
run_python_typecheck() { run_python_typecheck() {
need_cmd uv need_cmd uv
run_cmd uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary 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 pyright python/ferro_ta run_cmd uv run --with pyright python -m pyright python/ferro_ta
} }
run_python_test() { run_python_test() {
ensure_python_env ensure_python_env
run_cmd uv run --extra dev --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 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() { run_docs() {
@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
import sys import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts" SCRIPTS = ROOT / "scripts"
@@ -50,7 +50,9 @@ def _run_node_conformance() -> dict[str, list[float | None]]:
if shutil.which("node") is None: if shutil.which("node") is None:
pytest.skip("node is required for wasm/node conformance test") pytest.skip("node is required for wasm/node conformance test")
if not PKG_JS.exists(): if not PKG_JS.exists():
pytest.skip("wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`") pytest.skip(
"wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`"
)
_write_node_conformance_script(SCRIPT) _write_node_conformance_script(SCRIPT)
try: try:
@@ -78,7 +80,9 @@ def _assert_close_with_null_nan(
) -> None: ) -> None:
assert len(actual) == len(expected) assert len(actual) == len(expected)
a = np.array([np.nan if v is None else float(v) for v in actual], dtype=np.float64) 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) 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) np.testing.assert_allclose(a, e, atol=atol, rtol=0.0, equal_nan=True)
+9 -9
View File
@@ -1403,9 +1403,9 @@ class TestMCPCallTool:
instance_id = created["instance_id"] instance_id = created["instance_id"]
described = json.loads( described = json.loads(
handle_call_tool( handle_call_tool("describe_instance", {"instance_id": instance_id})[
"describe_instance", {"instance_id": instance_id} "content"
)["content"][0]["text"] ][0]["text"]
) )
method_names = [item["name"] for item in described["methods"]] method_names = [item["name"] for item in described["methods"]]
assert "aggregate" in method_names assert "aggregate" in method_names
@@ -1429,9 +1429,9 @@ class TestMCPCallTool:
assert "close" in aggregated assert "close" in aggregated
deleted = json.loads( deleted = json.loads(
handle_call_tool( handle_call_tool("delete_instance", {"instance_id": instance_id})[
"delete_instance", {"instance_id": instance_id} "content"
)["content"][0]["text"] ][0]["text"]
) )
assert deleted["deleted"] is True assert deleted["deleted"] is True
@@ -1441,9 +1441,9 @@ class TestMCPCallTool:
from ferro_ta.mcp import handle_call_tool from ferro_ta.mcp import handle_call_tool
wrapped = json.loads( wrapped = json.loads(
handle_call_tool( handle_call_tool("traced", {"func": {"callable": "SMA"}})["content"][0][
"traced", {"func": {"callable": "SMA"}} "text"
)["content"][0]["text"] ]
) )
instance_id = wrapped["instance_id"] instance_id = wrapped["instance_id"]
Generated
+6 -3
View File
@@ -2315,11 +2315,14 @@ wheels = [
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.11.0" version = "2.12.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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] [package.optional-dependencies]