删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。 - 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升 - 新增 SpreadCard/SurgeCard 前端组件 - 保留 momentum/trend/cumulative/trend_filter 扫描功能 - 更新文档和配置以反映新系统 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
5.0 KiB
Go
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
|
|
}
|