221 lines
5.1 KiB
Go
221 lines
5.1 KiB
Go
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
|
|
}
|