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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
047571921e
commit
b7767c95ae
+199
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user