mirror of
https://github.com/luxfi/maker.git
synced 2026-10-30 16:20:04 +00:00
The scale prices cross the wire on was declared five times — priceGridMul, gridMul, priceMultiplier, refPriceMultiplier, and again per command — and read back three ways: priceToGrid, gridToFloat, gridToPrice. Same number every time. dex.go said so out loud: "The maker re-declares it so it can size a deposit." The place a value is used had been braided into the value itself. It is one value, so it gets one name: PriceGridMul, exported because the commands need the same scale and were each declaring their own. Conversions become one inverse pair, GridOf and PriceOf, with PriceOfBig for a grid read off the wire as a word — no open-coded /1e8 left anywhere. Co-authored-by: Hanzo Dev <[email protected]>
634 lines
23 KiB
Go
634 lines
23 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package maker
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"math/big"
|
||
"sort"
|
||
"sync"
|
||
"time"
|
||
|
||
dex "github.com/luxfi/precompile/dex"
|
||
|
||
"github.com/luxfi/geth/common"
|
||
)
|
||
|
||
// coherence.go is the CONTINUOUS price-coherence service on the C-Chain 0x9999
|
||
// PoolManager: one maker pegs a 2-sided book to a live external reference (Kraken,
|
||
// via the existing MidProvider/provider_kraken.go) for each configured market, an
|
||
// arb loop corrects any drift past the no-arb band, and a probe taker exercises
|
||
// the book so the realized execution price is MEASURED, not assumed. It is the
|
||
// continuous, persistent-market production form of the trader-pkg coherence study:
|
||
// the study's RunCoherence proved the per-round economics on a fresh hermetic
|
||
// market; this runs forever on stable markets as a k8s Deployment, refreshing
|
||
// every requote interval and emitting Prometheus metrics + JSONL samples.
|
||
//
|
||
// Because V2/V3/V4 are three liquidity modes of the ONE 0x9999 pool (one pool id,
|
||
// one fee tier, one maker peg), keeping them coherent is exactly this — one maker
|
||
// pegging one price — not reconciling three separate AMMs.
|
||
|
||
// Oracle is a live reference-price source (a maker symbol -> mid). The existing
|
||
// MidProvider (an Aggregator over provider_kraken.go) satisfies it, so the live
|
||
// Kraken feed IS the oracle with no new code; FixedMid is the deterministic test
|
||
// reference.
|
||
type Oracle interface {
|
||
GetMidPrice(ctx context.Context, symbol string) (float64, error)
|
||
}
|
||
|
||
// FixedMid is a constant reference mid for deterministic tests / drift-only runs.
|
||
type FixedMid float64
|
||
|
||
// GetMidPrice returns the fixed value for any symbol.
|
||
func (f FixedMid) GetMidPrice(context.Context, string) (float64, error) { return float64(f), nil }
|
||
|
||
// CoherenceMarket is one pegged market on 0x9999: a (base, quote) ERC-20 pair whose
|
||
// price tracks the Kraken mid for Symbol.
|
||
type CoherenceMarket struct {
|
||
Symbol string // reference symbol the oracle prices, e.g. "ETH/USD"
|
||
KrakenPair string // Kraken query pair, e.g. "ETHUSD" / "XBTUSD"
|
||
Base common.Address // the priced asset (e.g. ETH) ERC-20
|
||
Quote common.Address // the numeraire (e.g. USD) ERC-20
|
||
Size uint64 // resting size per side, base units (size ≫ 1 so grid quantization is sub-bps)
|
||
|
||
// resolved at setup:
|
||
key dex.PoolKey
|
||
poolID common.Hash
|
||
sellBaseZeroFor bool // selling base into a bid is zeroForOne iff base == currency0
|
||
}
|
||
|
||
// CoherenceConfig parameterizes the service.
|
||
type CoherenceConfig struct {
|
||
Markets []CoherenceMarket
|
||
SpreadBps float64 // half-spread each side: bid=mid*(1-s/1e4), ask=mid*(1+s/1e4)
|
||
RequoteInterval time.Duration // peg cadence (study optimum ~60–120s: burn-minimized, arb holds the band)
|
||
ArbInterval time.Duration // drift-check cadence between requotes (0 = RequoteInterval/4)
|
||
ArbBandBps float64 // arb fires when |bookMid-ref|/ref·1e4 exceeds this (0 = SpreadBps)
|
||
ProbeInterval time.Duration // taker-probe cadence (0 = disabled); generates fills + realized tracking error
|
||
ProbeSize int64 // taker bite per probe, base units
|
||
DepositBuffer uint64 // per-asset vault top-up at setup (units); must exceed peak lock
|
||
// NoBook runs the service WITHOUT the 0x9999 CLOB: no vault approve/deposit, no
|
||
// market initialize, no resting quotes, no arb, no probe — only the AMM-venue
|
||
// reconciler. It makes the book exactly as optional as the reconciler already
|
||
// was, so one service covers "peg the book", "keep AMM pools coherent", or both,
|
||
// selected purely by configuration. The zero value is false, so an existing
|
||
// deployment keeps the book and is bit-for-bit unaffected.
|
||
NoBook bool
|
||
}
|
||
|
||
// CoherenceMetrics holds the live, measured health of the service (Prometheus +
|
||
// JSONL). Every field is derived from accepted on-chain state — no vendor numbers.
|
||
type CoherenceMetrics struct {
|
||
mu sync.Mutex
|
||
Requotes uint64
|
||
Arbs uint64
|
||
Probes uint64
|
||
Fills uint64
|
||
Errors uint64
|
||
Places uint64 // limit orders rested on the book venue
|
||
Cancels uint64 // resting orders pulled from the book venue
|
||
Reconciles uint64 // cross-venue corrective trades executed
|
||
Rebalances uint64 // position recenters (incl dry-run plans)
|
||
TotalGas uint64 // cumulative gas across all our txs (burn = gas·baseFee)
|
||
lastPegBps map[string]float64 // symbol -> last book-mid tracking error vs Kraken (bps)
|
||
lastRealBps map[string]float64 // symbol -> last realized fill tracking error vs Kraken (bps)
|
||
pegSamples map[string][]float64 // symbol -> peg-error samples (for p50/p99)
|
||
venueSpot map[string]float64 // "venue|symbol" -> last read spot
|
||
venueGapBps map[string]float64 // "venue|symbol" -> last spot-vs-oracle gap (bps)
|
||
startedAt time.Time
|
||
}
|
||
|
||
func newCoherenceMetrics() *CoherenceMetrics {
|
||
return &CoherenceMetrics{
|
||
lastPegBps: map[string]float64{},
|
||
lastRealBps: map[string]float64{},
|
||
pegSamples: map[string][]float64{},
|
||
venueSpot: map[string]float64{},
|
||
venueGapBps: map[string]float64{},
|
||
startedAt: time.Now(),
|
||
}
|
||
}
|
||
|
||
// recordVenue stores the last observed spot + oracle gap for a (venue, symbol), the
|
||
// cross-venue coherence view the /metrics endpoint exposes.
|
||
func (m *CoherenceMetrics) recordVenue(venue, symbol string, spot, gapBps float64) {
|
||
k := venue + "|" + symbol
|
||
m.mu.Lock()
|
||
m.venueSpot[k] = spot
|
||
m.venueGapBps[k] = gapBps
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
// Book ops are matcher RPCs, not C-Chain txs — counted separately from TotalGas.
|
||
func (m *CoherenceMetrics) places(n uint64) { m.mu.Lock(); m.Places += n; m.mu.Unlock() }
|
||
func (m *CoherenceMetrics) cancelled() { m.mu.Lock(); m.Cancels++; m.mu.Unlock() }
|
||
func (m *CoherenceMetrics) cancelledN(n uint64) { m.mu.Lock(); m.Cancels += n; m.mu.Unlock() }
|
||
|
||
func (m *CoherenceMetrics) addGas(g uint64) { m.mu.Lock(); m.TotalGas += g; m.mu.Unlock() }
|
||
|
||
func (m *CoherenceMetrics) recordPeg(symbol string, bps float64) {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
m.lastPegBps[symbol] = bps
|
||
s := m.pegSamples[symbol]
|
||
if len(s) >= 4096 { // bounded ring: drop the oldest
|
||
s = s[1:]
|
||
}
|
||
m.pegSamples[symbol] = append(s, math.Abs(bps))
|
||
}
|
||
|
||
func (m *CoherenceMetrics) recordReal(symbol string, bps float64) {
|
||
m.mu.Lock()
|
||
m.lastRealBps[symbol] = bps
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
// pct returns the p-quantile (0..1) of |peg error| for a symbol over the run.
|
||
func (m *CoherenceMetrics) pct(symbol string, p float64) float64 {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
s := append([]float64(nil), m.pegSamples[symbol]...)
|
||
if len(s) == 0 {
|
||
return 0
|
||
}
|
||
sort.Float64s(s)
|
||
i := int(p * float64(len(s)-1))
|
||
return s[i]
|
||
}
|
||
|
||
// CoherenceService is the running pegging+arb service.
|
||
type CoherenceService struct {
|
||
cfg CoherenceConfig
|
||
// p9 is the C-Chain 0x9999 venue: AMM pools, approvals, settlement. It no
|
||
// longer matches orders — swap() routes only to SettleSwap — so the resting
|
||
// book lives on `book` below. Two venues, not two paths to one.
|
||
p9 *Pool9999
|
||
book market
|
||
oracle Oracle
|
||
maker Account
|
||
taker Account // probe taker / arber — MUST be distinct from maker (self-trade prevention)
|
||
logger *slog.Logger
|
||
metrics *CoherenceMetrics
|
||
|
||
// Cross-venue reconciler (optional; nil venues => off). Keeps the AMM venues
|
||
// (V3, V4) coherent with the oracle, alongside arbMarket keeping the book coherent.
|
||
venues []Venue
|
||
reconcileCfg ReconcileConfig
|
||
loss *venueLoss
|
||
|
||
// Position manager (optional; nil => off). Recenters ops-owned range positions
|
||
// as the price moves ("up/down update"). Dry-run unless RebalanceConfig.Execute.
|
||
positions PositionManager
|
||
rebalance RebalanceConfig
|
||
rebal *rebalanceState
|
||
|
||
mu sync.Mutex
|
||
live map[common.Hash][]uint64 // poolID -> my resting order ids
|
||
probeBuy bool // alternates probe direction (inventory-neutral)
|
||
}
|
||
|
||
// NewCoherenceService builds the service. maker rests the book; taker probes/arbs
|
||
// (a distinct account — the matcher rejects a maker lifting its own orders).
|
||
func NewCoherenceService(cfg CoherenceConfig, p9 *Pool9999, book market, oracle Oracle, maker, taker Account, logger *slog.Logger) (*CoherenceService, error) {
|
||
if len(cfg.Markets) == 0 {
|
||
return nil, fmt.Errorf("coherence: no markets configured")
|
||
}
|
||
if book == nil {
|
||
return nil, fmt.Errorf("coherence: no book venue — resting quotes is this service's job and the C-Chain precompile no longer matches orders")
|
||
}
|
||
if maker.Addr == taker.Addr {
|
||
return nil, fmt.Errorf("coherence: maker and taker must be distinct accounts (self-trade prevention)")
|
||
}
|
||
if cfg.RequoteInterval <= 0 {
|
||
cfg.RequoteInterval = 90 * time.Second
|
||
}
|
||
if cfg.ArbInterval <= 0 {
|
||
cfg.ArbInterval = cfg.RequoteInterval / 4
|
||
}
|
||
if cfg.ArbBandBps <= 0 {
|
||
cfg.ArbBandBps = cfg.SpreadBps
|
||
}
|
||
if cfg.DepositBuffer == 0 {
|
||
cfg.DepositBuffer = 1_000_000_000_000_000 // 1e15 units (huge vs any lock; tiny in 18-dec token terms)
|
||
}
|
||
// Resolve each market's canonical pool key + orientation.
|
||
for i := range cfg.Markets {
|
||
m := &cfg.Markets[i]
|
||
if m.Size == 0 {
|
||
m.Size = 100_000 // default size: grid quantization < 0.01 bps at any plausible price
|
||
}
|
||
m.key = PoolKeyFor(m.Base, m.Quote)
|
||
m.poolID = common.Hash(m.key.ID())
|
||
m.sellBaseZeroFor = m.Base == m.key.Currency0.Address
|
||
}
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &CoherenceService{
|
||
cfg: cfg, p9: p9, oracle: oracle, maker: maker, taker: taker,
|
||
logger: logger, metrics: newCoherenceMetrics(),
|
||
live: map[common.Hash][]uint64{},
|
||
loss: newVenueLoss(),
|
||
rebal: newRebalanceState(),
|
||
}, nil
|
||
}
|
||
|
||
// SetReconciler enables the cross-venue reconciler with the given venues (book
|
||
// read-only for metrics; V3/V4 as Correctors) and caps. Called after construction
|
||
// so the base service (peg + arb only) stays a strict subset — an unconfigured
|
||
// reconciler simply never runs. A nil/empty venues slice is a no-op.
|
||
func (s *CoherenceService) SetReconciler(venues []Venue, cfg ReconcileConfig) {
|
||
live := venues[:0:0]
|
||
for _, v := range venues {
|
||
if v != nil {
|
||
live = append(live, v)
|
||
}
|
||
}
|
||
s.venues = live
|
||
s.reconcileCfg = cfg
|
||
}
|
||
|
||
// SetPositions enables position recentering with the given manager + config. A nil
|
||
// manager is a no-op (the loop never runs).
|
||
func (s *CoherenceService) SetPositions(pm PositionManager, cfg RebalanceConfig) {
|
||
s.positions = pm
|
||
s.rebalance = cfg
|
||
}
|
||
|
||
// Metrics exposes the live metrics (for the HTTP /metrics endpoint).
|
||
func (s *CoherenceService) Metrics() *CoherenceMetrics { return s.metrics }
|
||
|
||
// Setup makes the service ready to peg: approve the PoolManager to pull both
|
||
// assets for maker and taker, top up the maker's vault once with a generous buffer
|
||
// (cancels refund locks, so per-cycle deposits are unnecessary), bind+price each
|
||
// market, and reconcile any orders a prior pod left resting. Idempotent.
|
||
func (s *CoherenceService) Setup(ctx context.Context) error {
|
||
// AMM-venue-only operation touches nothing on 0x9999, so it must not grant the
|
||
// PoolManager an unlimited allowance or park a deposit in its vault. Skipping the
|
||
// whole block is what keeps "no standing approvals we did not need" true.
|
||
if s.cfg.NoBook {
|
||
s.logger.Info("book disabled (NoBook): skipping 0x9999 approve/deposit/initialize")
|
||
return nil
|
||
}
|
||
maxApprove := new(big.Int).Lsh(big.NewInt(1), 200) // effectively unlimited
|
||
// Distinct assets across all markets (a shared quote is approved/deposited once).
|
||
assets := map[common.Address]bool{}
|
||
for _, m := range s.cfg.Markets {
|
||
assets[m.Base] = true
|
||
assets[m.Quote] = true
|
||
}
|
||
for a := range assets {
|
||
if _, err := s.p9.ERC20Approve(s.maker, a, PoolManager, maxApprove); err != nil {
|
||
return fmt.Errorf("maker approve %s: %w", a.Hex(), err)
|
||
}
|
||
if _, err := s.p9.ERC20Approve(s.taker, a, PoolManager, maxApprove); err != nil {
|
||
return fmt.Errorf("taker approve %s: %w", a.Hex(), err)
|
||
}
|
||
// Top up once; cancels refund locks to available. Matcher custody, so
|
||
// there is no receipt and no gas to account.
|
||
if err := s.book.Deposit(ctx, AssetAddress(a.Hex()), s.cfg.DepositBuffer); err != nil {
|
||
return fmt.Errorf("maker deposit %s: %w", a.Hex(), err)
|
||
}
|
||
}
|
||
// Bind + price each market. Place self-binds permissionlessly, so a failed
|
||
// Initialize (already open) is non-fatal — log and continue.
|
||
for _, m := range s.cfg.Markets {
|
||
if r, err := s.p9.Initialize(s.maker, m.key, new(big.Int).Lsh(big.NewInt(1), 96)); err != nil {
|
||
s.logger.Info("initialize skipped (market already open or self-binds on place)", "symbol", m.Symbol, "reason", err)
|
||
} else {
|
||
s.metrics.addGas(r.GasUsed)
|
||
s.logger.Info("market initialized", "symbol", m.Symbol, "pool", m.poolID.Hex())
|
||
}
|
||
}
|
||
// Reconcile: cancel any orders this maker left resting from a prior run, so we
|
||
// reseed a clean book (stale far-from-market quotes get picked off otherwise).
|
||
if err := s.reconcile(ctx); err != nil {
|
||
s.logger.Warn("startup reconcile incomplete", "error", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// reconcile pulls every order left resting by a prior pod, one call per symbol.
|
||
// Refunds all locks to available balance.
|
||
func (s *CoherenceService) reconcile(ctx context.Context) error {
|
||
rc, ok := s.book.(restingCanceller)
|
||
if !ok {
|
||
s.logger.Info("book venue cannot enumerate resting orders; skipping startup reconcile")
|
||
return nil
|
||
}
|
||
total := 0
|
||
for _, m := range s.cfg.Markets {
|
||
n, err := rc.CancelRestingForSymbol(ctx, m.Symbol)
|
||
if err != nil {
|
||
s.logger.Warn("reconcile cancel failed", "symbol", m.Symbol, "error", err)
|
||
continue
|
||
}
|
||
total += n
|
||
s.metrics.cancelledN(uint64(n))
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
}
|
||
if total > 0 {
|
||
s.logger.Info("reconciled stale resting orders", "count", total)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// requoteMarket cancels the market's prior quotes and re-pegs a 2-sided book at
|
||
// the live Kraken mid. It records the book-mid tracking error vs the reference (the
|
||
// "right price" metric). A missing/zero reference skips the cycle (never peg to a
|
||
// bad price; keep the prior book).
|
||
func (s *CoherenceService) requoteMarket(ctx context.Context, m CoherenceMarket) error {
|
||
ref, err := s.oracle.GetMidPrice(ctx, m.Symbol)
|
||
if err != nil || ref <= 0 {
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Errors++
|
||
s.metrics.mu.Unlock()
|
||
return fmt.Errorf("ref %s: %v", m.Symbol, err)
|
||
}
|
||
|
||
// Cancel prior quotes (refunds locks to the vault).
|
||
s.mu.Lock()
|
||
prior := append([]uint64(nil), s.live[m.poolID]...)
|
||
s.mu.Unlock()
|
||
for _, id := range prior {
|
||
if err := s.book.Cancel(ctx, id); err != nil {
|
||
s.logger.Warn("cancel failed", "symbol", m.Symbol, "id", id, "error", err)
|
||
} else {
|
||
s.metrics.cancelled()
|
||
}
|
||
}
|
||
|
||
// Peg: bid below, ask above, symmetric around the reference.
|
||
half := s.cfg.SpreadBps / 1e4
|
||
bidGrid := GridOf(ref * (1 - half))
|
||
askGrid := GridOf(ref * (1 + half))
|
||
if bidGrid == 0 || askGrid == 0 {
|
||
return fmt.Errorf("bad grid for ref %v", ref)
|
||
}
|
||
|
||
// PlaceLimit returns the id, so there is nothing to infer by diffing.
|
||
bidID, err := s.book.PlaceLimit(ctx, m.Symbol, "buy", PriceOf(bidGrid), float64(m.Size))
|
||
if err != nil {
|
||
return fmt.Errorf("place bid: %w", err)
|
||
}
|
||
askID, err := s.book.PlaceLimit(ctx, m.Symbol, "sell", PriceOf(askGrid), float64(m.Size))
|
||
if err != nil {
|
||
return fmt.Errorf("place ask: %w", err)
|
||
}
|
||
s.metrics.places(2)
|
||
s.mu.Lock()
|
||
s.live[m.poolID] = []uint64{bidID, askID}
|
||
s.mu.Unlock()
|
||
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Requotes++
|
||
s.metrics.mu.Unlock()
|
||
|
||
// Measure the book mid vs the reference — the "right price" headline.
|
||
pegBps := s.measurePeg(m, ref)
|
||
s.logger.Info("requote",
|
||
"symbol", m.Symbol, "ref", round2(ref),
|
||
"bid", round2(ref*(1-half)), "ask", round2(ref*(1+half)),
|
||
"peg_err_bps", round2(pegBps), "bid_id", bidID, "ask_id", askID)
|
||
return nil
|
||
}
|
||
|
||
// measurePeg reads the book best bid/ask and records the book-mid tracking error
|
||
// vs the reference (bps). Returns the signed bps.
|
||
func (s *CoherenceService) measurePeg(m CoherenceMarket, ref float64) float64 {
|
||
bid, ask, fromBook, err := s.p9.BestBidAsk(m.poolID)
|
||
if err != nil || !fromBook || bid <= 0 || ask <= 0 {
|
||
return 0
|
||
}
|
||
bookMid := (bid + ask) / 2
|
||
bps := (bookMid - ref) / ref * 1e4
|
||
s.metrics.recordPeg(m.Symbol, bps)
|
||
return bps
|
||
}
|
||
|
||
// arbMarket corrects drift: if the book mid has wandered past the no-arb band from
|
||
// the reference, the taker trades the rich/cheap side to pull it back (and harvest
|
||
// the gap). On our own books this is mostly defensive — closing the gap before an
|
||
// external arber picks off a stale quote.
|
||
func (s *CoherenceService) arbMarket(ctx context.Context, m CoherenceMarket) {
|
||
ref, err := s.oracle.GetMidPrice(ctx, m.Symbol)
|
||
if err != nil || ref <= 0 {
|
||
return
|
||
}
|
||
bid, ask, fromBook, err := s.p9.BestBidAsk(m.poolID)
|
||
if err != nil || !fromBook || bid <= 0 || ask <= 0 {
|
||
return
|
||
}
|
||
band := s.cfg.ArbBandBps / 1e4
|
||
size := m.Size / 10
|
||
if size == 0 {
|
||
size = 1
|
||
}
|
||
switch {
|
||
case bid > ref*(1+band):
|
||
// Book bid is RICH (above ref+band): sell base into it (zeroForOne == sellBase).
|
||
if r, err := s.p9.Swap(s.taker, m.key, m.sellBaseZeroFor, int64(size), 1); err != nil {
|
||
s.logger.Warn("arb sell failed", "symbol", m.Symbol, "error", err)
|
||
} else {
|
||
s.metrics.addGas(r.GasUsed)
|
||
s.bumpArb()
|
||
s.logger.Info("arb sell (book rich)", "symbol", m.Symbol, "bid", round2(bid), "ref", round2(ref))
|
||
}
|
||
case ask < ref*(1-band):
|
||
// Book ask is CHEAP (below ref-band): buy base from it (opposite direction).
|
||
if r, err := s.p9.Swap(s.taker, m.key, !m.sellBaseZeroFor, int64(size), 1); err != nil {
|
||
s.logger.Warn("arb buy failed", "symbol", m.Symbol, "error", err)
|
||
} else {
|
||
s.metrics.addGas(r.GasUsed)
|
||
s.bumpArb()
|
||
s.logger.Info("arb buy (book cheap)", "symbol", m.Symbol, "ask", round2(ask), "ref", round2(ref))
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *CoherenceService) bumpArb() { s.metrics.mu.Lock(); s.metrics.Arbs++; s.metrics.mu.Unlock() }
|
||
|
||
// probeMarket has the taker trade ProbeSize base against the resting book and reads
|
||
// the settled DEXFill to MEASURE the realized execution price vs the reference.
|
||
// Direction alternates (sell into the bid, then buy from the ask) so probing is
|
||
// inventory-neutral and exercises both sides. This is the study's measured round,
|
||
// run continuously — the source of the realized fill-rate + tracking-error metric.
|
||
func (s *CoherenceService) probeMarket(ctx context.Context, m CoherenceMarket) {
|
||
ref, err := s.oracle.GetMidPrice(ctx, m.Symbol)
|
||
if err != nil || ref <= 0 {
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
buy := s.probeBuy
|
||
s.probeBuy = !s.probeBuy
|
||
s.mu.Unlock()
|
||
|
||
zeroForOne := m.sellBaseZeroFor
|
||
if buy {
|
||
zeroForOne = !m.sellBaseZeroFor
|
||
}
|
||
before, _ := s.p9.Block()
|
||
r, err := s.p9.Swap(s.taker, m.key, zeroForOne, s.cfg.ProbeSize, 1)
|
||
if err != nil {
|
||
s.logger.Warn("probe swap failed", "symbol", m.Symbol, "buy", buy, "error", err)
|
||
return
|
||
}
|
||
s.metrics.addGas(r.GasUsed)
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Probes++
|
||
s.metrics.mu.Unlock()
|
||
|
||
// Read the DEXFill this swap settled and derive the realized price.
|
||
fills, err := s.p9.Fills(before)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for i := range fills {
|
||
if fills[i].Taker == s.taker.Addr && fills[i].PoolID == m.poolID && fills[i].Block >= r.BlockNumber.Uint64() {
|
||
out := fills[i].AmountOut.Uint64()
|
||
// realized price: a SELL of S base yields ~out quote -> price = out/S;
|
||
// a BUY of S base spends S input for ~out base -> price ≈ probeSize/out.
|
||
var realized float64
|
||
if buy {
|
||
if out > 0 {
|
||
realized = float64(s.cfg.ProbeSize) / float64(out)
|
||
}
|
||
} else if s.cfg.ProbeSize > 0 {
|
||
realized = float64(out) / float64(s.cfg.ProbeSize)
|
||
}
|
||
if realized > 0 {
|
||
bps := (realized - ref) / ref * 1e4
|
||
s.metrics.recordReal(m.Symbol, bps)
|
||
s.metrics.mu.Lock()
|
||
s.metrics.Fills++
|
||
s.metrics.mu.Unlock()
|
||
s.logger.Info("probe fill",
|
||
"symbol", m.Symbol, "side", sideStr(buy), "ref", round2(ref),
|
||
"realized", round2(realized), "realized_err_bps", round2(bps), "block", fills[i].Block)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Run drives the service: setup, then peg on RequoteInterval, arb-check on
|
||
// ArbInterval, and probe on ProbeInterval, until ctx is cancelled.
|
||
func (s *CoherenceService) Run(ctx context.Context) error {
|
||
if err := s.Setup(ctx); err != nil {
|
||
return fmt.Errorf("setup: %w", err)
|
||
}
|
||
// Initial peg so the book is live immediately (book-enabled runs only).
|
||
if !s.cfg.NoBook {
|
||
for _, m := range s.cfg.Markets {
|
||
if err := s.requoteMarket(ctx, m); err != nil {
|
||
s.logger.Warn("initial requote failed", "symbol", m.Symbol, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The three book loops are tickers gated on the book being enabled, exactly as
|
||
// the reconciler and recenter loops are gated on theirs: a stopped ticker never
|
||
// fires, so a disabled concern costs nothing and needs no nil checks below.
|
||
book := !s.cfg.NoBook
|
||
requote := optionalTicker(book, s.cfg.RequoteInterval)
|
||
defer requote.Stop()
|
||
arb := optionalTicker(book, s.cfg.ArbInterval)
|
||
defer arb.Stop()
|
||
probe := optionalTicker(book && s.cfg.ProbeInterval > 0 && s.cfg.ProbeSize > 0, s.cfg.ProbeInterval)
|
||
defer probe.Stop()
|
||
// Cross-venue reconcile (V3/V4 coherence) + position recenter — both optional;
|
||
// a stopped ticker never fires, so an unconfigured superset costs nothing.
|
||
reconcileT := optionalTicker(len(s.venues) > 0 && s.reconcileCfg.Interval > 0, s.reconcileCfg.Interval)
|
||
defer reconcileT.Stop()
|
||
rebalanceT := optionalTicker(s.positions != nil && s.rebalance.Interval > 0, s.rebalance.Interval)
|
||
defer rebalanceT.Stop()
|
||
if len(s.venues) > 0 && s.reconcileCfg.Interval > 0 {
|
||
names := make([]string, len(s.venues))
|
||
for i, v := range s.venues {
|
||
names[i] = v.Name()
|
||
}
|
||
s.logger.Info("cross-venue reconciler enabled", "venues", names, "interval", s.reconcileCfg.Interval.String(),
|
||
"band_bps", s.reconcileCfg.Caps.BandBps, "max_notional", s.reconcileCfg.Caps.MaxNotionalQuote)
|
||
}
|
||
if s.positions != nil && s.rebalance.Interval > 0 {
|
||
s.logger.Info("position recenter enabled", "manager", s.positions.Name(),
|
||
"interval", s.rebalance.Interval.String(), "edge_bps", s.rebalance.EdgeBps,
|
||
"execute", s.rebalance.Execute)
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-requote.C:
|
||
for _, m := range s.cfg.Markets {
|
||
if err := s.requoteMarket(ctx, m); err != nil {
|
||
s.logger.Warn("requote failed", "symbol", m.Symbol, "error", err)
|
||
}
|
||
}
|
||
case <-arb.C:
|
||
for _, m := range s.cfg.Markets {
|
||
s.arbMarket(ctx, m)
|
||
}
|
||
case <-probe.C:
|
||
for _, m := range s.cfg.Markets {
|
||
s.probeMarket(ctx, m)
|
||
}
|
||
case <-reconcileT.C:
|
||
for _, m := range s.cfg.Markets {
|
||
s.reconcileMarket(ctx, m)
|
||
}
|
||
case <-rebalanceT.C:
|
||
for _, m := range s.cfg.Markets {
|
||
s.rebalanceMarket(ctx, m)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// optionalTicker returns a running ticker when on, else a stopped one (its channel
|
||
// never fires), so a select arm for a disabled feature is inert without a nil check.
|
||
func optionalTicker(on bool, d time.Duration) *time.Ticker {
|
||
if on && d > 0 {
|
||
return time.NewTicker(d)
|
||
}
|
||
t := time.NewTicker(time.Hour)
|
||
t.Stop()
|
||
return t
|
||
}
|
||
|
||
// --- small helpers ---
|
||
|
||
func toSet(ids []uint64) map[uint64]bool {
|
||
s := make(map[uint64]bool, len(ids))
|
||
for _, id := range ids {
|
||
s[id] = true
|
||
}
|
||
return s
|
||
}
|
||
|
||
func diffNew(before map[uint64]bool, after []uint64) []uint64 {
|
||
out := []uint64{}
|
||
for _, id := range after {
|
||
if !before[id] {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func round2(f float64) float64 { return math.Round(f*100) / 100 }
|
||
|
||
func sideStr(buy bool) string {
|
||
if buy {
|
||
return "buy"
|
||
}
|
||
return "sell"
|
||
}
|