Files
exchange-monitor-go/types.go
T
jackyu66gitandClaude Opus 4.6 b7767c95ae feat: 添加OKX行情接入+趋势检测+累积变动系统+界面重构
- 新增OKX WebSocket行情连接器,扩展4交易所价格监控
- 新增z-score趋势检测引擎(TrendDetector),识别价格异动/趋势启动
- 新增累积变动跟踪(CumulativeTracker),基于1min/5min多交易所共识
- 趋势事件和累积变动事件持久化到SQLite
- 新增Binance/OKX动量检测字段,扩展前端动量卡片至15列
- 迁移至macOS(darwin-arm64),更新前端依赖
- Dashboard网格重构:非交易卡片置顶,交易卡片置底
- TrackedCoin添加OK字段,添加ExBinance/ExOKX常量
- 前端新增趋势检测卡片、趋势历史卡片、累积变动卡片

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-06 13:26:05 +08:00

201 lines
5.1 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)
OK string // OKX symbol (BTC-USDT-SWAP)
}
// 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 {
if coin.BG == "" || coin.HL == "" {
continue
}
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)
}
}
}
}
}