Files
wickra/bindings/python/benchmarks/compare_libraries.py
T
kingchenc 05fe7ffa90 perf: bit-exact batch fast paths + streaming-first benchmark docs (#202)
## Summary
- Dedicated batch fast paths for **EMA, RSI, Bollinger, MACD and ATR** (used by the Python bindings): one allocation filled in a single pass, warmup encoded as `NaN`, no per-element `Option` or input re-validation. Each is **bit-for-bit equal** to replaying `update` — SMA/Bollinger keep the drift-reseed cadence, the EMA-family keep the seed division and `mul_add` recurrences. Adds the `BatchNanExt` extension trait.
- **Cross-library benchmark refresh**: `compare_libraries.py` reports the median across timing rounds (`--rounds` / `--streaming-rounds`), gains `--skip-batch` / `--skip-streaming`, and runs every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` drives the batch fast paths against `kand`.
- **README** benchmark section reordered streaming-first (the order-of-magnitude result), with measured TA-Lib/tulipy/pandas-ta numbers in place of the CI-only placeholders.

## Impact
- Python batch ~2× faster on EMA/RSI/MACD/ATR; streaming path unchanged.
- The `batch == streaming` equivalence stays bit-exact.

## Verification
- `cargo fmt` · `cargo clippy --workspace --all-targets --all-features -- -D warnings` (clean)
- `cargo test --workspace --all-features` — 3782 unit + 420 doc tests pass
- Python `pytest` — streaming-vs-batch, known-values, input-validation, smoke pass

## Notes
- Node/WASM bindings keep their existing batch; the fast paths are Python-only for now.
2026-06-08 00:17:58 +02:00

873 lines
28 KiB
Python

"""Cross-library benchmark: Wickra vs TA-Lib vs pandas-ta vs talipp vs finta.
Runs each library through identical batch and streaming workloads, then prints a
table of timings. Libraries that are not installed are skipped automatically, so
the script always produces output regardless of the local environment.
Usage::
python -m benchmarks.compare_libraries
python -m benchmarks.compare_libraries --size 50000 --streaming-window 5000
Notes:
- "Batch" means computing the indicator over the whole price series in one call,
which is what classic libraries support.
- "Streaming" simulates live trading: after seeding with ``streaming_window``
historical bars, we keep appending one new price and recomputing the latest
indicator value. Libraries without an incremental API have to recompute the
whole indicator on every tick; Wickra updates in O(1). This is the gap the
library was built to expose.
"""
from __future__ import annotations
import argparse
import contextlib
import importlib
import statistics
import time
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
import numpy as np
# --------------------------------------------------------------------------- #
# Library availability detection
# --------------------------------------------------------------------------- #
def _try_import(name: str):
try:
return importlib.import_module(name)
except Exception:
return None
TALIB = _try_import("talib")
PANDAS_TA = _try_import("pandas_ta")
TALIPP = _try_import("talipp.indicators") or _try_import("talipp")
FINTA = _try_import("finta")
TULIPY = _try_import("tulipy")
PD = _try_import("pandas")
import wickra as WICKRA # noqa: E402 -- the library under test must be importable
# --------------------------------------------------------------------------- #
# Timing helpers
# --------------------------------------------------------------------------- #
@dataclass
class Sample:
library: str
indicator: str
mode: str
seconds: float
iterations: int
@property
def per_iter_us(self) -> float:
return (self.seconds / self.iterations) * 1_000_000
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
Returns the *median* round's wall seconds for one round of ``iterations``
calls. Taking the median across several rounds damps the OS scheduling and
GC jitter that a single timing pass would otherwise bake into the result,
so the per-iteration figure is stable run-to-run. Callers keep dividing the
return value by ``iterations``.
"""
fn() # one warmup call to populate caches
rounds_s: List[float] = []
for _ in range(rounds):
start = time.perf_counter()
for _ in range(iterations):
fn()
rounds_s.append(time.perf_counter() - start)
return statistics.median(rounds_s)
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
rng = np.random.default_rng(seed)
walk = rng.standard_normal(n) * 0.4
return 100.0 + np.cumsum(walk)
def gen_ohlc(n: int, seed: int = 0xC0FFEE) -> tuple:
close = gen_prices(n, seed)
spread = 0.5 + np.abs(np.sin(np.arange(n) * 0.07))
high = close + spread
low = close - spread
volume = np.full(n, 1_000.0)
return high, low, close, volume
# --------------------------------------------------------------------------- #
# Per-library indicator runners. Each returns ``None`` to skip when unavailable.
# --------------------------------------------------------------------------- #
def wickra_sma_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.SMA(20).batch(prices)
def talib_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.SMA(prices, timeperiod=20))
def pandas_ta_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.sma(s, length=20)
def finta_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.SMA(df, period=20)
def talipp_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
# talipp's SMA accepts an initial list of values
from talipp.indicators import SMA # type: ignore
return lambda: SMA(period=20, input_values=list(prices))
def wickra_rsi_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.RSI(14).batch(prices)
def talib_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.RSI(prices, timeperiod=14))
def pandas_ta_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.rsi(s, length=14)
def finta_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.RSI(df, period=14)
def talipp_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import RSI # type: ignore
return lambda: RSI(period=14, input_values=list(prices))
def wickra_bollinger_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.BollingerBands(20, 2.0).batch(prices)
def wickra_ema_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.EMA(20).batch(prices)
def talib_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.EMA(prices, timeperiod=20))
def pandas_ta_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.ema(s, length=20)
def finta_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.EMA(df, period=20)
def talipp_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
return lambda: EMA(period=20, input_values=list(prices))
def wickra_macd_batch(prices: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.MACD().batch(prices)
def talib_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.MACD(prices))
def pandas_ta_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.macd(s)
def finta_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.MACD(df)
def talipp_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
return lambda: MACD(fast_period=12, slow_period=26, signal_period=9, input_values=list(prices))
def wickra_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Callable[[], None]:
return lambda: WICKRA.ATR(14).batch(high, low, close)
def talib_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TALIB is None else (lambda: TALIB.ATR(high, low, close, timeperiod=14))
def finta_atr_batch(_high: np.ndarray, _low: np.ndarray, _close: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": _close, "high": _high, "low": _low, "close": _close, "volume": np.ones_like(_close)})
return lambda: FINTA.TA.ATR(df, period=14)
def talipp_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import ATR # type: ignore
from talipp.ohlcv import OHLCV
bars = [OHLCV(open=c, high=h, low=l, close=c, volume=1.0, time=i) for i, (h, l, c) in enumerate(zip(high, low, close))]
return lambda: ATR(period=14, input_values=bars)
def talib_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIB is None:
return None
return lambda: TALIB.BBANDS(prices, timeperiod=20, nbdevup=2, nbdevdn=2)
def pandas_ta_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
s = PD.Series(prices)
return lambda: PANDAS_TA.bbands(s, length=20, std=2.0)
def finta_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if FINTA is None or PD is None:
return None
df = PD.DataFrame({"open": prices, "high": prices, "low": prices, "close": prices, "volume": np.ones_like(prices)})
return lambda: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0)
def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
return lambda: BB(period=20, std_dev_mult=2.0, input_values=list(prices))
# tulipy wraps the C "Tulip Indicators" library; it takes contiguous float64
# arrays and indicator options as positional arguments.
def tulipy_sma_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.sma(prices, 20))
def tulipy_ema_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.ema(prices, 20))
def tulipy_rsi_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.rsi(prices, 14))
def tulipy_macd_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.macd(prices, 12, 26, 9))
def tulipy_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.bbands(prices, 20, 2.0))
def tulipy_atr_batch(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> Optional[Callable[[], None]]:
return None if TULIPY is None else (lambda: TULIPY.atr(high, low, close, 14))
# --------------------------------------------------------------------------- #
# Streaming scenario: per-tick latency
# --------------------------------------------------------------------------- #
def wickra_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
rsi = WICKRA.RSI(14)
rsi.batch(seed) # warm up
for p in live:
rsi.update(float(p))
return run
def talib_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIB is None:
return None
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
TALIB.RSI(np.asarray(history), timeperiod=14)
return run
def pandas_ta_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if PANDAS_TA is None or PD is None:
return None
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
PANDAS_TA.rsi(PD.Series(history), length=14)
return run
def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import RSI # type: ignore
def run() -> None:
rsi = RSI(period=14, input_values=list(seed))
for p in live:
rsi.add(float(p))
return run
# Scalar streaming peers: Wickra and talipp both update incrementally in O(1),
# so this is the like-for-like per-tick comparison (batch-only libs are covered
# by the batch tables and the recompute contrast on RSI above).
def wickra_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
sma = WICKRA.SMA(20)
sma.batch(seed)
for p in live:
sma.update(float(p))
return run
def talipp_sma_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import SMA # type: ignore
def run() -> None:
sma = SMA(period=20, input_values=list(seed))
for p in live:
sma.add(float(p))
return run
def wickra_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
ema = WICKRA.EMA(20)
ema.batch(seed)
for p in live:
ema.update(float(p))
return run
def talipp_ema_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import EMA # type: ignore
def run() -> None:
ema = EMA(period=20, input_values=list(seed))
for p in live:
ema.add(float(p))
return run
def wickra_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
macd = WICKRA.MACD()
macd.batch(seed)
for p in live:
macd.update(float(p))
return run
def talipp_macd_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import MACD # type: ignore
def run() -> None:
macd = MACD(
fast_period=12, slow_period=26, signal_period=9, input_values=list(seed)
)
for p in live:
macd.add(float(p))
return run
def wickra_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Callable[[], None]:
def run() -> None:
bb = WICKRA.BollingerBands(20, 2.0)
bb.batch(seed)
for p in live:
bb.update(float(p))
return run
def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callable[[], None]]:
if TALIPP is None:
return None
from talipp.indicators import BB # type: ignore
def run() -> None:
bb = BB(period=20, std_dev_mult=2.0, input_values=list(seed))
for p in live:
bb.add(float(p))
return run
# Recompute streaming peers: batch-only libraries have no incremental API, so
# the only honest way to drive them tick-by-tick is to re-run the full batch
# over the grown history on every new price. These runners expose exactly that
# cost — the gap Wickra's O(1) update closes.
def _talib_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history))
return run
def _pandas_ta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(PD.Series(history))
return run
def _tulipy_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
fn(np.asarray(history, dtype=np.float64))
return run
def _finta_recompute_streaming(seed, live, fn):
def run() -> None:
history = list(seed)
for p in live:
history.append(float(p))
arr = np.asarray(history)
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
return run
def talib_sma_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
def pandas_ta_sma_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
def tulipy_sma_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
def finta_sma_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
def talib_ema_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
def pandas_ta_ema_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
def tulipy_ema_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
def finta_ema_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
def tulipy_rsi_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
def finta_rsi_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
def talib_macd_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
def pandas_ta_macd_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
def tulipy_macd_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
def finta_macd_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
def talib_bollinger_streaming(seed, live):
if TALIB is None:
return None
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
def pandas_ta_bollinger_streaming(seed, live):
if PANDAS_TA is None or PD is None:
return None
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
def tulipy_bollinger_streaming(seed, live):
if TULIPY is None:
return None
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
def finta_bollinger_streaming(seed, live):
if FINTA is None or PD is None:
return None
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
BATCH_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_batch),
("TA-Lib", talib_sma_batch),
("pandas-ta", pandas_ta_sma_batch),
("tulipy", tulipy_sma_batch),
("finta", finta_sma_batch),
("talipp", talipp_sma_batch),
]),
("EMA(20)", [
("Wickra", wickra_ema_batch),
("TA-Lib", talib_ema_batch),
("pandas-ta", pandas_ta_ema_batch),
("tulipy", tulipy_ema_batch),
("finta", finta_ema_batch),
("talipp", talipp_ema_batch),
]),
("RSI(14)", [
("Wickra", wickra_rsi_batch),
("TA-Lib", talib_rsi_batch),
("pandas-ta", pandas_ta_rsi_batch),
("tulipy", tulipy_rsi_batch),
("finta", finta_rsi_batch),
("talipp", talipp_rsi_batch),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_batch),
("TA-Lib", talib_macd_batch),
("pandas-ta", pandas_ta_macd_batch),
("tulipy", tulipy_macd_batch),
("finta", finta_macd_batch),
("talipp", talipp_macd_batch),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_batch),
("TA-Lib", talib_bollinger_batch),
("pandas-ta", pandas_ta_bollinger_batch),
("tulipy", tulipy_bollinger_batch),
("finta", finta_bollinger_batch),
("talipp", talipp_bollinger_batch),
]),
]
OHLC_INDICATORS = [
("ATR(14)", [
("Wickra", wickra_atr_batch),
("TA-Lib", talib_atr_batch),
("tulipy", tulipy_atr_batch),
("finta", finta_atr_batch),
("talipp", talipp_atr_batch),
]),
]
STREAMING_INDICATORS = [
("SMA(20)", [
("Wickra", wickra_sma_streaming),
("talipp", talipp_sma_streaming),
("TA-Lib", talib_sma_streaming),
("pandas-ta", pandas_ta_sma_streaming),
("tulipy", tulipy_sma_streaming),
("finta", finta_sma_streaming),
]),
("EMA(20)", [
("Wickra", wickra_ema_streaming),
("talipp", talipp_ema_streaming),
("TA-Lib", talib_ema_streaming),
("pandas-ta", pandas_ta_ema_streaming),
("tulipy", tulipy_ema_streaming),
("finta", finta_ema_streaming),
]),
("RSI(14)", [
("Wickra", wickra_rsi_streaming),
("talipp", talipp_rsi_streaming),
("TA-Lib", talib_rsi_streaming),
("pandas-ta", pandas_ta_rsi_streaming),
("tulipy", tulipy_rsi_streaming),
("finta", finta_rsi_streaming),
]),
("MACD(12, 26, 9)", [
("Wickra", wickra_macd_streaming),
("talipp", talipp_macd_streaming),
("TA-Lib", talib_macd_streaming),
("pandas-ta", pandas_ta_macd_streaming),
("tulipy", tulipy_macd_streaming),
("finta", finta_macd_streaming),
]),
("Bollinger(20, 2.0)", [
("Wickra", wickra_bollinger_streaming),
("talipp", talipp_bollinger_streaming),
("TA-Lib", talib_bollinger_streaming),
("pandas-ta", pandas_ta_bollinger_streaming),
("tulipy", tulipy_bollinger_streaming),
("finta", finta_bollinger_streaming),
]),
]
def run_batch(prices: np.ndarray, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in BATCH_INDICATORS:
for lib_name, factory in libs:
runner = factory(prices)
if runner is None:
continue
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_ohlc(
high: np.ndarray,
low: np.ndarray,
close: np.ndarray,
iterations: int,
rounds: int,
) -> List[Sample]:
out: List[Sample] = []
for indicator_name, libs in OHLC_INDICATORS:
for lib_name, factory in libs:
runner = factory(high, low, close)
if runner is None:
continue
secs = time_call(runner, iterations, rounds)
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
return out
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
out: List[Sample] = []
seed = prices[:streaming_window]
live = prices[streaming_window:]
if len(live) == 0:
return out
for indicator_name, libs in STREAMING_INDICATORS:
for lib_name, factory in libs:
runner = factory(seed, live)
if runner is None:
continue
secs = time_call(runner, iterations, rounds)
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
sample.iterations = iterations * len(live) # per-tick normalization
out.append(sample)
return out
def render_table(rows: List[Sample]) -> str:
if not rows:
return "(no results)"
grouped: Dict[str, List[Sample]] = {}
for r in rows:
key = f"{r.mode} | {r.indicator}"
grouped.setdefault(key, []).append(r)
lines: List[str] = []
lines.append("")
lines.append("Reading the tables: lower µs/op = faster. The 'vs Wickra' column says")
lines.append("how many times slower (or faster) the other library is compared to Wickra.")
for key, samples in grouped.items():
baseline = next((s for s in samples if s.library == "Wickra"), samples[0])
base = baseline.per_iter_us
lines.append("")
lines.append(key)
lines.append("-" * len(key))
lines.append(
f"{'library':<14} {'µs/op':>14} {'vs Wickra':>22} {'verdict':<10}"
)
winner = min(samples, key=lambda x: x.per_iter_us)
for s in sorted(samples, key=lambda x: x.per_iter_us):
ratio = s.per_iter_us / base if base > 0 else float("nan")
if s.library == "Wickra":
comparison = "(reference)"
elif s.per_iter_us > base:
comparison = f"{ratio:>5.2f}x slower"
else:
comparison = f"{base / s.per_iter_us:>5.2f}x faster"
verdict = "★ winner" if s is winner else ""
lines.append(
f"{s.library:<14} {s.per_iter_us:>14.3f} {comparison:>22} {verdict:<10}"
)
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else None)
parser.add_argument("--size", type=int, default=20_000, help="number of prices")
parser.add_argument("--iterations", type=int, default=20, help="batch repetitions per timing")
parser.add_argument(
"--rounds",
type=int,
default=5,
help="batch timing rounds; the median round is reported to damp jitter",
)
parser.add_argument(
"--streaming-window",
type=int,
default=5_000,
help="number of historical prices to seed before the live ticks begin",
)
parser.add_argument(
"--streaming-iterations",
type=int,
default=3,
help="repetitions of the streaming workload (each iteration replays all live ticks)",
)
parser.add_argument(
"--streaming-rounds",
type=int,
default=2,
help="streaming timing rounds; the median round is reported",
)
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
return parser.parse_args()
def main() -> None:
args = parse_args()
prices = gen_prices(args.size)
available = []
if TALIB is not None: available.append("TA-Lib")
if PANDAS_TA is not None: available.append("pandas-ta")
if TULIPY is not None: available.append("tulipy")
if FINTA is not None: available.append("finta")
if TALIPP is not None: available.append("talipp")
print(f"Wickra benchmark suite — wickra=v{WICKRA.__version__}")
print(f"Comparing against: {', '.join(available) if available else '(no peer libraries installed; install [bench] extra)'}")
print(f"Series length: {args.size} • batch iterations: {args.iterations}")
print(f"Streaming window: {args.streaming_window} seed, {args.size - args.streaming_window} live")
high, low, close, _ = gen_ohlc(args.size)
rows: List[Sample] = []
if not args.skip_batch:
rows += run_batch(prices, args.iterations, args.rounds)
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
if not args.skip_streaming:
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
print(render_table(rows))
if __name__ == "__main__":
main()