package main import ( "log" "sync" "time" ) // TrackedCoin represents a coin we monitor across exchanges. type TrackedCoin struct { Name string // Display name (BTC, ETH, etc.) BN string // Binance symbol (BTCUSDT) BG string // Bitget symbol (BTCUSDT) HL string // HyperLiquid symbol (BTC) } // PriceTick holds a price update with optional bid/ask. type PriceTick struct { Price float64 Bid float64 // 0 if unknown Ask float64 // 0 if unknown } // Spread holds bid/ask data for one exchange+coin. type Spread struct { Bid float64 Ask float64 Updated int64 // unix nano } // PriceStore holds the latest prices from all exchanges, thread-safe. type PriceStore struct { mu sync.RWMutex prices map[string]map[string]float64 // coin -> exchange -> price spreads map[string]map[string]*Spread // coin -> exchange -> spread } func NewPriceStore() *PriceStore { return &PriceStore{ prices: make(map[string]map[string]float64), spreads: make(map[string]map[string]*Spread), } } // Set stores a price update. If bid/ask are non-zero, also stores spread. func (s *PriceStore) Set(coin, exchange string, price float64) { s.mu.Lock() defer s.mu.Unlock() if s.prices[coin] == nil { s.prices[coin] = make(map[string]float64) } s.prices[coin][exchange] = price } // SetWithSpread stores price + bid/ask spread. func (s *PriceStore) SetWithSpread(coin, exchange string, price, bid, ask float64) { s.mu.Lock() defer s.mu.Unlock() if s.prices[coin] == nil { s.prices[coin] = make(map[string]float64) } s.prices[coin][exchange] = price if bid > 0 && ask > 0 { if s.spreads[coin] == nil { s.spreads[coin] = make(map[string]*Spread) } s.spreads[coin][exchange] = &Spread{ Bid: bid, Ask: ask, Updated: time.Now().UnixNano(), } } } func (s *PriceStore) Get(coin, exchange string) (float64, bool) { s.mu.RLock() defer s.mu.RUnlock() p, ok := s.prices[coin][exchange] return p, ok } // GetSpread returns the current bid-ask spread (as percentage of mid price). // Returns 0 if no spread data available. func (s *PriceStore) GetSpread(coin, exchange string) float64 { s.mu.RLock() defer s.mu.RUnlock() sp, ok := s.spreads[coin][exchange] if !ok || sp.Bid <= 0 || sp.Ask <= 0 { return 0 } mid := (sp.Bid + sp.Ask) / 2 if mid <= 0 { return 0 } return (sp.Ask - sp.Bid) / mid * 100 } // GetAll returns a snapshot of all prices. func (s *PriceStore) GetAll() map[string]map[string]float64 { s.mu.RLock() defer s.mu.RUnlock() snap := make(map[string]map[string]float64) for coin, exMap := range s.prices { snap[coin] = make(map[string]float64) for ex, p := range exMap { snap[coin][ex] = p } } return snap } // ArbOpportunity represents a profitable arbitrage route. type ArbOpportunity struct { Coin string Direction string // e.g. "BN->HL" BuyEx string SellEx string BuyPrice float64 SellPrice float64 NetProfit float64 // percentage after fees GrossBasis float64 // raw price difference % } // SpreadWindow tracks how long each coin's spread stays above threshold. // Used to measure the window of opportunity between threshold-crossing and // convergence — helps diagnose whether entry latency is a problem. type SpreadWindow struct { Coin string Direction string // "BG->HL" or "HL->BG" Since time.Time PeakNet float64 // highest netProfit % observed during this window } type SpreadWindowTracker struct { windows map[string]*SpreadWindow // key: "COIN:DIRECTION" } func NewSpreadWindowTracker() *SpreadWindowTracker { return &SpreadWindowTracker{windows: make(map[string]*SpreadWindow)} } func (swt *SpreadWindowTracker) Tick(snap map[string]map[string]float64, threshold float64) { now := time.Now() for _, coin := range TrackedCoins { exMap := snap[coin.Name] if exMap == nil { continue } bgP := exMap[ExBitget] hlP := exMap[ExHyperLiquid] if bgP <= 0 || hlP <= 0 { continue } // Check both directions — use netProfit() for exact fee model match // BG→HL: buy BG (Bitget 0.020%), sell HL (HL 0.015%) // HL→BG: buy HL (HL 0.015%), sell BG (Bitget 0.020%) type dirCheck struct { name string buyPrice float64 sellPrice float64 buyFee float64 sellFee float64 } for _, dir := range []dirCheck{ {"BG->HL", bgP, hlP, takerFees[ExBitget], takerFees[ExHyperLiquid]}, {"HL->BG", hlP, bgP, takerFees[ExHyperLiquid], takerFees[ExBitget]}, } { key := coin.Name + ":" + dir.name netSpr := netProfit(dir.buyPrice, dir.sellPrice, dir.buyFee, dir.sellFee) w, exists := swt.windows[key] if netSpr >= threshold { if !exists { swt.windows[key] = &SpreadWindow{ Coin: coin.Name, Direction: dir.name, Since: now, PeakNet: netSpr, } } else if netSpr > w.PeakNet { w.PeakNet = netSpr } } else { if exists { dur := now.Sub(w.Since) if dur > 100*time.Millisecond { log.Printf("[SpreadWindow] %s %s exceeded threshold for %v (peak net=%+.4f%%)", w.Coin, w.Direction, dur.Round(time.Millisecond), w.PeakNet) } delete(swt.windows, key) } } } } }