mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
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]>
335 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|