"""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()
