misc: binance momentum, telegram, frontend updates
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -32,6 +32,12 @@ type Config struct {
|
|||||||
TrendAnomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
|
TrendAnomalyMul float64 // z-score multiplier for alert threshold (default: 3.0)
|
||||||
TrendConfirmTicks int // ticks needed for state confirmation (default: 3)
|
TrendConfirmTicks int // ticks needed for state confirmation (default: 3)
|
||||||
TrendAlertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
|
TrendAlertCooldown int64 // ms cooldown between alerts for same coin (default: 60000)
|
||||||
|
|
||||||
|
// Binance momentum detection (1-minute Binance-only price change)
|
||||||
|
BinanceMomentumEnabled bool
|
||||||
|
BinanceMomentumThresholdPct float64 // default: 10.0
|
||||||
|
BinanceMomentumCooldownSec int // default: 300 (5 min)
|
||||||
|
BinanceMomentumWindowSec int // default: 60
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsonConfig maps config.json fields (non-secret defaults checked into git).
|
// jsonConfig maps config.json fields (non-secret defaults checked into git).
|
||||||
@@ -57,6 +63,12 @@ type jsonConfig struct {
|
|||||||
TrendAnomalyMul float64 `json:"trend_anomaly_mul"`
|
TrendAnomalyMul float64 `json:"trend_anomaly_mul"`
|
||||||
TrendConfirmTicks int `json:"trend_confirm_ticks"`
|
TrendConfirmTicks int `json:"trend_confirm_ticks"`
|
||||||
TrendAlertCooldown int64 `json:"trend_alert_cooldown_ms"`
|
TrendAlertCooldown int64 `json:"trend_alert_cooldown_ms"`
|
||||||
|
|
||||||
|
// Binance momentum detection
|
||||||
|
BinanceMomentumEnabled bool `json:"binance_momentum_enabled"`
|
||||||
|
BinanceMomentumThresholdPct float64 `json:"binance_momentum_threshold_pct"`
|
||||||
|
BinanceMomentumCooldownSec int `json:"binance_momentum_cooldown_sec"`
|
||||||
|
BinanceMomentumWindowSec int `json:"binance_momentum_window_sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
@@ -113,6 +125,12 @@ func LoadConfig() *Config {
|
|||||||
TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul),
|
TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul),
|
||||||
TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))),
|
TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))),
|
||||||
TrendAlertCooldown: int64(getFloat("TREND_ALERT_COOLDOWN_MS", float64(jsonCfg.TrendAlertCooldown))),
|
TrendAlertCooldown: int64(getFloat("TREND_ALERT_COOLDOWN_MS", float64(jsonCfg.TrendAlertCooldown))),
|
||||||
|
|
||||||
|
// Binance momentum detection
|
||||||
|
BinanceMomentumEnabled: getBool("BINANCE_MOMENTUM_ENABLED", jsonCfg.BinanceMomentumEnabled),
|
||||||
|
BinanceMomentumThresholdPct: getFloat("BINANCE_MOMENTUM_THRESHOLD_PCT", jsonCfg.BinanceMomentumThresholdPct),
|
||||||
|
BinanceMomentumCooldownSec: int(getFloat("BINANCE_MOMENTUM_COOLDOWN_SEC", float64(jsonCfg.BinanceMomentumCooldownSec))),
|
||||||
|
BinanceMomentumWindowSec: int(getFloat("BINANCE_MOMENTUM_WINDOW_SEC", float64(jsonCfg.BinanceMomentumWindowSec))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +155,12 @@ func loadJSONConfig() jsonConfig {
|
|||||||
TrendAnomalyMul: 3.0, // 3 sigma z-score threshold
|
TrendAnomalyMul: 3.0, // 3 sigma z-score threshold
|
||||||
TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation
|
TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation
|
||||||
TrendAlertCooldown: 60000, // 1 min cooldown
|
TrendAlertCooldown: 60000, // 1 min cooldown
|
||||||
|
|
||||||
|
// Binance momentum detection
|
||||||
|
BinanceMomentumEnabled: true,
|
||||||
|
BinanceMomentumThresholdPct: 10.0,
|
||||||
|
BinanceMomentumCooldownSec: 300, // 5 minutes between alerts per coin
|
||||||
|
BinanceMomentumWindowSec: 60, // 1-minute lookback
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile("config.json")
|
data, err := os.ReadFile("config.json")
|
||||||
@@ -192,10 +216,22 @@ func loadJSONConfig() jsonConfig {
|
|||||||
def.TrendAlertCooldown = cfg.TrendAlertCooldown
|
def.TrendAlertCooldown = cfg.TrendAlertCooldown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Binance momentum JSON overrides
|
||||||
|
if cfg.BinanceMomentumThresholdPct != 0 {
|
||||||
|
def.BinanceMomentumThresholdPct = cfg.BinanceMomentumThresholdPct
|
||||||
|
}
|
||||||
|
if cfg.BinanceMomentumCooldownSec != 0 {
|
||||||
|
def.BinanceMomentumCooldownSec = cfg.BinanceMomentumCooldownSec
|
||||||
|
}
|
||||||
|
if cfg.BinanceMomentumWindowSec != 0 {
|
||||||
|
def.BinanceMomentumWindowSec = cfg.BinanceMomentumWindowSec
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean fields: zero default is false, so use OR logic
|
// Boolean fields: zero default is false, so use OR logic
|
||||||
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
|
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
|
||||||
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
|
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
|
||||||
def.SurgeEnabled = cfg.SurgeEnabled || def.SurgeEnabled
|
def.SurgeEnabled = cfg.SurgeEnabled || def.SurgeEnabled
|
||||||
|
def.BinanceMomentumEnabled = cfg.BinanceMomentumEnabled || def.BinanceMomentumEnabled
|
||||||
|
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -13,5 +13,9 @@
|
|||||||
"trend_baseline_window": 600,
|
"trend_baseline_window": 600,
|
||||||
"trend_anomaly_mul": 3.0,
|
"trend_anomaly_mul": 3.0,
|
||||||
"trend_confirm_ticks": 3,
|
"trend_confirm_ticks": 3,
|
||||||
"trend_alert_cooldown_ms": 60000
|
"trend_alert_cooldown_ms": 60000,
|
||||||
|
"binance_momentum_enabled": true,
|
||||||
|
"binance_momentum_threshold_pct": 10.0,
|
||||||
|
"binance_momentum_cooldown_sec": 300,
|
||||||
|
"binance_momentum_window_sec": 60
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-1
@@ -212,9 +212,12 @@ type Dashboard struct {
|
|||||||
|
|
||||||
// Surge detector
|
// Surge detector
|
||||||
surgeDetector *SurgeDetector
|
surgeDetector *SurgeDetector
|
||||||
|
|
||||||
|
// Binance momentum detector
|
||||||
|
binanceMomentumDetector *BinanceMomentumDetector
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter, surgeDetector *SurgeDetector) *Dashboard {
|
func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter, surgeDetector *SurgeDetector, binanceMomentumDetector *BinanceMomentumDetector) *Dashboard {
|
||||||
d := &Dashboard{
|
d := &Dashboard{
|
||||||
hub: NewSSEHub(),
|
hub: NewSSEHub(),
|
||||||
history: newPriceHistory(),
|
history: newPriceHistory(),
|
||||||
@@ -229,6 +232,7 @@ func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config,
|
|||||||
cumulativeTracker: cumulativeTracker,
|
cumulativeTracker: cumulativeTracker,
|
||||||
trendFilter: trendFilter,
|
trendFilter: trendFilter,
|
||||||
surgeDetector: surgeDetector,
|
surgeDetector: surgeDetector,
|
||||||
|
binanceMomentumDetector: binanceMomentumDetector,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire trend event persistence to SQLite
|
// Wire trend event persistence to SQLite
|
||||||
@@ -286,6 +290,8 @@ func (d *Dashboard) Run() {
|
|||||||
mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals)
|
mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals)
|
||||||
mux.HandleFunc("GET /api/surge-events", d.handleSurgeEvents)
|
mux.HandleFunc("GET /api/surge-events", d.handleSurgeEvents)
|
||||||
mux.HandleFunc("GET /events", d.handleSSE)
|
mux.HandleFunc("GET /events", d.handleSSE)
|
||||||
|
mux.HandleFunc("GET /binance", d.handleBinanceIndex)
|
||||||
|
mux.HandleFunc("GET /api/binance-alerts", d.handleBinanceAlerts)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: d.addr,
|
Addr: d.addr,
|
||||||
@@ -435,6 +441,14 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
d.hub.Broadcast("surge", surgeSnap)
|
d.hub.Broadcast("surge", surgeSnap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 9. Binance momentum snapshot (1-minute change for all coins)
|
||||||
|
if d.binanceMomentumDetector != nil && d.cfg.BinanceMomentumEnabled {
|
||||||
|
bmSnap := d.binanceMomentumDetector.Snapshot()
|
||||||
|
if len(bmSnap) > 0 {
|
||||||
|
d.hub.Broadcast("binance_momentum", bmSnap)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +662,36 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleBinanceIndex serves the Binance momentum monitor page.
|
||||||
|
func (d *Dashboard) handleBinanceIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var data []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
data, err = os.ReadFile("frontend/dist/binance.html")
|
||||||
|
if err != nil {
|
||||||
|
data, err = staticFS.ReadFile("frontend/dist/binance.html")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Not found", 404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBinanceAlerts returns recent Binance momentum alerts.
|
||||||
|
func (d *Dashboard) handleBinanceAlerts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if d.binanceMomentumDetector == nil {
|
||||||
|
writeJSON(w, map[string]interface{}{"alerts": []interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alerts := d.binanceMomentumDetector.RecentAlerts(50)
|
||||||
|
if alerts == nil {
|
||||||
|
alerts = []BinanceAlert{}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"alerts": alerts})
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Binance Momentum Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/binance-main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{r as c,j as t,c as B,R as E}from"./client-DYDkQYN6.js";const w="https://www.binance.com/en/futures/";function M(){const[r,S]=c.useState([]),[h,g]=c.useState([]),[C,x]=c.useState("● 连接中..."),[v,u]=c.useState(!1),[i,y]=c.useState("change_abs"),[l,j]=c.useState("desc"),d=c.useRef(null),p=c.useCallback(()=>{const e=new EventSource("/events");d.current=e,e.addEventListener("connected",()=>{x("● 已连接"),u(!0)}),e.onmessage=a=>{try{const n=JSON.parse(a.data);switch(n.event){case"binance_momentum":S(n.data);break;case"binance_alert":g(s=>[n.data,...s].slice(0,200));break}}catch{}},e.onerror=()=>{x("● 已断开 (重连中...)"),u(!1),e.close(),setTimeout(p,3e3)}},[]);c.useEffect(()=>(p(),()=>{d.current&&d.current.close()}),[p]),c.useEffect(()=>{const a=setInterval(async()=>{try{const s=await(await fetch("/api/binance-alerts?limit=50")).json();s.alerts&&s.alerts.length>0&&g(_=>{const $=new Set(_.map(o=>o.timestamp)),N=[..._];for(const o of s.alerts)$.has(o.timestamp)||N.push(o);return N.slice(0,200)})}catch{}},1e4);return()=>clearInterval(a)},[]);const m=e=>{i===e?j(a=>a==="asc"?"desc":"asc"):(y(e),j("desc"))},f=[...r].sort((e,a)=>{let n,s;switch(i){case"coin":return n=e.coin,s=a.coin,l==="asc"?n.localeCompare(s):s.localeCompare(n);case"price":n=e.price||0,s=a.price||0;break;case"change_abs":n=Math.abs(e.change_pct)||0,s=Math.abs(a.change_pct)||0;break;case"change":n=e.change_pct||0,s=a.change_pct||0;break;default:return 0}return l==="asc"?n-s:s-n}),k=r.filter(e=>e.buffer_full).length,b=r.filter(e=>Math.abs(e.change_pct)>=10).length;return t.jsxs("div",{id:"bm-app",children:[t.jsxs("header",{children:[t.jsxs("div",{children:[t.jsx("h1",{children:"BN 1-Min Momentum Monitor"}),t.jsx("span",{className:"header-subtitle",children:"Binance 1分钟涨跌监控 | 阈值 ≥10%"})]}),t.jsxs("div",{className:"header-right",children:[t.jsxs("span",{className:"stat-badge",children:[r.length," coins"]}),t.jsxs("span",{className:"stat-badge",children:[k," ready"]}),b>0&&t.jsxs("span",{className:"stat-badge stat-alert",children:[b," alerting"]}),t.jsx("span",{className:v?"status-online":"status-offline",children:C})]})]}),h.length>0&&t.jsxs("section",{className:"card card-wide alert-section",children:[t.jsxs("h2",{children:["Alert History (",h.length,")"]}),t.jsx("div",{className:"alert-scroll",children:h.slice(0,50).map((e,a)=>{var n,s;return t.jsxs("div",{className:`alert-item alert-${e.direction}`,children:[t.jsx("span",{className:"alert-icon",children:e.direction==="up"?"🟢":"🔴"}),t.jsx("a",{href:`${w}${e.coin}USDT`,target:"_blank",rel:"noreferrer",className:"alert-coin",children:e.coin}),t.jsxs("span",{className:e.direction==="up"?"text-green":"text-red",children:[e.change_pct>=0?"+":"",(n=e.change_pct)==null?void 0:n.toFixed(2),"%"]}),t.jsxs("span",{className:"alert-price",children:["$",(s=e.price)==null?void 0:s.toFixed(4)]}),t.jsx("span",{className:"alert-time text-dim",children:new Date(e.timestamp).toLocaleTimeString()})]},a)})})]}),t.jsxs("section",{className:"card card-wide",children:[t.jsxs("h2",{children:["All Coins (",r.length,")"]}),t.jsx("div",{className:"table-wrap",style:{maxHeight:"calc(100vh - 200px)"},children:t.jsxs("table",{children:[t.jsx("thead",{children:t.jsxs("tr",{children:[t.jsxs("th",{onClick:()=>m("coin"),style:{cursor:"pointer"},children:["Coin ",i==="coin"?l==="asc"?"▲":"▼":""]}),t.jsxs("th",{onClick:()=>m("price"),style:{cursor:"pointer"},children:["Price ",i==="price"?l==="asc"?"▲":"▼":""]}),t.jsxs("th",{onClick:()=>m("change_abs"),style:{cursor:"pointer"},children:["1m Change ",i==="change_abs"?l==="asc"?"▲":"▼":""]}),t.jsx("th",{children:"Bar"})]})}),t.jsxs("tbody",{children:[f.map(e=>{const a=e.buffer_full?e.change_pct>=10?"row-surge-up":e.change_pct<=-10?"row-surge-down":e.change_pct>0?"row-up":e.change_pct<0?"row-down":"":"row-warmup",n=Math.min(Math.abs(e.change_pct)/15*100,100),s=e.change_pct>=10?"bar-green":e.change_pct<=-10?"bar-red":e.change_pct>0?"bar-green-dim":"bar-red-dim";return t.jsxs("tr",{className:a,children:[t.jsxs("td",{children:[t.jsx("a",{href:`${w}${e.coin}USDT`,target:"_blank",rel:"noreferrer",className:"coin-link",children:e.coin}),!e.buffer_full&&t.jsx("span",{className:"warmup-badge",children:"···"})]}),t.jsx("td",{className:"text-right mono",children:e.price?`$${e.price.toFixed(4)}`:"-"}),t.jsx("td",{className:`text-right mono ${e.change_pct>=10?"text-green":e.change_pct<=-10?"text-red":e.change_pct>0?"text-green":e.change_pct<0?"text-red":"text-dim"}`,children:e.buffer_full?`${e.change_pct>=0?"+":""}${e.change_pct.toFixed(2)}%`:"warming..."}),t.jsx("td",{children:t.jsx("div",{className:"bar-track",children:t.jsx("div",{className:`bar-fill ${s}`,style:{width:`${n}%`}})})})]},e.coin)}),f.length===0&&t.jsx("tr",{children:t.jsx("td",{colSpan:4,className:"loading",children:"Waiting for data..."})})]})]})})]})]})}B.createRoot(document.getElementById("root")).render(t.jsx(E.StrictMode,{children:t.jsx(M,{})}));
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#bm-app{max-width:1200px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:12px;flex-wrap:wrap;gap:8px}header h1{font-size:18px;font-weight:600}.header-subtitle{font-size:12px;color:var(--text-dim);margin-left:12px}.header-right{display:flex;align-items:center;gap:10px;font-size:13px}.stat-badge{background:#58a6ff1a;color:var(--accent);padding:2px 8px;border-radius:10px;font-size:12px;font-weight:500}.stat-alert{background:#f8514926;color:var(--red);animation:pulse 2s infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.6}}.status-online{color:var(--green)}.status-offline{color:var(--red)}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.alert-scroll{display:flex;flex-wrap:wrap;gap:6px;max-height:120px;overflow-y:auto}.alert-item{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:4px;font-size:13px;font-variant-numeric:tabular-nums}.alert-up{background:#3fb95014}.alert-down{background:#f8514914}.alert-icon{font-size:14px}.alert-coin{color:var(--accent);text-decoration:none;font-weight:600;min-width:50px}.alert-coin:hover{text-decoration:underline}.alert-price{color:var(--text-dim);min-width:90px}.alert-time{font-size:11px}.table-wrap{overflow-x:auto;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border);-webkit-user-select:none;user-select:none}th:hover{color:var(--accent)}td{padding:4px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.coin-link{color:var(--accent);text-decoration:none;font-weight:600}.coin-link:hover{text-decoration:underline}.warmup-badge{font-size:10px;color:var(--text-dim);margin-left:6px}.row-warmup td{opacity:.45}.row-surge-up td{background:#3fb9501a!important}.row-surge-up:hover td{background:#3fb9502e!important}.row-surge-down td{background:#f851491a!important}.row-surge-down:hover td{background:#f851492e!important}.row-up td{background:#3fb95008}.row-down td{background:#f8514908}.bar-track{width:100px;height:6px;background:#30363d80;border-radius:3px;overflow:hidden}.bar-fill{height:100%;border-radius:3px;transition:width .3s ease}.bar-green{background:var(--green)}.bar-red{background:var(--red)}.bar-green-dim{background:#3fb95080}.bar-red-dim{background:#f8514980}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}.mono{font-variant-numeric:tabular-nums;font-family:SF Mono,Cascadia Code,monospace}.loading{text-align:center;color:var(--text-dim);padding:20px!important}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){header{flex-direction:column}#bm-app{padding:8px}}
|
||||||
+40
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Binance Momentum Monitor</title>
|
||||||
|
<script type="module" crossorigin src="/static/assets/binance-C6HzvyUG.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/static/assets/client-DYDkQYN6.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/static/assets/binance-DE6-y9x2.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Vendored
+3
-2
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Exchange Monitor Dashboard</title>
|
<title>Exchange Monitor Dashboard</title>
|
||||||
<script type="module" crossorigin src="/static/assets/index-Czt_9K6K.js"></script>
|
<script type="module" crossorigin src="/static/assets/main-Cg5_dPUr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/static/assets/index-vvNDQq2K.css">
|
<link rel="modulepreload" crossorigin href="/static/assets/client-DYDkQYN6.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/static/assets/main-vvNDQq2K.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--card: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--red: #f85149;
|
||||||
|
--yellow: #d29922;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bm-app { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.header-subtitle { font-size: 12px; color: var(--text-dim); margin-left: 12px; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||||
|
|
||||||
|
.stat-badge {
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat-alert {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
color: var(--red);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online { color: var(--green); }
|
||||||
|
.status-offline { color: var(--red); }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.card-wide { }
|
||||||
|
.card h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Section */
|
||||||
|
.alert-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.alert-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.alert-up { background: rgba(63, 185, 80, 0.08); }
|
||||||
|
.alert-down { background: rgba(248, 81, 73, 0.08); }
|
||||||
|
.alert-icon { font-size: 14px; }
|
||||||
|
.alert-coin {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
.alert-coin:hover { text-decoration: underline; }
|
||||||
|
.alert-price { color: var(--text-dim); min-width: 90px; }
|
||||||
|
.alert-time { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-wrap { overflow-x: auto; overflow-y: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
th:hover { color: var(--accent); }
|
||||||
|
td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
||||||
|
|
||||||
|
.coin-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.coin-link:hover { text-decoration: underline; }
|
||||||
|
.warmup-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row highlights */
|
||||||
|
.row-warmup td { opacity: 0.45; }
|
||||||
|
.row-surge-up td { background: rgba(63, 185, 80, 0.1) !important; }
|
||||||
|
.row-surge-up:hover td { background: rgba(63, 185, 80, 0.18) !important; }
|
||||||
|
.row-surge-down td { background: rgba(248, 81, 73, 0.1) !important; }
|
||||||
|
.row-surge-down:hover td { background: rgba(248, 81, 73, 0.18) !important; }
|
||||||
|
.row-up td { background: rgba(63, 185, 80, 0.03); }
|
||||||
|
.row-down td { background: rgba(248, 81, 73, 0.03); }
|
||||||
|
|
||||||
|
/* Bar */
|
||||||
|
.bar-track {
|
||||||
|
width: 100px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(48, 54, 61, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.bar-green { background: var(--green); }
|
||||||
|
.bar-red { background: var(--red); }
|
||||||
|
.bar-green-dim { background: rgba(63, 185, 80, 0.5); }
|
||||||
|
.bar-red-dim { background: rgba(248, 81, 73, 0.5); }
|
||||||
|
|
||||||
|
/* Utility */
|
||||||
|
.text-green { color: var(--green); }
|
||||||
|
.text-red { color: var(--red); }
|
||||||
|
.text-dim { color: var(--text-dim); }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.mono { font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Cascadia Code', monospace; }
|
||||||
|
.loading { text-align: center; color: var(--text-dim); padding: 20px !important; }
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header { flex-direction: column; }
|
||||||
|
#bm-app { padding: 8px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
|
||||||
|
const BINANCE_BASE = 'https://www.binance.com/en/futures/'
|
||||||
|
|
||||||
|
function BinanceApp() {
|
||||||
|
const [coins, setCoins] = useState([])
|
||||||
|
const [alerts, setAlerts] = useState([])
|
||||||
|
const [connStatus, setConnStatus] = useState('● 连接中...')
|
||||||
|
const [connOnline, setConnOnline] = useState(false)
|
||||||
|
const [sortBy, setSortBy] = useState('change_abs')
|
||||||
|
const [sortDir, setSortDir] = useState('desc')
|
||||||
|
const esRef = useRef(null)
|
||||||
|
|
||||||
|
// Connect to SSE
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
const es = new EventSource('/events')
|
||||||
|
esRef.current = es
|
||||||
|
|
||||||
|
es.addEventListener('connected', () => {
|
||||||
|
setConnStatus('● 已连接')
|
||||||
|
setConnOnline(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Backend sends events as unnamed SSE messages with JSON {event, data, ts}.
|
||||||
|
// Use onmessage and dispatch by msg.event field.
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data)
|
||||||
|
switch (msg.event) {
|
||||||
|
case 'binance_momentum':
|
||||||
|
setCoins(msg.data)
|
||||||
|
break
|
||||||
|
case 'binance_alert':
|
||||||
|
setAlerts(prev => [msg.data, ...prev].slice(0, 200))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
setConnStatus('● 已断开 (重连中...)')
|
||||||
|
setConnOnline(false)
|
||||||
|
es.close()
|
||||||
|
setTimeout(connect, 3000)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect()
|
||||||
|
return () => {
|
||||||
|
if (esRef.current) esRef.current.close()
|
||||||
|
}
|
||||||
|
}, [connect])
|
||||||
|
|
||||||
|
// Poll alerts as fallback
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAlerts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/binance-alerts?limit=50')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.alerts && data.alerts.length > 0) {
|
||||||
|
setAlerts(prev => {
|
||||||
|
const existing = new Set(prev.map(a => a.timestamp))
|
||||||
|
const merged = [...prev]
|
||||||
|
for (const a of data.alerts) {
|
||||||
|
if (!existing.has(a.timestamp)) {
|
||||||
|
merged.push(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged.slice(0, 200)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const timer = setInterval(fetchAlerts, 10000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Sort coins
|
||||||
|
const handleSort = (col) => {
|
||||||
|
if (sortBy === col) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortBy(col)
|
||||||
|
setSortDir('desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedCoins = [...coins].sort((a, b) => {
|
||||||
|
let va, vb
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'coin':
|
||||||
|
va = a.coin; vb = b.coin
|
||||||
|
return sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va)
|
||||||
|
case 'price':
|
||||||
|
va = a.price || 0; vb = b.price || 0
|
||||||
|
break
|
||||||
|
case 'change_abs':
|
||||||
|
va = Math.abs(a.change_pct) || 0; vb = Math.abs(b.change_pct) || 0
|
||||||
|
break
|
||||||
|
case 'change':
|
||||||
|
va = a.change_pct || 0; vb = b.change_pct || 0
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? va - vb : vb - va
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const fullCount = coins.filter(c => c.buffer_full).length
|
||||||
|
const alertingCount = coins.filter(c => Math.abs(c.change_pct) >= 10).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="bm-app">
|
||||||
|
{/* Header */}
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>BN 1-Min Momentum Monitor</h1>
|
||||||
|
<span className="header-subtitle">Binance 1分钟涨跌监控 | 阈值 ≥10%</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-right">
|
||||||
|
<span className="stat-badge">{coins.length} coins</span>
|
||||||
|
<span className="stat-badge">{fullCount} ready</span>
|
||||||
|
{alertingCount > 0 && <span className="stat-badge stat-alert">{alertingCount} alerting</span>}
|
||||||
|
<span className={connOnline ? 'status-online' : 'status-offline'}>{connStatus}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Alert History */}
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<section className="card card-wide alert-section">
|
||||||
|
<h2>Alert History ({alerts.length})</h2>
|
||||||
|
<div className="alert-scroll">
|
||||||
|
{alerts.slice(0, 50).map((a, i) => (
|
||||||
|
<div key={i} className={`alert-item alert-${a.direction}`}>
|
||||||
|
<span className="alert-icon">{a.direction === 'up' ? '🟢' : '🔴'}</span>
|
||||||
|
<a href={`${BINANCE_BASE}${a.coin}USDT`} target="_blank" rel="noreferrer" className="alert-coin">{a.coin}</a>
|
||||||
|
<span className={a.direction === 'up' ? 'text-green' : 'text-red'}>
|
||||||
|
{a.change_pct >= 0 ? '+' : ''}{a.change_pct?.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
<span className="alert-price">${a.price?.toFixed(4)}</span>
|
||||||
|
<span className="alert-time text-dim">{new Date(a.timestamp).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coins Table */}
|
||||||
|
<section className="card card-wide">
|
||||||
|
<h2>All Coins ({coins.length})</h2>
|
||||||
|
<div className="table-wrap" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th onClick={() => handleSort('coin')} style={{ cursor: 'pointer' }}>
|
||||||
|
Coin {sortBy === 'coin' ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
<th onClick={() => handleSort('price')} style={{ cursor: 'pointer' }}>
|
||||||
|
Price {sortBy === 'price' ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
<th onClick={() => handleSort('change_abs')} style={{ cursor: 'pointer' }}>
|
||||||
|
1m Change {sortBy === 'change_abs' ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
<th>Bar</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedCoins.map(c => {
|
||||||
|
const cls = !c.buffer_full ? 'row-warmup'
|
||||||
|
: c.change_pct >= 10 ? 'row-surge-up'
|
||||||
|
: c.change_pct <= -10 ? 'row-surge-down'
|
||||||
|
: c.change_pct > 0 ? 'row-up'
|
||||||
|
: c.change_pct < 0 ? 'row-down'
|
||||||
|
: ''
|
||||||
|
const barPct = Math.min(Math.abs(c.change_pct) / 15 * 100, 100)
|
||||||
|
const barCls = c.change_pct >= 10 ? 'bar-green'
|
||||||
|
: c.change_pct <= -10 ? 'bar-red'
|
||||||
|
: c.change_pct > 0 ? 'bar-green-dim'
|
||||||
|
: 'bar-red-dim'
|
||||||
|
return (
|
||||||
|
<tr key={c.coin} className={cls}>
|
||||||
|
<td>
|
||||||
|
<a href={`${BINANCE_BASE}${c.coin}USDT`} target="_blank" rel="noreferrer" className="coin-link">
|
||||||
|
{c.coin}
|
||||||
|
</a>
|
||||||
|
{!c.buffer_full && <span className="warmup-badge">···</span>}
|
||||||
|
</td>
|
||||||
|
<td className="text-right mono">
|
||||||
|
{c.price ? `$${c.price.toFixed(4)}` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className={`text-right mono ${c.change_pct >= 10 ? 'text-green' : c.change_pct <= -10 ? 'text-red' : c.change_pct > 0 ? 'text-green' : c.change_pct < 0 ? 'text-red' : 'text-dim'}`}>
|
||||||
|
{c.buffer_full ? `${c.change_pct >= 0 ? '+' : ''}${c.change_pct.toFixed(2)}%` : 'warming...'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="bar-track">
|
||||||
|
<div className={`bar-fill ${barCls}`} style={{ width: `${barPct}%` }} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{sortedCoins.length === 0 && (
|
||||||
|
<tr><td colSpan={4} className="loading">Waiting for data...</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BinanceApp
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import BinanceApp from './BinanceApp'
|
||||||
|
import './BinanceApp.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BinanceApp />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -13,5 +14,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
binance: resolve(__dirname, 'binance.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ func main() {
|
|||||||
loadDotEnv()
|
loadDotEnv()
|
||||||
cfg := LoadConfig()
|
cfg := LoadConfig()
|
||||||
|
|
||||||
|
// Initialize Telegram sender
|
||||||
|
telegramSender := NewTelegramSender(cfg.TelegramBotToken, cfg.TelegramChatID)
|
||||||
|
if telegramSender.IsEnabled() {
|
||||||
|
log.Printf("[Telegram] Alerts enabled -> chat %s", cfg.TelegramChatID)
|
||||||
|
} else {
|
||||||
|
log.Println("[Telegram] Alerts disabled (missing token or chat ID)")
|
||||||
|
}
|
||||||
|
|
||||||
store := NewPriceStore()
|
store := NewPriceStore()
|
||||||
|
|
||||||
// Initialize momentum tracker (for momentum scanning mode)
|
// Initialize momentum tracker (for momentum scanning mode)
|
||||||
@@ -79,8 +87,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Binance momentum detector (1-minute Binance price change)
|
||||||
|
binanceMomentumDetector := NewBinanceMomentumDetector(
|
||||||
|
cfg.BinanceMomentumWindowSec, 50, // actual loop tick is fixed 50ms
|
||||||
|
cfg.BinanceMomentumThresholdPct, cfg.BinanceMomentumCooldownSec,
|
||||||
|
)
|
||||||
|
if cfg.BinanceMomentumEnabled {
|
||||||
|
log.Printf("[BinanceMomentum] Detection enabled (window=%ds, threshold=%.1f%%, cooldown=%ds)",
|
||||||
|
cfg.BinanceMomentumWindowSec, cfg.BinanceMomentumThresholdPct, cfg.BinanceMomentumCooldownSec)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize dashboard (web server + SSE)
|
// Initialize dashboard (web server + SSE)
|
||||||
dashboard := NewDashboard(store, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter, surgeDetector)
|
dashboard := NewDashboard(store, database, ":8888", cfg, momentumTracker, trendDetector, cumulativeTracker, trendFilter, surgeDetector, binanceMomentumDetector)
|
||||||
go dashboard.Run()
|
go dashboard.Run()
|
||||||
|
|
||||||
// Context for graceful shutdown
|
// Context for graceful shutdown
|
||||||
@@ -172,6 +190,30 @@ func main() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
snap := store.GetAll()
|
snap := store.GetAll()
|
||||||
|
|
||||||
|
// Feed Binance prices to momentum detector
|
||||||
|
if cfg.BinanceMomentumEnabled {
|
||||||
|
for _, tc := range TrackedCoins {
|
||||||
|
if exMap := snap[tc.Name]; exMap != nil {
|
||||||
|
if bnPrice, ok := exMap[ExBinance]; ok && bnPrice > 0 {
|
||||||
|
binanceMomentumDetector.Record(tc.Name, bnPrice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alerts := binanceMomentumDetector.Detect()
|
||||||
|
for _, alert := range alerts {
|
||||||
|
log.Printf("[BinanceMomentum] %s %s %.2f%% ($%.4f)",
|
||||||
|
alert.Coin, alert.Direction, alert.ChangePct, alert.Price)
|
||||||
|
dashboard.BroadcastEvent("binance_alert", alert)
|
||||||
|
if telegramSender.IsEnabled() {
|
||||||
|
go func(a BinanceAlert) {
|
||||||
|
if err := telegramSender.SendAlert(a); err != nil {
|
||||||
|
log.Printf("[Telegram] Failed to send alert for %s: %v", a.Coin, err)
|
||||||
|
}
|
||||||
|
}(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Feed prices to momentum tracker (for momentum scanning or trend detection)
|
// Feed prices to momentum tracker (for momentum scanning or trend detection)
|
||||||
if cfg.MomentumEnabled || cfg.TrendEnabled {
|
if cfg.MomentumEnabled || cfg.TrendEnabled {
|
||||||
for coin, exMap := range snap {
|
for coin, exMap := range snap {
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramSender sends alerts to a Telegram chat via Bot API.
|
||||||
|
type TelegramSender struct {
|
||||||
|
botToken string
|
||||||
|
chatID string
|
||||||
|
client *http.Client
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTelegramSender creates a sender. Returns a no-op sender if token or chatID is empty.
|
||||||
|
func NewTelegramSender(botToken, chatID string) *TelegramSender {
|
||||||
|
return &TelegramSender{
|
||||||
|
botToken: botToken,
|
||||||
|
chatID: chatID,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enabled: botToken != "" && chatID != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns true if both token and chatID are configured.
|
||||||
|
func (t *TelegramSender) IsEnabled() bool {
|
||||||
|
return t.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendAlert sends a formatted momentum alert to Telegram.
|
||||||
|
func (t *TelegramSender) SendAlert(alert BinanceAlert) error {
|
||||||
|
if !t.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message
|
||||||
|
icon := "🔴"
|
||||||
|
directionWord := "暴跌"
|
||||||
|
if alert.Direction == "up" {
|
||||||
|
icon = "🟢"
|
||||||
|
directionWord = "暴涨"
|
||||||
|
}
|
||||||
|
sign := "+"
|
||||||
|
if alert.ChangePct < 0 {
|
||||||
|
sign = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
text := fmt.Sprintf("%s *%s* %s %s%.2f%% | $%.4f",
|
||||||
|
icon, alert.Coin, directionWord, sign, alert.ChangePct, alert.Price)
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": t.chatID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.botToken)
|
||||||
|
resp, err := t.client.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("telegram API call failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("telegram API returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Telegram] Alert sent: %s %s %.2f%%", alert.Coin, alert.Direction, alert.ChangePct)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user