- 新增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>
479 lines
13 KiB
Go
479 lines
13 KiB
Go
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
|
|
}
|