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