删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。 - 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升 - 新增 SpreadCard/SurgeCard 前端组件 - 保留 momentum/trend/cumulative/trend_filter 扫描功能 - 更新文档和配置以反映新系统 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
337 lines
9.1 KiB
Go
337 lines
9.1 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"math"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SurgeDetector detects anomalous 3-exchange max spreads using per-coin adaptive baselines.
|
|
// Theory: when a coin starts moving sharply, different exchanges update at different speeds,
|
|
// creating a temporary spike in inter-exchange spread. This detector captures that moment.
|
|
type SurgeDetector struct {
|
|
mu sync.Mutex
|
|
coins map[string]*coinSurgeState
|
|
|
|
// Config
|
|
enabled bool
|
|
windowSize int // rolling window samples (default: 600 = ~30s at 50ms tick)
|
|
baselineMul float64 // baseline * N = threshold (default: 3.0)
|
|
minAbsSpreadPct float64 // minimum absolute spread % to trigger (default: 0.05)
|
|
cooldownSec int // seconds between alerts for same coin (default: 60)
|
|
|
|
// Recent events (ring buffer)
|
|
events []SurgeEvent
|
|
eventIdx int
|
|
maxEvents int
|
|
|
|
// DB persistence callback
|
|
onEvent func(SurgeEvent)
|
|
}
|
|
|
|
// coinSurgeState holds per-coin adaptive baseline data.
|
|
type coinSurgeState struct {
|
|
spreads []float64 // rolling window of recent spread values
|
|
lastAlertAt time.Time
|
|
}
|
|
|
|
// SurgeEvent represents a detected surge anomaly.
|
|
type SurgeEvent struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Coin string `json:"coin"`
|
|
BnPrice float64 `json:"bn_price"`
|
|
OkxPrice float64 `json:"okx_price"`
|
|
BgPrice float64 `json:"bg_price"`
|
|
SpreadPct float64 `json:"spread_pct"` // current 3-exchange max spread
|
|
BaselinePct float64 `json:"baseline_pct"` // adaptive baseline at time of event
|
|
ThresholdPct float64 `json:"threshold_pct"` // trigger threshold
|
|
Ratio float64 `json:"ratio"` // spread / threshold
|
|
Direction string `json:"direction"` // "up" or "down"
|
|
LeadingExchange string `json:"leading_exchange"` // which exchange moved first/furthest
|
|
MidPrice float64 `json:"mid_price"` // median of 3 prices
|
|
}
|
|
|
|
// SurgeSnapshot holds current spread/baseline state for a coin (SSE push).
|
|
type SurgeSnapshot struct {
|
|
Coin string `json:"coin"`
|
|
SpreadPct float64 `json:"spread_pct"`
|
|
BaselinePct float64 `json:"baseline_pct"`
|
|
ThresholdPct float64 `json:"threshold_pct"`
|
|
Direction string `json:"direction,omitempty"` // "up"/"down" if currently surging
|
|
WindowSize int `json:"window_size"` // current number of samples in window
|
|
}
|
|
|
|
func NewSurgeDetector() *SurgeDetector {
|
|
return &SurgeDetector{
|
|
coins: make(map[string]*coinSurgeState),
|
|
events: make([]SurgeEvent, 200),
|
|
maxEvents: 200,
|
|
}
|
|
}
|
|
|
|
// Configure sets detection parameters.
|
|
func (sd *SurgeDetector) Configure(windowSize int, baselineMul, minAbsSpreadPct float64, cooldownSec int) {
|
|
sd.enabled = true
|
|
sd.windowSize = windowSize
|
|
sd.baselineMul = baselineMul
|
|
sd.minAbsSpreadPct = minAbsSpreadPct
|
|
sd.cooldownSec = cooldownSec
|
|
}
|
|
|
|
// SetOnEvent sets the DB persistence callback.
|
|
func (sd *SurgeDetector) SetOnEvent(fn func(SurgeEvent)) {
|
|
sd.onEvent = fn
|
|
}
|
|
|
|
// Tick processes one snapshot tick, detecting surges for all coins.
|
|
// Returns newly detected events for immediate SSE broadcast.
|
|
func (sd *SurgeDetector) Tick(snap map[string]map[string]float64) []SurgeEvent {
|
|
if !sd.enabled {
|
|
return nil
|
|
}
|
|
|
|
sd.mu.Lock()
|
|
defer sd.mu.Unlock()
|
|
|
|
var newEvents []SurgeEvent
|
|
now := time.Now()
|
|
|
|
for _, coin := range TrackedCoins {
|
|
exMap := snap[coin.Name]
|
|
if exMap == nil {
|
|
continue
|
|
}
|
|
|
|
bnP := exMap[ExBinance]
|
|
okxP := exMap[ExOKX]
|
|
bgP := exMap[ExBitget]
|
|
|
|
// Need at least 2 exchanges
|
|
prices := []float64{}
|
|
if bnP > 0 { prices = append(prices, bnP) }
|
|
if okxP > 0 { prices = append(prices, okxP) }
|
|
if bgP > 0 { prices = append(prices, bgP) }
|
|
if len(prices) < 2 {
|
|
continue
|
|
}
|
|
|
|
// Compute 3-exchange max spread
|
|
minP, maxP := prices[0], prices[0]
|
|
for _, p := range prices[1:] {
|
|
if p < minP { minP = p }
|
|
if p > maxP { maxP = p }
|
|
}
|
|
spread := (maxP - minP) / minP * 100
|
|
|
|
// Get or create coin state
|
|
state, exists := sd.coins[coin.Name]
|
|
if !exists {
|
|
state = &coinSurgeState{
|
|
spreads: make([]float64, 0, sd.windowSize),
|
|
}
|
|
sd.coins[coin.Name] = state
|
|
}
|
|
|
|
// Add spread to rolling window
|
|
state.spreads = append(state.spreads, spread)
|
|
if len(state.spreads) > sd.windowSize {
|
|
state.spreads = state.spreads[len(state.spreads)-sd.windowSize:]
|
|
}
|
|
|
|
// Need minimum samples for baseline (at least 10)
|
|
if len(state.spreads) < 10 {
|
|
continue
|
|
}
|
|
|
|
// Compute baseline = median of recent spreads
|
|
baseline := median(state.spreads)
|
|
|
|
// Threshold = baseline * multiplier, but at least minAbsSpreadPct
|
|
threshold := baseline * sd.baselineMul
|
|
if threshold < sd.minAbsSpreadPct {
|
|
threshold = sd.minAbsSpreadPct
|
|
}
|
|
|
|
// Check if spread exceeds threshold AND cooldown has passed
|
|
if spread < threshold {
|
|
continue
|
|
}
|
|
if !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) {
|
|
continue
|
|
}
|
|
|
|
// Surge detected — determine direction
|
|
midPrice := median(prices)
|
|
var direction, leadingEx string
|
|
|
|
// Find max and min exchanges for reporting
|
|
exPrices := map[string]float64{}
|
|
if bnP > 0 { exPrices[ExBinance] = bnP }
|
|
if okxP > 0 { exPrices[ExOKX] = okxP }
|
|
if bgP > 0 { exPrices[ExBitget] = bgP }
|
|
|
|
var maxEx, minEx string
|
|
var maxVal, minVal float64 = -1, math.MaxFloat64
|
|
for ex, p := range exPrices {
|
|
if p > maxVal { maxVal = p; maxEx = ex }
|
|
if p < minVal { minVal = p; minEx = ex }
|
|
}
|
|
|
|
// Direction: if highest is further from median than lowest → up, else down
|
|
if (maxVal - midPrice) > (midPrice - minVal) {
|
|
direction = "up"
|
|
leadingEx = maxEx
|
|
} else {
|
|
direction = "down"
|
|
leadingEx = minEx
|
|
}
|
|
|
|
ratio := spread / threshold
|
|
|
|
event := SurgeEvent{
|
|
Timestamp: now,
|
|
Coin: coin.Name,
|
|
BnPrice: bnP,
|
|
OkxPrice: okxP,
|
|
BgPrice: bgP,
|
|
SpreadPct: math.Round(spread*10000) / 10000,
|
|
BaselinePct: math.Round(baseline*10000) / 10000,
|
|
ThresholdPct: math.Round(threshold*10000) / 10000,
|
|
Ratio: math.Round(ratio*100) / 100,
|
|
Direction: direction,
|
|
LeadingExchange: leadingEx,
|
|
MidPrice: math.Round(midPrice*10000) / 10000,
|
|
}
|
|
|
|
state.lastAlertAt = now
|
|
newEvents = append(newEvents, event)
|
|
|
|
// Store in ring buffer
|
|
sd.events[sd.eventIdx%sd.maxEvents] = event
|
|
sd.eventIdx++
|
|
|
|
log.Printf("[Surge] %s %s surge detected: spread=%.4f%% baseline=%.4f%% threshold=%.4f%% ratio=%.1fx leading=%s",
|
|
coin.Name, direction, event.SpreadPct, event.BaselinePct, event.ThresholdPct, event.Ratio, leadingEx)
|
|
|
|
// Persist to DB if callback set
|
|
if sd.onEvent != nil {
|
|
sd.onEvent(event)
|
|
}
|
|
}
|
|
|
|
return newEvents
|
|
}
|
|
|
|
// GetRecentEvents returns the most recent N surge events.
|
|
func (sd *SurgeDetector) GetRecentEvents(n int) []SurgeEvent {
|
|
sd.mu.Lock()
|
|
defer sd.mu.Unlock()
|
|
|
|
if n <= 0 || n > sd.maxEvents {
|
|
n = sd.maxEvents
|
|
}
|
|
|
|
total := sd.eventIdx
|
|
if total > sd.maxEvents {
|
|
total = sd.maxEvents
|
|
}
|
|
|
|
result := make([]SurgeEvent, 0, total)
|
|
for i := 0; i < total; i++ {
|
|
idx := (sd.eventIdx - total + i) % sd.maxEvents
|
|
if idx < 0 {
|
|
idx += sd.maxEvents
|
|
}
|
|
ev := sd.events[idx]
|
|
if ev.Coin != "" {
|
|
result = append(result, ev)
|
|
}
|
|
}
|
|
|
|
// Return at most n, most recent first
|
|
if len(result) <= n {
|
|
// Reverse to get newest first
|
|
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
|
result[i], result[j] = result[j], result[i]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Take last n and reverse
|
|
out := make([]SurgeEvent, n)
|
|
for i := 0; i < n; i++ {
|
|
out[i] = result[len(result)-1-i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Snapshot returns current spread/baseline state for all coins (SSE push).
|
|
func (sd *SurgeDetector) Snapshot() []SurgeSnapshot {
|
|
sd.mu.Lock()
|
|
defer sd.mu.Unlock()
|
|
|
|
var result []SurgeSnapshot
|
|
now := time.Now()
|
|
|
|
for _, coin := range TrackedCoins {
|
|
state, exists := sd.coins[coin.Name]
|
|
if !exists || len(state.spreads) < 10 {
|
|
continue
|
|
}
|
|
|
|
currentSpread := state.spreads[len(state.spreads)-1]
|
|
baseline := median(state.spreads)
|
|
threshold := baseline * sd.baselineMul
|
|
if threshold < sd.minAbsSpreadPct {
|
|
threshold = sd.minAbsSpreadPct
|
|
}
|
|
|
|
snap := SurgeSnapshot{
|
|
Coin: coin.Name,
|
|
SpreadPct: math.Round(currentSpread*10000) / 10000,
|
|
BaselinePct: math.Round(baseline*10000) / 10000,
|
|
ThresholdPct: math.Round(threshold*10000) / 10000,
|
|
WindowSize: len(state.spreads),
|
|
}
|
|
|
|
// Check if currently surging (within cooldown)
|
|
if currentSpread >= threshold && !state.lastAlertAt.IsZero() && now.Sub(state.lastAlertAt).Seconds() < float64(sd.cooldownSec) {
|
|
if state.spreads[len(state.spreads)-1] >= threshold {
|
|
snap.Direction = "up" // placeholder, real direction calculated in Tick
|
|
}
|
|
}
|
|
|
|
result = append(result, snap)
|
|
}
|
|
|
|
// Sort by spread descending
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].SpreadPct > result[j].SpreadPct
|
|
})
|
|
|
|
// Limit to top 50
|
|
if len(result) > 50 {
|
|
result = result[:50]
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// median computes the median of a slice of float64s.
|
|
// The input slice is NOT modified.
|
|
func median(vals []float64) float64 {
|
|
if len(vals) == 0 {
|
|
return 0
|
|
}
|
|
sorted := make([]float64, len(vals))
|
|
copy(sorted, vals)
|
|
sort.Float64s(sorted)
|
|
n := len(sorted)
|
|
if n%2 == 1 {
|
|
return sorted[n/2]
|
|
}
|
|
return (sorted[n/2-1] + sorted[n/2]) / 2
|
|
}
|