Files
maker/reconcile_test.go
zeekayandHanzo Dev b52cb4efc5 coherence: cross-venue reconciler + position rebalance + V4 discovery
Venue reconciler pegs every live venue (0x9999 book, V3 slot0, V4 via
state.json) to the Kraken oracle; drift past band => bounded corrective
trade with notional caps + inventory floors. Rebalance re-centers ops
range positions on oracle as price moves (rate-limited, idempotent). V4
pools auto-discovered from the migration's state.json (absent => book+V3
only). Tests: reconcile/rebalance/v4state all pass.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-04 20:48:56 -07:00

129 lines
5.1 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package maker
import (
"math"
"testing"
"time"
)
// TestDecideReconcile drives the PURE cross-venue reconciliation decision through
// its full gate lattice: band, notional cap, inventory floors, daily loss bound,
// direction, and the slippage min-out floor. No network, no clock, no chain.
func TestDecideReconcile(t *testing.T) {
// Base caps: 25 bps band, 1000 quote/cycle, 50 bps slippage, ample reserves.
base := ReconcileCaps{
BandBps: 25, MaxNotionalQuote: 1000, SlippageBps: 50,
MinBaseReserve: 10, MinQuoteReserve: 10, DailyLossCapQuote: 0,
}
const ref = 100.0
cases := []struct {
name string
spot float64
caps ReconcileCaps
baseAvail float64
quoteAvail float64
dayLoss float64
wantSide ReconcileSide
}{
{"within-band-hold", 100.1, base, 1e6, 1e6, 0, Hold}, // 10 bps < 25
{"rich-sell", 101.0, base, 1e6, 1e6, 0, SellBase}, // +100 bps
{"cheap-buy", 99.0, base, 1e6, 1e6, 0, BuyBase}, // -100 bps
{"rich-but-base-floor", 101.0, base, 10, 1e6, 0, Hold}, // base==reserve
{"cheap-but-quote-floor", 99.0, base, 1e6, 10, 0, Hold}, // quote==reserve
{"zero-notional-disabled", 101.0, capsWith(base, func(c *ReconcileCaps) { c.MaxNotionalQuote = 0 }), 1e6, 1e6, 0, Hold},
{"daily-loss-halt", 101.0, capsWith(base, func(c *ReconcileCaps) { c.DailyLossCapQuote = 5 }), 1e6, 1e6, 5, Hold},
{"daily-loss-under-cap", 101.0, capsWith(base, func(c *ReconcileCaps) { c.DailyLossCapQuote = 5 }), 1e6, 1e6, 4, SellBase},
{"bad-spot-hold", 0, base, 1e6, 1e6, 0, Hold},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
act := decideReconcile(c.spot, ref, c.caps, c.baseAvail, c.quoteAvail, c.dayLoss)
if act.Side != c.wantSide {
t.Fatalf("side = %v, want %v (reason: %s)", act.Side, c.wantSide, act.Reason)
}
})
}
}
// TestDecideReconcileSizingAndFloor asserts the exact sizes + min-out floors so the
// notional cap and slippage protection are correct, not just directionally right.
func TestDecideReconcileSizingAndFloor(t *testing.T) {
caps := ReconcileCaps{BandBps: 10, MaxNotionalQuote: 500, SlippageBps: 50, MinBaseReserve: 0, MinQuoteReserve: 0}
const ref = 100.0
// RICH (spot 110): sell base. baseSize = notional/spot = 500/110; minOut(quote)
// = baseSize·ref·(1-0.005).
sell := decideReconcile(110, ref, caps, 1e6, 1e6, 0)
if sell.Side != SellBase {
t.Fatalf("want SellBase, got %v", sell.Side)
}
wantBase := 500.0 / 110.0
if !closeF(sell.BaseSize, wantBase, 1e-9) {
t.Errorf("baseSize = %v, want %v", sell.BaseSize, wantBase)
}
if wantMinOut := wantBase * ref * (1 - 0.005); !closeF(sell.MinOut, wantMinOut, 1e-9) {
t.Errorf("sell minOut = %v, want %v", sell.MinOut, wantMinOut)
}
// CHEAP (spot 90): buy base with the full notional; minOut(base) = notional/ref·(1-slip).
buy := decideReconcile(90, ref, caps, 1e6, 1e6, 0)
if buy.Side != BuyBase {
t.Fatalf("want BuyBase, got %v", buy.Side)
}
if !closeF(buy.QuoteSize, 500, 1e-9) {
t.Errorf("quoteSize = %v, want 500", buy.QuoteSize)
}
if wantMinOut := 500.0 / ref * (1 - 0.005); !closeF(buy.MinOut, wantMinOut, 1e-9) {
t.Errorf("buy minOut = %v, want %v", buy.MinOut, wantMinOut)
}
}
// TestDecideReconcileClampsToReserve proves a correction never spends below the
// inventory floor: the size is clamped to (avail - reserve), not the notional cap.
func TestDecideReconcileClampsToReserve(t *testing.T) {
caps := ReconcileCaps{BandBps: 10, MaxNotionalQuote: 1e9, SlippageBps: 0, MinBaseReserve: 100, MinQuoteReserve: 100}
// RICH with only 150 base and a 100 floor: at most 50 base may be sold.
sell := decideReconcile(200, 100, caps, 150, 1e6, 0)
if sell.Side != SellBase || !closeF(sell.BaseSize, 50, 1e-9) {
t.Fatalf("want SellBase 50 clamped to reserve, got %v size %v", sell.Side, sell.BaseSize)
}
// CHEAP with only 130 quote and a 100 floor: at most 30 quote may be spent.
buy := decideReconcile(50, 100, caps, 1e6, 130, 0)
if buy.Side != BuyBase || !closeF(buy.QuoteSize, 30, 1e-9) {
t.Fatalf("want BuyBase 30 clamped to reserve, got %v size %v", buy.Side, buy.QuoteSize)
}
}
// TestVenueLossRollover proves the daily-loss ledger accumulates within a day and
// resets at UTC midnight (per venue+symbol).
func TestVenueLossRollover(t *testing.T) {
vl := newVenueLoss()
d1 := time.Date(2026, 7, 4, 23, 0, 0, 0, time.UTC)
if got := vl.add("v3", "LBTC/LUX", 3, d1); got != 3 {
t.Fatalf("first add = %v, want 3", got)
}
if got := vl.add("v3", "LBTC/LUX", 2, d1); got != 5 {
t.Fatalf("second add = %v, want 5", got)
}
// Different symbol is a separate bucket.
if got := vl.get("v3", "LSOL/LUX", d1); got != 0 {
t.Fatalf("other symbol = %v, want 0", got)
}
// Next UTC day resets.
d2 := time.Date(2026, 7, 5, 0, 1, 0, 0, time.UTC)
if got := vl.get("v3", "LBTC/LUX", d2); got != 0 {
t.Fatalf("after rollover = %v, want 0", got)
}
}
func capsWith(c ReconcileCaps, f func(*ReconcileCaps)) ReconcileCaps {
f(&c)
return c
}
func closeF(a, b, tol float64) bool { return math.Abs(a-b) <= tol*(1+math.Abs(b)) }