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
- 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: |
+3
View File
@@ -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
+2 -1
View File
@@ -6,7 +6,7 @@ default_language_version:
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
+5 -5
View File
@@ -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)
-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
> 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 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``.
+1 -1
View File
@@ -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
}
]
}
}
+1 -1
View File
@@ -59,4 +59,4 @@
"sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7"
}
}
}
}
@@ -93,4 +93,4 @@
"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
}
]
}
}
+9 -3
View File
@@ -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(
+2 -1
View File
@@ -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
+2 -6
View File
@@ -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(
+6 -2
View File
@@ -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")
+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:
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
+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
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)
+4 -4
View File
@@ -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}"
+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)):
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(
+1 -1
View File
@@ -16094,4 +16094,4 @@
],
"datetime": "2026-03-23T17:14:05.427766+00:00",
"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]] = []
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
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).
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
View File
@@ -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):
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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",
+2 -1
View File
@@ -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",
-1
View File
@@ -22,7 +22,6 @@
"outputs": [],
"source": [
"import numpy as np\n",
"\n",
"from ferro_ta.streaming import (\n",
" StreamingATR,\n",
" StreamingBBands,\n",
+55 -33
View File
@@ -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
}
]
}
}
+53 -31
View File
@@ -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
View File
@@ -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"
}
}
}
}
+69 -47
View File
@@ -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
}
]
}
}
+1 -1
View File
@@ -282,4 +282,4 @@
]
}
}
}
}
+59 -37
View File
@@ -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
}
]
}
}
+1
View File
@@ -104,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"]
+3 -1
View File
@@ -158,7 +158,9 @@ def rsi_strategy(
c = np.asarray(close, dtype=np.float64)
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,
)
@@ -173,7 +173,9 @@ def strategy_payoff(
return np.zeros_like(grid)
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:
_normalize_rust_error(err)
+3 -1
View File
@@ -33,7 +33,9 @@ __all__ = [
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 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
+9 -3
View File
@@ -32,7 +32,9 @@ 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)
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)
@@ -217,7 +219,9 @@ def _safe_git_head(root: Path) -> str | 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)
rust_core = _extract_core_exports(root)
wasm_exports = _extract_wasm_exports(root)
@@ -279,7 +283,9 @@ def main() -> None:
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)
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}")
-1
View File
@@ -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+$")
+5 -5
View File
@@ -66,8 +66,8 @@ ensure_python_env() {
fi
need_cmd uv
run_cmd uv sync --extra dev --extra docs
run_cmd uv run --extra dev --extra docs maturin develop --release
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
}
@@ -110,13 +110,13 @@ run_python_lint() {
run_python_typecheck() {
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 pyright pyright python/ferro_ta
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 --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() {
@@ -1,8 +1,8 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS = ROOT / "scripts"
@@ -50,7 +50,9 @@ 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`")
pytest.skip(
"wasm/pkg not found; run `wasm-pack build --target nodejs --out-dir pkg`"
)
_write_node_conformance_script(SCRIPT)
try:
@@ -78,7 +80,9 @@ def _assert_close_with_null_nan(
) -> 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)
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)
+9 -9
View File
@@ -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"]
Generated
+6 -3
View File
@@ -2315,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]