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
@@ -0,0 +1,478 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrendState represents the state of a coin's trend detection lifecycle.
|
||||
type TrendState string
|
||||
|
||||
const (
|
||||
TrendIdle TrendState = "idle"
|
||||
TrendAlert TrendState = "alert" // anomaly detected, awaiting confirmation
|
||||
TrendConfirmed TrendState = "confirmed" // trend confirmed by 3+ exchanges
|
||||
TrendExhausting TrendState = "exhausting" // momentum fading
|
||||
)
|
||||
|
||||
// TrendDirection indicates the direction of a detected trend.
|
||||
type TrendDirection string
|
||||
|
||||
const (
|
||||
TrendUp TrendDirection = "up"
|
||||
TrendDown TrendDirection = "down"
|
||||
)
|
||||
|
||||
// TrendEvent records a state transition for one coin, persisted in a ring buffer for UI display.
|
||||
type TrendEvent struct {
|
||||
Coin string `json:"coin"`
|
||||
PrevState string `json:"prev_state"`
|
||||
NewState string `json:"new_state"`
|
||||
Direction string `json:"direction"`
|
||||
ZScore float64 `json:"z_score"`
|
||||
Volatility float64 `json:"volatility"`
|
||||
BGChange float64 `json:"bg_change"`
|
||||
HLChange float64 `json:"hl_change"`
|
||||
BNChange float64 `json:"bn_change"`
|
||||
OKXChange float64 `json:"okx_change"`
|
||||
ExAgree int `json:"ex_agree"`
|
||||
ExTotal int `json:"ex_total"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
const maxTrendEvents = 500
|
||||
|
||||
// TrendEntry is one coin's trend data sent via SSE.
|
||||
type TrendEntry struct {
|
||||
Coin string `json:"coin"`
|
||||
State TrendState `json:"state"`
|
||||
Direction TrendDirection `json:"direction"`
|
||||
AnomalyScore float64 `json:"anomaly_score"` // max z-score across all exchanges
|
||||
Volatility float64 `json:"volatility"` // current EMA volatility baseline
|
||||
BGChange float64 `json:"bg_change"` // 15s change %
|
||||
HLChange float64 `json:"hl_change"`
|
||||
BNChange float64 `json:"bn_change"`
|
||||
OKXChange float64 `json:"okx_change"`
|
||||
AlertedAt int64 `json:"alerted_at,omitempty"` // unix millis
|
||||
ConfirmedAt int64 `json:"confirmed_at,omitempty"` // unix millis
|
||||
Duration string `json:"duration,omitempty"` // how long in current state
|
||||
ExChanges int `json:"ex_changes"` // how many exchanges agree on direction
|
||||
}
|
||||
|
||||
// exchangeChange holds the 15s change % for one exchange.
|
||||
type exchangeChange struct {
|
||||
name string
|
||||
change float64
|
||||
}
|
||||
|
||||
// trendCoinState tracks the state machine for one coin.
|
||||
type trendCoinState struct {
|
||||
state TrendState
|
||||
direction TrendDirection
|
||||
anomalyScore float64
|
||||
volatility float64
|
||||
|
||||
alertedAt time.Time
|
||||
confirmedAt time.Time
|
||||
stateSince time.Time
|
||||
|
||||
// For confirmation: track how many consecutive ticks agree
|
||||
confirmCount int
|
||||
misalignCount int
|
||||
}
|
||||
|
||||
// TrendDetector detects price anomalies and confirms trends across exchanges.
|
||||
type TrendDetector struct {
|
||||
mu sync.RWMutex
|
||||
coins map[string]*trendCoinState
|
||||
momentum *MomentumTracker
|
||||
|
||||
// Configuration
|
||||
baselineWindow int // ticks for EMA baseline (default: 600 = 30s at 50ms)
|
||||
anomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
|
||||
confirmTicks int // ticks needed for confirmation (default: 3)
|
||||
alertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
|
||||
|
||||
// Event history ring buffer (for UI display)
|
||||
events [maxTrendEvents]TrendEvent
|
||||
eventsHead int
|
||||
eventsLen int
|
||||
|
||||
// OnEvent is called whenever a state transition is recorded.
|
||||
// Set this to persist events to database.
|
||||
OnEvent func(TrendEvent)
|
||||
}
|
||||
|
||||
// NewTrendDetector creates a trend detector that reads from MomentumTracker.
|
||||
func NewTrendDetector(mt *MomentumTracker) *TrendDetector {
|
||||
return &TrendDetector{
|
||||
coins: make(map[string]*trendCoinState),
|
||||
momentum: mt,
|
||||
baselineWindow: 600, // ~30s at 50ms tick
|
||||
anomalyMul: 3.0, // 3 sigma
|
||||
confirmTicks: 3, // 3 consecutive ticks
|
||||
alertCooldown: 60000, // 1 min
|
||||
}
|
||||
}
|
||||
|
||||
// Configure sets trend detection parameters.
|
||||
func (td *TrendDetector) Configure(baselineWindow int, anomalyMul float64, confirmTicks int, alertCooldownMs int64) {
|
||||
td.mu.Lock()
|
||||
defer td.mu.Unlock()
|
||||
if baselineWindow > 0 {
|
||||
td.baselineWindow = baselineWindow
|
||||
}
|
||||
if anomalyMul > 0 {
|
||||
td.anomalyMul = anomalyMul
|
||||
}
|
||||
if confirmTicks > 0 {
|
||||
td.confirmTicks = confirmTicks
|
||||
}
|
||||
if alertCooldownMs > 0 {
|
||||
td.alertCooldown = alertCooldownMs
|
||||
}
|
||||
}
|
||||
|
||||
// recordEvent stores a state transition in the ring buffer.
|
||||
func (td *TrendDetector) recordEvent(coin, prevState, newState, direction string, zScore, vola float64, bgC, hlC, bnC, okxC float64, exAgree, exTotal int) {
|
||||
ev := TrendEvent{
|
||||
Coin: coin,
|
||||
PrevState: prevState,
|
||||
NewState: newState,
|
||||
Direction: direction,
|
||||
ZScore: math.Round(zScore*100) / 100,
|
||||
Volatility: math.Round(vola*10000) / 10000,
|
||||
BGChange: bgC,
|
||||
HLChange: hlC,
|
||||
BNChange: bnC,
|
||||
OKXChange: okxC,
|
||||
ExAgree: exAgree,
|
||||
ExTotal: exTotal,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
td.events[td.eventsHead] = ev
|
||||
td.eventsHead = (td.eventsHead + 1) % maxTrendEvents
|
||||
if td.eventsLen < maxTrendEvents {
|
||||
td.eventsLen++
|
||||
}
|
||||
|
||||
// Fire callback for DB persistence
|
||||
if td.OnEvent != nil {
|
||||
td.OnEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// GetEvents returns trend event history, newest first.
|
||||
func (td *TrendDetector) GetEvents(limit int) []TrendEvent {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
|
||||
n := td.eventsLen
|
||||
if limit > 0 && limit < n {
|
||||
n = limit
|
||||
}
|
||||
result := make([]TrendEvent, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (td.eventsHead - 1 - i + maxTrendEvents) % maxTrendEvents
|
||||
if td.events[idx].Timestamp == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, td.events[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Tick runs one iteration of trend detection.
|
||||
// Reads exchange changes from MomentumTracker buffers, computes volatility baselines,
|
||||
// and advances the state machine for each coin.
|
||||
func (td *TrendDetector) Tick() {
|
||||
// Get all momentum entries to access exchange changes
|
||||
entries := td.momentum.Snapshot(0)
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
td.mu.Lock()
|
||||
defer td.mu.Unlock()
|
||||
|
||||
for _, entry := range entries {
|
||||
// Collect 15s changes from all 4 exchanges
|
||||
var changes []exchangeChange
|
||||
if entry.BG15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBitget, change: entry.BG15s})
|
||||
}
|
||||
if entry.HL15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExHyperLiquid, change: entry.HL15s})
|
||||
}
|
||||
if entry.BN15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExBinance, change: entry.BN15s})
|
||||
}
|
||||
if entry.OKX15s != 0 {
|
||||
changes = append(changes, exchangeChange{name: ExOKX, change: entry.OKX15s})
|
||||
}
|
||||
|
||||
if len(changes) < 3 {
|
||||
continue // need at least 3 exchanges for reliable detection
|
||||
}
|
||||
|
||||
// Compute aggregate stats
|
||||
_, std := meanStdDev(changes)
|
||||
maxAbs := 0.0
|
||||
agreeUp := 0
|
||||
agreeDown := 0
|
||||
for _, c := range changes {
|
||||
abs := math.Abs(c.change)
|
||||
if abs > maxAbs {
|
||||
maxAbs = abs
|
||||
}
|
||||
if c.change > 0.001 {
|
||||
agreeUp++
|
||||
} else if c.change < -0.001 {
|
||||
agreeDown++
|
||||
}
|
||||
}
|
||||
|
||||
// Z-score: how anomalous is the max movement?
|
||||
var zScore float64
|
||||
if std > 0.0001 {
|
||||
zScore = maxAbs / std
|
||||
}
|
||||
|
||||
// Update or create coin state
|
||||
cs, exists := td.coins[entry.Coin]
|
||||
if !exists {
|
||||
cs = &trendCoinState{
|
||||
state: TrendIdle,
|
||||
stateSince: time.Now(),
|
||||
}
|
||||
td.coins[entry.Coin] = cs
|
||||
}
|
||||
|
||||
// Update volatility baseline (EMA of maxAbs)
|
||||
if cs.volatility == 0 {
|
||||
cs.volatility = maxAbs
|
||||
} else {
|
||||
alpha := 2.0 / float64(td.baselineWindow+1)
|
||||
cs.volatility = cs.volatility*(1-alpha) + maxAbs*alpha
|
||||
}
|
||||
|
||||
// Update anomaly score
|
||||
cs.anomalyScore = zScore
|
||||
|
||||
// Determine majority direction
|
||||
majorityDir := TrendUp
|
||||
majorityCount := agreeUp
|
||||
if agreeDown > agreeUp {
|
||||
majorityDir = TrendDown
|
||||
majorityCount = agreeDown
|
||||
}
|
||||
|
||||
// State machine transitions
|
||||
now := time.Now()
|
||||
switch cs.state {
|
||||
case TrendIdle:
|
||||
// Alert if z-score exceeds threshold AND majority exchanges agree
|
||||
if zScore >= td.anomalyMul && majorityCount >= 3 {
|
||||
cs.state = TrendAlert
|
||||
cs.direction = majorityDir
|
||||
cs.alertedAt = now
|
||||
cs.stateSince = now
|
||||
cs.confirmCount = 1
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "idle", "alert", string(majorityDir),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
case TrendAlert:
|
||||
// Check if majority still agrees
|
||||
if majorityCount >= 3 && majorityDir == cs.direction {
|
||||
cs.confirmCount++
|
||||
cs.misalignCount = 0
|
||||
if cs.confirmCount >= td.confirmTicks {
|
||||
cs.state = TrendConfirmed
|
||||
cs.confirmedAt = now
|
||||
cs.stateSince = now
|
||||
td.recordEvent(entry.Coin, "alert", "confirmed", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
} else {
|
||||
cs.misalignCount++
|
||||
if cs.misalignCount >= td.confirmTicks {
|
||||
// Failed to confirm — back to idle
|
||||
cs.state = TrendIdle
|
||||
cs.stateSince = now
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "alert", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
case TrendConfirmed:
|
||||
// Check if momentum is exhausting (fewer than 3 exchanges agree)
|
||||
// Also track if z-score drops below threshold
|
||||
if majorityCount < 2 || zScore < td.anomalyMul*0.5 {
|
||||
cs.state = TrendExhausting
|
||||
cs.stateSince = now
|
||||
td.recordEvent(entry.Coin, "confirmed", "exhausting", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
|
||||
case TrendExhausting:
|
||||
// After exhausting, go back to idle
|
||||
if time.Since(cs.stateSince) > 5*time.Second {
|
||||
cs.state = TrendIdle
|
||||
cs.stateSince = now
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
// Also immediately go to idle if below threshold
|
||||
if zScore < td.anomalyMul*0.3 || majorityCount < 1 {
|
||||
cs.state = TrendIdle
|
||||
cs.stateSince = now
|
||||
cs.confirmCount = 0
|
||||
cs.misalignCount = 0
|
||||
td.recordEvent(entry.Coin, "exhausting", "idle", string(cs.direction),
|
||||
zScore, cs.volatility, entry.BG15s, entry.HL15s, entry.BN15s, entry.OKX15s,
|
||||
majorityCount, len(changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stale entries (no update for > 60s)
|
||||
cutoff := time.Now().Add(-60 * time.Second)
|
||||
for coin, cs := range td.coins {
|
||||
if cs.state == TrendIdle && cs.stateSince.Before(cutoff) {
|
||||
delete(td.coins, coin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns current trend state for all coins.
|
||||
func (td *TrendDetector) Snapshot() []TrendEntry {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
|
||||
entries := td.momentum.Snapshot(0)
|
||||
entryMap := make(map[string]MomentumEntry, len(entries))
|
||||
for _, e := range entries {
|
||||
entryMap[e.Coin] = e
|
||||
}
|
||||
|
||||
var result []TrendEntry
|
||||
for coin, cs := range td.coins {
|
||||
if cs.state == TrendIdle {
|
||||
continue // skip idle coins
|
||||
}
|
||||
|
||||
entry := TrendEntry{
|
||||
Coin: coin,
|
||||
State: cs.state,
|
||||
Direction: cs.direction,
|
||||
AnomalyScore: math.Round(cs.anomalyScore*100) / 100,
|
||||
Volatility: math.Round(cs.volatility*10000) / 10000,
|
||||
ExChanges: 0,
|
||||
}
|
||||
|
||||
if me, ok := entryMap[coin]; ok {
|
||||
entry.BGChange = me.BG15s
|
||||
entry.HLChange = me.HL15s
|
||||
entry.BNChange = me.BN15s
|
||||
entry.OKXChange = me.OKX15s
|
||||
|
||||
// Count how many exchanges agree with the trend direction
|
||||
agree := 0
|
||||
changes := []float64{entry.BGChange, entry.HLChange, entry.BNChange, entry.OKXChange}
|
||||
for _, c := range changes {
|
||||
if cs.direction == TrendUp && c > 0.001 {
|
||||
agree++
|
||||
} else if cs.direction == TrendDown && c < -0.001 {
|
||||
agree++
|
||||
}
|
||||
}
|
||||
entry.ExChanges = agree
|
||||
}
|
||||
|
||||
if !cs.alertedAt.IsZero() {
|
||||
entry.AlertedAt = cs.alertedAt.UnixMilli()
|
||||
}
|
||||
if !cs.confirmedAt.IsZero() {
|
||||
entry.ConfirmedAt = cs.confirmedAt.UnixMilli()
|
||||
}
|
||||
|
||||
// Duration in current state
|
||||
dur := time.Since(cs.stateSince).Round(time.Second)
|
||||
entry.Duration = dur.String()
|
||||
|
||||
result = append(result, entry)
|
||||
}
|
||||
|
||||
// Sort: confirmed first, then alert, then exhausting
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
order := map[TrendState]int{
|
||||
TrendConfirmed: 0,
|
||||
TrendAlert: 1,
|
||||
TrendExhausting: 2,
|
||||
}
|
||||
oi := order[result[i].State]
|
||||
oj := order[result[j].State]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return result[i].AnomalyScore > result[j].AnomalyScore
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// meanStdDev computes mean and standard deviation of exchange change values.
|
||||
func meanStdDev(changes []exchangeChange) (mean, stdDev float64) {
|
||||
if len(changes) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
var sum float64
|
||||
for _, c := range changes {
|
||||
sum += c.change
|
||||
}
|
||||
mean = sum / float64(len(changes))
|
||||
|
||||
var varianceSum float64
|
||||
for _, c := range changes {
|
||||
diff := c.change - mean
|
||||
varianceSum += diff * diff
|
||||
}
|
||||
variance := varianceSum / float64(len(changes))
|
||||
stdDev = math.Sqrt(variance)
|
||||
|
||||
return mean, stdDev
|
||||
}
|
||||
|
||||
// IsTrending returns true if the given coin is in confirmed trend state.
|
||||
func (td *TrendDetector) IsTrending(coin string) bool {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
cs, ok := td.coins[coin]
|
||||
return ok && cs.state == TrendConfirmed
|
||||
}
|
||||
|
||||
// GetTrendingCoins returns all coins currently in confirmed trend.
|
||||
func (td *TrendDetector) GetTrendingCoins() map[string]TrendDirection {
|
||||
td.mu.RLock()
|
||||
defer td.mu.RUnlock()
|
||||
result := make(map[string]TrendDirection)
|
||||
for coin, cs := range td.coins {
|
||||
if cs.state == TrendConfirmed {
|
||||
result[coin] = cs.direction
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user