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)
|
||||
TrendConfirmTicks int // ticks needed for state confirmation (default: 3)
|
||||
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).
|
||||
@@ -57,6 +63,12 @@ type jsonConfig struct {
|
||||
TrendAnomalyMul float64 `json:"trend_anomaly_mul"`
|
||||
TrendConfirmTicks int `json:"trend_confirm_ticks"`
|
||||
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 {
|
||||
@@ -113,6 +125,12 @@ func LoadConfig() *Config {
|
||||
TrendAnomalyMul: getFloat("TREND_ANOMALY_MUL", jsonCfg.TrendAnomalyMul),
|
||||
TrendConfirmTicks: int(getFloat("TREND_CONFIRM_TICKS", float64(jsonCfg.TrendConfirmTicks))),
|
||||
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
|
||||
TrendConfirmTicks: 3, // 3 consecutive ticks for confirmation
|
||||
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")
|
||||
@@ -192,10 +216,22 @@ func loadJSONConfig() jsonConfig {
|
||||
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
|
||||
def.MomentumEnabled = cfg.MomentumEnabled || def.MomentumEnabled
|
||||
def.TrendEnabled = cfg.TrendEnabled || def.TrendEnabled
|
||||
def.SurgeEnabled = cfg.SurgeEnabled || def.SurgeEnabled
|
||||
def.BinanceMomentumEnabled = cfg.BinanceMomentumEnabled || def.BinanceMomentumEnabled
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
+5
-1
@@ -13,5 +13,9 @@
|
||||
"trend_baseline_window": 600,
|
||||
"trend_anomaly_mul": 3.0,
|
||||
"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
|
||||
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{
|
||||
hub: NewSSEHub(),
|
||||
history: newPriceHistory(),
|
||||
@@ -229,6 +232,7 @@ func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config,
|
||||
cumulativeTracker: cumulativeTracker,
|
||||
trendFilter: trendFilter,
|
||||
surgeDetector: surgeDetector,
|
||||
binanceMomentumDetector: binanceMomentumDetector,
|
||||
}
|
||||
|
||||
// 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/surge-events", d.handleSurgeEvents)
|
||||
mux.HandleFunc("GET /events", d.handleSSE)
|
||||
mux.HandleFunc("GET /binance", d.handleBinanceIndex)
|
||||
mux.HandleFunc("GET /api/binance-alerts", d.handleBinanceAlerts)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: d.addr,
|
||||
@@ -435,6 +441,14 @@ func (d *Dashboard) broadcastLoop() {
|
||||
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{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Exchange Monitor Dashboard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-Czt_9K6K.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-vvNDQq2K.css">
|
||||
<script type="module" crossorigin src="/static/assets/main-Cg5_dPUr.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/static/assets/client-DYDkQYN6.js">
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/main-vvNDQq2K.css">
|
||||
</head>
|
||||
<body>
|
||||
<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 react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -13,5 +14,11 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
binance: resolve(__dirname, 'binance.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -33,6 +33,14 @@ func main() {
|
||||
loadDotEnv()
|
||||
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()
|
||||
|
||||
// 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)
|
||||
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()
|
||||
|
||||
// Context for graceful shutdown
|
||||
@@ -172,6 +190,30 @@ func main() {
|
||||
now := time.Now()
|
||||
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)
|
||||
if cfg.MomentumEnabled || cfg.TrendEnabled {
|
||||
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