Files
QuanTAlib/python/tests/benchmark.py
T
Miha Kralj 0468283d45 Add Python benchmark: quantalib NativeAOT vs pandas-ta comparison
- New python/tests/benchmark.py mirrors C# perf/Benchmark.cs indicators
  (SMA, EMA, WMA, HMA, ADOSC, CORRELATION, SKEW) at 500K bars
- quantalib NativeAOT via ctypes FFI: 2-68x faster than pandas-ta
- Updated docs/benchmarks.md with Python benchmark results section
2026-03-13 22:40:59 -07:00

405 lines
14 KiB
Python

#!/usr/bin/env python3
"""QuanTAlib Python benchmark -- mirrors perf/Benchmark.cs indicators.
Compares:
- quantalib (NativeAOT via ctypes FFI)
- pandas-ta (pure Python / numpy)
- pandas (rolling baseline where applicable)
Indicators benchmarked (matching C# Benchmark.cs):
SMA, EMA, WMA, HMA, ADOSC, CORRELATION, SKEW
Usage:
cd python && python tests/benchmark.py
cd python && python tests/benchmark.py --bars 100000
cd python && python tests/benchmark.py --bars 500000 --period 220 --iterations 5
"""
from __future__ import annotations
import argparse
import gc
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
# Ensure parent directory is on sys.path so `import quantalib` works
# when running as `python tests/benchmark.py` from python/ directory.
_HERE = Path(__file__).resolve().parent
_PKG_ROOT = _HERE.parent
if str(_PKG_ROOT) not in sys.path:
sys.path.insert(0, str(_PKG_ROOT))
import numpy as np
# ---------------------------------------------------------------------------
# Optional imports -- benchmark degrades gracefully
# ---------------------------------------------------------------------------
try:
import pandas as pd
except ImportError:
pd = None # type: ignore[assignment]
try:
import pandas_ta as ta # type: ignore[import-untyped]
except ImportError:
ta = None # type: ignore[assignment]
try:
import quantalib as qtl
except (ImportError, OSError) as _err:
qtl = None # type: ignore[assignment]
print(f"[bench] quantalib not available: {_err}", file=sys.stderr)
# ===========================================================================
# Data generation -- Geometric Brownian Motion (matches C# GBM)
# ===========================================================================
def generate_gbm(
n: int,
start_price: float = 100.0,
mu: float = 0.05,
sigma: float = 0.2,
seed: int = 42,
) -> dict[str, np.ndarray]:
"""Generate synthetic OHLCV bars via GBM, same params as C# Benchmark."""
rng = np.random.default_rng(seed)
dt = 1.0 / (252 * 390) # ~1 minute bars, 252 days x 390 min/day
# Log-normal random walk for close prices
log_returns = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * rng.standard_normal(n)
close = start_price * np.exp(np.cumsum(log_returns))
# Synthetic OHLV from close
spread = sigma * np.sqrt(dt) * close
high = close + np.abs(rng.standard_normal(n)) * spread
low = close - np.abs(rng.standard_normal(n)) * spread
opn = close + rng.standard_normal(n) * spread * 0.5
volume = np.abs(rng.standard_normal(n) * 1_000_000 + 5_000_000)
return {
"open": opn.astype(np.float64),
"high": high.astype(np.float64),
"low": low.astype(np.float64),
"close": close.astype(np.float64),
"volume": volume.astype(np.float64),
}
# ===========================================================================
# Timing helper
# ===========================================================================
@dataclass
class BenchResult:
name: str
library: str
mean_us: float = 0.0 # microseconds
std_us: float = 0.0
alloc_note: str = ""
def _bench(fn, iterations: int, warmup: int = 2) -> tuple[float, float]:
"""Run *fn* and return (mean_us, std_us)."""
for _ in range(warmup):
fn()
gc.disable()
times: list[float] = []
for _ in range(iterations):
t0 = time.perf_counter_ns()
fn()
t1 = time.perf_counter_ns()
times.append((t1 - t0) / 1_000.0) # ns -> us
gc.enable()
arr = np.array(times)
return float(np.mean(arr)), float(np.std(arr))
# ===========================================================================
# Benchmark definitions
# ===========================================================================
def run_benchmarks(
bars: int, period: int, iterations: int
) -> list[BenchResult]:
results: list[BenchResult] = []
data = generate_gbm(bars)
close_np = data["close"]
open_np = data["open"]
high_np = data["high"]
low_np = data["low"]
vol_np = data["volume"]
# Build pandas objects if pandas available
close_pd = pd.Series(close_np, name="close") if pd is not None else None
open_pd = pd.Series(open_np, name="open") if pd is not None else None
# Build DataFrame for pandas-ta
df_ta = None
if pd is not None and ta is not None:
df_ta = pd.DataFrame({
"open": open_np,
"high": high_np,
"low": low_np,
"close": close_np,
"volume": vol_np,
})
print(f"\n{'=' * 76}")
print(f" QuanTAlib Python Benchmark")
print(f" Bars: {bars:,} | Period: {period} | Iterations: {iterations}")
print(f" Python {sys.version.split()[0]} | NumPy {np.__version__}", end="")
if pd is not None:
print(f" | pandas {pd.__version__}", end="")
if ta is not None:
print(f" | pandas-ta {ta.version}", end="")
if qtl is not None:
print(f" | quantalib {getattr(qtl, '__version__', '?')}", end="")
print(f"\n{'=' * 76}\n")
# -- SMA --
_category("SMA", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=lambda: qtl.sma(close_np, period=period) if qtl else None,
pta_fn=lambda: ta.sma(close_pd, length=period) if (ta and close_pd is not None) else None,
pd_fn=lambda: close_pd.rolling(period).mean() if close_pd is not None else None,
pd_label="pandas rolling")
# -- EMA --
_category("EMA", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=lambda: qtl.ema(close_np, period=period) if qtl else None,
pta_fn=lambda: ta.ema(close_pd, length=period) if (ta and close_pd is not None) else None,
pd_fn=lambda: close_pd.ewm(span=period, adjust=False).mean() if close_pd is not None else None,
pd_label="pandas ewm")
# -- WMA --
def _pd_wma():
if close_pd is None:
return None
weights = np.arange(1, period + 1, dtype=np.float64)
return close_pd.rolling(period).apply(
lambda x: np.dot(x, weights) / weights.sum(), raw=True
)
_category("WMA", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=lambda: qtl.wma(close_np, period=period) if qtl else None,
pta_fn=lambda: ta.wma(close_pd, length=period) if (ta and close_pd is not None) else None,
pd_fn=_pd_wma,
pd_label="pandas rolling+apply")
# -- HMA --
_category("HMA", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=lambda: qtl.hma(close_np, period=period) if qtl else None,
pta_fn=lambda: ta.hma(close_pd, length=period) if (ta and close_pd is not None) else None,
pd_fn=None,
pd_label=None)
# -- ADOSC --
def _qtl_adosc():
if qtl is None:
return None
return qtl.adosc(high_np, low_np, close_np, vol_np,
fastPeriod=3, slowPeriod=10)
def _pta_adosc():
if ta is None or df_ta is None:
return None
return ta.adosc(df_ta["high"], df_ta["low"], df_ta["close"],
df_ta["volume"], fast=3, slow=10)
_category("ADOSC", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=_qtl_adosc,
pta_fn=_pta_adosc,
pd_fn=None,
pd_label=None)
# -- CORRELATION --
def _qtl_corr():
if qtl is None:
return None
return qtl.correlation(close_np, open_np, period=period)
def _pd_corr():
if close_pd is None or open_pd is None:
return None
return close_pd.rolling(period).corr(open_pd)
_category("CORRELATION", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=_qtl_corr,
pta_fn=None, # pandas-ta has no rolling correlation
pd_fn=_pd_corr,
pd_label="pandas rolling.corr")
# -- SKEW --
def _pd_skew():
if close_pd is None:
return None
return close_pd.rolling(period).skew()
_category("SKEW", results, bars, period, iterations,
close_np, close_pd, df_ta,
qtl_fn=lambda: qtl.skew(close_np, period=period) if qtl else None,
pta_fn=lambda: ta.skew(close_pd, length=period) if (ta and close_pd is not None) else None,
pd_fn=_pd_skew,
pd_label="pandas rolling.skew")
return results
def _category(
name: str,
results: list[BenchResult],
bars: int,
period: int,
iterations: int,
close_np,
close_pd,
df_ta,
qtl_fn,
pta_fn,
pd_fn,
pd_label,
):
"""Benchmark a single indicator category across all available libraries."""
sep = "-" * (60 - len(name))
print(f" -- {name} {sep}")
# quantalib (NativeAOT)
if qtl is not None and qtl_fn is not None:
try:
mean, std = _bench(qtl_fn, iterations)
r = BenchResult(name, "quantalib (NativeAOT)", mean, std, "0 B (ctypes)")
results.append(r)
print(f" quantalib : {mean:>12,.1f} us +/- {std:>8,.1f} us")
except Exception as e:
print(f" quantalib : FAILED -- {e}")
else:
print(f" quantalib : not available")
# pandas-ta
if ta is not None and pta_fn is not None:
try:
mean, std = _bench(pta_fn, iterations)
r = BenchResult(name, "pandas-ta", mean, std)
results.append(r)
print(f" pandas-ta : {mean:>12,.1f} us +/- {std:>8,.1f} us")
except Exception as e:
print(f" pandas-ta : FAILED -- {e}")
elif pta_fn is None:
print(f" pandas-ta : N/A (no equivalent)")
else:
print(f" pandas-ta : not installed")
# pandas baseline
if pd is not None and pd_fn is not None:
try:
mean, std = _bench(pd_fn, iterations)
r = BenchResult(name, pd_label or "pandas", mean, std)
results.append(r)
lbl = (pd_label or "pandas")
print(f" {lbl:<18s}: {mean:>12,.1f} us +/- {std:>8,.1f} us")
except Exception as e:
lbl = (pd_label or "pandas")
print(f" {lbl:<18s}: FAILED -- {e}")
print()
# ===========================================================================
# Markdown report
# ===========================================================================
def print_markdown(results: list[BenchResult], bars: int, period: int):
"""Print results as Markdown table, grouped by indicator."""
print(f"\n## Python Benchmark Results")
print(f"\n**{bars:,} bars, period={period}**\n")
# Group by indicator name
from collections import OrderedDict
groups: dict[str, list[BenchResult]] = OrderedDict()
for r in results:
groups.setdefault(r.name, []).append(r)
print("| Indicator | Library | Mean (us) | StdDev (us) | vs. fastest |")
print("|-----------|---------|----------:|------------:|------------:|")
for indicator, group in groups.items():
fastest = min(g.mean_us for g in group)
for r in group:
ratio = r.mean_us / fastest if fastest > 0 else 0
ratio_str = "**1.00x**" if ratio < 1.01 else f"{ratio:.2f}x"
print(f"| {r.name:<11s} | {r.library:<25s} | {r.mean_us:>10,.1f} | {r.std_us:>10,.1f} | {ratio_str:>11s} |")
print()
# Summary comparison table: quantalib vs pandas-ta
qtl_map: dict[str, float] = {}
pta_map: dict[str, float] = {}
pd_map: dict[str, float] = {}
for r in results:
if "quantalib" in r.library:
qtl_map[r.name] = r.mean_us
elif "pandas-ta" in r.library:
pta_map[r.name] = r.mean_us
elif "pandas" in r.library:
pd_map[r.name] = r.mean_us
if qtl_map and pta_map:
print("### quantalib vs pandas-ta Speedup\n")
print("| Indicator | quantalib (us) | pandas-ta (us) | Speedup |")
print("|-----------|---------------:|---------------:|--------:|")
for name in qtl_map:
if name in pta_map:
q = qtl_map[name]
p = pta_map[name]
speedup = p / q if q > 0 else float("inf")
print(f"| {name:<11s} | {q:>13,.1f} | {p:>13,.1f} | {speedup:>6.1f}x |")
print()
if qtl_map and pd_map:
print("### quantalib vs pandas Speedup\n")
print("| Indicator | quantalib (us) | pandas (us) | Speedup |")
print("|-----------|---------------:|------------:|--------:|")
for name in qtl_map:
if name in pd_map:
q = qtl_map[name]
p = pd_map[name]
speedup = p / q if q > 0 else float("inf")
print(f"| {name:<11s} | {q:>13,.1f} | {p:>11,.1f} | {speedup:>6.1f}x |")
print()
# ===========================================================================
# Entry point
# ===========================================================================
def main():
parser = argparse.ArgumentParser(description="QuanTAlib Python Benchmark")
parser.add_argument("--bars", type=int, default=500_000,
help="Number of bars (default: 500000)")
parser.add_argument("--period", type=int, default=220,
help="Indicator period (default: 220)")
parser.add_argument("--iterations", type=int, default=10,
help="Timing iterations (default: 10)")
parser.add_argument("--markdown", action="store_true", default=True,
help="Print Markdown report (default: True)")
args = parser.parse_args()
results = run_benchmarks(args.bars, args.period, args.iterations)
if args.markdown and results:
print_markdown(results, args.bars, args.period)
if __name__ == "__main__":
main()