misc: binance momentum, telegram, frontend updates

This commit is contained in:
jackyu66git
2026-06-03 09:39:54 +08:00
parent b2ff322ef3
commit 23f3c61c9f
20 changed files with 928 additions and 86 deletions
+220
View File
@@ -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
}
+36
View File
@@ -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
View File
@@ -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
} }
+46 -2
View File
@@ -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(),
@@ -228,7 +231,8 @@ func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config,
trendDetector: trendDetector, trendDetector: trendDetector,
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)
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -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>
+3 -2
View File
@@ -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>
+193
View File
@@ -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; }
}
+215
View File
@@ -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分钟涨跌监控 | 阈值 &ge;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
+10
View File
@@ -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>,
)
+7
View File
@@ -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'),
},
},
}, },
}) })
+43 -1
View File
@@ -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
View File
@@ -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
}