Files
wickra/bindings/csharp/gen_golden_test.py
T
kingchencandGitHub 4f708d410d test: golden-pin the four de-duplicated indicators across all bindings (#305)
* test: golden-pin the four de-duplicated indicators across all C-ABI bindings

Extend gen_golden to emit reference fixtures for AdOscillator (ADOSC),
IntradayIntensity, AwesomeOscillatorHistogram and AverageDrawdown, and replay
them through the Go / C# / Java / R golden harnesses so their corrected
definitions stay bit-identical to the Rust core in every binding. Go suite
verified locally (gcc 13 + cgo): all 9 golden tests pass; C#/Java/R use the
same fixtures and harness pattern (CI-verified). First step of extending the
golden coverage beyond the seven archetype representatives.

* test: golden-pin the scalar-output tranche (308 indicators) against Rust

Extend gen_golden with a generated emit_scalar that writes reference fixtures
for every single-f64-output indicator (scalar / candle / pairwise input) using
valid constructor params, and add a manifest-driven generic Python golden
replay that reconstructs each by its native name and checks it bit-for-bit
against the Rust output. 308 indicators now value-tied to the Rust core in
Python (pytest: 308/308). Takes golden coverage from the 7 archetype
representatives to 308+ of the catalogue.

22 scalar indicators with non-default constructor constraints are skipped by
gen_golden for now (logged), as are non-f64-output ones; multi-output, exotic
inputs and the per-indicator arg arities of the C-ABI/Node replays follow.
Generated + verified locally with the full toolchain.

* test: golden-pin the multi-output tranche (70 indicators) in Python

Add a generated emit_multi to gen_golden (per-indicator Output-field access,
one CSV column per field) and a manifest-driven generic Python replay that
checks every field of each multi-output indicator against the Rust reference.
70 multi-output indicators now value-tied to Rust in Python; combined with the
scalar tranche, 378 indicators are golden-pinned. 8 multi with non-default
param constraints and 5 with non-f64 Output fields (Option/Vec/i64) are
deferred. pytest green.

* test(golden): add 30 constraint-tuned indicators to scalar/multi golden suite

Emit golden fixtures for 22 scalar-output and 8 multi-output indicators
whose constructors need non-default parameters (Alma, Jma, Psar, T3, Mama,
DoubleBollinger, ZigZag, ...). All 408 fixtures replay bit-for-bit through
the Python binding.

* test(golden): cover 36 missed scalar/multi indicators

Add 26 single-output (LinearRegression family, HT cycle, Candle
volatility estimators, DrawdownDuration) and 10 multi-output
(BollingerBands, MACD/MACDEXT/MACDFIX, Camarilla, VWAP bands, ...)
indicators to the golden suite. 444 fixtures replay bit-for-bit
through the Python binding.

* test(golden): cover 50 exotic-input indicators

Add deterministic synthetic feeders for the DerivativesTick (17),
CrossSection (15), Trade (8), TradeQuote (3) and OrderBook (7)
families, derived from the shared OHLCV input series in both
gen_golden and a new Python replay harness (test_golden_exotic).
All 494 fixtures replay bit-for-bit through the Python binding.

* test(golden): complete 514-indicator golden coverage

Add the final tranches: 3 mixed multi-output indicators (Ichimoku,
WilliamsFractals, LeadLagCrossCorrelation), 6 histogram profiles
(time/volume seasonality + TPO/volume price profiles), 10 alt-chart
bar builders and the footprint. Every one of the 514 distinct
indicators now has a Rust-generated g_<Canonical>.csv fixture and a
generic Python replay (scalar/multi/exotic/profile/bars), all passing
bit-for-bit.

* test(golden): add generic Node replay for all 514 indicators

A manifest-driven node:test harness reconstructs every indicator by its
native class, feeds the same synthetic stream derived from the shared
golden input, and checks output bit-for-bit against the Rust reference
fixtures (scalar/multi/exotic/profile/bars). node_manifest.json is
generated from index.d.ts plus the Python-side manifests. 514/514 pass.

* test(golden): add generated Go replay for all 514 indicators

golden_all_test.go (generated by gen_golden_test.py) reconstructs every
Go indicator, feeds the shared synthetic stream and checks output
bit-for-bit against the Rust reference fixtures. A reflection-based
comparator flattens multi-output structs, profiles and bar slices so one
path covers all archetypes. This is the first C-ABI binding verified
across the full catalogue. 514/514 pass.

* test(golden): add generated C# replay for all 514 indicators

GoldenAllTests.g.cs (generated by gen_golden_test.py) reconstructs every
C# indicator, feeds the shared synthetic stream and checks output
bit-for-bit against the Rust reference fixtures via a reflection-based
flatten covering scalar/multi/profile/bar archetypes. 514/514 pass.

Also add the '#nullable enable' directive the compiler requires to the
generated Indicators.g.cs, clearing the four CS8669 warnings on the
nullable double[] profile return types.

* fix(java): marshal C ABI bool params correctly; add 514 golden replay

The Java FFM binding marshalled the cross-section state flags (newHigh,
newLow, aboveMa, onBuySignal) as JAVA_DOUBLE arrays, but the C ABI takes
them as const bool* (one byte each), so the native side read the low byte
of each 8-byte double and saw every flag as false. Add WickraNative.
boolSegment and use it across the 15 cross-section indicators. Also pass
the MacdExt MaType arguments as byte to match the uint8_t downcall
descriptor (was int, throwing WrongMethodTypeException).

Add GoldenAllTest.java (generated by gen_golden_test.py): a reflection
runner replaying all 514 indicators against the Rust reference fixtures.
The bugs above were found by this test; 514/514 now pass.

* fix(r): marshal C ABI bool flags correctly; add 514 golden replay

The R wrapper passed the cross-section state flags as (bool *)REAL(x),
reinterpreting the 8-byte doubles as 1-byte bools so the native side read
every flag as false. Add wk_bool_vec to convert each flag vector into a
real C bool buffer and use it for all 15 cross-section update wrappers.

Add test-golden-all.R + generated golden_specs.R: a reflective runner
replaying all 514 indicators against the Rust reference fixtures. The bug
above was found by this test; verified 514/514 pass locally.

* test(golden): add WASM replay for all 514 indicators

A manifest-driven node:test harness loads the nodejs-target wasm-pack
build, reconstructs every indicator by its JS class, feeds the shared
synthetic stream and checks output bit-for-bit against the Rust
reference fixtures. wasm_manifest.json is generated from the wasm .d.ts
plus the shared manifests; a recursive flattener covers scalar, multi
(Reflect objects), profile and bar shapes. 514/514 pass locally
(wasm-pack build --target nodejs, then node --test).

* test(golden): add C and C++ replay for all 514 indicators

golden_test.c (generated by gen_golden_test.py) drives every indicator
through the C ABI (wickra.h) and checks output bit-for-bit against the
Rust reference fixtures. golden_test.cpp #includes the same source so the
identical runner is compiled and run under both gcc (C) and g++ (C++) via
the CMake targets golden_test / golden_test_cpp — proving the extern "C"
header is consumable from each language. Both 514/514 (verified via ctest).

* test(golden): gofmt the generated Go golden replay

* test(golden): make the Node fixture reader CRLF-safe and pin fixtures to LF
2026-06-15 04:48:51 +02:00

301 lines
12 KiB
Python

"""Generate Wickra.Tests/GoldenAllTests.g.cs: a value-parity test that replays
the shared golden input through every one of the 514 C# indicators and checks
output bit-for-bit against the Rust reference fixtures g_<Canonical>.csv.
Run from repo root: python bindings/csharp/gen_golden_test.py
"""
import glob
import json
import os
import re
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
G = os.path.join(ROOT, "testdata", "golden")
GEN = open(os.path.join(ROOT, "bindings", "csharp", "Wickra", "Generated", "Indicators.g.cs"), encoding="utf-8").read()
# C# constructor parameter types per class.
ctor_types = {}
cur = None
for line in GEN.splitlines():
m = re.match(r"public sealed class (\w+)", line)
if m:
cur = m.group(1)
continue
if cur:
cm = re.match(r"\s*public %s\(([^)]*)\)" % re.escape(cur), line)
if cm:
ps = cm.group(1).strip()
types = [p.strip().rsplit(" ", 1)[0].strip() for p in ps.split(",")] if ps else []
ctor_types[cur] = types
cur = None
# Unified archetype + params, keyed by canonical (== C# class name).
spec = {}
for e in json.load(open(os.path.join(G, "scalar_manifest.json"))):
arch = {"f64": "scalar_f64", "Candle": "scalar_candle", "(f64, f64)": "pairwise"}[e["input"]]
spec[e["canonical"]] = {"arch": arch, "params": e["params"]}
for e in json.load(open(os.path.join(G, "multi_manifest.json"))):
arch = {"f64": "multi_f64", "Candle": "multi_candle", "(f64, f64)": "multi_pairwise"}[e["input"]]
spec[e["canonical"]] = {"arch": arch, "params": e["params"], "n": e["n"]}
ex = json.load(open(os.path.join(G, "exotic_manifest.json")))
for e in ex["deriv"]:
spec[e["canonical"]] = {"arch": "deriv_multi" if "n" in e else "deriv", "params": e["params"], "n": e.get("n")}
for e in ex["cross"]:
spec[e["canonical"]] = {"arch": "cross", "params": e["params"]}
for e in ex["trade"]:
spec[e["canonical"]] = {"arch": "trade", "params": e["params"]}
for e in ex["trademid"]:
spec[e["canonical"]] = {"arch": "trademid", "params": e["params"]}
for e in ex["ob"]:
spec[e["canonical"]] = {"arch": "ob", "params": e["params"]}
for e in json.load(open(os.path.join(G, "profile_manifest.json"))):
spec[e["canonical"]] = {"arch": "profile_" + e["kind"], "params": e["params"], "width": e["width"]}
for e in json.load(open(os.path.join(G, "bars_manifest.json"))):
arch = "footprint" if e["canonical"] == "Footprint" else "bars_" + e["feed"]
spec[e["canonical"]] = {"arch": arch, "params": e["params"]}
canons = sorted(os.path.basename(f)[2:-4] for f in glob.glob(os.path.join(G, "g_*.csv")))
def lit(value, cstype):
if cstype == "int":
return str(int(round(value)))
if cstype == "uint":
return f"{int(round(value))}u"
if cstype == "byte":
return f"(byte){int(round(value))}"
f = float(value)
s = repr(f)
return s if ("." in s or "e" in s or "E" in s) else s + ".0"
def ctor_call(canon):
types = ctor_types.get(canon, [])
vals = spec[canon]["params"]
args = ", ".join(lit(v, t) for v, t in zip(vals, types))
return f"new Wickra.{canon}({args})"
def block(canon):
s = spec[canon]
a = s["arch"]
L = [f" [Fact]", f" public void Golden_{canon}()", " {"]
L.append(f" using var ind = {ctor_call(canon)};")
L.append(" var got = new List<double[]>();")
L.append(" for (var i = 0; i < Rows.Length; i++)")
L.append(" {")
L.append(" var r = Rows[i];")
if a == "scalar_f64":
L.append(" got.Add(new[] { ind.Update(r[3]) });")
elif a == "pairwise":
L.append(" got.Add(new[] { ind.Update(r[3], r[0]) });")
elif a == "scalar_candle":
L.append(" got.Add(new[] { ind.Update(r[0], r[1], r[2], r[3], r[4], i) });")
elif a == "trade":
L.append(" got.Add(new[] { ind.Update(r[3], r[4], r[3] >= r[0], i) });")
elif a == "trademid":
L.append(" got.Add(new[] { ind.Update(r[3], r[4], r[3] >= r[0], i, (r[1] + r[2]) / 2) });")
elif a == "ob":
L.append(" var (bp, bs, ap, asz) = ObLists(r);")
L.append(" got.Add(new[] { ind.Update(bp, bs, ap, asz) });")
elif a == "deriv":
L.append(" var d = DerivFields(r);")
L.append(" got.Add(new[] { ind.Update(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8], d[9], d[10], i) });")
elif a == "deriv_multi":
L.append(" var d = DerivFields(r);")
L.append(" got.Add(FlattenNullable(ind.Update(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8], d[9], d[10], i), %d));" % s["n"])
elif a == "cross":
L.append(" var (ch, vo, nh, nl, am, ob) = CrossLists(r);")
L.append(" got.Add(new[] { ind.Update(ch, vo, nh, nl, am, ob, i) });")
elif a == "multi_f64":
L.append(" got.Add(FlattenNullable(ind.Update(r[3]), %d));" % s["n"])
elif a == "multi_pairwise":
L.append(" got.Add(FlattenNullable(ind.Update(r[3], r[0]), %d));" % s["n"])
elif a == "multi_candle":
L.append(" got.Add(FlattenNullable(ind.Update(r[0], r[1], r[2], r[3], r[4], i), %d));" % s["n"])
elif a == "profile_bins":
L.append(" var bins = ind.Update(r[0], r[1], r[2], r[3], r[4], i);")
L.append(" got.Add(bins ?? NanRow(%d));" % s["width"])
elif a == "profile_pricebins":
L.append(" got.Add(FlattenNullable(ind.Update(r[0], r[1], r[2], r[3], r[4], i), %d));" % s["width"])
elif a == "bars_close":
L.append(" got.Add(FlattenBars(ind.Update(r[3], r[3], r[3], r[3], 1.0, 0)));")
elif a == "bars_candle4":
L.append(" got.Add(FlattenBars(ind.Update(r[0], r[1], r[2], r[3], 1.0, 0)));")
elif a == "bars_candle5":
L.append(" got.Add(FlattenBars(ind.Update(r[0], r[1], r[2], r[3], r[4], 0)));")
elif a == "footprint":
L.append(" got.Add(FlattenBars(ind.Update(r[3], r[4], r[3] >= r[0], i)));")
else:
raise SystemExit("arch " + a)
L.append(" }")
L.append(f' Compare("{canon}", got);')
L.append(" }")
return "\n".join(L)
HEADER = '''// <auto-generated>
// Generated by gen_golden_test.py. DO NOT EDIT.
//
// Value-parity for every one of the 514 C# indicators: the shared golden input
// is replayed through each one and checked bit-for-bit against the Rust
// reference fixtures testdata/golden/g_<Canonical>.csv. Multi-output, profile
// and bar shapes are flattened by reflection so one comparator covers all
// archetypes. Regenerate with: python bindings/csharp/gen_golden_test.py
// </auto-generated>
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using Xunit;
namespace Wickra.Tests;
public class GoldenAllTests
{
private const double Tol = 1e-6;
private static readonly double[][] Rows = LoadInput();
private static string GoldenDir([System.Runtime.CompilerServices.CallerFilePath] string file = "") =>
Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, "..", "..", "..", "testdata", "golden"));
private static double Cell(string s) =>
s == "nan" ? double.NaN
: s == "inf" ? double.PositiveInfinity
: s == "-inf" ? double.NegativeInfinity
: double.Parse(s, CultureInfo.InvariantCulture);
private static double[][] LoadInput()
{
var lines = File.ReadAllLines(Path.Combine(GoldenDir(), "input.csv"));
return lines.Skip(1).Where(l => l.Length > 0)
.Select(l => l.Split(',').Select(x => double.Parse(x, CultureInfo.InvariantCulture)).ToArray())
.ToArray();
}
// Keep blank lines (a candle on which no bar closed) so rows stay aligned.
private static double[]?[] ReadFixture(string name)
{
var lines = File.ReadAllLines(Path.Combine(GoldenDir(), "g_" + name + ".csv"));
return lines.Skip(1).Select(l => l.Length == 0 ? Array.Empty<double>() : l.Split(',').Select(Cell).ToArray()).ToArray();
}
private static double[] NanRow(int n)
{
var r = new double[n];
for (var i = 0; i < n; i++) r[i] = double.NaN;
return r;
}
private static double[] FlattenStruct(object o)
{
var props = o.GetType()
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.OrderBy(p => p.MetadataToken);
var list = new List<double>();
foreach (var p in props)
{
var v = p.GetValue(o);
switch (v)
{
case double d: list.Add(d); break;
case float f: list.Add(f); break;
case long l: list.Add(l); break;
case int n: list.Add(n); break;
case double[] arr: list.AddRange(arr); break;
}
}
return list.ToArray();
}
private static double[] FlattenNullable<T>(T? value, int width) where T : struct =>
value.HasValue ? FlattenStruct(value.Value) : NanRow(width);
private static double[] FlattenBars<T>(T[] bars)
{
var list = new List<double>();
foreach (var bar in bars) list.AddRange(FlattenStruct(bar!));
return list.ToArray();
}
private static double[] DerivFields(double[] r)
{
double o = r[0], h = r[1], l = r[2], c = r[3], v = r[4];
return new[]
{
(c - o) / c * 0.01, c, c - 0.5, c + 1.0, v * 10.0, v * 0.6, v * 0.4,
v * 0.55, v * 0.45, h - c, c - l,
};
}
private static (double[], double[], bool[], bool[], bool[], bool[]) CrossLists(double[] r)
{
double o = r[0], c = r[3], v = r[4];
var change = new double[5];
var volume = new double[5];
var nh = new bool[5];
var nl = new bool[5];
var am = new bool[5];
var ob = new bool[5];
for (var j = 0; j < 5; j++)
{
change[j] = (c - o) + j;
volume[j] = v + j * 10.0;
nh[j] = j % 2 == 0;
nl[j] = j % 3 == 0;
am[j] = j % 2 == 0;
ob[j] = j % 3 == 0;
}
return (change, volume, nh, nl, am, ob);
}
private static (double[], double[], double[], double[]) ObLists(double[] r)
{
double c = r[3], v = r[4];
var bp = new double[5];
var bs = new double[5];
var ap = new double[5];
var asz = new double[5];
for (var k = 0; k < 5; k++)
{
var kf = k + 1;
bp[k] = c - 0.1 * kf;
bs[k] = v / kf;
ap[k] = c + 0.1 * kf;
asz[k] = v * 0.9 / kf;
}
return (bp, bs, ap, asz);
}
private static void Compare(string name, List<double[]> got)
{
var exp = ReadFixture(name);
Assert.True(exp.Length == got.Count, $"{name}: {exp.Length} fixture rows vs {got.Count} computed");
for (var i = 0; i < exp.Length; i++)
{
var want = exp[i]!;
var g = got[i];
Assert.True(want.Length == g.Length, $"{name} row {i}: arity {g.Length} vs {want.Length}");
for (var k = 0; k < want.Length; k++)
{
var w = want[k];
if (double.IsNaN(w)) { Assert.True(double.IsNaN(g[k]), $"{name} row {i} col {k}: want NaN got {g[k]}"); continue; }
if (double.IsInfinity(w)) { Assert.True(double.IsInfinity(g[k]) && Math.Sign(g[k]) == Math.Sign(w), $"{name} row {i} col {k}: want {w} got {g[k]}"); continue; }
var tol = Tol * Math.Max(1.0, Math.Abs(w));
Assert.True(Math.Abs(g[k] - w) <= tol, $"{name} row {i} col {k}: got {g[k]} want {w}");
}
}
}
'''
out = [HEADER]
for canon in canons:
out.append(block(canon))
out.append("}")
open(os.path.join(ROOT, "bindings", "csharp", "Wickra.Tests", "GoldenAllTests.g.cs"), "w", encoding="utf-8").write("\n".join(out) + "\n")
print("generated GoldenAllTests.g.cs with", len(canons), "indicators")