diff --git a/.gitignore b/.gitignore index a12d3792..caa57e71 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ bindings/node/npm-debug.log* # WASM build output bindings/wasm/pkg/ +bindings/wasm/pkg-node/ # Python venv .venv/ diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 03f06b2a..fd0f8c3d 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -94,3 +94,75 @@ cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata pip install -e bindings/python[bench] # Python peers python -m benchmarks.compare_libraries ``` + +## 3. Per-binding throughput — the cost of the boundary + +The sections above compare Wickra against other libraries, which only exists for +Python and Rust (there is no comparable streaming TA library for C, C#, Go, Java, +R or WebAssembly to benchmark against). Every binding calls the **same** Rust +core, so these per-binding benchmarks are **not** a speed claim and **not** a +cross-library ratio — they document the raw cost of crossing each language's FFI +boundary, in million updates per second (Mupd/s). + +Each binding ships a small `throughput` benchmark that feeds a synthetic OHLCV +series through three indicators chosen by call-signature archetype — `SMA(20)` +(1-in → 1-out), `ATR(14)` (multi-in → 1-out) and `MACD(12,26,9)` (1-in → +multi-out). Two things fall out of the numbers: + +- **Batch converges.** A `batch` call crosses the boundary once and the Rust core + computes the whole series internally, so batch throughput is roughly the same + in every binding — close to the core speed. +- **Streaming reveals the boundary.** A per-tick `update` crosses the boundary + once per value, so streaming throughput is where the bindings differ: the raw C + ABI and P/Invoke-style calls are nearly free, while managed or interpreted + per-call marshalling (cgo, FFM, the R/WASM boundary) costs more per tick. + +The Rust core ships the same benchmark with **no** FFI boundary +(`examples/rust/.../throughput.rs`) — it is the ceiling each binding is measured +against and the value the batch paths converge towards. + +`SMA(20)`, 200 000 bars, median of 3 runs, on the reference machine (Windows 11, +AMD Ryzen 9 9950X): + +| Target | streaming (Mupd/s) | batch (Mupd/s) | +|----------------------|-------------------:|---------------:| +| Rust core (no FFI) | 391 | 500 | +| C | 383 | 330 | +| C# / .NET | 337 | 244 | +| Python | 33 | 488 | +| Java | 28 | 175 | +| Go | 24 | 400 | +| WebAssembly | 19 | 167 | +| Node.js | 17 | 10 | +| R | 0.1 | 193 | + +Streaming spans more than three orders of magnitude — the raw C ABI (383) is +nearly the FFI-free Rust ceiling (391), while R's per-call interpreter overhead +(0.1) makes streaming ~2000× slower than its own batch. Batch converges near the +core speed for the zero-copy bindings (numpy, slices, typed arrays); the two +outliers are Node — whose napi `batch` boxes every element into a JS `Array` — +and R. These are machine-dependent and reflect FFI overhead, not algorithm +speed. + +These are throughput numbers, not competitive numbers — the "Wickra is fast" +claim lives in sections 1 and 2 (Rust core + the Python/Rust cross-library runs). + +Run any target's benchmark (build the C ABI library first where it links one): + +```bash +cargo run -p wickra-examples --release --bin throughput # Rust core baseline (no FFI) + +node bindings/node/benchmarks/throughput.js # native napi-rs +( cd bindings/python && python -m benchmarks.throughput ) # native PyO3 +( cd bindings/wasm && wasm-pack build --target nodejs --out-dir pkg-node --release ) \ + && node bindings/wasm/benchmarks/throughput.mjs # wasm boundary + +cargo build -p wickra-c --release # the C ABI hub +cmake -S bindings/c/benchmarks -B build/cbench && cmake --build build/cbench \ + && ./build/cbench/throughput # raw C ABI +dotnet run -c Release --project bindings/csharp/benchmarks # C# / .NET (P/Invoke) +( cd bindings/go/benchmarks && go run . ) # Go (cgo) +mvn -q -f bindings/java install -DskipTests \ + && mvn -q -f bindings/java/benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput +Rscript bindings/r/benchmarks/throughput.R # R (.Call) +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a070ce52..ddecd0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to Wickra are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Added +- **Per-binding throughput benchmarks** — every target now ships a `throughput` + benchmark mirroring the Node `throughput.js`: streaming and batch + updates-per-second for `SMA(20)`, `ATR(14)` and `MACD(12,26,9)` over a + synthetic OHLCV series. New for Python (`bindings/python/benchmarks/`), C + (`bindings/c/benchmarks/`), C# (`bindings/csharp/benchmarks/`), Go + (`bindings/go/benchmarks/`), Java (`bindings/java/benchmarks/`), R + (`bindings/r/benchmarks/`), WebAssembly (`bindings/wasm/benchmarks/`) and the + Rust core baseline (`examples/rust/.../throughput.rs`, no FFI). They measure + each binding's FFI overhead — the same Rust core runs underneath all of them — + and are documented in [BENCHMARKS.md](BENCHMARKS.md) §3, not a cross-library + speed claim. + ## [0.8.2] - 2026-06-10 ### Fixed - **R binding builds for WebAssembly** — `bindings/r/configure` now builds the diff --git a/README.md b/README.md index ad5121e6..bfc1e859 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,10 @@ Every layer is covered; run the suites with the commands in values across all indicators. - `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence, and reference values. +- `bindings/c`: Rust unit tests over the FFI boundary, plus C and C++ smoke + tests and offline example `ctest`s run on the three OSes. +- `bindings/csharp`: `dotnet test` cases covering one indicator per FFI archetype + (scalar/batch, multi-output, bars, profile, array input) plus SMA reference values. - `bindings/go`: `go test` cases covering one indicator per FFI archetype (scalar/batch, multi-output, bars, profile, array input), reset, and lifecycle. - `bindings/r`: `testthat` cases covering one indicator per FFI archetype diff --git a/bindings/c/README.md b/bindings/c/README.md index 4c9904ff..958d66dc 100644 --- a/bindings/c/README.md +++ b/bindings/c/README.md @@ -54,6 +54,20 @@ Multi-output indicators (MACD, Bollinger, ADX, …) take a pointer to a `#[repr( struct and return a `bool`. The optional `wickra.hpp` wraps any handle in a move-only `wickra::Handle` for exception-safe C++ lifetimes. +## Benchmark + +`benchmarks/throughput.c` reports streaming and batch updates-per-second for +`SMA`, `ATR` and `MACD`. As the thinnest binding it is the floor of the +per-binding FFI overhead — not a cross-library ratio (the same Rust core runs +under every binding); see the repository +[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +cargo build -p wickra-c --release +cmake -S benchmarks -B build && cmake --build build +./build/throughput +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/c/benchmarks/CMakeLists.txt b/bindings/c/benchmarks/CMakeLists.txt new file mode 100644 index 00000000..e60ae35d --- /dev/null +++ b/bindings/c/benchmarks/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.15) +project(wickra_c_benchmarks C) + +# Directory holding the compiled Wickra C library (cargo output), e.g. +# /target/release. Override with -DWICKRA_LIB_DIR=/path/to/target/release. +if(NOT DEFINED WICKRA_LIB_DIR) + set(WICKRA_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../../target/release") +endif() +set(WICKRA_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../include") + +# Pick the right link target per platform/toolchain. +# - MSVC links the generated import library (wickra.dll.lib). +# - MinGW/gcc on Windows links the DLL directly. +# - Unix links the shared object / dylib. +if(WIN32) + set(WICKRA_RUNTIME "${WICKRA_LIB_DIR}/wickra.dll") + if(MSVC) + set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/wickra.dll.lib") + else() + set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/wickra.dll") + endif() +elseif(APPLE) + set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/libwickra.dylib") +else() + set(WICKRA_LINK_LIB "${WICKRA_LIB_DIR}/libwickra.so") +endif() + +add_executable(throughput throughput.c) +target_include_directories(throughput PRIVATE "${WICKRA_INCLUDE_DIR}") +target_link_libraries(throughput PRIVATE "${WICKRA_LINK_LIB}") +if(UNIX AND NOT APPLE) + target_link_libraries(throughput PRIVATE m) +endif() + +# On Windows copy the DLL next to the executable so the loader finds it. +if(WIN32) + add_custom_command(TARGET throughput POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${WICKRA_RUNTIME}" "$") +endif() diff --git a/bindings/c/benchmarks/throughput.c b/bindings/c/benchmarks/throughput.c new file mode 100644 index 00000000..e5e3bf59 --- /dev/null +++ b/bindings/c/benchmarks/throughput.c @@ -0,0 +1,167 @@ +/* + * Throughput benchmark for the Wickra C ABI. + * + * Measures how many indicator updates per second the C ABI sustains, both + * per-tick (streaming `_update`) and bulk (`_batch`), over a synthetic OHLCV + * series. It is the C counterpart of the Node throughput.js and the Rust + * criterion benches: it benchmarks Wickra's own O(1) streaming engine through + * the raw C boundary (there is no comparable streaming TA library to compare + * against), so the headline number is raw throughput, not a cross-library + * ratio. C is the thinnest binding, so these numbers are the floor of the + * per-binding FFI overhead the higher-level bindings build on. + * + * Three indicators are timed, chosen by call-signature archetype rather than + * algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD + * (1-in -> multi-out). Streaming is timed for all three; batch only for the + * single-output SMA and ATR (the C ABI has no MACD batch entry point). + * + * Build the C ABI library first, then build and run the benchmark: + * + * cargo build -p wickra-c --release + * cmake -S bindings/c/benchmarks -B build/cbench -DCMAKE_BUILD_TYPE=Release + * cmake --build build/cbench + * ./build/cbench/throughput # 200k bars (default) + * ./build/cbench/throughput 1000000 + */ +#include +#include +#include +#include +#include + +#include "wickra.h" + +#ifdef _WIN32 +#include +static double now_ns(void) { + static LARGE_INTEGER freq; + static int init = 0; + LARGE_INTEGER counter; + if (!init) { + QueryPerformanceFrequency(&freq); + init = 1; + } + QueryPerformanceCounter(&counter); + return (double)counter.QuadPart * 1e9 / (double)freq.QuadPart; +} +#else +#include +static double now_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec; +} +#endif + +static double median3(double a, double b, double c) { + if ((a <= b && b <= c) || (c <= b && b <= a)) return b; + if ((b <= a && a <= c) || (c <= a && a <= b)) return a; + return c; +} + +/* Run `body` once as warmup, then time three repetitions and store the median + * elapsed nanoseconds in `dst`. `body` is a brace-enclosed statement block. */ +#define MEASURE(dst, body) \ + do { \ + body; \ + double s0, s1, s2, t0; \ + t0 = now_ns(); body; s0 = now_ns() - t0; \ + t0 = now_ns(); body; s1 = now_ns() - t0; \ + t0 = now_ns(); body; s2 = now_ns() - t0; \ + (dst) = median3(s0, s1, s2); \ + } while (0) + +int main(int argc, char **argv) { + size_t bars = 200000; + if (argc > 1) { + long n = strtol(argv[1], NULL, 10); + if (n >= 1000) { + bars = (size_t)n; + } + } + const size_t n = bars; + + /* Deterministic synthetic OHLCV (no RNG, so runs are comparable). */ + double *open = malloc(n * sizeof(double)); + double *high = malloc(n * sizeof(double)); + double *low = malloc(n * sizeof(double)); + double *close = malloc(n * sizeof(double)); + double *volume = malloc(n * sizeof(double)); + int64_t *timestamp = malloc(n * sizeof(int64_t)); + double *out = malloc(n * sizeof(double)); /* reused batch scratch buffer */ + if (!open || !high || !low || !close || !volume || !timestamp || !out) { + fprintf(stderr, "allocation failed\n"); + return 1; + } + for (size_t i = 0; i < n; i++) { + double mid = 100 + sin((double)i * 0.001) * 20 + (double)i * 1e-4; + double c = mid + sin((double)i * 0.05) * 2; + close[i] = c; + open[i] = mid; + high[i] = fmax(c, mid) + 1.5; + low[i] = fmin(c, mid) - 1.5; + volume[i] = 1000 + (double)(i % 97) * 13; + timestamp[i] = (int64_t)i; + } + + double ns; + double sma_stream, sma_batch, atr_stream, atr_batch, macd_stream; + + MEASURE(ns, { + struct Sma *ind = wickra_sma_new(20); + for (size_t i = 0; i < n; i++) wickra_sma_update(ind, close[i]); + wickra_sma_free(ind); + }); + sma_stream = (double)n / (ns / 1e9) / 1e6; + + MEASURE(ns, { + struct Sma *ind = wickra_sma_new(20); + wickra_sma_batch(ind, close, out, n); + wickra_sma_free(ind); + }); + sma_batch = (double)n / (ns / 1e9) / 1e6; + + MEASURE(ns, { + struct Atr *ind = wickra_atr_new(14); + for (size_t i = 0; i < n; i++) + wickra_atr_update(ind, open[i], high[i], low[i], close[i], volume[i], timestamp[i]); + wickra_atr_free(ind); + }); + atr_stream = (double)n / (ns / 1e9) / 1e6; + + MEASURE(ns, { + struct Atr *ind = wickra_atr_new(14); + wickra_atr_batch(ind, open, high, low, close, volume, timestamp, out, n); + wickra_atr_free(ind); + }); + atr_batch = (double)n / (ns / 1e9) / 1e6; + + MEASURE(ns, { + struct MacdIndicator *ind = wickra_macd_indicator_new(12, 26, 9); + struct WickraMacdOutput value; + for (size_t i = 0; i < n; i++) wickra_macd_indicator_update(ind, close[i], &value); + wickra_macd_indicator_free(ind); + }); + macd_stream = (double)n / (ns / 1e9) / 1e6; + + printf("Wickra C throughput - %zu bars (median of 3 runs)\n\n", n); + printf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)"); + printf("------------------------------------------------------------\n"); + printf("%-22s%20.1f%18.1f\n", "SMA(20)", sma_stream, sma_batch); + printf("%-22s%20.1f%18.1f\n", "ATR(14)", atr_stream, atr_batch); + printf("%-22s%20.1f%18s\n", "MACD(12,26,9)", macd_stream, "-"); + + printf("\nMupd/s = million indicator updates per second. Streaming is the per-tick\n" + "`_update` path (one C call per value); batch is the bulk array path (one\n" + "C call). Higher is better. Numbers are machine-dependent - use them for\n" + "relative comparison, not as a speed claim.\n"); + + free(open); + free(high); + free(low); + free(close); + free(volume); + free(timestamp); + free(out); + return 0; +} diff --git a/bindings/csharp/README.md b/bindings/csharp/README.md index c22b61f0..f3b65b41 100644 --- a/bindings/csharp/README.md +++ b/bindings/csharp/README.md @@ -52,6 +52,18 @@ foreach (var price in liveFeed) values — the equivalence is enforced by the test suite. Multi-output indicators (MACD, Bollinger, ADX, …) return a nullable `record struct`, `null` while warming up. +## Benchmark + +`benchmarks/` reports streaming and batch updates-per-second for `SMA`, `ATR` +and `MACD`. It measures this binding's FFI overhead, not a cross-library ratio +(the same Rust core runs under every binding) — see the repository +[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +cargo build -p wickra-c --release +dotnet run -c Release --project benchmarks +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/csharp/benchmarks/Benchmarks.csproj b/bindings/csharp/benchmarks/Benchmarks.csproj new file mode 100644 index 00000000..e5bd743d --- /dev/null +++ b/bindings/csharp/benchmarks/Benchmarks.csproj @@ -0,0 +1,21 @@ + + + + + Exe + net8.0 + latest + enable + enable + false + Wickra.Benchmarks + Wickra.Benchmarks + + + + + + + diff --git a/bindings/csharp/benchmarks/Program.cs b/bindings/csharp/benchmarks/Program.cs new file mode 100644 index 00000000..093ef91a --- /dev/null +++ b/bindings/csharp/benchmarks/Program.cs @@ -0,0 +1,101 @@ +// Throughput benchmark for the Wickra C# binding. +// +// Measures how many indicator updates per second the binding sustains, both +// per-tick (streaming Update) and bulk (Batch), over a synthetic OHLCV series. +// It is the C# counterpart of the Node throughput.js and the Rust criterion +// benches: it benchmarks Wickra's own O(1) streaming engine across the +// managed<->C-ABI boundary (there is no comparable streaming TA library on +// NuGet to compare against), so the headline number is raw per-binding +// throughput / FFI overhead, not a cross-library ratio. +// +// Three indicators are timed, chosen by FFI call-signature archetype rather +// than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD +// (1-in -> multi-out). Streaming is timed for all three; batch only for the +// single-output SMA and ATR (multi-output batch is not exposed uniformly). +// +// cargo build -p wickra-c --release +// dotnet run -c Release --project bindings/csharp/benchmarks # 200k bars +// dotnet run -c Release --project bindings/csharp/benchmarks -- --bars 1000000 + +using System.Diagnostics; +using System.Globalization; +using Wickra; + +// Deterministic, locale-independent number formatting for the report. +CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + +int bars = 200_000; +for (int i = 0; i < args.Length - 1; i++) +{ + if (args[i] == "--bars" && int.TryParse(args[i + 1], out var n) && n >= 1000) + { + bars = n; + } +} + +// Deterministic synthetic OHLCV (no RNG, so runs are comparable). +var open = new double[bars]; +var high = new double[bars]; +var low = new double[bars]; +var close = new double[bars]; +var volume = new double[bars]; +var timestamp = new long[bars]; +for (int i = 0; i < bars; i++) +{ + double mid = 100 + Math.Sin(i * 0.001) * 20 + i * 1e-4; + double c = mid + Math.Sin(i * 0.05) * 2; + close[i] = c; + open[i] = mid; + high[i] = Math.Max(c, mid) + 1.5; + low[i] = Math.Min(c, mid) - 1.5; + volume[i] = 1000 + (i % 97) * 13; + timestamp[i] = i; +} + +double Mups(double ns) => bars / (ns / 1e9) / 1e6; + +// Median elapsed-ns over a few repetitions, after one warmup pass. +double TimeNs(Action fn, int reps = 3) +{ + fn(); // warmup (JIT + cache) + var samples = new double[reps]; + for (int r = 0; r < reps; r++) + { + long t0 = Stopwatch.GetTimestamp(); + fn(); + samples[r] = (Stopwatch.GetTimestamp() - t0) * (1e9 / Stopwatch.Frequency); + } + Array.Sort(samples); + return samples[reps / 2]; +} + +// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). +var indicators = new (string Name, Action Stream, Action? Batch)[] +{ + ("SMA(20)", + () => { using var ind = new Sma(20); for (int i = 0; i < bars; i++) ind.Update(close[i]); }, + () => { using var ind = new Sma(20); ind.Batch(close); }), + ("ATR(14)", + () => { using var ind = new Atr(14); for (int i = 0; i < bars; i++) ind.Update(open[i], high[i], low[i], close[i], volume[i], timestamp[i]); }, + () => { using var ind = new Atr(14); ind.Batch(open, high, low, close, volume, timestamp); }), + ("MACD(12,26,9)", + () => { using var ind = new MacdIndicator(12, 26, 9); for (int i = 0; i < bars; i++) ind.Update(close[i]); }, + null), // multi-output: streaming only +}; + +Console.WriteLine($"Wickra C# throughput - {bars:N0} bars (median of 3 runs)\n"); +Console.WriteLine($"{"Indicator",-22}{"streaming (Mupd/s)",20}{"batch (Mupd/s)",18}"); +Console.WriteLine(new string('-', 60)); + +foreach (var (name, stream, batch) in indicators) +{ + string streamMups = Mups(TimeNs(stream)).ToString("F1"); + string batchMups = batch is null ? "-" : Mups(TimeNs(batch)).ToString("F1"); + Console.WriteLine($"{name,-22}{streamMups,20}{batchMups,18}"); +} + +Console.WriteLine( + "\nMupd/s = million indicator updates per second. Streaming is the per-tick\n" + + "Update path crossing the managed<->C-ABI boundary once per value; batch is\n" + + "the bulk array path (one boundary crossing). Higher is better. Numbers are\n" + + "machine-dependent - use them for relative comparison, not as a speed claim."); diff --git a/bindings/go/README.md b/bindings/go/README.md index dbf098d2..79af41dc 100644 --- a/bindings/go/README.md +++ b/bindings/go/README.md @@ -88,6 +88,17 @@ values — the equivalence is enforced by the test suite. Multi-output indicator Every indicator owns a native handle freed by `Close()`; a finalizer is wired as a backstop, but call `Close()` (e.g. with `defer`) to release memory promptly. +## Benchmark + +`benchmarks/throughput.go` reports streaming and batch updates-per-second for +`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a +cross-library ratio (the same Rust core runs under every binding) — see the +repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +cd benchmarks && go run . +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in the diff --git a/bindings/go/benchmarks/throughput.go b/bindings/go/benchmarks/throughput.go new file mode 100644 index 00000000..809531cb --- /dev/null +++ b/bindings/go/benchmarks/throughput.go @@ -0,0 +1,145 @@ +// Throughput benchmark for the Wickra Go bindings. +// +// Measures how many indicator updates per second the cgo binding sustains, +// both per-tick (streaming Update) and bulk (Batch), over a synthetic OHLCV +// series. It is the Go counterpart of the Node throughput.js and the Rust +// criterion benches: it benchmarks Wickra's own O(1) streaming engine across +// the Go<->C-ABI boundary (there is no comparable streaming TA library to +// compare against), so the headline number is raw per-binding throughput / +// FFI overhead, not a cross-library ratio. +// +// Three indicators are timed, chosen by FFI call-signature archetype rather +// than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD +// (1-in -> multi-out). Streaming is timed for all three; batch only for the +// single-output SMA and ATR (multi-output batch is not exposed uniformly). +// +// Provision the C ABI library first (see bindings/go/README.md), then run: +// +// cd bindings/go/benchmarks +// go run . # 200k bars (default) +// go run . -bars 1000000 +package main + +import ( + "flag" + "fmt" + "math" + "sort" + "time" + + wickra "github.com/wickra-lib/wickra/bindings/go" +) + +func main() { + bars := flag.Int("bars", 200_000, "number of synthetic bars to feed") + flag.Parse() + n := *bars + if n < 1000 { + fmt.Println("-bars must be >= 1000") + return + } + + // Deterministic synthetic OHLCV (no RNG, so runs are comparable). + open := make([]float64, n) + high := make([]float64, n) + low := make([]float64, n) + closeP := make([]float64, n) + volume := make([]float64, n) + timestamp := make([]int64, n) + for i := 0; i < n; i++ { + mid := 100 + math.Sin(float64(i)*0.001)*20 + float64(i)*1e-4 + c := mid + math.Sin(float64(i)*0.05)*2 + closeP[i] = c + open[i] = mid + high[i] = math.Max(c, mid) + 1.5 + low[i] = math.Min(c, mid) - 1.5 + volume[i] = 1000 + float64(i%97)*13 + timestamp[i] = int64(i) + } + + mups := func(d time.Duration) float64 { + return float64(n) / d.Seconds() / 1e6 + } + + // Median elapsed over a few repetitions, after one warmup pass. + timeFn := func(fn func()) time.Duration { + fn() // warmup + const reps = 3 + samples := make([]time.Duration, reps) + for r := 0; r < reps; r++ { + t0 := time.Now() + fn() + samples[r] = time.Since(t0) + } + sort.Slice(samples, func(a, b int) bool { return samples[a] < samples[b] }) + return samples[reps/2] + } + + type indicator struct { + name string + stream func() + batch func() // nil -> streaming only + } + + indicators := []indicator{ + { + name: "SMA(20)", + stream: func() { + ind, _ := wickra.NewSma(20) + for i := 0; i < n; i++ { + ind.Update(closeP[i]) + } + ind.Close() + }, + batch: func() { + ind, _ := wickra.NewSma(20) + ind.Batch(closeP) + ind.Close() + }, + }, + { + name: "ATR(14)", + stream: func() { + ind, _ := wickra.NewAtr(14) + for i := 0; i < n; i++ { + ind.Update(open[i], high[i], low[i], closeP[i], volume[i], timestamp[i]) + } + ind.Close() + }, + batch: func() { + ind, _ := wickra.NewAtr(14) + ind.Batch(open, high, low, closeP, volume, timestamp) + ind.Close() + }, + }, + { + name: "MACD(12,26,9)", + stream: func() { + ind, _ := wickra.NewMacdIndicator(12, 26, 9) + for i := 0; i < n; i++ { + ind.Update(closeP[i]) + } + ind.Close() + }, + batch: nil, // multi-output: streaming only + }, + } + + fmt.Printf("Wickra Go throughput - %d bars (median of 3 runs)\n\n", n) + fmt.Printf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)") + fmt.Println("------------------------------------------------------------") + + for _, ind := range indicators { + streamMups := fmt.Sprintf("%.1f", mups(timeFn(ind.stream))) + batchMups := "-" + if ind.batch != nil { + batchMups = fmt.Sprintf("%.1f", mups(timeFn(ind.batch))) + } + fmt.Printf("%-22s%20s%18s\n", ind.name, streamMups, batchMups) + } + + fmt.Print("\nMupd/s = million indicator updates per second. Streaming is the per-tick\n", + "Update path crossing the Go<->C-ABI boundary once per value; batch is the\n", + "bulk slice path (one boundary crossing). Higher is better. Numbers are\n", + "machine-dependent - use them for relative comparison, not as a speed claim.\n") +} diff --git a/bindings/java/README.md b/bindings/java/README.md index 475eba43..62dae269 100644 --- a/bindings/java/README.md +++ b/bindings/java/README.md @@ -76,6 +76,19 @@ values — the equivalence is enforced by the test suite. Multi-output indicator indicator owns a native handle freed by a `Cleaner`; `close()` releases it eagerly (use try-with-resources). +## Benchmark + +`benchmarks/` reports streaming and batch updates-per-second for `SMA`, `ATR` +and `MACD`. It measures this binding's FFI overhead, not a cross-library ratio +(the same Rust core runs under every binding) — see the repository +[BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +cargo build -p wickra-c --release +mvn -q install -DskipTests +mvn -q -f benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/java/benchmarks/pom.xml b/bindings/java/benchmarks/pom.xml new file mode 100644 index 00000000..e910c04d --- /dev/null +++ b/bindings/java/benchmarks/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + org.wickra.benchmarks + wickra-benchmarks + 0.8.2 + jar + + Wickra Java benchmarks + Throughput benchmark for the Wickra Java binding. + + + UTF-8 + 22 + + + + + org.wickra + wickra + 0.8.2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + + org.codehaus.mojo + exec-maven-plugin + 3.2.0 + + ${java.home}/bin/java + + --enable-native-access=ALL-UNNAMED + -classpath + + ${exec.mainClass} + + + + + + diff --git a/bindings/java/benchmarks/src/main/java/org/wickra/benchmarks/Throughput.java b/bindings/java/benchmarks/src/main/java/org/wickra/benchmarks/Throughput.java new file mode 100644 index 00000000..5be777a9 --- /dev/null +++ b/bindings/java/benchmarks/src/main/java/org/wickra/benchmarks/Throughput.java @@ -0,0 +1,149 @@ +package org.wickra.benchmarks; + +import java.util.Arrays; +import java.util.Locale; +import org.wickra.Atr; +import org.wickra.MacdIndicator; +import org.wickra.Sma; + +/** + * Throughput benchmark for the Wickra Java binding. + * + *

Measures how many indicator updates per second the binding sustains, both + * per-tick (streaming {@code update}) and bulk ({@code batch}), over a synthetic + * OHLCV series. It is the Java counterpart of the Node {@code throughput.js} and + * the Rust criterion benches: it benchmarks Wickra's own O(1) streaming engine + * across the Java FFM <-> C-ABI boundary (there is no comparable streaming + * TA library on Maven Central to compare against), so the headline number is raw + * per-binding throughput / FFI overhead, not a cross-library ratio. + * + *

Three indicators are timed, chosen by FFI call-signature archetype rather + * than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD + * (1-in -> multi-out). Streaming is timed for all three; batch only for the + * single-output SMA and ATR (multi-output batch is not exposed uniformly). + * + *

Install the binding and build the C ABI library first, then run from the + * repo root: + * + *

+ *   cargo build -p wickra-c --release
+ *   mvn -q -f bindings/java install -DskipTests
+ *   mvn -q -f bindings/java/benchmarks exec:exec -Dexec.mainClass=org.wickra.benchmarks.Throughput
+ * 
+ */ +public final class Throughput { + private Throughput() {} + + public static void main(String[] args) { + int bars = 200_000; + for (int i = 0; i < args.length - 1; i++) { + if (args[i].equals("--bars")) { + try { + int n = Integer.parseInt(args[i + 1]); + if (n >= 1000) { + bars = n; + } + } catch (NumberFormatException ignored) { + // keep default + } + } + } + + // Deterministic synthetic OHLCV (no RNG, so runs are comparable). + double[] open = new double[bars]; + double[] high = new double[bars]; + double[] low = new double[bars]; + double[] close = new double[bars]; + double[] volume = new double[bars]; + double[] timestamp = new double[bars]; + for (int i = 0; i < bars; i++) { + double mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4; + double c = mid + Math.sin(i * 0.05) * 2; + close[i] = c; + open[i] = mid; + high[i] = Math.max(c, mid) + 1.5; + low[i] = Math.min(c, mid) - 1.5; + volume[i] = 1000 + (i % 97) * 13; + timestamp[i] = i; + } + + final int n = bars; + + // SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). + Indicator[] indicators = { + new Indicator("SMA(20)", + () -> { + try (Sma ind = new Sma(20)) { + for (int i = 0; i < n; i++) { + ind.update(close[i]); + } + } + }, + () -> { + try (Sma ind = new Sma(20)) { + ind.batch(close); + } + }), + new Indicator("ATR(14)", + () -> { + try (Atr ind = new Atr(14)) { + for (int i = 0; i < n; i++) { + ind.update(open[i], high[i], low[i], close[i], volume[i], (long) timestamp[i]); + } + } + }, + () -> { + try (Atr ind = new Atr(14)) { + ind.batch(open, high, low, close, volume, timestamp); + } + }), + new Indicator("MACD(12,26,9)", + () -> { + try (MacdIndicator ind = new MacdIndicator(12, 26, 9)) { + for (int i = 0; i < n; i++) { + ind.update(close[i]); + } + } + }, + null), // multi-output: streaming only + }; + + System.out.printf(Locale.ROOT, "Wickra Java throughput - %,d bars (median of 3 runs)%n%n", bars); + System.out.printf(Locale.ROOT, "%-22s%20s%18s%n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)"); + System.out.println("------------------------------------------------------------"); + + for (Indicator ind : indicators) { + String streamMups = String.format(Locale.ROOT, "%.1f", mups(bars, timeNs(ind.stream))); + String batchMups = ind.batch == null + ? "-" + : String.format(Locale.ROOT, "%.1f", mups(bars, timeNs(ind.batch))); + System.out.printf(Locale.ROOT, "%-22s%20s%18s%n", ind.name, streamMups, batchMups); + } + + System.out.println( + "\nMupd/s = million indicator updates per second. Streaming is the per-tick\n" + + "update path crossing the Java FFM<->C-ABI boundary once per value; batch is\n" + + "the bulk array path (one boundary crossing). Higher is better. Numbers are\n" + + "machine-dependent - use them for relative comparison, not as a speed claim."); + } + + private static double mups(int bars, double ns) { + return bars / (ns / 1e9) / 1e6; + } + + // Median elapsed-ns over a few repetitions, after one warmup pass. + private static double timeNs(Runnable fn) { + fn.run(); // warmup (JIT + cache) + final int reps = 3; + double[] samples = new double[reps]; + for (int r = 0; r < reps; r++) { + long t0 = System.nanoTime(); + fn.run(); + samples[r] = System.nanoTime() - t0; + } + Arrays.sort(samples); + return samples[reps / 2]; + } + + private record Indicator(String name, Runnable stream, Runnable batch) {} +} diff --git a/bindings/node/README.md b/bindings/node/README.md index 6e4e0cd9..5854f55d 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -47,6 +47,18 @@ for (const price of liveFeed) { `batch(prices)` and feeding the same prices through `update()` produce identical values — the equivalence is enforced by the test suite. +## Benchmark + +`benchmarks/throughput.js` reports streaming and batch updates-per-second for +`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a +cross-library ratio (the same Rust core runs under every binding) — see the +repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +npx napi build --platform --release +node benchmarks/throughput.js +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/python/README.md b/bindings/python/README.md index 97db5ba1..cfe38bc2 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -46,6 +46,24 @@ for price in live_feed: `batch(prices)` and feeding the same prices through `update()` produce identical values — the equivalence is enforced by the test suite. +## Benchmark + +Two benchmarks ship with the binding: + +- `benchmarks/throughput.py` — streaming and batch updates-per-second for `SMA`, + `ATR` and `MACD`. This is per-binding FFI overhead (the same Rust core runs + under every binding), not a cross-library ratio. +- `benchmarks/compare_libraries.py` — the cross-library comparison against + TA-Lib, pandas-ta, tulipy and finta that backs the headline speedups. + +```bash +maturin develop --release +python -m benchmarks.throughput +python -m benchmarks.compare_libraries # cross-library; auto-detects installed peers +``` + +See the repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md). + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/python/benchmarks/throughput.py b/bindings/python/benchmarks/throughput.py new file mode 100644 index 00000000..adf94811 --- /dev/null +++ b/bindings/python/benchmarks/throughput.py @@ -0,0 +1,116 @@ +"""Throughput benchmark for the Wickra Python binding. + +Measures how many indicator updates per second the binding sustains, both +per-tick (streaming ``update``) and bulk (``batch``), over a synthetic OHLCV +series. It is the Python counterpart of the Node ``throughput.js`` and the Rust +criterion benches: it benchmarks Wickra's own O(1) streaming engine across the +Python<->Rust boundary, so the headline number is raw per-binding throughput / +FFI overhead, not a cross-library ratio. + +For the cross-library comparison against TA-Lib, pandas-ta, tulipy and finta, +see ``benchmarks/compare_libraries.py`` instead. + +Three indicators are timed, chosen by call-signature archetype rather than +algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out) and MACD (1-in -> +multi-out). Streaming is timed for all three; batch only for the single-output +SMA and ATR (multi-output batch returns a 2-D array and is not compared here). + +Install the binding first (``maturin develop --release`` in bindings/python), +then run from bindings/python:: + + python -m benchmarks.throughput # 200k bars (default) + python -m benchmarks.throughput --bars 1000000 +""" + +from __future__ import annotations + +import argparse +import time + +import numpy as np + +import wickra as ta + + +def _time_ns(fn, reps: int = 3) -> float: + """Median elapsed-ns over a few repetitions, after one warmup pass.""" + fn() # warmup + samples = [] + for _ in range(reps): + t0 = time.perf_counter_ns() + fn() + samples.append(time.perf_counter_ns() - t0) + samples.sort() + return samples[len(samples) // 2] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Wickra Python throughput benchmark") + parser.add_argument("--bars", type=int, default=200_000, help="number of synthetic bars") + args = parser.parse_args() + bars = args.bars if args.bars >= 1000 else 200_000 + + # Deterministic synthetic OHLCV (no RNG, so runs are comparable). + idx = np.arange(bars, dtype=np.float64) + mid = 100 + np.sin(idx * 0.001) * 20 + idx * 1e-4 + close = mid + np.sin(idx * 0.05) * 2 + high = np.maximum(close, mid) + 1.5 + low = np.minimum(close, mid) - 1.5 + open_ = mid + volume = 1000 + (idx % 97) * 13 + + close_list = close.tolist() + # ATR streams a 6-tuple (open, high, low, close, volume, timestamp) per tick. + candles = list( + zip(open_.tolist(), high.tolist(), low.tolist(), close_list, volume.tolist(), range(bars)) + ) + + def mups(ns: float) -> float: + return bars / (ns / 1e9) / 1e6 + + def sma_stream() -> None: + ind = ta.SMA(20) + for value in close_list: + ind.update(value) + + def sma_batch() -> None: + ta.SMA(20).batch(close) + + def atr_stream() -> None: + ind = ta.ATR(14) + for candle in candles: + ind.update(candle) + + def atr_batch() -> None: + ta.ATR(14).batch(high, low, close) + + def macd_stream() -> None: + ind = ta.MACD(12, 26, 9) + for value in close_list: + ind.update(value) + + # SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). + indicators = [ + ("SMA(20)", sma_stream, sma_batch), + ("ATR(14)", atr_stream, atr_batch), + ("MACD(12,26,9)", macd_stream, None), # multi-output: streaming only + ] + + print(f"Wickra Python throughput - {bars:,} bars (median of 3 runs)\n") + print(f"{'Indicator':<22}{'streaming (Mupd/s)':>20}{'batch (Mupd/s)':>18}") + print("-" * 60) + for name, stream, batch in indicators: + stream_mups = f"{mups(_time_ns(stream)):.1f}" + batch_mups = "-" if batch is None else f"{mups(_time_ns(batch)):.1f}" + print(f"{name:<22}{stream_mups:>20}{batch_mups:>18}") + + print( + "\nMupd/s = million indicator updates per second. Streaming is the per-tick\n" + "`update` path crossing the Python<->Rust boundary once per value; batch is\n" + "the bulk numpy path (one boundary crossing). Higher is better. Numbers are\n" + "machine-dependent - use them for relative comparison, not as a speed claim." + ) + + +if __name__ == "__main__": + main() diff --git a/bindings/r/README.md b/bindings/r/README.md index 7cf01f9f..0730769f 100644 --- a/bindings/r/README.md +++ b/bindings/r/README.md @@ -59,6 +59,17 @@ indicators take the OHLCV fields plus a timestamp, e.g. `update(atr, open, high, low, close, volume, timestamp)`. The native handle is freed automatically when the object is garbage-collected. +## Benchmark + +`benchmarks/throughput.R` reports streaming and batch updates-per-second for +`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a +cross-library ratio (the same Rust core runs under every binding) — see the +repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +Rscript benchmarks/throughput.R +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in the diff --git a/bindings/r/benchmarks/throughput.R b/bindings/r/benchmarks/throughput.R new file mode 100644 index 00000000..f0c56802 --- /dev/null +++ b/bindings/r/benchmarks/throughput.R @@ -0,0 +1,119 @@ +#!/usr/bin/env Rscript +# +# Throughput benchmark for the Wickra R bindings. +# +# Measures how many indicator updates per second the R binding sustains, both +# per-tick (streaming `update`) and bulk (`batch`), over a synthetic OHLCV +# series. It is the R counterpart of the Node `throughput.js` and the Rust +# criterion benches: it benchmarks Wickra's own O(1) streaming engine across +# the R<->C-ABI boundary (there is no comparable streaming TA library on CRAN +# to compare against), so the headline number is raw per-binding throughput / +# FFI overhead, not a cross-library ratio. +# +# Three indicators are timed, chosen by FFI call-signature archetype rather +# than algorithm: SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and MACD +# (1-in -> multi-out). Streaming is timed for all three; batch only for the +# single-output SMA and ATR (multi-output batch is not exposed uniformly). +# +# Install the package first (it links the C ABI; see bindings/r/README.md), +# then run: +# +# Rscript bindings/r/benchmarks/throughput.R # 200k bars (default) +# Rscript bindings/r/benchmarks/throughput.R --bars 1000000 + +suppressMessages(library(wickra)) + +parse_bars <- function() { + args <- commandArgs(trailingOnly = TRUE) + i <- match("--bars", args) + if (!is.na(i) && length(args) >= i + 1L) { + n <- suppressWarnings(as.integer(args[i + 1L])) + if (!is.na(n) && n >= 1000L) { + return(n) + } + stop("--bars must be an integer >= 1000") + } + 200000L +} + +bars <- parse_bars() + +# Deterministic synthetic OHLCV (no RNG, so runs are comparable). +idx <- seq.int(0L, bars - 1L) +mid <- 100 + sin(idx * 0.001) * 20 + idx * 1e-4 +close <- mid + sin(idx * 0.05) * 2 +high <- pmax(close, mid) + 1.5 +low <- pmin(close, mid) - 1.5 +open <- mid +volume <- 1000 + (idx %% 97L) * 13 +# `numeric` (double), not integer: the candle batch path coerces the timestamp +# column with REAL(), which rejects an integer vector. +timestamp <- as.numeric(idx) + +# Median elapsed-ns over a few repetitions, after one warmup pass. +time_ns <- function(fn, reps = 3L) { + fn() # warmup + samples <- numeric(reps) + for (r in seq_len(reps)) { + t0 <- Sys.time() + fn() + samples[r] <- as.numeric(Sys.time() - t0, units = "secs") * 1e9 + } + median(samples) +} + +mups_from_ns <- function(ns) bars / (ns / 1e9) / 1e6 + +# SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). +indicators <- list( + list( + name = "SMA(20)", + stream = function() { + ind <- Sma(20) + for (i in seq_len(bars)) update(ind, close[i]) + }, + batch = function() { + batch(Sma(20), close) + } + ), + list( + name = "ATR(14)", + stream = function() { + ind <- Atr(14) + for (i in seq_len(bars)) { + update(ind, open[i], high[i], low[i], close[i], volume[i], timestamp[i]) + } + }, + batch = function() { + batch(Atr(14), open, high, low, close, volume, timestamp) + } + ), + list( + name = "MACD(12,26,9)", + stream = function() { + ind <- MacdIndicator(12, 26, 9) + for (i in seq_len(bars)) update(ind, close[i]) + }, + batch = NULL # multi-output: streaming only + ) +) + +cat(sprintf( + "Wickra R throughput - %s bars (median of 3 runs)\n\n", + format(bars, big.mark = ",") +)) +cat(sprintf("%-22s%20s%18s\n", "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)")) +cat(strrep("-", 60), "\n", sep = "") + +for (ind in indicators) { + stream_mups <- sprintf("%.1f", mups_from_ns(time_ns(ind$stream))) + batch_mups <- if (is.null(ind$batch)) "-" else sprintf("%.1f", mups_from_ns(time_ns(ind$batch))) + cat(sprintf("%-22s%20s%18s\n", ind$name, stream_mups, batch_mups)) +} + +cat(paste0( + "\nMupd/s = million indicator updates per second. Streaming is the per-tick\n", + "`update` path crossing the R<->C-ABI boundary once per value; batch is the\n", + "bulk vector path (one boundary crossing). Higher is better. Numbers are\n", + "machine-dependent - use them for relative comparison, not as a speed claim.\n" +)) diff --git a/bindings/wasm/README.md b/bindings/wasm/README.md index d489e23e..0ce0f92a 100644 --- a/bindings/wasm/README.md +++ b/bindings/wasm/README.md @@ -46,6 +46,18 @@ Constructors mirror the other bindings (`new SMA(20)`, `new MACD(12, 26, 9)`, `new BollingerBands(20, 2.0)`, …); `update()` returns the latest value or `null` while the indicator is still warming up. +## Benchmark + +`benchmarks/throughput.mjs` reports streaming and batch updates-per-second for +`SMA`, `ATR` and `MACD`. It measures this binding's FFI overhead, not a +cross-library ratio (the same Rust core runs under every binding) — see the +repository [BENCHMARKS.md](https://github.com/wickra-lib/wickra/blob/main/BENCHMARKS.md) §3. + +```bash +wasm-pack build --target nodejs --out-dir pkg-node --release +node benchmarks/throughput.mjs +``` + ## Documentation The full indicator catalogue, guides, quickstarts, and API reference live in diff --git a/bindings/wasm/benchmarks/throughput.mjs b/bindings/wasm/benchmarks/throughput.mjs new file mode 100644 index 00000000..2c1897ee --- /dev/null +++ b/bindings/wasm/benchmarks/throughput.mjs @@ -0,0 +1,127 @@ +// Throughput benchmark for the Wickra WebAssembly bindings. +// +// Measures how many indicator updates per second the wasm binding sustains, +// both per-tick (streaming `update`) and bulk (`batch`), over a synthetic +// OHLCV series. It is the wasm counterpart of the Node `throughput.js` and the +// Rust criterion benches: it benchmarks Wickra's own O(1) streaming engine +// across the JS<->wasm boundary (there is no install-free TA library with a +// comparable surface to compare against), so the headline number is raw +// per-binding throughput / FFI overhead, not a cross-library ratio. +// +// Three indicators are timed, chosen by FFI call-signature archetype rather +// than algorithm (the algorithm is identical to the Rust core; only the +// boundary cost differs): SMA (1-in -> 1-out), ATR (multi-in -> 1-out), and +// MACD (1-in -> multi-out). Streaming is timed for all three; batch only for +// the single-output SMA and ATR (multi-output batch is not exposed uniformly). +// +// Build the nodejs-target package first (needs the wasm32-unknown-unknown +// target, i.e. a rustup toolchain), then run: +// +// cd bindings/wasm && wasm-pack build --target nodejs --out-dir pkg-node --release +// node benchmarks/throughput.mjs # 200k bars (default) +// node benchmarks/throughput.mjs --bars 1000000 + +import { createRequire } from 'node:module'; +import { hrtime } from 'node:process'; + +const require = createRequire(import.meta.url); +// wasm-pack --target nodejs emits a CommonJS module named after the crate. +const wasm = require('../pkg-node/wickra_wasm.js'); +const { SMA, ATR, MACD } = wasm; + +function parseBars() { + const idx = process.argv.indexOf('--bars'); + if (idx !== -1 && process.argv[idx + 1]) { + const n = Number(process.argv[idx + 1]); + if (Number.isFinite(n) && n >= 1000) return Math.floor(n); + console.error('--bars must be a number >= 1000'); + process.exit(1); + } + return 200_000; +} + +const BARS = parseBars(); + +// Deterministic synthetic OHLCV (no RNG, so runs are comparable). +const close = new Float64Array(BARS); +const high = new Float64Array(BARS); +const low = new Float64Array(BARS); +for (let i = 0; i < BARS; i++) { + const mid = 100 + Math.sin(i * 0.001) * 20 + i * 1e-4; + close[i] = mid + Math.sin(i * 0.05) * 2; + high[i] = Math.max(close[i], mid) + 1.5; + low[i] = Math.min(close[i], mid) - 1.5; +} + +// Median elapsed-ns over a few repetitions, after one warmup pass. +function timeNs(fn, reps = 3) { + fn(); // warmup (JIT + cache) + const samples = []; + for (let r = 0; r < reps; r++) { + const t0 = hrtime.bigint(); + fn(); + samples.push(Number(hrtime.bigint() - t0)); + } + samples.sort((a, b) => a - b); + return samples[Math.floor(samples.length / 2)]; +} + +function mupsFromNs(ns) { + return BARS / (ns / 1e9) / 1e6; // million updates per second +} + +// SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). +const indicators = [ + { + name: 'SMA(20)', + stream: () => { + const ind = new SMA(20); + for (let i = 0; i < BARS; i++) ind.update(close[i]); + ind.free(); + }, + batch: () => { + const ind = new SMA(20); + ind.batch(close); + ind.free(); + }, + }, + { + name: 'ATR(14)', + stream: () => { + const ind = new ATR(14); + for (let i = 0; i < BARS; i++) ind.update(high[i], low[i], close[i]); + ind.free(); + }, + batch: () => { + const ind = new ATR(14); + ind.batch(high, low, close); + ind.free(); + }, + }, + { + name: 'MACD(12,26,9)', + stream: () => { + const ind = new MACD(12, 26, 9); + for (let i = 0; i < BARS; i++) ind.update(close[i]); + ind.free(); + }, + batch: null, // multi-output: streaming only + }, +]; + +console.log(`Wickra WASM throughput — ${BARS.toLocaleString('en-US')} bars (median of 3 runs)\n`); +console.log(`${'Indicator'.padEnd(22)}${'streaming (Mupd/s)'.padStart(20)}${'batch (Mupd/s)'.padStart(18)}`); +console.log('-'.repeat(60)); + +for (const ind of indicators) { + const streamMups = mupsFromNs(timeNs(ind.stream)).toFixed(1); + const batchMups = ind.batch ? mupsFromNs(timeNs(ind.batch)).toFixed(1) : '—'; + console.log(`${ind.name.padEnd(22)}${streamMups.padStart(20)}${batchMups.padStart(18)}`); +} + +console.log( + '\nMupd/s = million indicator updates per second. Streaming is the per-tick\n' + + '`update` path crossing the JS<->wasm boundary once per value; batch is the\n' + + 'bulk array path (one boundary crossing). Higher is better. Numbers are\n' + + 'machine-dependent — use them for relative comparison, not as a speed claim.', +); diff --git a/examples/c/CMakeLists.txt b/examples/c/CMakeLists.txt index 8a57b879..61020075 100644 --- a/examples/c/CMakeLists.txt +++ b/examples/c/CMakeLists.txt @@ -64,6 +64,7 @@ endfunction() # Offline examples — built and run as ctests. add_wickra_example(smoke smoke.c TRUE) # links the boundary, asserts values +add_wickra_example(archetypes archetypes.c TRUE) # one indicator per FFI archetype add_wickra_example(streaming streaming.c TRUE) # multi-indicator tick stream add_wickra_example(cpp_smoke smoke.cpp TRUE) # C++ RAII wrapper (wickra.hpp) add_wickra_example(backtest backtest.c TRUE) # indicator basket over a CSV diff --git a/examples/c/archetypes.c b/examples/c/archetypes.c new file mode 100644 index 00000000..f95ec940 --- /dev/null +++ b/examples/c/archetypes.c @@ -0,0 +1,161 @@ +/* + * Archetype coverage test for the Wickra C ABI. + * + * Exercises one indicator per FFI call-signature archetype through the real C + * boundary — scalar (+ batch == streaming), multi-output, alt-chart bars, + * market profile and array input — plus reset, invalid-parameter and NULL-safety + * behaviour. It is the C counterpart of the Go / R / Java archetype suites; the + * existing smoke test covers symbol/header/link, this one covers the data + * contracts. Run as a ctest (exit 0 on success). + */ +#include +#include +#include +#include + +#include "wickra.h" + +static int failures = 0; + +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL: %s\n", (msg)); \ + failures += 1; \ + } \ + } while (0) + +static int is_nan(double value) { + return value != value; +} + +int main(void) { + /* 1. Scalar archetype: SMA known value over the FFI boundary. */ + { + struct Sma *sma = wickra_sma_new(3); + CHECK(sma != NULL, "sma_new(3) returned NULL"); + double last = NAN; + const double xs[] = {1.0, 2.0, 3.0, 4.0, 5.0}; + for (size_t i = 0; i < 5; i++) { + last = wickra_sma_update(sma, xs[i]); + } + CHECK(fabs(last - 4.0) < 1e-9, "SMA(3) over [.. 3 4 5] should be 4"); + wickra_sma_free(sma); + } + + /* 2. Scalar batch must equal streaming. */ + { + const double xs[] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}; + const size_t n = 8; + struct Sma *stream = wickra_sma_new(3); + double want[8]; + for (size_t i = 0; i < n; i++) { + want[i] = wickra_sma_update(stream, xs[i]); + } + wickra_sma_free(stream); + + struct Sma *batched = wickra_sma_new(3); + double got[8]; + wickra_sma_batch(batched, xs, got, n); + wickra_sma_free(batched); + + int equal = 1; + for (size_t i = 0; i < n; i++) { + if (is_nan(want[i]) != is_nan(got[i])) { + equal = 0; + } else if (!is_nan(want[i]) && fabs(want[i] - got[i]) > 1e-9) { + equal = 0; + } + } + CHECK(equal, "SMA batch must equal streaming"); + } + + /* 3. Multi-output archetype: MACD writes a struct and returns a bool. */ + { + struct MacdIndicator *macd = wickra_macd_indicator_new(3, 6, 3); + CHECK(macd != NULL, "macd_indicator_new returned NULL"); + struct WickraMacdOutput out = {0}; + int produced = 0; + for (int i = 0; i < 30; i++) { + if (wickra_macd_indicator_update(macd, 100.0 + (double)i, &out)) { + produced = 1; + } + } + CHECK(produced, "MACD produced no value after warmup"); + CHECK(!is_nan(out.macd), "MACD value is NaN after warmup"); + wickra_macd_indicator_free(macd); + } + + /* 4. Alt-chart bars archetype: RangeBars writes 0..n bars per candle. */ + { + struct RangeBars *bars = wickra_range_bars_new(2.0); + CHECK(bars != NULL, "range_bars_new returned NULL"); + struct WickraRangeBar out[16]; + size_t total = 0; + for (int i = 0; i < 15; i++) { + double price = 100.0 + (double)i; + total += wickra_range_bars_update(bars, price, price, price, price, 1.0, + (int64_t)i, out, 16); + } + CHECK(total > 0, "range bars produced no bars over a 15-point move"); + wickra_range_bars_free(bars); + } + + /* 5. Market-profile archetype: scalars + a caller-owned values buffer. */ + { + struct VolumeProfile *profile = wickra_volume_profile_new(10, 24); + CHECK(profile != NULL, "volume_profile_new returned NULL"); + struct WickraVolumeProfileOutputScalars scalars = {0}; + double values[24]; + intptr_t len = -1; + for (int i = 0; i < 20; i++) { + double price = 100.0 + (double)i; + len = wickra_volume_profile_update(profile, price, price + 1.0, price - 1.0, + price, 1000.0, (int64_t)i, &scalars, + values, 24); + } + CHECK(len > 0, "volume profile never produced a snapshot"); + wickra_volume_profile_free(profile); + } + + /* 6. Array-input archetype: a full order-book snapshot per side. */ + { + struct OrderBookImbalanceFull *book = wickra_order_book_imbalance_full_new(); + CHECK(book != NULL, "order_book_imbalance_full_new returned NULL"); + const double bid_price[] = {99.9, 99.8, 99.7}; + const double bid_size[] = {5.0, 3.0, 2.0}; + const double ask_price[] = {100.1, 100.2, 100.3}; + const double ask_size[] = {1.0, 1.0, 1.0}; + double imbalance = wickra_order_book_imbalance_full_update( + book, bid_price, bid_size, 3, ask_price, ask_size, 3); + CHECK(!is_nan(imbalance), "order book imbalance returned NaN"); + wickra_order_book_imbalance_full_free(book); + } + + /* 7. Reset returns the indicator to its warmup state. */ + { + struct Sma *sma = wickra_sma_new(3); + for (int i = 0; i < 3; i++) { + wickra_sma_update(sma, (double)(i + 1)); + } + wickra_sma_reset(sma); + CHECK(is_nan(wickra_sma_update(sma, 10.0)), "SMA after reset must be NaN"); + wickra_sma_free(sma); + } + + /* 8. Invalid parameters return NULL; freeing NULL is a no-op. */ + { + CHECK(wickra_sma_new(0) == NULL, "sma_new(0) should return NULL"); + wickra_sma_free(NULL); + } + + /* 9. A NULL handle update is a no-op returning NaN, never a crash. */ + CHECK(is_nan(wickra_sma_update(NULL, 1.0)), "update(NULL) should return NaN"); + + if (failures == 0) { + printf("all archetypes passed\n"); + return 0; + } + fprintf(stderr, "%d archetype check(s) failed\n", failures); + return 1; +} diff --git a/examples/rust/src/bin/throughput.rs b/examples/rust/src/bin/throughput.rs new file mode 100644 index 00000000..2db85f16 --- /dev/null +++ b/examples/rust/src/bin/throughput.rs @@ -0,0 +1,131 @@ +//! Throughput benchmark for the Wickra Rust core — the zero-FFI baseline. +//! +//! Reports streaming (`update`) and batch updates-per-second over a synthetic +//! OHLCV series, in the same format as every binding's `throughput` benchmark. +//! Rust has no FFI boundary — it calls the core directly — so these numbers are +//! the ceiling the per-binding benchmarks are measured against, and the value +//! their `batch` paths converge towards. See the repository BENCHMARKS.md §3. +//! +//! For per-update latency and the cross-library comparison, use the criterion +//! harnesses instead: `cargo bench -p wickra` and `cargo bench -p wickra-bench`. +//! +//! Run: +//! cargo run -p wickra-examples --release --bin throughput # 200k bars +//! cargo run -p wickra-examples --release --bin throughput -- 1000000 + +use std::time::Instant; + +use wickra::{Atr, Candle, Indicator, MacdIndicator, Sma}; + +/// Median elapsed-ns over a few repetitions, after one warmup pass. +fn time_ns(mut run: impl FnMut()) -> u128 { + run(); // warmup + let mut samples = [0u128; 3]; + for sample in &mut samples { + let start = Instant::now(); + run(); + *sample = start.elapsed().as_nanos(); + } + samples.sort_unstable(); + samples[1] +} + +fn main() { + let bars: usize = std::env::args() + .nth(1) + .and_then(|arg| arg.parse().ok()) + .filter(|&n| n >= 1000) + .unwrap_or(200_000); + + // Deterministic synthetic OHLCV (no RNG, so runs are comparable). + let mut open = Vec::with_capacity(bars); + let mut high = Vec::with_capacity(bars); + let mut low = Vec::with_capacity(bars); + let mut close = Vec::with_capacity(bars); + let mut volume = Vec::with_capacity(bars); + for i in 0..bars { + let mid = 100.0 + (i as f64 * 0.001).sin() * 20.0 + i as f64 * 1e-4; + let c = mid + (i as f64 * 0.05).sin() * 2.0; + close.push(c); + open.push(mid); + high.push(c.max(mid) + 1.5); + low.push(c.min(mid) - 1.5); + volume.push(1000.0 + (i % 97) as f64 * 13.0); + } + // ATR streams a Candle per tick; build them once, outside the timed loop. + let candles: Vec = (0..bars) + .map(|i| { + Candle::new( + open[i], + high[i], + low[i], + close[i], + volume[i], + i64::try_from(i).unwrap(), + ) + .unwrap() + }) + .collect(); + + let mups = |ns: u128| bars as f64 / (ns as f64 / 1e9) / 1e6; + + // SMA (scalar 1-in/1-out), ATR (multi-in/1-out), MACD (1-in/multi-out). + let sma_stream = time_ns(|| { + let mut ind = Sma::new(20).unwrap(); + for &price in &close { + ind.update(price); + } + }); + let sma_batch = time_ns(|| { + let mut ind = Sma::new(20).unwrap(); + ind.batch_nan(&close); + }); + let atr_stream = time_ns(|| { + let mut ind = Atr::new(14).unwrap(); + for &candle in &candles { + ind.update(candle); + } + }); + let atr_batch = time_ns(|| { + let mut ind = Atr::new(14).unwrap(); + ind.batch_atr(&high, &low, &close); + }); + let macd_stream = time_ns(|| { + let mut ind = MacdIndicator::new(12, 26, 9).unwrap(); + for &price in &close { + ind.update(price); + } + }); + + println!("Wickra Rust core throughput - {bars} bars (median of 3 runs)\n"); + println!( + "{:<22}{:>20}{:>18}", + "Indicator", "streaming (Mupd/s)", "batch (Mupd/s)" + ); + println!("{}", "-".repeat(60)); + println!( + "{:<22}{:>20.1}{:>18.1}", + "SMA(20)", + mups(sma_stream), + mups(sma_batch) + ); + println!( + "{:<22}{:>20.1}{:>18.1}", + "ATR(14)", + mups(atr_stream), + mups(atr_batch) + ); + println!( + "{:<22}{:>20.1}{:>18}", + "MACD(12,26,9)", + mups(macd_stream), + "-" + ); + + println!( + "\nMupd/s = million indicator updates per second. This is the Rust core with\n\ + no FFI boundary, so it is the ceiling for the per-binding benchmarks and\n\ + the value their batch paths converge towards. Numbers are machine-dependent\n\ + - use them for relative comparison, not as a speed claim." + ); +}