Files
maker/coherence.go
zeekayandHanzo Dev 83b6e971c2 refactor: one price grid, one inverse pair
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]>
2026-07-29 17:44:28 -07:00

634 lines
23 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
}