Files
maker/rebalance.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

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
}