Per-binding throughput benchmarks + test-coverage gaps (#246)
Adds a `throughput` benchmark to every target and closes two small test-coverage documentation/QA gaps. One PR, no merge of binding code beyond the additive benchmarks and one C test. ## 1. Per-binding throughput benchmarks (all 9 targets) Each benchmark feeds a deterministic synthetic OHLCV series through three indicators chosen by **FFI call-signature archetype** (not algorithm — the same Rust core runs underneath all bindings): - `SMA(20)` — 1-in → 1-out (baseline boundary cost) - `ATR(14)` — multi-in → 1-out (input marshalling) - `MACD(12,26,9)` — 1-in → multi-out (output marshalling) Streaming is timed for all three; batch for the single-output SMA and ATR (median of 3 runs, after a warmup pass). New: Python (PyO3), WASM, C (CMake), C# (Stopwatch), Go, Java (FFM), R, and the Rust core baseline (`examples/rust/.../throughput.rs`, **no FFI** — the ceiling the bindings are measured against and the value their batch paths converge towards). Node already had `throughput.js`. **Not a speed claim:** there is no comparable streaming TA library for C, C#, Go, Java, R or WASM to compare against, so these are raw per-binding throughput numbers documenting each language's FFI overhead — see BENCHMARKS.md §3. The "Wickra is fast" claim still lives in §1/§2 (Rust core + the Python/Rust cross-library runs). ## 2. README `## Testing`: C# and C bullets The section listed every layer except C# and C, even though both have suites. Adds the two missing bullets. ## 3. C archetype ctest `examples/c/archetypes.c` drives one indicator per FFI archetype through the real C boundary (scalar + batch==streaming, multi-output, bars, profile, array input) plus reset, invalid-parameter and NULL-safety — the C counterpart of the Go/R/Java archetype suites. Runs on three OSes via the existing CMake/ctest. ## Notes - Benchmarks are not CI-gated (manual-run scripts, like the existing `throughput.js`); no `ci.yml`/`release.yml` changes. - Docs: BENCHMARKS.md §3, a `## Benchmark` section in every binding README, a CHANGELOG entry. - Verified locally by running: Rust, Python, C, C#, Go, Java (real numbers); the C archetype ctest with `-Wall -Wextra -Wpedantic -Werror`. WASM and R are API-correct and syntax-checked but need their own toolchains to run.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <math.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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<Candle> = (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."
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user