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

196 lines
5.0 KiB
Go

package main
import (
"math"
"sort"
"sync"
)
// momentumWindow defines a time window for momentum calculation.
// At ~50ms per tick, N ticks ≈ N * 50ms.
type momentumWindow struct {
Name string // JSON key: "t1s", "t5s", "t15s"
Ticks int // how many ticks to look back
Label string // human-readable: "1s", "5s", "15s"
}
var momentumWindows = []momentumWindow{
{"t1s", 20, "1s"},
{"t5s", 100, "5s"},
{"t15s", 300, "15s"},
{"t60s", 1200, "60s"},
}
const maxMomentumRecords = 600
// momentumBuffer is a fixed-size ring buffer of prices for one coin+exchange.
type momentumBuffer struct {
prices [maxMomentumRecords]float64
head int // next write index
count int // total records written (capped at maxMomentumRecords)
}
// MomentumEntry is one coin's momentum data sent via SSE.
type MomentumEntry struct {
Coin string `json:"coin"`
BG1s float64 `json:"bg_1s"`
BG5s float64 `json:"bg_5s"`
BG15s float64 `json:"bg_15s"`
BG60s float64 `json:"bg_60s"`
BN1s float64 `json:"bn_1s"`
BN5s float64 `json:"bn_5s"`
BN15s float64 `json:"bn_15s"`
BN60s float64 `json:"bn_60s"`
OKX1s float64 `json:"okx_1s"`
OKX5s float64 `json:"okx_5s"`
OKX15s float64 `json:"okx_15s"`
OKX60s float64 `json:"okx_60s"`
Score float64 `json:"score"` // max abs change across all windows
Direction string `json:"direction"` // "up", "down", "flat", "mixed"
}
// MomentumTracker tracks price momentum across all coins and exchanges.
type MomentumTracker struct {
mu sync.RWMutex
buffers map[string]map[string]*momentumBuffer // coin -> exchange -> buffer
}
func NewMomentumTracker() *MomentumTracker {
return &MomentumTracker{
buffers: make(map[string]map[string]*momentumBuffer),
}
}
// Record adds a price point for a coin+exchange.
func (mt *MomentumTracker) Record(coin, exchange string, price float64) {
mt.mu.Lock()
defer mt.mu.Unlock()
if mt.buffers[coin] == nil {
mt.buffers[coin] = make(map[string]*momentumBuffer)
}
buf, ok := mt.buffers[coin][exchange]
if !ok {
buf = &momentumBuffer{}
mt.buffers[coin][exchange] = buf
}
buf.prices[buf.head] = price
buf.head = (buf.head + 1) % maxMomentumRecords
if buf.count < maxMomentumRecords {
buf.count++
}
}
// Snapshot returns all coins with momentum data, sorted by score descending.
// Only includes coins where at least one window has non-zero change.
// Limited to maxResults entries.
func (mt *MomentumTracker) Snapshot(thresholdPct float64) []MomentumEntry {
mt.mu.RLock()
defer mt.mu.RUnlock()
var result []MomentumEntry
for coin, exMap := range mt.buffers {
bgBuf, hasBG := exMap[ExBitget]
bnBuf, hasBN := exMap[ExBinance]
okBuf, hasOK := exMap[ExOKX]
if !hasBG && !hasBN && !hasOK {
continue
}
entry := MomentumEntry{Coin: coin}
var allChanges []float64
if hasBG {
changes := calcWindows(bgBuf)
entry.BG1s = changes[0]
entry.BG5s = changes[1]
entry.BG15s = changes[2]
entry.BG60s = changes[3]
allChanges = append(allChanges, changes[:]...)
}
if hasBN {
changes := calcWindows(bnBuf)
entry.BN1s = changes[0]
entry.BN5s = changes[1]
entry.BN15s = changes[2]
entry.BN60s = changes[3]
allChanges = append(allChanges, changes[:]...)
}
if hasOK {
changes := calcWindows(okBuf)
entry.OKX1s = changes[0]
entry.OKX5s = changes[1]
entry.OKX15s = changes[2]
entry.OKX60s = changes[3]
allChanges = append(allChanges, changes[:]...)
}
// Score: max absolute change across all windows
var maxAbs float64
for _, c := range allChanges {
abs := math.Abs(c)
if abs > maxAbs {
maxAbs = abs
}
}
entry.Score = math.Round(maxAbs*10000) / 10000
// Direction: majority vote across all windows
if maxAbs > 0.001 {
posCount := 0
negCount := 0
for _, c := range allChanges {
if c > 0.001 {
posCount++
} else if c < -0.001 {
negCount++
}
}
total := posCount + negCount
if total == 0 {
entry.Direction = "flat"
} else if float64(posCount)/float64(total) >= 0.66 {
entry.Direction = "up"
} else if float64(negCount)/float64(total) >= 0.66 {
entry.Direction = "down"
} else {
entry.Direction = "mixed"
}
} else {
entry.Direction = "flat"
}
result = append(result, entry)
}
// Sort by score descending
sort.Slice(result, func(i, j int) bool {
return result[i].Score > result[j].Score
})
if len(result) > 200 {
result = result[:200]
}
return result
}
// calcWindows computes change% for all windows: (current - old) / old * 100.
// Returns 0 for windows that don't have enough data yet.
func calcWindows(buf *momentumBuffer) [4]float64 {
var result [4]float64
for i, w := range momentumWindows {
if buf.count < w.Ticks+1 {
continue
}
currentIdx := (buf.head - 1 + maxMomentumRecords) % maxMomentumRecords
oldIdx := (currentIdx - w.Ticks + maxMomentumRecords) % maxMomentumRecords
current := buf.prices[currentIdx]
old := buf.prices[oldIdx]
if old > 0 {
result[i] = math.Round((current-old)/old*10000) / 10000
}
}
return result
}