test(golden): language-neutral fixtures + per-binding parity runners (#255)

**Task 5 — golden-fixture parity for the C-ABI bindings.** Lifts the C#/Go/Java/R tests from *one indicator per archetype* toward reference-value parity, catching FFI wiring bugs (swapped params, wrong multi-output field) the math-only core tests cannot see.

## What's here
- **`examples/rust/src/bin/gen_golden.rs`** + **`testdata/golden/*.csv`** — a Rust generator computing a deterministic OHLCV series plus the core's reference outputs for a curated archetype-spanning set: scalar (`Sma`/`Ema`/`Rsi`), candle (`Atr`), scalar multi-output (`MACD`), candle multi-output (`ADX`), pairwise (`Beta`). `nan` marks warmup. Regenerate with `cargo run -p wickra-examples --bin gen_golden`.
- **Parity runners** replaying the identical fixtures through each FFI (rel-tol 1e-6), each a standard test in the binding's existing suite (no `ci.yml` change — rides `dotnet test` / `go test` / `mvn install` / `R CMD`+testthat). A walk-up search locates `testdata/golden` regardless of run dir.
  - **C#** (`bindings/csharp/.../GoldenTests.cs`) —  validated locally, 7/7 pass.
  - **Go** (`bindings/go/golden_test.go`) —  validated locally, pass.
  - **Java** (`bindings/java/.../GoldenTests.java`) — modeled on the archetype API; validated by CI (no local mvn).
  - **R** (`bindings/r/tests/testthat/test-golden.R`) — modeled on the archetype API; validated by CI (no local Rscript).

## Notes
- The curated set spans every marshalling archetype; extending the indicator list is mechanical (add to the generator + regenerate). Bars/profile archetypes can be added next.
- No new CI jobs.
This commit is contained in:
kingchenc
2026-06-11 15:22:29 +02:00
committed by GitHub
parent 395a2289f4
commit 8bfe24ac1d
13 changed files with 1397 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
//! Generate the language-neutral golden fixtures consumed by the per-binding
//! parity runners (`bindings/{csharp,go,java,r}`).
//!
//! Run from the repo root: `cargo run -p wickra-examples --bin gen_golden`.
//! It writes a deterministic OHLCV input series plus the reference outputs of a
//! curated set of indicators spanning the FFI archetypes (scalar, candle,
//! scalar multi-output, candle multi-output, pairwise), computed by the Rust
//! core. Each binding runner replays the same input through its own FFI and
//! checks it matches these values — catching wiring bugs (swapped params,
//! wrong multi-output index) that the math-only core tests cannot see.
//!
//! `nan` marks a warmup slot where the indicator returned `None`.
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use wickra::{Adx, Atr, Beta, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
const N: usize = 80;
/// Deterministic OHLCV bar `i`: a varied, non-degenerate path so every
/// indicator gets real movement (no constant returns, no zero ranges).
fn bar(i: usize) -> (f64, f64, f64, f64, f64) {
let t = i as f64;
let close = 100.0 + 10.0 * (t * 0.3).sin() + 0.5 * t;
let open = close - (t * 0.5).cos();
let span = 1.0 + 0.5 * (t * 0.7).sin().abs();
let high = close.max(open) + span;
let low = close.min(open) - span;
let volume = 1000.0 + 100.0 * (t * 0.2).sin().abs();
(open, high, low, close, volume)
}
fn cell(v: Option<f64>) -> String {
match v {
Some(x) => format!("{x}"),
None => "nan".to_owned(),
}
}
fn write_csv(dir: &Path, name: &str, header: &str, rows: &[String]) {
let mut out = String::new();
let _ = writeln!(out, "{header}");
for r in rows {
let _ = writeln!(out, "{r}");
}
let path = dir.join(format!("{name}.csv"));
fs::write(&path, out).expect("write fixture");
println!("wrote {}", path.display());
}
fn main() {
let dir = Path::new("testdata/golden");
fs::create_dir_all(dir).expect("create testdata/golden");
let candles: Vec<Candle> = (0..N)
.map(|i| {
let (o, h, l, c, v) = bar(i);
Candle::new(o, h, l, c, v, i as i64).expect("valid candle")
})
.collect();
let closes: Vec<f64> = candles.iter().map(|c| c.close).collect();
// input.csv — the single shared series every runner reads.
let mut input = vec![String::from("open,high,low,close,volume")];
for c in &candles {
input.push(format!(
"{},{},{},{},{}",
c.open, c.high, c.low, c.close, c.volume
));
}
let input_header = input.remove(0);
write_csv(dir, "input", &input_header, &input);
// scalar (close-driven), single output. Each is a distinct type, so write
// them out separately rather than from a heterogeneous collection.
{
let mut sma = Sma::new(14).unwrap();
let rows: Vec<String> = closes.iter().map(|&c| cell(sma.update(c))).collect();
write_csv(dir, "sma", "sma", &rows);
}
{
let mut ema = Ema::new(14).unwrap();
let rows: Vec<String> = closes.iter().map(|&c| cell(ema.update(c))).collect();
write_csv(dir, "ema", "ema", &rows);
}
{
let mut rsi = Rsi::new(14).unwrap();
let rows: Vec<String> = closes.iter().map(|&c| cell(rsi.update(c))).collect();
write_csv(dir, "rsi", "rsi", &rows);
}
// candle, single output.
{
let mut atr = Atr::new(14).unwrap();
let rows: Vec<String> = candles.iter().map(|&c| cell(atr.update(c))).collect();
write_csv(dir, "atr", "atr", &rows);
}
// scalar multi-output: MACD(12,26,9).
{
let mut macd = MacdIndicator::new(12, 26, 9).unwrap();
let rows: Vec<String> = closes
.iter()
.map(|&c| match macd.update(c) {
Some(o) => format!("{},{},{}", o.macd, o.signal, o.histogram),
None => "nan,nan,nan".to_owned(),
})
.collect();
write_csv(dir, "macd", "macd,signal,histogram", &rows);
}
// candle multi-output: ADX(14).
{
let mut adx = Adx::new(14).unwrap();
let rows: Vec<String> = candles
.iter()
.map(|&c| match adx.update(c) {
Some(o) => format!("{},{},{}", o.plus_di, o.minus_di, o.adx),
None => "nan,nan,nan".to_owned(),
})
.collect();
write_csv(dir, "adx", "plus_di,minus_di,adx", &rows);
}
// pairwise: Beta(20) over (close, open).
{
let mut beta = Beta::new(20).unwrap();
let rows: Vec<String> = candles
.iter()
.map(|&c| cell(beta.update((c.close, c.open))))
.collect();
write_csv(dir, "beta", "beta", &rows);
}
println!("golden fixtures written to {}", dir.display());
}