Files
wickra/bindings/go/gen_golden_test.py
T

344 lines
12 KiB
Python
Raw Normal View History

"""Generate golden_all_test.go: a value-parity test that replays the shared
golden input through every one of the 514 Go indicators and checks output
bit-for-bit against the Rust-generated g_<Canonical>.csv fixtures.
Run from repo root: python bindings/go/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", "go", "indicators_gen.go"), encoding="utf-8").read()
# Canonical core Indicator::name() per indicator, shared across every binding.
NAMES = json.load(open(os.path.join(G, "names.json")))
# Go constructor parameter types, keyed by canonical (== Go type name).
ctor_types = {}
for m in re.finditer(r"func New(\w+)\(([^)]*)\)\s*\(\*\w+, error\)", GEN):
name, ps = m.group(1), m.group(2).strip()
types = []
if ps:
for p in ps.split(","):
p = p.strip()
_, _, ty = p.partition(" ")
types.append(ty.strip())
ctor_types[name] = types
# Unified archetype + params, keyed by canonical.
spec = {} # canon -> dict(arch, params, width?, n?)
scal = json.load(open(os.path.join(G, "scalar_manifest.json")))
for e in scal:
inp = e["input"]
arch = {"f64": "scalar_f64", "Candle": "scalar_candle", "(f64, f64)": "pairwise"}[inp]
spec[e["canonical"]] = {"arch": arch, "params": e["params"]}
for e in json.load(open(os.path.join(G, "multi_manifest.json"))):
inp = e["input"]
arch = {"f64": "multi_f64", "Candle": "multi_candle", "(f64, f64)": "multi_pairwise"}[inp]
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 go_param(value, gotype):
intlike = gotype in ("int", "int32", "int64", "uint", "uintptr", "usize")
if intlike:
return str(int(round(value)))
# float64
return repr(float(value)) if "." in repr(float(value)) or "e" in repr(float(value)) else f"{float(value)}"
def ctor_call(canon):
types = ctor_types.get(canon, [])
vals = spec[canon]["params"]
args = ", ".join(go_param(v, t) for v, t in zip(vals, types))
return f"New{canon}({args})"
# Update-call expression + output handling per archetype.
def block(canon):
s = spec[canon]
a = s["arch"]
ctor = ctor_call(canon)
lines = [f'\tt.Run("{canon}", func(t *testing.T) {{']
lines.append(f"\t\tind, err := {ctor}")
lines.append('\t\tif err != nil {')
lines.append(f'\t\t\tt.Fatalf("new {canon}: %v", err)')
lines.append("\t\t}")
lines.append(f'\t\tif n := ind.Name(); n != {json.dumps(NAMES[canon])} {{')
lines.append(f'\t\t\tt.Errorf("name: got %q want %q", n, {json.dumps(NAMES[canon])})')
lines.append("\t\t}")
lines.append("\t\tgot := make([][]float64, len(rows))")
lines.append("\t\tfor i, r := range rows {")
if a == "scalar_f64":
upd = "ind.Update(r[3])"
lines.append(f"\t\t\tgot[i] = []float64{{{upd}}}")
elif a == "pairwise":
lines.append("\t\t\tgot[i] = []float64{ind.Update(r[3], r[0])}")
elif a == "scalar_candle":
lines.append("\t\t\tgot[i] = []float64{ind.Update(r[0], r[1], r[2], r[3], r[4], int64(i))}")
elif a == "trade":
lines.append("\t\t\tgot[i] = []float64{ind.Update(r[3], r[4], r[3] >= r[0], int64(i))}")
elif a == "trademid":
lines.append("\t\t\tgot[i] = []float64{ind.Update(r[3], r[4], r[3] >= r[0], int64(i), (r[1]+r[2])/2)}")
elif a == "ob":
lines.append("\t\t\tbp, bs, ap, as_ := obLists(r)")
lines.append("\t\t\tgot[i] = []float64{ind.Update(bp, bs, ap, as_)}")
elif a == "deriv":
lines.append("\t\t\td := derivFields(r)")
lines.append("\t\t\tgot[i] = []float64{ind.Update(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8], d[9], d[10], int64(i))}")
elif a == "deriv_multi":
lines.append("\t\t\td := derivFields(r)")
lines.append("\t\t\tout, ok := ind.Update(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7], d[8], d[9], d[10], int64(i))")
lines.append(f"\t\t\tgot[i] = reflectRow(out, ok, {s['n']})")
elif a == "cross":
lines.append("\t\t\tch, vo, nh, nl, am, ob_ := crossLists(r)")
lines.append("\t\t\tgot[i] = []float64{ind.Update(ch, vo, nh, nl, am, ob_, int64(i))}")
elif a in ("multi_f64",):
lines.append("\t\t\tout, ok := ind.Update(r[3])")
lines.append(f"\t\t\tgot[i] = reflectRow(out, ok, {s['n']})")
elif a == "multi_pairwise":
lines.append("\t\t\tout, ok := ind.Update(r[3], r[0])")
lines.append(f"\t\t\tgot[i] = reflectRow(out, ok, {s['n']})")
elif a == "multi_candle":
lines.append("\t\t\tout, ok := ind.Update(r[0], r[1], r[2], r[3], r[4], int64(i))")
lines.append(f"\t\t\tgot[i] = reflectRow(out, ok, {s['n']})")
elif a == "profile_bins":
lines.append("\t\t\tbins, ok := ind.Update(r[0], r[1], r[2], r[3], r[4], int64(i))")
lines.append(f"\t\t\tif ok {{ got[i] = bins }} else {{ got[i] = nanRow({s['width']}) }}")
elif a == "profile_pricebins":
lines.append("\t\t\tout, ok := ind.Update(r[0], r[1], r[2], r[3], r[4], int64(i))")
lines.append(f"\t\t\tgot[i] = reflectRow(out, ok, {s['width']})")
elif a == "bars_close":
lines.append("\t\t\tgot[i] = flattenBars(ind.Update(r[3], r[3], r[3], r[3], 1.0, 0))")
elif a == "bars_candle4":
lines.append("\t\t\tgot[i] = flattenBars(ind.Update(r[0], r[1], r[2], r[3], 1.0, 0))")
elif a == "bars_candle5":
lines.append("\t\t\tgot[i] = flattenBars(ind.Update(r[0], r[1], r[2], r[3], r[4], 0))")
elif a == "footprint":
lines.append("\t\t\tgot[i] = flattenBars(ind.Update(r[3], r[4], r[3] >= r[0], int64(i)))")
else:
raise SystemExit("unknown arch " + a)
lines.append("\t\t}")
lines.append(f'\t\tcompareGolden(t, "{canon}", got)')
lines.append("\t})")
return "\n".join(lines)
HEADER = '''// Code generated by gen_golden_test.py. DO NOT EDIT.
//
// Value-parity for every one of the 514 Go 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 a single comparator covers all
// archetypes. Regenerate with: python bindings/go/gen_golden_test.py
package wickra
import (
\t"bufio"
\t"math"
\t"os"
\t"reflect"
\t"strings"
\t"testing"
)
// readGoldenRaw keeps blank lines (a candle on which no bar closed) so bar rows
// stay aligned to the input; non-bar fixtures contain no blank lines.
func readGoldenRaw(t *testing.T, name string) [][]string {
\tt.Helper()
\tf, err := os.Open("../../testdata/golden/" + name + ".csv")
\tif err != nil {
\t\tt.Fatalf("open %s: %v", name, err)
\t}
\tdefer f.Close()
\tvar rows [][]string
\tsc := bufio.NewScanner(f)
\tsc.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
\tfirst := true
\tfor sc.Scan() {
\t\tline := sc.Text()
\t\tif first {
\t\t\tfirst = false
\t\t\tcontinue
\t\t}
\t\tif line == "" {
\t\t\trows = append(rows, []string{})
\t\t\tcontinue
\t\t}
\t\trows = append(rows, strings.Split(line, ","))
\t}
\treturn rows
}
func nanRow(n int) []float64 {
\tr := make([]float64, n)
\tfor i := range r {
\t\tr[i] = math.NaN()
\t}
\treturn r
}
func reflectRow(out any, ok bool, width int) []float64 {
\tif !ok {
\t\treturn nanRow(width)
\t}
\tv := reflect.ValueOf(out)
\trow := make([]float64, 0, width)
\tfor k := 0; k < v.NumField(); k++ {
\t\trow = appendField(row, v.Field(k))
\t}
\treturn row
}
func appendField(row []float64, f reflect.Value) []float64 {
\tswitch f.Kind() {
\tcase reflect.Float64, reflect.Float32:
\t\treturn append(row, f.Float())
\tcase reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
\t\treturn append(row, float64(f.Int()))
\tcase reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
\t\treturn append(row, float64(f.Uint()))
\tcase reflect.Slice:
\t\tfor j := 0; j < f.Len(); j++ {
\t\t\trow = appendField(row, f.Index(j))
\t\t}
\t\treturn row
\tdefault:
\t\treturn row
\t}
}
func flattenBars(bars any) []float64 {
\tv := reflect.ValueOf(bars)
\trow := []float64{}
\tfor i := 0; i < v.Len(); i++ {
\t\tbar := v.Index(i)
\t\tfor k := 0; k < bar.NumField(); k++ {
\t\t\trow = appendField(row, bar.Field(k))
\t\t}
\t}
\treturn row
}
// Synthetic feeds derived from one OHLCV row, identical to gen_golden's Rust
// construction (DerivativesTick / CrossSection / OrderBook).
func derivFields(r []float64) [11]float64 {
\to, h, l, c, v := r[0], r[1], r[2], r[3], r[4]
\treturn [11]float64{
\t\t(c - o) / c * 0.01, // funding_rate
\t\tc, // mark_price
\t\tc - 0.5, // index_price
\t\tc + 1.0, // futures_price
\t\tv * 10.0, // open_interest
\t\tv * 0.6, // long_size
\t\tv * 0.4, // short_size
\t\tv * 0.55, // taker_buy_volume
\t\tv * 0.45, // taker_sell_volume
\t\th - c, // long_liquidation
\t\tc - l, // short_liquidation
\t}
}
func crossLists(r []float64) ([]float64, []float64, []bool, []bool, []bool, []bool) {
\to, c, v := r[0], r[3], r[4]
\tchange := make([]float64, 5)
\tvolume := make([]float64, 5)
\tnewHigh := make([]bool, 5)
\tnewLow := make([]bool, 5)
\taboveMa := make([]bool, 5)
\tonBuy := make([]bool, 5)
\tfor j := 0; j < 5; j++ {
\t\tjf := float64(j)
\t\tchange[j] = (c - o) + jf
\t\tvolume[j] = v + jf*10.0
\t\tnewHigh[j] = j%2 == 0
\t\tnewLow[j] = j%3 == 0
\t\taboveMa[j] = j%2 == 0
\t\tonBuy[j] = j%3 == 0
\t}
\treturn change, volume, newHigh, newLow, aboveMa, onBuy
}
func obLists(r []float64) ([]float64, []float64, []float64, []float64) {
\tc, v := r[3], r[4]
\tbidPx := make([]float64, 5)
\tbidSz := make([]float64, 5)
\taskPx := make([]float64, 5)
\taskSz := make([]float64, 5)
\tfor k := 0; k < 5; k++ {
\t\tkf := float64(k + 1)
\t\tbidPx[k] = c - 0.1*kf
\t\tbidSz[k] = v / kf
\t\taskPx[k] = c + 0.1*kf
\t\taskSz[k] = v * 0.9 / kf
\t}
\treturn bidPx, bidSz, askPx, askSz
}
func compareGolden(t *testing.T, name string, got [][]float64) {
\tt.Helper()
\texp := readGoldenRaw(t, "g_"+name)
\tif len(exp) != len(got) {
\t\tt.Fatalf("%s: %d fixture rows vs %d computed", name, len(exp), len(got))
\t}
\tfor i := range exp {
\t\tif len(exp[i]) != len(got[i]) {
\t\t\tt.Fatalf("%s row %d: arity %d vs %d", name, i, len(got[i]), len(exp[i]))
\t\t}
\t\tfor k := range exp[i] {
\t\t\twant := goldenCell(exp[i][k])
\t\t\tg := got[i][k]
\t\t\tif math.IsNaN(want) {
\t\t\t\tif !math.IsNaN(g) {
\t\t\t\t\tt.Fatalf("%s row %d col %d: want NaN got %v", name, i, k, g)
\t\t\t\t}
\t\t\t\tcontinue
\t\t\t}
\t\t\tif math.IsInf(want, 0) {
\t\t\t\tif !math.IsInf(g, 0) || (g > 0) != (want > 0) {
\t\t\t\t\tt.Fatalf("%s row %d col %d: want %v got %v", name, i, k, want, g)
\t\t\t\t}
\t\t\t\tcontinue
\t\t\t}
\t\t\ttol := goldenTol * math.Max(1.0, math.Abs(want))
\t\t\tif math.Abs(g-want) > tol {
\t\t\t\tt.Fatalf("%s row %d col %d: got %v want %v", name, i, k, g, want)
\t\t\t}
\t\t}
\t}
}
func TestGoldenAll(t *testing.T) {
\trows := goldenInput(t)
'''
# bars need blank-line-preserving fixture reads; reuse readGolden but it skips
# blanks. We need a raw reader for bars and input.
out = [HEADER]
for canon in canons:
out.append(block(canon))
out.append("}")
open(os.path.join(ROOT, "bindings", "go", "golden_all_test.go"), "w", encoding="utf-8").write("\n".join(out) + "\n")
print("generated golden_all_test.go with", len(canons), "indicators")