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]>
251 lines
10 KiB
Go
251 lines
10 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// rebalance.go is POSITION MANAGEMENT — the "as price moves, update positions
|
|
// up/down" pillar. For each ops-owned concentrated-liquidity range position (V3
|
|
// NFTs the ops accounts hold; V4 range positions once live — treasury 0x9011
|
|
// positions stay READ-ONLY here), when the live pool price drifts to a range edge
|
|
// (or outside the range), the maker recenters the position on the current oracle
|
|
// price: decrease → collect → re-add centered. It is rate-limited (a minimum
|
|
// interval and a minimum oracle drift since the last recenter), idempotent (a
|
|
// position already centered holds), and DRY-RUN by default (it computes and logs
|
|
// the recenter plan; it only sends when REBALANCE_EXECUTE is set).
|
|
//
|
|
// DECOMPLECTED: decideRebalance is a PURE function of (pool price, range, oracle
|
|
// ref, last center, time since last, config) → recenter-or-hold. The tick↔price
|
|
// conversion (tickToPrice / priceToTick) is a separate pure numeric concern with a
|
|
// round-trip test. The position READ and the decrease/collect/mint EXECUTE are the
|
|
// only effectful edges, behind one interface.
|
|
|
|
package maker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
)
|
|
|
|
// RebalanceConfig parameterizes position recentering. A zero Interval disables the
|
|
// loop entirely (compatible superset — nothing runs unless configured).
|
|
type RebalanceConfig struct {
|
|
Interval time.Duration // recenter-check cadence (0 = disabled)
|
|
EdgeBps float64 // trigger when pool price is within EdgeBps of an edge (or outside)
|
|
WidthBps float64 // half-width of the re-centered range around the oracle
|
|
MinInterval time.Duration // minimum wall time between recenters of the same position
|
|
MinDriftBps float64 // require oracle to have drifted ≥ this from the last center
|
|
Execute bool // false (default) = dry-run: compute + log, never send
|
|
Decimals int // pair decimals for scaling
|
|
}
|
|
|
|
// RebalanceAction is the pure decision: recenter to [NewLow, NewHigh] (quote-per-
|
|
// base prices) or hold, with a human reason.
|
|
type RebalanceAction struct {
|
|
Rebalance bool
|
|
NewLow float64
|
|
NewHigh float64
|
|
Reason string
|
|
}
|
|
|
|
// decideRebalance is the PURE recenter decision, in price space. poolPrice is the
|
|
// live marginal price; rangeLow/rangeHigh bound the position; oracleRef is the fair
|
|
// price to recenter on; lastCenter is the oracle price at the previous recenter (0 if
|
|
// never); sinceLast is wall time since the previous recenter.
|
|
//
|
|
// It recenters iff ALL hold: enough wall time has passed (MinInterval), the oracle
|
|
// has drifted enough since the last center (MinDriftBps), and the pool price has
|
|
// reached a range edge (within EdgeBps) or left the range. The new range is
|
|
// oracle·(1 ± WidthBps/1e4). Otherwise it holds — a position comfortably inside its
|
|
// range, or one recentered too recently, or on too small a drift, is left alone
|
|
// (idempotent, rate-limited, no churn).
|
|
func decideRebalance(poolPrice, rangeLow, rangeHigh, oracleRef, lastCenter float64, sinceLast time.Duration, cfg RebalanceConfig) RebalanceAction {
|
|
if oracleRef <= 0 || poolPrice <= 0 || rangeLow <= 0 || rangeHigh <= rangeLow {
|
|
return RebalanceAction{Reason: "bad inputs (non-positive price or degenerate range)"}
|
|
}
|
|
if cfg.WidthBps <= 0 {
|
|
return RebalanceAction{Reason: "width is zero (recenter disabled)"}
|
|
}
|
|
if cfg.MinInterval > 0 && sinceLast < cfg.MinInterval {
|
|
return RebalanceAction{Reason: fmt.Sprintf("rate-limited (%.0fs < %.0fs)", sinceLast.Seconds(), cfg.MinInterval.Seconds())}
|
|
}
|
|
if lastCenter > 0 && cfg.MinDriftBps > 0 {
|
|
driftBps := math.Abs(oracleRef-lastCenter) / lastCenter * 1e4
|
|
if driftBps < cfg.MinDriftBps {
|
|
return RebalanceAction{Reason: fmt.Sprintf("insufficient drift (%.2f < %.2f bps)", driftBps, cfg.MinDriftBps)}
|
|
}
|
|
}
|
|
edge := cfg.EdgeBps / 1e4
|
|
nearLow := poolPrice <= rangeLow*(1+edge)
|
|
nearHigh := poolPrice >= rangeHigh*(1-edge)
|
|
outside := poolPrice < rangeLow || poolPrice > rangeHigh
|
|
if !nearLow && !nearHigh && !outside {
|
|
return RebalanceAction{Reason: fmt.Sprintf("inside range [%.6g, %.6g] at %.6g", rangeLow, rangeHigh, poolPrice)}
|
|
}
|
|
w := cfg.WidthBps / 1e4
|
|
return RebalanceAction{
|
|
Rebalance: true,
|
|
NewLow: oracleRef * (1 - w),
|
|
NewHigh: oracleRef * (1 + w),
|
|
Reason: fmt.Sprintf("price %.6g at edge of [%.6g, %.6g]; recenter on %.6g", poolPrice, rangeLow, rangeHigh, oracleRef),
|
|
}
|
|
}
|
|
|
|
// --- position abstraction (effectful edges) ----------------------------------
|
|
|
|
// RangePosition is one ops-owned concentrated-liquidity position, priced in
|
|
// quote-per-base terms (RangeLow < RangeHigh) with the live pool price.
|
|
type RangePosition struct {
|
|
ID string // opaque id (V3 tokenId hex / V4 position id) for rate-limit keying
|
|
RangeLow float64 // quote-per-base lower bound
|
|
RangeHigh float64 // quote-per-base upper bound
|
|
PoolPrice float64 // live marginal price (quote-per-base)
|
|
}
|
|
|
|
// PositionManager reads ops-owned range positions for a market and recenters one to
|
|
// a new [low, high]. The V3 NPM implementation is v3positions.go's; a V4 impl lands
|
|
// with the V4 PoolManager. Recenter honors dryRun (compute + return a plan, no send).
|
|
type PositionManager interface {
|
|
Name() string
|
|
Positions(ctx context.Context, m CoherenceMarket) ([]RangePosition, error)
|
|
Recenter(ctx context.Context, acct Account, m CoherenceMarket, pos RangePosition, newLow, newHigh float64, dryRun bool) (RebalanceResult, error)
|
|
}
|
|
|
|
// RebalanceResult is the outcome of one recenter (or its dry-run plan).
|
|
type RebalanceResult struct {
|
|
Gas uint64
|
|
DryRun bool
|
|
Txs []common.Hash // decrease, collect, mint (as sent)
|
|
}
|
|
|
|
// rebalanceState tracks the last recenter time + oracle center per position, so the
|
|
// pure decision's rate-limit + drift gates have real history to consult.
|
|
type rebalanceState struct {
|
|
mu sync.Mutex
|
|
lastAt map[string]time.Time
|
|
center map[string]float64
|
|
}
|
|
|
|
func newRebalanceState() *rebalanceState {
|
|
return &rebalanceState{lastAt: map[string]time.Time{}, center: map[string]float64{}}
|
|
}
|
|
|
|
func (r *rebalanceState) get(id string, now time.Time) (sinceLast time.Duration, center float64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if t, ok := r.lastAt[id]; ok {
|
|
sinceLast = now.Sub(t)
|
|
} else {
|
|
sinceLast = 1 << 62 // never recentered: no rate-limit
|
|
}
|
|
return sinceLast, r.center[id]
|
|
}
|
|
|
|
func (r *rebalanceState) mark(id string, center float64, now time.Time) {
|
|
r.mu.Lock()
|
|
r.lastAt[id] = now
|
|
r.center[id] = center
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// rebalanceMarket recenters every ops-owned range position on the market whose live
|
|
// price has reached a range edge, per the pure decision. Rate-limited + idempotent
|
|
// via rebalanceState; dry-run unless cfg.Execute. Treasury positions are NOT touched
|
|
// — the PositionManager is constructed to enumerate only the ops accounts' positions.
|
|
func (s *CoherenceService) rebalanceMarket(ctx context.Context, m CoherenceMarket) {
|
|
if s.positions == nil {
|
|
return
|
|
}
|
|
ref, err := s.oracle.GetMidPrice(ctx, m.Symbol)
|
|
if err != nil || ref <= 0 {
|
|
return
|
|
}
|
|
positions, err := s.positions.Positions(ctx, m)
|
|
if err != nil {
|
|
s.logger.Warn("rebalance: read positions failed", "venue", s.positions.Name(), "symbol", m.Symbol, "error", err)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
for _, pos := range positions {
|
|
sinceLast, lastCenter := s.rebal.get(pos.ID, now)
|
|
act := decideRebalance(pos.PoolPrice, pos.RangeLow, pos.RangeHigh, ref, lastCenter, sinceLast, s.rebalance)
|
|
if !act.Rebalance {
|
|
continue
|
|
}
|
|
dry := !s.rebalance.Execute
|
|
res, err := s.positions.Recenter(ctx, s.maker, m, pos, act.NewLow, act.NewHigh, dry)
|
|
if err != nil {
|
|
s.logger.Warn("rebalance: recenter failed", "id", pos.ID, "symbol", m.Symbol, "error", err)
|
|
continue
|
|
}
|
|
if !dry {
|
|
s.metrics.addGas(res.Gas)
|
|
s.rebal.mark(pos.ID, ref, now) // only a real recenter advances the rate-limit clock
|
|
}
|
|
s.metrics.mu.Lock()
|
|
s.metrics.Rebalances++
|
|
s.metrics.mu.Unlock()
|
|
s.logger.Info("position recenter",
|
|
"venue", s.positions.Name(), "symbol", m.Symbol, "id", pos.ID,
|
|
"old_range", fmt.Sprintf("[%.6g,%.6g]", pos.RangeLow, pos.RangeHigh),
|
|
"new_range", fmt.Sprintf("[%.6g,%.6g]", act.NewLow, act.NewHigh),
|
|
"pool", round2(pos.PoolPrice), "ref", round2(ref), "dry_run", dry, "reason", act.Reason)
|
|
}
|
|
}
|
|
|
|
// --- tick <-> price (pure numeric, round-trip tested) ------------------------
|
|
|
|
// tickBase is the V3/V4 tick base: price ratio per tick = 1.0001^tick.
|
|
const tickBase = 1.0001
|
|
|
|
// tickToPrice converts a V3/V4 tick to the human quote-per-base price for a pair
|
|
// with the given base/quote decimals and orientation (baseIsToken0). raw(tick) =
|
|
// 1.0001^tick is token1_wei/token0_wei; the human token1/token0 price is
|
|
// raw·10^dec0/10^dec1; quote-per-base is that when base==token0, else its inverse.
|
|
func tickToPrice(tick int32, baseDecimals, quoteDecimals int, baseIsToken0 bool) float64 {
|
|
raw := math.Pow(tickBase, float64(tick))
|
|
var dec0, dec1 int
|
|
if baseIsToken0 {
|
|
dec0, dec1 = baseDecimals, quoteDecimals
|
|
} else {
|
|
dec0, dec1 = quoteDecimals, baseDecimals
|
|
}
|
|
human := raw * math.Pow10(dec0) / math.Pow10(dec1) // token1 per token0
|
|
if human <= 0 {
|
|
return 0
|
|
}
|
|
if baseIsToken0 {
|
|
return human
|
|
}
|
|
return 1 / human
|
|
}
|
|
|
|
// priceToTick is the inverse: the tick whose tickToPrice is nearest to the target
|
|
// quote-per-base price, snapped DOWN to a multiple of tickSpacing (the V3/V4 range
|
|
// grid). It is used to translate a recenter's [low, high] prices into mintable tick
|
|
// bounds.
|
|
func priceToTick(price float64, baseDecimals, quoteDecimals int, baseIsToken0 bool, tickSpacing int32) int32 {
|
|
if price <= 0 {
|
|
return 0
|
|
}
|
|
// Recover token1/token0 human price, then the wei ratio raw, then the tick.
|
|
human := price
|
|
if !baseIsToken0 {
|
|
human = 1 / price
|
|
}
|
|
var dec0, dec1 int
|
|
if baseIsToken0 {
|
|
dec0, dec1 = baseDecimals, quoteDecimals
|
|
} else {
|
|
dec0, dec1 = quoteDecimals, baseDecimals
|
|
}
|
|
raw := human * math.Pow10(dec1) / math.Pow10(dec0) // token1_wei / token0_wei
|
|
tick := int32(math.Round(math.Log(raw) / math.Log(tickBase)))
|
|
if tickSpacing > 1 {
|
|
tick = (tick / tickSpacing) * tickSpacing
|
|
}
|
|
return tick
|
|
}
|