Files
jackyu66gitandClaude Opus 4.6 d38782490c feat: 重构为三所价差异动监控系统
删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。
- 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升
- 新增 SpreadCard/SurgeCard 前端组件
- 保留 momentum/trend/cumulative/trend_filter 扫描功能
- 更新文档和配置以反映新系统

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 02:01:18 +08:00

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
}