From 0468283d4535ae4d6eab0371bdbacad46eb0959e Mon Sep 17 00:00:00 2001 From: Miha Kralj Date: Fri, 13 Mar 2026 22:40:59 -0700 Subject: [PATCH] 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 --- docs/benchmarks.md | 65 +++++- python/tests/benchmark.py | 404 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 python/tests/benchmark.py diff --git a/docs/benchmarks.md b/docs/benchmarks.md index d6e45a00..29590ba1 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -115,6 +115,53 @@ Span mode represents maximum speed. Production code often needs different trade- Even QuanTAlib's slowest mode (Eventing with complete event infrastructure, 16.8 MB allocations) processes 500,000 EMA values in 3.5 milliseconds. Still faster than Ooples at 14.5 ms and Skender at 26.6 ms for identical calculation. The "slow" path here beats other libraries' only path. +## Python Benchmark: quantalib vs pandas-ta + +The same indicators benchmarked in C# are also available through Python via NativeAOT shared library + ctypes FFI. This comparison measures the real-world cost of calling QuanTAlib from Python versus using pandas-ta (the most popular pure-Python technical analysis library). + +### Test Environment + +| Component | Specification | +| :-------- | :------------ | +| Data Size | 500,000 bars | +| Period | 220 | +| Python | 3.12.10 | +| NumPy | 2.2.6 | +| pandas | 3.0.1 | +| pandas-ta | 0.4.71b0 | +| quantalib | 0.8.7 (NativeAOT via ctypes) | + +### quantalib vs pandas-ta + +| Indicator | quantalib | pandas-ta | Speedup | +| :-------- | --------: | --------: | ------: | +| **SMA** | **1,308 μs** | 64,110 μs | **49×** | +| **EMA** | **1,083 μs** | 4,486 μs | **4.1×** | +| **WMA** | **1,223 μs** | 83,763 μs | **68×** | +| **HMA** | **2,709 μs** | 154,508 μs | **57×** | +| **ADOSC** | **1,655 μs** | 14,821 μs | **9.0×** | +| **SKEW** | **3,987 μs** | 8,077 μs | **2.0×** | + +SMA and WMA expose the largest gaps. pandas-ta implements SMA as a rolling window in pure Python/numpy, while quantalib calls the same SIMD-optimized C# code (via NativeAOT) that beats TA-Lib in the C# benchmarks above. WMA at 68× faster reflects the dot-product advantage: eight FMA operations per cycle versus Python's element-at-a-time loop. + +### quantalib vs pandas builtins + +pandas itself provides optimized C implementations for common rolling operations. Fair comparison: + +| Indicator | quantalib | pandas | Speedup | +| :-------- | --------: | -----: | ------: | +| **SMA** | **1,308 μs** | 6,950 μs (rolling.mean) | **5.3×** | +| **EMA** | **1,083 μs** | 3,652 μs (ewm.mean) | **3.4×** | +| **WMA** | **1,223 μs** | 686,561 μs (rolling+apply) | **561×** | +| **CORRELATION** | **20,450 μs** | 35,904 μs (rolling.corr) | **1.8×** | +| **SKEW** | **3,987 μs** | 8,164 μs (rolling.skew) | **2.0×** | + +Even against pandas' C-optimized rolling operations, quantalib NativeAOT wins 2-5× on simple indicators. WMA is the extreme case: pandas lacks a native WMA implementation, falling back to `rolling().apply()` with a Python lambda — 561× slower. + +### FFI Overhead + +The ctypes foreign function interface adds approximately 5-15 microseconds per invocation. At 500,000 bars, this overhead disappears into noise. Below ~100 bars, FFI marshaling dominates and pandas-ta's pure-Python approach wins on latency. Above ~1,000 bars, NativeAOT SIMD takes over decisively. + ## Methodology [BenchmarkDotNet](https://benchmarkdotnet.org/) handles all performance testing. The framework provides: @@ -139,18 +186,22 @@ git clone https://github.com/mihakralj/QuanTAlib.git cd QuanTAlib ``` -Navigate to the performance project: +### C# Benchmarks ```bash cd perf -``` - -Run benchmarks in Release configuration: - -```bash dotnet run -c Release ``` Debug builds include instrumentation that destroys performance measurements. Release configuration or the numbers mean nothing. -Results vary by CPU generation, but relative ratios (QuanTAlib vs competitors) remain consistent across hardware. The architectures that make something fast stay fast; the ones that allocate memory keep allocating. \ No newline at end of file +### Python Benchmarks + +```bash +cd python +python tests/benchmark.py --bars 500000 --period 220 --iterations 10 +``` + +Adjust `--bars` and `--period` to match your use case. The script degrades gracefully — if pandas-ta or quantalib is unavailable, it benchmarks whatever is installed. + +Results vary by CPU generation, but relative ratios (QuanTAlib vs competitors) remain consistent across hardware. The architectures that make something fast stay fast; the ones that allocate memory keep allocating. diff --git a/python/tests/benchmark.py b/python/tests/benchmark.py new file mode 100644 index 00000000..962b7e3d --- /dev/null +++ b/python/tests/benchmark.py @@ -0,0 +1,404 @@ +#!/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()