Files
exchange-monitor-go/momentum.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

200 lines
5.1 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"},
}
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"`
HL1s float64 `json:"hl_1s"`
HL5s float64 `json:"hl_5s"`
HL15s float64 `json:"hl_15s"`
BN1s float64 `json:"bn_1s"`
BN5s float64 `json:"bn_5s"`
BN15s float64 `json:"bn_15s"`
OKX1s float64 `json:"okx_1s"`
OKX5s float64 `json:"okx_5s"`
OKX15s float64 `json:"okx_15s"`
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]
hlBuf, hasHL := exMap[ExHyperLiquid]
bnBuf, hasBN := exMap[ExBinance]
okBuf, hasOK := exMap[ExOKX]
if !hasBG && !hasHL && !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]
allChanges = append(allChanges, changes[:]...)
}
if hasHL {
changes := calcWindows(hlBuf)
entry.HL1s = changes[0]
entry.HL5s = changes[1]
entry.HL15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
if hasBN {
changes := calcWindows(bnBuf)
entry.BN1s = changes[0]
entry.BN5s = changes[1]
entry.BN15s = changes[2]
allChanges = append(allChanges, changes[:]...)
}
if hasOK {
changes := calcWindows(okBuf)
entry.OKX1s = changes[0]
entry.OKX5s = changes[1]
entry.OKX15s = changes[2]
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 3 windows: (current - old) / old * 100.
// Returns 0 for windows that don't have enough data yet.
func calcWindows(buf *momentumBuffer) [3]float64 {
var result [3]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
}