package main import ( "math" "sync" "time" ) // bmBuffer is a per-coin ring buffer for Binance prices. type bmBuffer struct { prices []float64 head int count int } func newBmBuffer(capacity int) *bmBuffer { return &bmBuffer{ prices: make([]float64, capacity), } } func (b *bmBuffer) push(price float64) { b.prices[b.head] = price b.head = (b.head + 1) % len(b.prices) if b.count < len(b.prices) { b.count++ } } func (b *bmBuffer) isFull() bool { return b.count == len(b.prices) } // oldest returns the price written exactly `capacity` ticks ago. func (b *bmBuffer) oldest() float64 { if !b.isFull() { return 0 } return b.prices[b.head] } // newest returns the most recently written price. func (b *bmBuffer) newest() float64 { if b.count == 0 { return 0 } idx := b.head - 1 if idx < 0 { idx = len(b.prices) - 1 } return b.prices[idx] } // BinanceAlert is emitted when a coin's 1-minute Binance change exceeds threshold. type BinanceAlert struct { Coin string `json:"coin"` Price float64 `json:"price"` OldPrice float64 `json:"old_price"` ChangePct float64 `json:"change_pct"` Direction string `json:"direction"` ThresholdPct float64 `json:"threshold_pct"` Timestamp time.Time `json:"timestamp"` } // BinanceMomentumSnapshot is the current state of a coin for SSE push. type BinanceMomentumSnapshot struct { Coin string `json:"coin"` Price float64 `json:"price"` ChangePct float64 `json:"change_pct"` Direction string `json:"direction"` BufferFull bool `json:"buffer_full"` } // BinanceMomentumDetector tracks Binance price changes over a configurable window. type BinanceMomentumDetector struct { mu sync.Mutex windowTicks int thresholdPct float64 cooldownSec int buffers map[string]*bmBuffer lastAlertAt map[string]time.Time recentAlerts []BinanceAlert maxAlerts int } // NewBinanceMomentumDetector creates a detector for all TrackedCoins. func NewBinanceMomentumDetector(windowSec int, tickMs int, thresholdPct float64, cooldownSec int) *BinanceMomentumDetector { windowTicks := windowSec * 1000 / tickMs if windowTicks < 1 { windowTicks = 1 } d := &BinanceMomentumDetector{ windowTicks: windowTicks, thresholdPct: thresholdPct, cooldownSec: cooldownSec, buffers: make(map[string]*bmBuffer, len(TrackedCoins)), lastAlertAt: make(map[string]time.Time), maxAlerts: 200, } for _, tc := range TrackedCoins { d.buffers[tc.Name] = newBmBuffer(windowTicks) } return d } // Record appends the current Binance price for a coin into its ring buffer. func (d *BinanceMomentumDetector) Record(coin string, price float64) { d.mu.Lock() buf, ok := d.buffers[coin] if ok { buf.push(price) } d.mu.Unlock() } // Detect checks all coins for threshold breaches. Returns new alerts. func (d *BinanceMomentumDetector) Detect() []BinanceAlert { d.mu.Lock() defer d.mu.Unlock() now := time.Now() var alerts []BinanceAlert for coin, buf := range d.buffers { if !buf.isFull() { continue } old := buf.oldest() current := buf.newest() if old <= 0 || current <= 0 { continue } changePct := (current - old) / old * 100 if math.Abs(changePct) < d.thresholdPct { continue } lastAt, exists := d.lastAlertAt[coin] if exists && now.Sub(lastAt).Seconds() < float64(d.cooldownSec) { continue } dir := "up" if changePct < 0 { dir = "down" } alert := BinanceAlert{ Coin: coin, Price: current, OldPrice: old, ChangePct: changePct, Direction: dir, ThresholdPct: d.thresholdPct, Timestamp: now, } alerts = append(alerts, alert) d.lastAlertAt[coin] = now // Store in recent alerts ring buffer d.recentAlerts = append(d.recentAlerts, alert) if len(d.recentAlerts) > d.maxAlerts { d.recentAlerts = d.recentAlerts[len(d.recentAlerts)-d.maxAlerts:] } } return alerts } // Snapshot returns the current momentum state for all coins. func (d *BinanceMomentumDetector) Snapshot() []BinanceMomentumSnapshot { d.mu.Lock() defer d.mu.Unlock() snapshots := make([]BinanceMomentumSnapshot, 0, len(d.buffers)) for coin, buf := range d.buffers { s := BinanceMomentumSnapshot{ Coin: coin, BufferFull: buf.isFull(), } if buf.isFull() { old := buf.oldest() current := buf.newest() s.Price = current if old > 0 { s.ChangePct = (current - old) / old * 100 if s.ChangePct >= 0 { s.Direction = "up" } else { s.Direction = "down" } } } else if buf.count > 0 { s.Price = buf.newest() s.Direction = "flat" } snapshots = append(snapshots, s) } return snapshots } // RecentAlerts returns the last N alerts. func (d *BinanceMomentumDetector) RecentAlerts(limit int) []BinanceAlert { d.mu.Lock() defer d.mu.Unlock() if limit <= 0 || limit > len(d.recentAlerts) { limit = len(d.recentAlerts) } if limit == 0 { return nil } start := len(d.recentAlerts) - limit r := make([]BinanceAlert, limit) copy(r, d.recentAlerts[start:]) // Reverse so newest is first for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return r }