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
+447
@@ -0,0 +1,447 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CmState represents a coin's cumulative move state.
|
||||
type CmState string
|
||||
|
||||
const (
|
||||
CmNeutral CmState = "neutral"
|
||||
CmRising CmState = "rising" // strong upward consensus across exchanges
|
||||
CmFalling CmState = "falling" // strong downward consensus across exchanges
|
||||
)
|
||||
|
||||
// exChange holds a per-exchange price change percentage.
|
||||
type exChange struct {
|
||||
name string
|
||||
change float64
|
||||
}
|
||||
|
||||
// CmEvent records a cumulative move state transition, persisted to DB.
|
||||
type CmEvent struct {
|
||||
Coin string `json:"coin"`
|
||||
PrevState string `json:"prev_state"`
|
||||
NewState string `json:"new_state"`
|
||||
Direction string `json:"direction"`
|
||||
Score float64 `json:"score"` // avg_change% × ex_agree
|
||||
AvgChange float64 `json:"avg_change"` // average change% across all exchanges
|
||||
ExAgree int `json:"ex_agree"`
|
||||
ExTotal int `json:"ex_total"`
|
||||
BGChange1m float64 `json:"bg_1m"`
|
||||
HLChange1m float64 `json:"hl_1m"`
|
||||
BNChange1m float64 `json:"bn_1m"`
|
||||
OKXChange1m float64 `json:"okx_1m"`
|
||||
BGChange5m float64 `json:"bg_5m"`
|
||||
HLChange5m float64 `json:"hl_5m"`
|
||||
BNChange5m float64 `json:"bn_5m"`
|
||||
OKXChange5m float64 `json:"okx_5m"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CmSnapshot is a point-in-time price snapshot for all exchanges for one coin.
|
||||
type cmSnapshot struct {
|
||||
time int64
|
||||
prices map[string]float64 // exchange → price
|
||||
}
|
||||
|
||||
// CumulativeTracker monitors multi-exchange cumulative price changes.
|
||||
// Takes 1-second snapshots, computes 1m/5m changes, detects consensus surges.
|
||||
type CumulativeTracker struct {
|
||||
mu sync.RWMutex
|
||||
coins map[string][]cmSnapshot // coin → ring buffer of snapshots
|
||||
heads map[string]int
|
||||
counts map[string]int
|
||||
|
||||
// Per-coin state
|
||||
states map[string]CmState
|
||||
prevState map[string]CmState
|
||||
|
||||
// Ring buffer config
|
||||
maxSnapshots int // 5min worth at 1s = 300
|
||||
|
||||
// Thresholds
|
||||
minExchanges int // need at least this many exchanges with data (default: 3)
|
||||
surgePct1m float64 // 1m change% threshold to trigger (default: 0.5%)
|
||||
surgePct5m float64 // 5m change% threshold to trigger (default: 1.0%)
|
||||
|
||||
// Event history (in-memory ring buffer)
|
||||
events [maxTrendEvents]CmEvent
|
||||
eventsHead int
|
||||
eventsLen int
|
||||
|
||||
// Callback for DB persistence
|
||||
OnEvent func(CmEvent)
|
||||
}
|
||||
|
||||
// NewCumulativeTracker creates a tracker with default thresholds.
|
||||
func NewCumulativeTracker() *CumulativeTracker {
|
||||
return &CumulativeTracker{
|
||||
coins: make(map[string][]cmSnapshot),
|
||||
heads: make(map[string]int),
|
||||
counts: make(map[string]int),
|
||||
states: make(map[string]CmState),
|
||||
prevState: make(map[string]CmState),
|
||||
maxSnapshots: 300, // 5min at 1s
|
||||
minExchanges: 3,
|
||||
surgePct1m: 0.5, // 0.5% in 1min
|
||||
surgePct5m: 1.0, // 1.0% in 5min
|
||||
}
|
||||
}
|
||||
|
||||
// Record stores a price snapshot for a coin at the current time.
|
||||
// Call this once per second with all exchange prices for each coin.
|
||||
func (ct *CumulativeTracker) Record(coin string, prices map[string]float64) {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
snap := cmSnapshot{
|
||||
time: time.Now().UnixMilli(),
|
||||
prices: prices,
|
||||
}
|
||||
|
||||
// Initialize buffer if needed
|
||||
if ct.coins[coin] == nil {
|
||||
ct.coins[coin] = make([]cmSnapshot, ct.maxSnapshots)
|
||||
ct.heads[coin] = 0
|
||||
ct.counts[coin] = 0
|
||||
ct.states[coin] = CmNeutral
|
||||
ct.prevState[coin] = CmNeutral
|
||||
}
|
||||
|
||||
buf := ct.coins[coin]
|
||||
head := ct.heads[coin]
|
||||
buf[head] = snap
|
||||
ct.heads[coin] = (head + 1) % ct.maxSnapshots
|
||||
if ct.counts[coin] < ct.maxSnapshots {
|
||||
ct.counts[coin]++
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrent returns current cumulative change info for all coins, sorted by score desc.
|
||||
func (ct *CumulativeTracker) GetCurrent() []map[string]interface{} {
|
||||
ct.mu.RLock()
|
||||
defer ct.mu.RUnlock()
|
||||
|
||||
var results []map[string]interface{}
|
||||
|
||||
for coin, buf := range ct.coins {
|
||||
count := ct.counts[coin]
|
||||
if count < 10 {
|
||||
continue // not enough data
|
||||
}
|
||||
head := ct.heads[coin]
|
||||
|
||||
// Get current snapshot (most recent)
|
||||
currentIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
|
||||
current := buf[currentIdx]
|
||||
if current.time == 0 {
|
||||
continue
|
||||
}
|
||||
if len(current.prices) < ct.minExchanges {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find snapshots from ~60s ago and ~300s ago
|
||||
now := current.time
|
||||
oneMinAgo := now - 60000
|
||||
fiveMinAgo := now - 300000
|
||||
var snap1m, snap5m *cmSnapshot
|
||||
var found1m, found5m bool
|
||||
|
||||
// Walk backwards from current to find closest snapshots
|
||||
for i := 0; i < count && i < ct.maxSnapshots; i++ {
|
||||
idx := (currentIdx - i + ct.maxSnapshots) % ct.maxSnapshots
|
||||
s := &buf[idx]
|
||||
if s.time == 0 {
|
||||
continue
|
||||
}
|
||||
if !found1m && s.time <= oneMinAgo {
|
||||
snap1m = s
|
||||
found1m = true
|
||||
}
|
||||
if !found5m && s.time <= fiveMinAgo {
|
||||
snap5m = s
|
||||
found5m = true
|
||||
}
|
||||
}
|
||||
if !found1m {
|
||||
// Use oldest available as 1m approximation
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute 1m changes per exchange
|
||||
var changes1m, changes5m []exChange
|
||||
|
||||
for ex, curP := range current.prices {
|
||||
if curP <= 0 {
|
||||
continue
|
||||
}
|
||||
if oldP, ok := snap1m.prices[ex]; ok && oldP > 0 {
|
||||
chg := (curP - oldP) / oldP * 100
|
||||
changes1m = append(changes1m, exChange{name: ex, change: chg})
|
||||
}
|
||||
if found5m && snap5m != nil {
|
||||
if oldP, ok := snap5m.prices[ex]; ok && oldP > 0 {
|
||||
chg := (curP - oldP) / oldP * 100
|
||||
changes5m = append(changes5m, exChange{name: ex, change: chg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(changes1m) < ct.minExchanges {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute averages and agreement
|
||||
var sum1m, sum5m float64
|
||||
agreeUp1m, agreeDown1m := 0, 0
|
||||
agreeUp5m, agreeDown5m := 0, 0
|
||||
|
||||
for _, c := range changes1m {
|
||||
sum1m += c.change
|
||||
if c.change > 0.001 {
|
||||
agreeUp1m++
|
||||
} else if c.change < -0.001 {
|
||||
agreeDown1m++
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range changes5m {
|
||||
sum5m += c.change
|
||||
if c.change > 0.005 {
|
||||
agreeUp5m++
|
||||
} else if c.change < -0.005 {
|
||||
agreeDown5m++
|
||||
}
|
||||
}
|
||||
|
||||
avg1m := sum1m / float64(len(changes1m))
|
||||
var avg5m float64
|
||||
if len(changes5m) >= ct.minExchanges {
|
||||
avg5m = sum5m / float64(len(changes5m))
|
||||
}
|
||||
|
||||
// Determine direction and agreement
|
||||
majorityDir := "up"
|
||||
majority := agreeUp1m
|
||||
if agreeDown1m > agreeUp1m {
|
||||
majorityDir = "down"
|
||||
majority = agreeDown1m
|
||||
}
|
||||
|
||||
// Score: abs(avg1m) × agreement (weighted by magnitude)
|
||||
absAvg := math.Abs(avg1m)
|
||||
score := absAvg * float64(majority)
|
||||
|
||||
entry := map[string]interface{}{
|
||||
"coin": coin,
|
||||
"avg_1m": math.Round(avg1m*10000) / 10000,
|
||||
"avg_5m": math.Round(avg5m*10000) / 10000,
|
||||
"score": math.Round(score*100) / 100,
|
||||
"direction": majorityDir,
|
||||
"ex_agree": majority,
|
||||
"ex_total": len(changes1m),
|
||||
}
|
||||
|
||||
// Individual exchange changes
|
||||
for _, c := range changes1m {
|
||||
entry[c.name+"_1m"] = math.Round(c.change*10000) / 10000
|
||||
}
|
||||
if len(changes5m) >= ct.minExchanges {
|
||||
for _, c := range changes5m {
|
||||
entry[c.name+"_5m"] = math.Round(c.change*10000) / 10000
|
||||
}
|
||||
}
|
||||
|
||||
// Current state
|
||||
entry["state"] = string(ct.states[coin])
|
||||
|
||||
results = append(results, entry)
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
si, _ := results[i]["score"].(float64)
|
||||
sj, _ := results[j]["score"].(float64)
|
||||
return si > sj
|
||||
})
|
||||
|
||||
if len(results) > 100 {
|
||||
results = results[:100]
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Tick runs one detection cycle: updates state machines, fires events.
|
||||
func (ct *CumulativeTracker) Tick() {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
for coin, buf := range ct.coins {
|
||||
count := ct.counts[coin]
|
||||
if count < 60 {
|
||||
continue // need at least 1min of data
|
||||
}
|
||||
head := ct.heads[coin]
|
||||
currentIdx := (head - 1 + ct.maxSnapshots) % ct.maxSnapshots
|
||||
current := buf[currentIdx]
|
||||
if current.time == 0 || len(current.prices) < ct.minExchanges {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find 1min ago snapshot
|
||||
oneMinAgo := current.time - 60000
|
||||
var snap1m *cmSnapshot
|
||||
for i := 0; i < count && i < ct.maxSnapshots; i++ {
|
||||
idx := (currentIdx - i + ct.maxSnapshots) % ct.maxSnapshots
|
||||
s := &buf[idx]
|
||||
if s.time > 0 && s.time <= oneMinAgo {
|
||||
snap1m = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if snap1m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute 1m changes
|
||||
var changes []exChange
|
||||
for ex, curP := range current.prices {
|
||||
if curP <= 0 {
|
||||
continue
|
||||
}
|
||||
if oldP, ok := snap1m.prices[ex]; ok && oldP > 0 {
|
||||
chg := (curP - oldP) / oldP * 100
|
||||
changes = append(changes, exChange{name: ex, change: chg})
|
||||
}
|
||||
}
|
||||
if len(changes) < ct.minExchanges {
|
||||
continue
|
||||
}
|
||||
|
||||
var sum float64
|
||||
agreeUp, agreeDown := 0, 0
|
||||
for _, c := range changes {
|
||||
sum += c.change
|
||||
if c.change > 0.001 {
|
||||
agreeUp++
|
||||
} else if c.change < -0.001 {
|
||||
agreeDown++
|
||||
}
|
||||
}
|
||||
avg := sum / float64(len(changes))
|
||||
majority := agreeUp
|
||||
majorityDir := "up"
|
||||
if agreeDown > agreeUp {
|
||||
majority = agreeDown
|
||||
majorityDir = "down"
|
||||
}
|
||||
|
||||
// Determine new state
|
||||
absAvg := math.Abs(avg)
|
||||
newState := ct.states[coin]
|
||||
|
||||
// Map exchange changes for individual values
|
||||
exMap := make(map[string]float64)
|
||||
for _, c := range changes {
|
||||
exMap[c.name] = c.change
|
||||
}
|
||||
|
||||
if absAvg >= ct.surgePct1m && majority >= ct.minExchanges {
|
||||
if majorityDir == "up" {
|
||||
if ct.states[coin] == CmNeutral || ct.states[coin] == CmFalling {
|
||||
ct.prevState[coin] = ct.states[coin]
|
||||
ct.states[coin] = CmRising
|
||||
newState = CmRising
|
||||
// Fire event
|
||||
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "rising", majorityDir,
|
||||
absAvg*float64(majority), avg, majority, len(changes), exMap)
|
||||
ct.storeEvent(ev)
|
||||
}
|
||||
} else {
|
||||
if ct.states[coin] == CmNeutral || ct.states[coin] == CmRising {
|
||||
ct.prevState[coin] = ct.states[coin]
|
||||
ct.states[coin] = CmFalling
|
||||
newState = CmFalling
|
||||
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "falling", majorityDir,
|
||||
absAvg*float64(majority), avg, majority, len(changes), exMap)
|
||||
ct.storeEvent(ev)
|
||||
}
|
||||
}
|
||||
} else if absAvg < ct.surgePct1m*0.3 || majority < 2 {
|
||||
if ct.states[coin] != CmNeutral {
|
||||
ct.prevState[coin] = ct.states[coin]
|
||||
ct.states[coin] = CmNeutral
|
||||
ev := ct.makeEvent(coin, string(ct.prevState[coin]), "neutral", majorityDir,
|
||||
absAvg*float64(majority), avg, majority, len(changes), exMap)
|
||||
ct.storeEvent(ev)
|
||||
}
|
||||
}
|
||||
_ = newState
|
||||
}
|
||||
}
|
||||
|
||||
// makeEvent builds a CmEvent struct with 1m and 5m data.
|
||||
func (ct *CumulativeTracker) makeEvent(coin, prevState, newState, direction string, score, avgChange float64, exAgree, exTotal int, exChanges map[string]float64) CmEvent {
|
||||
return CmEvent{
|
||||
Coin: coin,
|
||||
PrevState: prevState,
|
||||
NewState: newState,
|
||||
Direction: direction,
|
||||
Score: math.Round(score*100) / 100,
|
||||
AvgChange: math.Round(avgChange*10000) / 10000,
|
||||
ExAgree: exAgree,
|
||||
ExTotal: exTotal,
|
||||
BGChange1m: exChanges[ExBitget],
|
||||
HLChange1m: exChanges[ExHyperLiquid],
|
||||
BNChange1m: exChanges[ExBinance],
|
||||
OKXChange1m: exChanges[ExOKX],
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// storeEvent adds to ring buffer and fires callback.
|
||||
func (ct *CumulativeTracker) storeEvent(ev CmEvent) {
|
||||
ct.events[ct.eventsHead] = ev
|
||||
ct.eventsHead = (ct.eventsHead + 1) % maxTrendEvents
|
||||
if ct.eventsLen < maxTrendEvents {
|
||||
ct.eventsLen++
|
||||
}
|
||||
if ct.OnEvent != nil {
|
||||
ct.OnEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// GetEvents returns stored events, newest first.
|
||||
func (ct *CumulativeTracker) GetEvents(limit int) []CmEvent {
|
||||
ct.mu.RLock()
|
||||
defer ct.mu.RUnlock()
|
||||
|
||||
n := ct.eventsLen
|
||||
if limit > 0 && limit < n {
|
||||
n = limit
|
||||
}
|
||||
result := make([]CmEvent, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (ct.eventsHead - 1 - i + maxTrendEvents) % maxTrendEvents
|
||||
if ct.events[idx].Timestamp == 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, ct.events[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTopCoins returns top surging coins by score.
|
||||
func (ct *CumulativeTracker) GetTopCoins(limit int) []map[string]interface{} {
|
||||
all := ct.GetCurrent()
|
||||
if limit > 0 && limit < len(all) {
|
||||
return all[:limit]
|
||||
}
|
||||
return all
|
||||
}
|
||||
Reference in New Issue
Block a user