- New SpreadWindowTracker in types.go watches BG↔HL spread for all tracked coins, both directions - Logs duration when spread stays above trade threshold then converges - Wired into main loop after each scan tick - Filters sub-100ms windows as noise
189 lines
4.6 KiB
Go
189 lines
4.6 KiB
Go
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
|
|
}
|
|
|
|
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
|
|
for _, dir := range []struct {
|
|
name string
|
|
low float64
|
|
high float64
|
|
}{
|
|
{"BG->HL", bgP, hlP},
|
|
{"HL->BG", hlP, bgP},
|
|
} {
|
|
key := coin.Name + ":" + dir.name
|
|
spread := (dir.high - dir.low) / dir.low * 100
|
|
netSpr := spread - (makerFees[ExBitget] + makerFees[ExHyperLiquid]) // rough net
|
|
|
|
w, exists := swt.windows[key]
|
|
if netSpr >= threshold {
|
|
if !exists {
|
|
swt.windows[key] = &SpreadWindow{
|
|
Coin: coin.Name,
|
|
Direction: dir.name,
|
|
Since: now,
|
|
}
|
|
}
|
|
} 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), netSpr)
|
|
}
|
|
delete(swt.windows, key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|