Files
maker/reboot_test.go
zeekayandHanzo Dev 1ac4c806b2 Go 1.27.1, and the code it can say more simply
Modules declare go 1.27.1 and the build images follow. go fix applied the
modernizers; omitzero is not applied, since it changes what the JSON says.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-16 19:23:15 -07:00

335 lines
12 KiB
Go

package maker
import (
"context"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"testing"
"time"
)
// reboot_test.go proves the trading-reboot path: a deterministic, continuous,
// anchor-pinned gap history; correct AMM implied-price resolution; and an
// orchestrator that resolves the last on-chain mid, seeds a funded two-sided book
// at it (reusing the real fake-venue server), paints the gap, and requotes.
// TestGapBackfillContinuityAndAnchor checks every invariant the display layer
// relies on: candle count, open==prev-close continuity, closes bounded to
// ±driftBps of the anchor, the final close pinned to the anchor, High/Low
// bracketing, positive volume, and bit-for-bit determinism across runs.
func TestGapBackfillContinuityAndAnchor(t *testing.T) {
const (
anchor = 12.5
driftBps = 50.0
seed = int64(0xC0FFEE)
)
from := int64(1750186501)
interval := time.Hour
to := from + 48*int64(interval.Seconds()) // 48 candles
stepSecs := int64(interval.Seconds())
cs := GapBackfill("LUX/LUSD", anchor, from, to, interval, driftBps, seed)
wantN := int((to - from) / stepSecs)
if len(cs) != wantN {
t.Fatalf("candle count = %d, want %d", len(cs), wantN)
}
lo := anchor * (1 - driftBps/10000)
hi := anchor * (1 + driftBps/10000)
const eps = 1e-9
for i, c := range cs {
if c.Close < lo-eps || c.Close > hi+eps {
t.Errorf("candle %d close %v outside drift band [%v,%v]", i, c.Close, lo, hi)
}
if c.High < math.Max(c.Open, c.Close)-eps {
t.Errorf("candle %d high %v < max(open,close)", i, c.High)
}
if c.Low > math.Min(c.Open, c.Close)+eps {
t.Errorf("candle %d low %v > min(open,close)", i, c.Low)
}
if c.Low <= 0 {
t.Errorf("candle %d low %v <= 0", i, c.Low)
}
if c.Volume <= 0 {
t.Errorf("candle %d volume %v <= 0", i, c.Volume)
}
if i > 0 && math.Abs(c.Open-cs[i-1].Close) > eps {
t.Errorf("candle %d open %v != prev close %v (discontinuity)", i, c.Open, cs[i-1].Close)
}
wantOpen := from + int64(i)*stepSecs
if c.OpenTime != wantOpen || c.CloseTime != wantOpen+stepSecs {
t.Errorf("candle %d times = (%d,%d), want (%d,%d)", i, c.OpenTime, c.CloseTime, wantOpen, wantOpen+stepSecs)
}
if c.Interval != "1h" {
t.Errorf("candle %d interval = %q, want 1h", i, c.Interval)
}
}
// The history's tail joins the live seed at exactly the anchor.
if last := cs[len(cs)-1].Close; last != anchor {
t.Fatalf("final close = %v, want anchor %v exactly", last, anchor)
}
// Determinism: same seed => identical candles.
cs2 := GapBackfill("LUX/LUSD", anchor, from, to, interval, driftBps, seed)
if !reflect.DeepEqual(cs, cs2) {
t.Fatal("GapBackfill not deterministic across runs with the same seed")
}
}
// fakeSubgraph serves a fixed GraphQL JSON body over POST.
func fakeSubgraph(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("subgraph method = %s, want POST", r.Method)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
return srv
}
// TestAMMReserveImpliedPrice feeds a fake subgraph and asserts LastPrice computes
// the correct implied mid (quote per base) and source, for both the reserve path
// and the indexer-price path with FLIPPED token orientation.
func TestAMMReserveImpliedPrice(t *testing.T) {
const (
lux = "0x0000000000000000000000000000000000000000"
lusd = "0x1111111111111111111111111111111111111111"
)
t.Run("reserve ratio, base==token0", func(t *testing.T) {
// token0=LUX, token1=LUSD; reserves 1000 LUX / 12500 LUSD => 12.5 LUSD per LUX.
body := `{"data":{"pairs":[{"id":"0xpair","token0":{"id":"` + lux + `","symbol":"LUX"},` +
`"token1":{"id":"` + lusd + `","symbol":"LUSD"},"reserve0":"1000","reserve1":"12500",` +
`"token0Price":"","token1Price":"","swaps":[]}]}}`
srv := fakeSubgraph(t, body)
src := &AMMPriceSource{URL: srv.URL, Tokens: map[string][2]string{"LUX/LUSD": {lux, lusd}}}
mid, source, err := src.LastPrice(context.Background(), "LUX/LUSD")
if err != nil {
t.Fatalf("LastPrice: %v", err)
}
if source != "amm-reserve" {
t.Errorf("source = %q, want amm-reserve", source)
}
if math.Abs(mid-12.5) > 1e-9 {
t.Errorf("mid = %v, want 12.5", mid)
}
})
t.Run("indexer price, flipped orientation (base==token1)", func(t *testing.T) {
// token0=LUSD, token1=LUX (flipped). token0Price = token0 per token1 =
// LUSD per LUX = quote per base = 12.5. Reserves present too, but the
// quoted price must win (source amm-price).
body := `{"data":{"pairs":[{"id":"0xpair","token0":{"id":"` + lusd + `","symbol":"LUSD"},` +
`"token1":{"id":"` + lux + `","symbol":"LUX"},"reserve0":"12500","reserve1":"1000",` +
`"token0Price":"12.5","token1Price":"0.08","swaps":[]}]}}`
srv := fakeSubgraph(t, body)
src := &AMMPriceSource{URL: srv.URL, Tokens: map[string][2]string{"LUX/LUSD": {lux, lusd}}}
mid, source, err := src.LastPrice(context.Background(), "LUX/LUSD")
if err != nil {
t.Fatalf("LastPrice: %v", err)
}
if source != "amm-price" {
t.Errorf("source = %q, want amm-price", source)
}
if math.Abs(mid-12.5) > 1e-9 {
t.Errorf("mid = %v, want 12.5", mid)
}
})
t.Run("no pool is a clean error", func(t *testing.T) {
srv := fakeSubgraph(t, `{"data":{"pairs":[]}}`)
src := &AMMPriceSource{URL: srv.URL, Tokens: map[string][2]string{"LUX/LUSD": {lux, lusd}}}
if _, _, err := src.LastPrice(context.Background(), "LUX/LUSD"); err == nil {
t.Fatal("expected an error when no pool resolves")
}
})
}
// fakePrices is a PriceSource returning chosen last prices per symbol.
type fakePrices struct{ mids map[string]float64 }
func (f fakePrices) LastPrice(_ context.Context, symbol string) (float64, string, error) {
if m, ok := f.mids[symbol]; ok {
return m, "fake", nil
}
return 0, "", fmt.Errorf("fakePrices: no price for %s", symbol)
}
// captureSink records every candle written, for assertion.
type captureSink struct {
mu sync.Mutex
candles []Candle
}
func (s *captureSink) WriteCandle(c Candle) error {
s.mu.Lock()
s.candles = append(s.candles, c)
s.mu.Unlock()
return nil
}
// TestRebootResolvesMidAndSeeds wires the orchestrator against the EXISTING
// fake-venue server (seeder_test.go, no signer) with the production CUSTODY config:
// a fake price source returns last prices DIFFERENT from the configured mids;
// ResolveMids must adopt them; Seed must FUND-then-place a two-sided book straddling
// the RESOLVED mid (every order funded => 12 placed, available drained to exactly 0);
// Backfill must paint the gap, anchored to the resolved mids, into the capture sink.
//
// Maintain-requote is proven separately in TestRebootMaintainRequotes: the shared
// fake server models lock-on-place but not refund-on-cancel, and a SELL's base lock
// is price-independent, so a custody requote cannot re-fund against it. The real
// venue refunds on cancel; the fixture is reused as-is, not forked.
func TestRebootResolvesMidAndSeeds(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial dex: %v", err)
}
defer dex.Close()
cfg := custodyConfig() // configured: LUX/LUSD=12.50, LUX/LETH=0.004 (custody)
chosen := map[string]float64{"LUX/LUSD": 20.0, "LUX/LETH": 0.01}
sink := &captureSink{}
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
// Reboot and Seeder are built from the SAME cfg, so a mid ResolveMids writes
// into Cfg.Pairs flows into the Seeder (shared Pairs backing array).
rb := &Reboot{
Seeder: seeder,
Prices: fakePrices{mids: chosen},
Sink: sink,
Cfg: cfg,
GapFrom: 1750186501,
Interval: 50 * time.Millisecond,
DriftBps: 50,
Logger: quietLogger(),
}
// (1) ResolveMids adopts the chosen last prices.
rb.ResolveMids(ctx)
for i := range rb.Cfg.Pairs {
sym := rb.Cfg.Pairs[i].Symbol
if rb.Cfg.Pairs[i].Mid != chosen[sym] {
t.Fatalf("%s mid = %v, want resolved %v", sym, rb.Cfg.Pairs[i].Mid, chosen[sym])
}
}
// (2) Seed funds-then-places a 2-sided book AT the resolved mid. placed==12
// means every order's lock was funded (the fake venue rejects an underfunded
// place); available drained to 0 means the maker deposited EXACTLY the locks.
placed, err := seeder.Seed(ctx)
if err != nil {
t.Fatalf("seed: %v", err)
}
if placed != 12 {
t.Fatalf("placed = %d, want 12 (every order funded)", placed)
}
bk := srv.book(SymbolPoolID("LUX/LUSD"))
bid, _ := bk.best(sideBuy)
ask, _ := bk.best(sideSell)
if got := roundTo8((bid + ask) / 2); got != roundTo8(chosen["LUX/LUSD"]) {
t.Fatalf("seeded mid = %v, want resolved %v", got, chosen["LUX/LUSD"])
}
for i := range rb.Cfg.Pairs {
p := rb.Cfg.Pairs[i]
if av := srv.availableOf("maker", p.Base); av != 0 {
t.Errorf("%s base available = %d after seed, want 0 (deposit==locks)", p.Symbol, av)
}
if av := srv.availableOf("maker", p.Quote); av != 0 {
t.Errorf("%s quote available = %d after seed, want 0 (deposit==locks)", p.Symbol, av)
}
}
// (3) Backfill paints the gap into the capture sink: 24h / 1h * 2 pairs = 48,
// each pair's history anchored to its RESOLVED mid (final close == anchor).
now := rb.GapFrom + 24*3600
n, err := rb.Backfill(now)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if n != 48 || len(sink.candles) != n {
t.Fatalf("backfill wrote n=%d sink=%d, want 48", n, len(sink.candles))
}
for _, c := range sink.candles {
if c.CloseTime == now && c.Close != chosen[c.Symbol] {
t.Errorf("final %s close = %v, want anchor %v", c.Symbol, c.Close, chosen[c.Symbol])
}
}
}
// TestRebootMaintainRequotes proves the orchestrator's full resolve -> seed ->
// backfill -> maintain flow with a NON-custody config (the requote mechanic the
// shared fake server supports across cycles): each Maintain cancels the prior
// quotes and re-places fresh ones around the resolved mid at constant depth.
func TestRebootMaintainRequotes(t *testing.T) {
ctx := context.Background()
addr, srv := startCLOB(t)
dex, err := DialDEX(ctx, addr, "maker")
if err != nil {
t.Fatalf("dial dex: %v", err)
}
defer dex.Close()
cfg := seedConfig() // non-custody LUX/LUSD, LUX/LETH
chosen := map[string]float64{"LUX/LUSD": 15.0, "LUX/LETH": 0.005}
sink := &captureSink{}
seeder := NewSeeder(cfg, dex, &MidSpreadStrategy{}, quietLogger())
rb := &Reboot{
Seeder: seeder,
Prices: fakePrices{mids: chosen},
Sink: sink,
Cfg: cfg,
GapFrom: 1750186501,
Interval: 50 * time.Millisecond,
DriftBps: 50,
Logger: quietLogger(),
}
rb.ResolveMids(ctx)
placed, err := seeder.Seed(ctx)
if err != nil {
t.Fatalf("seed: %v", err)
}
if placed != 12 {
t.Fatalf("placed = %d, want 12", placed)
}
bk := srv.book(SymbolPoolID("LUX/LUSD"))
bid, _ := bk.best(sideBuy)
ask, _ := bk.best(sideSell)
if got := roundTo8((bid + ask) / 2); got != roundTo8(chosen["LUX/LUSD"]) {
t.Fatalf("seeded mid = %v, want resolved %v", got, chosen["LUX/LUSD"])
}
// Backfill: 12h / 1h * 2 pairs = 24 candles.
if n, err := rb.Backfill(rb.GapFrom + 12*3600); err != nil {
t.Fatalf("backfill: %v", err)
} else if n != 24 {
t.Fatalf("backfill n = %d, want 24", n)
}
// Maintain requotes at constant depth across cycles (cancel + replace).
for cycle := range 3 {
if _, err := seeder.Maintain(ctx); err != nil {
t.Fatalf("maintain %d: %v", cycle, err)
}
if nb, na := srv.book(SymbolPoolID("LUX/LUSD")).counts(); nb != cfg.Levels || na != cfg.Levels {
t.Fatalf("after maintain %d: %d/%d depth, want %d/%d", cycle, nb, na, cfg.Levels, cfg.Levels)
}
}
}