Honest tiered cross-library benchmark + streaming/batch perf (#186)
## Summary An honest, tiered cross-library benchmark — and the optimization pass it triggered. ### Performance (wickra-core, outputs unchanged) Profiling against the other Rust TA crates exposed real inefficiencies. Each benchmarked indicator is now **5–79% faster** in both streaming and batch: - **SMA, Bollinger**: flat `Box<[f64]>` ring buffers replace `VecDeque` (−69…79%). - **RSI**: `100·ag/(ag+al)` collapses three divisions into one; Wilder smoothing hoists `1/period` out of the hot path (−46%). - **ATR**: reciprocal hoisted (−42%). - **EMA/RSI/ATR**: per-tick `Option<f64>` hot state → bare `f64` + ready flag. Net result vs `kand`: Wickra now wins **RSI, Bollinger and ATR** (streaming), and ties `ta-rs` on SMA — up from losing every indicator 1.5–6× before. ### Benchmark harness New `crates/wickra-bench` (publish=false): a Criterion benchmark comparing Wickra against `kand`, `ta-rs` and `yata` on an identical BTCUSDT candle series, in streaming and batch modes. Peer APIs were verified against their source, not guessed. Wired into the nightly `cross-library-bench` workflow as a separate job. ### Honest README The benchmark section is rewritten into three layered tables (Rust core vs Rust crates; Python vs the Python ecosystem) that **show the losses as well as the wins**. The "only library that combines…" claim is gone; the new framing is breadth + multi-language reach + the deliberate safety trade-off that costs raw speed. Added an origin/why-slower rationale and a star CTA. ### Python benchmark Added `tulipy` runners and expanded per-tick streaming coverage to SMA/EMA/RSI/ MACD/Bollinger. `bench.in`/`bench.txt` now lock `TA-Lib` + `tulipy` (hash-pinned); `pandas-ta` stays out (it requires Python ≥ 3.12, the bench runs on 3.11). ### Notes - TA-Lib/tulipy numbers in the README Python table are marked ⧗ — they are produced by the CI Linux job (C extensions don't build cleanly on every desktop), not measured locally. - The matching `wickra-docs` prose update is committed separately and will be pushed with the release, per the docs-don't-lead-the-registries rule. Verified locally: `cargo fmt`, `cargo test --workspace --all-features` (3413 core + bindings), `cargo clippy --workspace --all-targets --all-features -D warnings`, Node build + 498 tests, and pytest all green.
This commit is contained in:
@@ -49,6 +49,7 @@ 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
|
||||
|
||||
@@ -275,6 +276,34 @@ def talipp_bollinger_batch(prices: np.ndarray) -> Optional[Callable[[], None]]:
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -329,6 +358,105 @@ def talipp_rsi_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[Callabl
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Runner
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -339,6 +467,7 @@ BATCH_INDICATORS = [
|
||||
("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),
|
||||
]),
|
||||
@@ -346,6 +475,7 @@ BATCH_INDICATORS = [
|
||||
("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),
|
||||
]),
|
||||
@@ -353,6 +483,7 @@ BATCH_INDICATORS = [
|
||||
("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),
|
||||
]),
|
||||
@@ -360,6 +491,7 @@ BATCH_INDICATORS = [
|
||||
("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),
|
||||
]),
|
||||
@@ -367,6 +499,7 @@ BATCH_INDICATORS = [
|
||||
("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),
|
||||
]),
|
||||
@@ -376,18 +509,35 @@ 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),
|
||||
]),
|
||||
("EMA(20)", [
|
||||
("Wickra", wickra_ema_streaming),
|
||||
("talipp", talipp_ema_streaming),
|
||||
]),
|
||||
("RSI(14)", [
|
||||
("Wickra", wickra_rsi_streaming),
|
||||
("TA-Lib", talib_rsi_streaming),
|
||||
("pandas-ta", pandas_ta_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
]),
|
||||
("MACD(12, 26, 9)", [
|
||||
("Wickra", wickra_macd_streaming),
|
||||
("talipp", talipp_macd_streaming),
|
||||
]),
|
||||
("Bollinger(20, 2.0)", [
|
||||
("Wickra", wickra_bollinger_streaming),
|
||||
("talipp", talipp_bollinger_streaming),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
@@ -501,6 +651,7 @@ def main() -> None:
|
||||
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__}")
|
||||
|
||||
@@ -39,6 +39,7 @@ bench = [
|
||||
"pytest-benchmark>=4",
|
||||
"TA-Lib; platform_system != 'Windows'",
|
||||
"pandas-ta>=0.3.14b",
|
||||
"tulipy>=0.4; platform_system != 'Windows'",
|
||||
"talipp>=2",
|
||||
"finta>=1.3",
|
||||
"pandas>=2",
|
||||
|
||||
Reference in New Issue
Block a user