- 新增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>
200 lines
5.1 KiB
Go
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
|
|
}
|