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"}, {"t60s", 1200, "60s"}, } 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"` BG60s float64 `json:"bg_60s"` BN1s float64 `json:"bn_1s"` BN5s float64 `json:"bn_5s"` BN15s float64 `json:"bn_15s"` BN60s float64 `json:"bn_60s"` OKX1s float64 `json:"okx_1s"` OKX5s float64 `json:"okx_5s"` OKX15s float64 `json:"okx_15s"` OKX60s float64 `json:"okx_60s"` 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] bnBuf, hasBN := exMap[ExBinance] okBuf, hasOK := exMap[ExOKX] if !hasBG && !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] entry.BG60s = changes[3] allChanges = append(allChanges, changes[:]...) } if hasBN { changes := calcWindows(bnBuf) entry.BN1s = changes[0] entry.BN5s = changes[1] entry.BN15s = changes[2] entry.BN60s = changes[3] allChanges = append(allChanges, changes[:]...) } if hasOK { changes := calcWindows(okBuf) entry.OKX1s = changes[0] entry.OKX5s = changes[1] entry.OKX15s = changes[2] entry.OKX60s = changes[3] 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 windows: (current - old) / old * 100. // Returns 0 for windows that don't have enough data yet. func calcWindows(buf *momentumBuffer) [4]float64 { var result [4]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 }