Files
ferro-ta/benchmarks/bench_vs_talib.py
T

482 lines
15 KiB
Python
Raw Normal View History

2026-03-23 23:34:28 +05:30
"""
ferro_ta vs TA-Lib speed comparison.
2026-03-24 11:09:48 +05:30
Measures throughput (M bars/s) for both libraries on the same synthetic data
and parameters. The output is intentionally evidence-heavy:
2026-03-23 23:34:28 +05:30
2026-03-24 11:09:48 +05:30
- median timings
- per-run timing samples
- variability stats
- Python-tracked peak allocation snapshots
- machine, runtime, and build metadata
2026-03-23 23:34:28 +05:30
2026-03-24 11:09:48 +05:30
This is meant to support a narrow claim: ferro-ta is often faster on selected
indicators, not universally faster.
2026-03-23 23:34:28 +05:30
"""
from __future__ import annotations
import argparse
import json
2026-03-24 11:09:48 +05:30
import math
2026-03-23 23:34:28 +05:30
import sys
import time
2026-03-24 11:09:48 +05:30
import tracemalloc
2026-03-23 23:34:28 +05:30
from typing import Any
import numpy as np
try:
import talib # noqa: F401
2026-03-24 11:09:48 +05:30
2026-03-23 23:34:28 +05:30
TALIB_AVAILABLE = True
except ImportError:
TALIB_AVAILABLE = False
talib = None # type: ignore[assignment]
import ferro_ta
2026-03-24 11:09:48 +05:30
try:
from benchmarks.metadata import benchmark_metadata, package_versions
except ModuleNotFoundError: # pragma: no cover - script execution fallback
from metadata import benchmark_metadata, package_versions
2026-03-23 23:34:28 +05:30
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
N_WARMUP = 1
N_RUNS = 7
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
2026-03-24 11:09:48 +05:30
TIE_EPSILON = 0.05
2026-03-23 23:34:28 +05:30
_rng = np.random.default_rng(42)
2026-03-24 11:09:48 +05:30
def _median(values: list[float]) -> float:
ordered = sorted(values)
mid = len(ordered) // 2
if len(ordered) % 2:
return ordered[mid]
return (ordered[mid - 1] + ordered[mid]) / 2.0
2026-03-23 23:34:28 +05:30
2026-03-24 11:09:48 +05:30
def _summary_stats(samples_ms: list[float]) -> dict[str, float]:
if not samples_ms:
return {
"median_ms": 0.0,
"mean_ms": 0.0,
"min_ms": 0.0,
"max_ms": 0.0,
"stddev_ms": 0.0,
"cv_pct": 0.0,
}
mean_ms = sum(samples_ms) / len(samples_ms)
variance = (
sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1)
if len(samples_ms) > 1
else 0.0
)
stddev_ms = math.sqrt(variance)
cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0
2026-03-23 23:34:28 +05:30
return {
2026-03-24 11:09:48 +05:30
"median_ms": round(_median(samples_ms), 4),
"mean_ms": round(mean_ms, 4),
"min_ms": round(min(samples_ms), 4),
"max_ms": round(max(samples_ms), 4),
"stddev_ms": round(stddev_ms, 4),
"cv_pct": round(cv_pct, 3),
2026-03-23 23:34:28 +05:30
}
2026-03-24 11:09:48 +05:30
def _outcome(speedup: float) -> str:
if speedup > 1.0 + TIE_EPSILON:
return "ferro_ta_win"
if speedup < 1.0 - TIE_EPSILON:
return "talib_win"
return "tie"
2026-03-23 23:34:28 +05:30
def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]:
2026-03-24 11:09:48 +05:30
rows = [row for row in results if row.get("size") == size and "speedup" in row]
2026-03-23 23:34:28 +05:30
if not rows:
return {"size": size, "rows": 0}
2026-03-24 11:09:48 +05:30
speedups = [float(row["speedup"]) for row in rows]
wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win")
ties = sum(1 for row in rows if row.get("outcome") == "tie")
losses = sum(1 for row in rows if row.get("outcome") == "talib_win")
2026-03-23 23:34:28 +05:30
return {
"size": size,
"rows": len(rows),
"wins": wins,
2026-03-24 11:09:48 +05:30
"ties": ties,
"losses": losses,
"win_rate": round(wins / len(rows), 4),
"non_loss_rate": round((wins + ties) / len(rows), 4),
"median_speedup": round(_median(speedups), 4),
2026-03-23 23:34:28 +05:30
"min_speedup": round(min(speedups), 4),
"max_speedup": round(max(speedups), 4),
2026-03-24 11:09:48 +05:30
"talib_wins_or_ties": [
row["indicator"]
for row in rows
if row.get("outcome") in {"talib_win", "tie"}
],
2026-03-23 23:34:28 +05:30
}
2026-03-24 11:09:48 +05:30
def _synthetic_ohlcv(
n: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0,
# volume >= 0, and low <= open, close <= high, high >= open.
2026-03-23 23:34:28 +05:30
close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5)
open_ = close + _rng.standard_normal(n) * 0.2
high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3)
low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3)
high = np.maximum(high, low)
low = np.maximum(low, 0.0)
2026-03-24 11:09:48 +05:30
high = np.maximum(high, low)
2026-03-23 23:34:28 +05:30
open_ = np.clip(open_, low, high)
close = np.clip(close, low, high)
volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000
return open_, high, low, close, volume
2026-03-24 11:09:48 +05:30
def _timed_runs_ms(fn, *args, **kwargs) -> list[float]:
2026-03-23 23:34:28 +05:30
for _ in range(N_WARMUP):
fn(*args, **kwargs)
2026-03-24 11:09:48 +05:30
samples_ms: list[float] = []
2026-03-23 23:34:28 +05:30
for _ in range(N_RUNS):
t0 = time.perf_counter()
fn(*args, **kwargs)
2026-03-24 11:09:48 +05:30
samples_ms.append((time.perf_counter() - t0) * 1000.0)
return samples_ms
def _python_peak_bytes(fn, *args, **kwargs) -> int | None:
try:
tracemalloc.start()
tracemalloc.reset_peak()
fn(*args, **kwargs)
_, peak = tracemalloc.get_traced_memory()
return int(peak)
except Exception:
return None
finally:
tracemalloc.stop()
def _throughput_m_bars_s(size: int, median_ms: float) -> float:
if median_ms <= 0:
return 0.0
return (size / 1e6) / (median_ms / 1000.0)
# ---------------------------------------------------------------------------
# Benchmarked callables
# ---------------------------------------------------------------------------
2026-03-23 23:34:28 +05:30
def _run_ft_sma(o, h, l, c, v, n):
return ferro_ta.SMA(c[:n], timeperiod=14)
def _run_ta_sma(o, h, l, c, v, n):
return talib.SMA(c[:n], timeperiod=14)
def _run_ft_ema(o, h, l, c, v, n):
return ferro_ta.EMA(c[:n], timeperiod=14)
def _run_ta_ema(o, h, l, c, v, n):
return talib.EMA(c[:n], timeperiod=14)
def _run_ft_rsi(o, h, l, c, v, n):
return ferro_ta.RSI(c[:n], timeperiod=14)
def _run_ta_rsi(o, h, l, c, v, n):
return talib.RSI(c[:n], timeperiod=14)
def _run_ft_bbands(o, h, l, c, v, n):
return ferro_ta.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
def _run_ta_bbands(o, h, l, c, v, n):
return talib.BBANDS(c[:n], timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
def _run_ft_macd(o, h, l, c, v, n):
return ferro_ta.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9)
def _run_ta_macd(o, h, l, c, v, n):
return talib.MACD(c[:n], fastperiod=12, slowperiod=26, signalperiod=9)
def _run_ft_atr(o, h, l, c, v, n):
return ferro_ta.ATR(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ta_atr(o, h, l, c, v, n):
return talib.ATR(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ft_stoch(o, h, l, c, v, n):
return ferro_ta.STOCH(h[:n], l[:n], c[:n])
def _run_ta_stoch(o, h, l, c, v, n):
return talib.STOCH(h[:n], l[:n], c[:n])
def _run_ft_adx(o, h, l, c, v, n):
return ferro_ta.ADX(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ta_adx(o, h, l, c, v, n):
return talib.ADX(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ft_cci(o, h, l, c, v, n):
return ferro_ta.CCI(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ta_cci(o, h, l, c, v, n):
return talib.CCI(h[:n], l[:n], c[:n], timeperiod=14)
def _run_ft_obv(o, h, l, c, v, n):
return ferro_ta.OBV(c[:n], v[:n])
def _run_ta_obv(o, h, l, c, v, n):
return talib.OBV(c[:n], v[:n])
def _run_ft_mfi(o, h, l, c, v, n):
return ferro_ta.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14)
def _run_ta_mfi(o, h, l, c, v, n):
return talib.MFI(h[:n], l[:n], c[:n], v[:n], timeperiod=14)
def _run_ft_wma(o, h, l, c, v, n):
return ferro_ta.WMA(c[:n], timeperiod=14)
def _run_ta_wma(o, h, l, c, v, n):
return talib.WMA(c[:n], timeperiod=14)
COMPARISON_CASES = [
("SMA", _run_ft_sma, _run_ta_sma),
("EMA", _run_ft_ema, _run_ta_ema),
("RSI", _run_ft_rsi, _run_ta_rsi),
("BBANDS", _run_ft_bbands, _run_ta_bbands),
("MACD", _run_ft_macd, _run_ta_macd),
("ATR", _run_ft_atr, _run_ta_atr),
("STOCH", _run_ft_stoch, _run_ta_stoch),
("ADX", _run_ft_adx, _run_ta_adx),
("CCI", _run_ft_cci, _run_ta_cci),
("OBV", _run_ft_obv, _run_ta_obv),
("MFI", _run_ft_mfi, _run_ta_mfi),
("WMA", _run_ft_wma, _run_ta_wma),
]
SKIP_1M_FOR = {"STOCH", "ADX"}
def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]:
max_size = max(sizes)
open_, high, low, close, volume = _synthetic_ohlcv(max_size)
2026-03-24 11:09:48 +05:30
results: list[dict[str, Any]] = []
2026-03-23 23:34:28 +05:30
col_label = 10
col_size = 10
col_ft_ms = 12
col_ta_ms = 12
col_speedup = 10
col_ft_m = 12
col_ta_m = 12
if not TALIB_AVAILABLE:
2026-03-24 11:09:48 +05:30
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"
)
2026-03-23 23:34:28 +05:30
print(
f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup"
)
2026-03-23 23:34:28 +05:30
print(f"Sizes: {sizes}")
print()
2026-03-24 11:09:48 +05:30
2026-03-23 23:34:28 +05:30
header = (
f"{'Indicator':<{col_label}} {'Size':<{col_size}} "
f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} "
f"{'Speedup':<{col_speedup}} {'ferro_ta(M/s)':<{col_ft_m}} {'TA-Lib(M/s)':<{col_ta_m}}"
)
print(header)
print("-" * len(header))
for name, ft_run, ta_run in COMPARISON_CASES:
for size in sizes:
if size == 1_000_000 and name in SKIP_1M_FOR:
continue
2026-03-24 11:09:48 +05:30
ft_samples_ms = _timed_runs_ms(
ft_run, open_, high, low, close, volume, size
)
2026-03-24 11:09:48 +05:30
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
)
2026-03-24 11:09:48 +05:30
row: dict[str, Any] = {
"indicator": name,
"size": size,
"input_layout": {
"dtype": "float64",
"contiguous": True,
},
"ferro_ta_ms": round(ft_median_ms, 4),
"ferro_ta_m_bars_s": round(ft_m_bars_s, 2),
"ferro_ta_runs_ms": [round(sample, 4) for sample in ft_samples_ms],
"ferro_ta_stats": ft_stats,
"python_peak_allocation_bytes": {
"ferro_ta": ft_peak_bytes,
},
}
2026-03-23 23:34:28 +05:30
if TALIB_AVAILABLE:
ta_samples_ms = _timed_runs_ms(
ta_run, open_, high, low, close, volume, size
)
2026-03-24 11:09:48 +05:30
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")
)
2026-03-24 11:09:48 +05:30
outcome = _outcome(speedup)
ta_peak_bytes = _python_peak_bytes(
ta_run, open_, high, low, close, volume, size
)
2026-03-23 23:34:28 +05:30
print(
f"{name:<{col_label}} {size:<{col_size}} "
2026-03-24 11:09:48 +05:30
f"{ft_median_ms:<{col_ft_ms}.3f} {ta_median_ms:<{col_ta_ms}.3f} "
f"{speedup:<{col_speedup}.2f}x {ft_m_bars_s:<{col_ft_m}.1f} {ta_m_bars_s:<{col_ta_m}.1f}"
2026-03-23 23:34:28 +05:30
)
2026-03-24 11:09:48 +05:30
row.update(
{
"talib_ms": round(ta_median_ms, 4),
"talib_m_bars_s": round(ta_m_bars_s, 2),
"talib_runs_ms": [round(sample, 4) for sample in ta_samples_ms],
"talib_stats": ta_stats,
"speedup": round(speedup, 4),
"outcome": outcome,
}
)
row["python_peak_allocation_bytes"]["talib"] = ta_peak_bytes
2026-03-23 23:34:28 +05:30
else:
print(
f"{name:<{col_label}} {size:<{col_size}} "
2026-03-24 11:09:48 +05:30
f"{ft_median_ms:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} "
f"{'N/A':<{col_speedup}} {ft_m_bars_s:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}"
2026-03-23 23:34:28 +05:30
)
2026-03-24 11:09:48 +05:30
2026-03-23 23:34:28 +05:30
results.append(row)
print()
if TALIB_AVAILABLE and results:
2026-03-24 11:09:48 +05:30
wins = sum(1 for row in results if row.get("outcome") == "ferro_ta_win")
total = len([row for row in results if "speedup" in row])
print(f"Summary: ferro_ta ahead outside the tie band on {wins}/{total} rows.")
2026-03-23 23:34:28 +05:30
print()
2026-03-24 11:09:48 +05:30
2026-03-23 23:34:28 +05:30
if json_path:
2026-03-24 11:09:48 +05:30
metadata = benchmark_metadata(
"benchmark_vs_talib",
extra={
"dataset": {
"generator": "synthetic_ohlcv",
"sizes": sizes,
"dtype": "float64",
"array_layout": "C-contiguous",
"seed": 42,
},
"methodology": {
"warmup_runs": N_WARMUP,
"measured_runs": N_RUNS,
"reported_metric": "median_ms",
"speedup_definition": "talib_median_ms / ferro_ta_median_ms",
"tie_band": f"{1.0 - TIE_EPSILON:.2f} to {1.0 + TIE_EPSILON:.2f}",
"input_layout_notes": (
"Benchmarks use contiguous float64 arrays. If your workload "
"passes non-contiguous arrays or other dtypes, benchmark that "
"separately because wrapper overhead can dominate."
),
"allocation_notes": (
"python_peak_allocation_bytes is a tracemalloc snapshot of "
"Python-tracked allocations only; it is not a full native RSS "
"or allocator profile."
),
},
"packages": package_versions("numpy", "ferro-ta", "TA-Lib"),
},
)
2026-03-23 23:34:28 +05:30
out = {
2026-03-24 11:09:48 +05:30
"schema_version": 2,
"command": " ".join(["python", *sys.argv]),
2026-03-23 23:34:28 +05:30
"n_warmup": N_WARMUP,
"n_runs": N_RUNS,
"sizes": sizes,
"talib_available": TALIB_AVAILABLE,
2026-03-24 11:09:48 +05:30
"runtime": metadata["runtime"],
"git": metadata["git"],
"metadata": metadata,
2026-03-23 23:34:28 +05:30
"summary": {
"total_rows": len(results),
2026-03-24 11:09:48 +05:30
"by_size": [_summary_for_size(results, size) for size in sizes],
2026-03-23 23:34:28 +05:30
},
"results": results,
}
if not TALIB_AVAILABLE:
2026-03-24 11:09:48 +05:30
out["note"] = "ferro_ta only; ta-lib not installed"
with open(json_path, "w", encoding="utf-8") as handle:
json.dump(out, handle, indent=2)
2026-03-23 23:34:28 +05:30
print(f"Results written to {json_path}")
2026-03-24 11:09:48 +05:30
2026-03-23 23:34:28 +05:30
return results
def main() -> int:
2026-03-24 11:09:48 +05:30
parser = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison")
parser.add_argument("--json", default=None, help="Write results to JSON file")
parser.add_argument(
2026-03-23 23:34:28 +05:30
"--sizes",
type=int,
nargs="+",
default=DEFAULT_SIZES,
help="Bar counts to benchmark (default: 10000 100000 1000000)",
)
2026-03-24 11:09:48 +05:30
args = parser.parse_args()
2026-03-23 23:34:28 +05:30
run_comparison(args.sizes, args.json)
return 0
if __name__ == "__main__":
sys.exit(main())