feat: 重构为三所价差异动监控系统
删除 HyperLiquid + 全部交易功能,构建自适应 surge 检测器。 - 新增 surge_detector.go: 每币独立滚动窗口基线,检测三所价差异常飙升 - 新增 SpreadCard/SurgeCard 前端组件 - 保留 momentum/trend/cumulative/trend_filter 扫描功能 - 更新文档和配置以反映新系统 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
559d7bb870
commit
d38782490c
+89
-326
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -132,17 +131,17 @@ func (ph *priceHistory) GetHistory(coin, exchange string, limit int) []pricePoin
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Spread History — tracks BG↔HL spread % per coin (P3-2)
|
||||
// Spread History — tracks 3-exchange max spread % per coin
|
||||
// ============================================================
|
||||
|
||||
type spreadPoint struct {
|
||||
T int64 `json:"t"`
|
||||
Spread float64 `json:"s"` // spread % (positive = BG cheaper than HL for BG->HL direction)
|
||||
Spread float64 `json:"s"` // 3-exchange max spread %
|
||||
}
|
||||
|
||||
type spreadHistory struct {
|
||||
mu sync.RWMutex
|
||||
buffers map[string][]spreadPoint // coin -> spread points
|
||||
buffers map[string][]spreadPoint
|
||||
}
|
||||
|
||||
func newSpreadHistory() *spreadHistory {
|
||||
@@ -186,19 +185,18 @@ type Dashboard struct {
|
||||
history *priceHistory
|
||||
spreads *spreadHistory
|
||||
store *PriceStore
|
||||
trader *Trader
|
||||
db *db.DB
|
||||
addr string
|
||||
cfg *Config
|
||||
|
||||
// cached arb scan results
|
||||
// cached scan results
|
||||
mu sync.RWMutex
|
||||
lastScan []*ArbOpportunity
|
||||
lastScan []ThreeExSpread
|
||||
scanTime time.Time
|
||||
|
||||
// P3-5: connection status — exchange -> last update time
|
||||
// connection status — exchange -> last update time
|
||||
connMu sync.RWMutex
|
||||
connMap map[string]time.Time // exchange name -> last price timestamp
|
||||
connMap map[string]time.Time
|
||||
|
||||
// Momentum tracker
|
||||
momentumTracker *MomentumTracker
|
||||
@@ -211,23 +209,26 @@ type Dashboard struct {
|
||||
|
||||
// Trend filter (K-line based quiet + EMA filter)
|
||||
trendFilter *TrendFilter
|
||||
|
||||
// Surge detector
|
||||
surgeDetector *SurgeDetector
|
||||
}
|
||||
|
||||
func NewDashboard(store *PriceStore, trader *Trader, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter) *Dashboard {
|
||||
func NewDashboard(store *PriceStore, database *db.DB, addr string, cfg *Config, momentumTracker *MomentumTracker, trendDetector *TrendDetector, cumulativeTracker *CumulativeTracker, trendFilter *TrendFilter, surgeDetector *SurgeDetector) *Dashboard {
|
||||
d := &Dashboard{
|
||||
hub: NewSSEHub(),
|
||||
history: newPriceHistory(),
|
||||
spreads: newSpreadHistory(),
|
||||
store: store,
|
||||
trader: trader,
|
||||
db: database,
|
||||
addr: addr,
|
||||
cfg: cfg,
|
||||
connMap: make(map[string]time.Time),
|
||||
hub: NewSSEHub(),
|
||||
history: newPriceHistory(),
|
||||
spreads: newSpreadHistory(),
|
||||
store: store,
|
||||
db: database,
|
||||
addr: addr,
|
||||
cfg: cfg,
|
||||
connMap: make(map[string]time.Time),
|
||||
momentumTracker: momentumTracker,
|
||||
trendDetector: trendDetector,
|
||||
cumulativeTracker: cumulativeTracker,
|
||||
trendFilter: trendFilter,
|
||||
surgeDetector: surgeDetector,
|
||||
}
|
||||
|
||||
// Wire trend event persistence to SQLite
|
||||
@@ -269,28 +270,22 @@ func (d *Dashboard) Run() {
|
||||
if diskFS := os.DirFS("frontend/dist"); true {
|
||||
if _, diskErr := fs.Stat(diskFS, "index.html"); diskErr == nil {
|
||||
staticSub = diskFS
|
||||
log.Printf("[Web] Serving from disk: frontend/dist/ (hot reload enabled)")
|
||||
}
|
||||
}
|
||||
if err != nil && staticSub == nil {
|
||||
log.Printf("[Web] Failed to create static sub-fs: %v", err)
|
||||
} else {
|
||||
if err == nil && staticSub != nil {
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
||||
}
|
||||
|
||||
mux.HandleFunc("GET /", d.handleIndex)
|
||||
mux.HandleFunc("GET /api/status", d.handleStatus)
|
||||
mux.HandleFunc("GET /api/history", d.handleHistory)
|
||||
mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory) // P3-2
|
||||
mux.HandleFunc("GET /api/trades", d.handleTrades)
|
||||
mux.HandleFunc("GET /api/trade/", d.handleTradeDetail)
|
||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus) // P3-5
|
||||
mux.HandleFunc("GET /api/spread-history", d.handleSpreadHistory)
|
||||
mux.HandleFunc("GET /api/connections", d.handleConnStatus)
|
||||
mux.HandleFunc("GET /api/trend-history", d.handleTrendHistory)
|
||||
mux.HandleFunc("GET /api/cm-history", d.handleCmHistory)
|
||||
mux.HandleFunc("GET /api/trend-signals", d.handleTrendSignals)
|
||||
mux.HandleFunc("GET /api/surge-events", d.handleSurgeEvents)
|
||||
mux.HandleFunc("GET /events", d.handleSSE)
|
||||
mux.HandleFunc("POST /api/stop", d.handleStop)
|
||||
mux.HandleFunc("POST /api/start", d.handleStart)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: d.addr,
|
||||
@@ -305,68 +300,6 @@ func (d *Dashboard) Run() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stats computation — kept separate from trading logic
|
||||
// ============================================================
|
||||
|
||||
// DetailedStats holds aggregated PnL and duration statistics.
|
||||
type DetailedStats struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
TotalPnlUSD float64 `json:"total_pnl_usd"` // sum of all trade PnL in USD
|
||||
CapitalPnlPct float64 `json:"capital_pnl_pct"` // TotalPnlUSD / InitialCapital * 100
|
||||
AvgPnlPct float64 `json:"avg_pnl_pct"`
|
||||
MaxProfitPct float64 `json:"max_profit_pct"`
|
||||
MaxLossPct float64 `json:"max_loss_pct"`
|
||||
AvgDuration string `json:"avg_duration"`
|
||||
TotalDuration string `json:"total_duration"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
LosingTrades int `json:"losing_trades"`
|
||||
WinRate float64 `json:"win_rate"`
|
||||
}
|
||||
|
||||
// calcDetailedStats computes trading statistics from a slice of closed trades.
|
||||
// This is a pure function — no dependency on Trader internals.
|
||||
func calcDetailedStats(trades []TradeRecord, initialCapital float64) DetailedStats {
|
||||
ds := DetailedStats{}
|
||||
if len(trades) == 0 {
|
||||
return ds
|
||||
}
|
||||
var totalDur time.Duration
|
||||
ds.MaxLossPct = 1e9 // sentinel
|
||||
for _, tr := range trades {
|
||||
ds.TotalTrades++
|
||||
ds.TotalPnlUSD += tr.PnlUSD
|
||||
if tr.PnlPct >= 0 {
|
||||
ds.WinningTrades++
|
||||
if tr.PnlPct > ds.MaxProfitPct {
|
||||
ds.MaxProfitPct = tr.PnlPct
|
||||
}
|
||||
} else {
|
||||
ds.LosingTrades++
|
||||
if tr.PnlPct < ds.MaxLossPct {
|
||||
ds.MaxLossPct = tr.PnlPct
|
||||
}
|
||||
}
|
||||
if !tr.ClosedAt.IsZero() && !tr.OpenedAt.IsZero() {
|
||||
totalDur += tr.ClosedAt.Sub(tr.OpenedAt)
|
||||
}
|
||||
}
|
||||
if ds.MaxLossPct == 1e9 {
|
||||
ds.MaxLossPct = 0
|
||||
}
|
||||
if ds.TotalTrades > 0 {
|
||||
ds.CapitalPnlPct = ds.TotalPnlUSD / initialCapital * 100
|
||||
ds.AvgPnlPct = ds.TotalPnlUSD / float64(ds.TotalTrades) / initialCapital * 100
|
||||
ds.WinRate = float64(ds.WinningTrades) / float64(ds.TotalTrades) * 100
|
||||
}
|
||||
if totalDur > 0 {
|
||||
avgDur := totalDur / time.Duration(ds.TotalTrades)
|
||||
ds.AvgDuration = avgDur.Round(time.Second).String()
|
||||
ds.TotalDuration = totalDur.Round(time.Second).String()
|
||||
}
|
||||
return ds
|
||||
}
|
||||
|
||||
// broadcastLoop pushes data to SSE clients every 1 second.
|
||||
func (d *Dashboard) broadcastLoop() {
|
||||
tick := time.NewTicker(1 * time.Second)
|
||||
@@ -378,7 +311,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Prices + spreads + connection status
|
||||
// 1. Prices + 3-exchange spreads
|
||||
var prices []map[string]interface{}
|
||||
for _, coin := range TrackedCoins {
|
||||
exMap := snap[coin.Name]
|
||||
@@ -398,135 +331,48 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// P3-2: Calculate BG↔HL spread and record
|
||||
// Calculate 3-exchange max spread
|
||||
bnP := exMap[ExBinance]
|
||||
okxP := exMap[ExOKX]
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP > 0 && hlP > 0 {
|
||||
spreadPct := (hlP - bgP) / bgP * 100
|
||||
entry["bg_hl_spread"] = spreadPct
|
||||
if bnP > 0 && okxP > 0 && bgP > 0 {
|
||||
prices_ := []float64{bnP, okxP, bgP}
|
||||
minP, maxP := prices_[0], prices_[0]
|
||||
for _, p := range prices_[1:] {
|
||||
if p < minP { minP = p }
|
||||
if p > maxP { maxP = p }
|
||||
}
|
||||
spreadPct := (maxP - minP) / minP * 100
|
||||
entry["spread_3ex"] = spreadPct
|
||||
d.spreads.Record(coin.Name, spreadPct)
|
||||
|
||||
// Both directions net profit after fees (4 taker fees: 2 entry + 2 exit)
|
||||
cost := bgP * (1 + takerFees[ExBitget]/100)
|
||||
revenue := hlP * (1 - takerFees[ExHyperLiquid]/100)
|
||||
netBG := (revenue/cost-1)*100 - 2*(takerFees[ExBitget]+takerFees[ExHyperLiquid])
|
||||
|
||||
cost = hlP * (1 + takerFees[ExHyperLiquid]/100)
|
||||
revenue = bgP * (1 - takerFees[ExBitget]/100)
|
||||
netHL := (revenue/cost-1)*100 - 2*(takerFees[ExHyperLiquid]+takerFees[ExBitget])
|
||||
|
||||
entry["net_bg_to_hl"] = math.Round(netBG*10000) / 10000
|
||||
entry["net_hl_to_bg"] = math.Round(netHL*10000) / 10000
|
||||
}
|
||||
|
||||
prices = append(prices, entry)
|
||||
}
|
||||
d.hub.Broadcast("prices", prices)
|
||||
|
||||
// 2. Open positions with live PnL (P3-3) — read from decoupled snapshot, never blocks trader
|
||||
positions := d.trader.ReadSnapshot()
|
||||
posList := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
posEntry := map[string]interface{}{
|
||||
"coin": pos.Coin,
|
||||
"direction": pos.Direction,
|
||||
"amount_usd": pos.AmountUSD,
|
||||
"entry_spread": pos.EntrySpread,
|
||||
"scales": pos.ScaleLevels,
|
||||
"duration": time.Since(pos.StartedAt).Round(time.Second).String(),
|
||||
"started_at": pos.StartedAt.Format("15:04:05"),
|
||||
"started_ts": pos.StartedAt.UnixMilli(),
|
||||
"long_exchange": pos.LongLeg.Exchange,
|
||||
"short_exchange": pos.ShortLeg.Exchange,
|
||||
"long_entry": pos.LongLeg.EntryPrice,
|
||||
"short_entry": pos.ShortLeg.EntryPrice,
|
||||
"db_trade_id": pos.DBTradeID,
|
||||
}
|
||||
|
||||
// Calculate live PnL from current prices — use weighted average for scale-ins
|
||||
if exMap := snap[pos.Coin]; exMap != nil {
|
||||
bgP := exMap[ExBitget]
|
||||
hlP := exMap[ExHyperLiquid]
|
||||
if bgP > 0 && hlP > 0 {
|
||||
var longCurrent, shortCurrent float64
|
||||
if pos.LongLeg.Exchange == ExBitget {
|
||||
longCurrent, shortCurrent = bgP, hlP
|
||||
} else {
|
||||
longCurrent, shortCurrent = hlP, bgP
|
||||
}
|
||||
longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD/float64(max(1, len(pos.LongEntryPrices))))
|
||||
shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD/float64(max(1, len(pos.ShortEntryPrices))))
|
||||
longPnl := (longCurrent - longAvg) / longAvg * 100
|
||||
shortPnl := (shortAvg - shortCurrent) / shortAvg * 100
|
||||
feeEntryUSD := float64(1+pos.ScaleLevels) * (pos.AmountUSD / float64(max(1, 1+pos.ScaleLevels))) * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
|
||||
feeExitUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100
|
||||
pricePnLUSD := pos.AmountUSD * (longPnl + shortPnl) / 100
|
||||
netPnLUSD := pricePnLUSD - feeEntryUSD - feeExitUSD
|
||||
|
||||
currentSpread := (hlP - bgP) / bgP * 100
|
||||
if pos.LongLeg.Exchange == ExHyperLiquid {
|
||||
// HL→BG: spread positive when bgP > hlP
|
||||
currentSpread = (bgP - hlP) / hlP * 100
|
||||
}
|
||||
posEntry["current_spread"] = math.Round(currentSpread*10000) / 10000
|
||||
posEntry["pnl_est"] = math.Round(netPnLUSD*10000) / 10000
|
||||
}
|
||||
}
|
||||
|
||||
posList = append(posList, posEntry)
|
||||
}
|
||||
d.hub.Broadcast("positions", posList)
|
||||
|
||||
// 3. Arb scan results
|
||||
// 2. 3-exchange scan results
|
||||
d.mu.RLock()
|
||||
scanCopy := d.lastScan
|
||||
d.mu.RUnlock()
|
||||
|
||||
if len(scanCopy) > 0 {
|
||||
scanList := make([]map[string]interface{}, 0, len(scanCopy))
|
||||
for _, opp := range scanCopy {
|
||||
for _, s := range scanCopy {
|
||||
scanList = append(scanList, map[string]interface{}{
|
||||
"coin": opp.Coin,
|
||||
"direction": opp.Direction,
|
||||
"buy_ex": opp.BuyEx,
|
||||
"sell_ex": opp.SellEx,
|
||||
"buy_price": opp.BuyPrice,
|
||||
"sell_price": opp.SellPrice,
|
||||
"net_profit": opp.NetProfit,
|
||||
"gross": opp.GrossBasis,
|
||||
"coin": s.Coin,
|
||||
"spread_pct": s.SpreadPct,
|
||||
"bn_price": s.BnPrice,
|
||||
"okx_price": s.OkxPrice,
|
||||
"bg_price": s.BgPrice,
|
||||
"max_ex": s.MaxEx,
|
||||
"min_ex": s.MinEx,
|
||||
})
|
||||
}
|
||||
d.hub.Broadcast("arb", scanList)
|
||||
d.hub.Broadcast("spread_3ex", scanList)
|
||||
}
|
||||
|
||||
// 4. Stats + connection status (P3-5)
|
||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||
detail := calcDetailedStats(d.trader.GetClosedTrades(), d.trader.cfg.InitialCapital)
|
||||
stats := map[string]interface{}{
|
||||
"total_trades": total,
|
||||
"converged": converged,
|
||||
"diverged": diverged,
|
||||
"flat": flat,
|
||||
"open_positions": len(positions),
|
||||
"coins": len(prices),
|
||||
"capital": d.trader.cfg.InitialCapital,
|
||||
|
||||
// Detailed PnL & duration stats (session only)
|
||||
"detail": map[string]interface{}{
|
||||
"total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100,
|
||||
"capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000,
|
||||
"avg_pnl": detail.AvgPnlPct,
|
||||
"max_profit": detail.MaxProfitPct,
|
||||
"max_loss": detail.MaxLossPct,
|
||||
"avg_dur": detail.AvgDuration,
|
||||
"win_rate": detail.WinRate,
|
||||
"wins": detail.WinningTrades,
|
||||
"losses": detail.LosingTrades,
|
||||
"total_dur": detail.TotalDuration,
|
||||
},
|
||||
}
|
||||
|
||||
// Connection status
|
||||
// 3. Connection status
|
||||
d.connMu.RLock()
|
||||
connInfo := make(map[string]string)
|
||||
for ex, lastTime := range d.connMap {
|
||||
@@ -540,51 +386,14 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
d.connMu.RUnlock()
|
||||
stats["connections"] = connInfo
|
||||
|
||||
// Trading status
|
||||
stats["trading"] = map[string]interface{}{
|
||||
"active": !d.trader.IsShuttingDown(),
|
||||
"mode": d.trader.ModeLabel(),
|
||||
"test": d.trader.cfg.TestMode,
|
||||
"target": d.trader.realTradesTarget,
|
||||
"done": d.trader.realTradesDone,
|
||||
status := map[string]interface{}{
|
||||
"coins": len(prices),
|
||||
"connections": connInfo,
|
||||
}
|
||||
d.hub.Broadcast("status", status)
|
||||
|
||||
// Per-exchange fund tracking
|
||||
exFunds := d.trader.GetExchangeFunds()
|
||||
exFundsMap := make(map[string]map[string]float64, len(exFunds))
|
||||
for ex, ef := range exFunds {
|
||||
exFundsMap[ex] = map[string]float64{
|
||||
"balance": math.Round(ef.Balance*100) / 100,
|
||||
"total_fee": math.Round(ef.TotalFee*100) / 100,
|
||||
"total_pnl": math.Round(ef.TotalPnl*100) / 100,
|
||||
}
|
||||
}
|
||||
stats["exchange_funds"] = exFundsMap
|
||||
|
||||
// Blacklist — stale spread coins
|
||||
bl := d.trader.GetBlacklist()
|
||||
blList := make([]map[string]interface{}, 0, len(bl))
|
||||
for coin, t := range bl {
|
||||
if d.trader.cfg.BlacklistDuration > 0 && time.Since(t) >= d.trader.cfg.BlacklistDuration {
|
||||
continue // expired, will be cleaned up on next check
|
||||
}
|
||||
remaining := time.Duration(0)
|
||||
if d.trader.cfg.BlacklistDuration > 0 {
|
||||
remaining = d.trader.cfg.BlacklistDuration - time.Since(t)
|
||||
}
|
||||
blList = append(blList, map[string]interface{}{
|
||||
"coin": coin,
|
||||
"since": t.Format("15:04:05"),
|
||||
"remaining_sec": int(remaining.Seconds()),
|
||||
})
|
||||
}
|
||||
stats["blacklist"] = blList
|
||||
|
||||
d.hub.Broadcast("stats", stats)
|
||||
|
||||
// 5. Momentum data (if enabled and tracker is available)
|
||||
// 4. Momentum data (if enabled)
|
||||
if d.momentumTracker != nil && d.cfg.MomentumEnabled {
|
||||
momentumData := d.momentumTracker.Snapshot(d.cfg.MomentumThresholdPct)
|
||||
if len(momentumData) > 0 {
|
||||
@@ -592,7 +401,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Trend detection (if enabled)
|
||||
// 5. Trend detection (if enabled)
|
||||
if d.trendDetector != nil && d.cfg.TrendEnabled {
|
||||
d.trendDetector.Tick()
|
||||
trendData := d.trendDetector.Snapshot()
|
||||
@@ -601,7 +410,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Cumulative change tracking (always on if tracker exists)
|
||||
// 6. Cumulative change tracking
|
||||
if d.cumulativeTracker != nil {
|
||||
d.cumulativeTracker.Tick()
|
||||
cmData := d.cumulativeTracker.GetTopCoins(30)
|
||||
@@ -610,7 +419,7 @@ func (d *Dashboard) broadcastLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Trend filter (K-line based quiet + EMA)
|
||||
// 7. Trend filter (K-line based quiet + EMA)
|
||||
if d.trendFilter != nil {
|
||||
d.trendFilter.Tick()
|
||||
filterData := d.trendFilter.Snapshot(0)
|
||||
@@ -618,16 +427,24 @@ func (d *Dashboard) broadcastLoop() {
|
||||
d.hub.Broadcast("trend_filter", filterData)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Surge status (current spread/baseline for all coins)
|
||||
if d.surgeDetector != nil && d.cfg.SurgeEnabled {
|
||||
surgeSnap := d.surgeDetector.Snapshot()
|
||||
if len(surgeSnap) > 0 {
|
||||
d.hub.Broadcast("surge", surgeSnap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public methods called from main.go / trader
|
||||
// Public methods called from main.go
|
||||
// ============================================================
|
||||
|
||||
func (d *Dashboard) UpdateScan(opps []*ArbOpportunity) {
|
||||
func (d *Dashboard) UpdateScan(spreads []ThreeExSpread) {
|
||||
d.mu.Lock()
|
||||
d.lastScan = opps
|
||||
d.lastScan = spreads
|
||||
d.scanTime = time.Now()
|
||||
d.mu.Unlock()
|
||||
}
|
||||
@@ -636,14 +453,14 @@ func (d *Dashboard) RecordPrice(coin, exchange string, price float64) {
|
||||
d.history.Record(coin, exchange, price)
|
||||
}
|
||||
|
||||
// RecordConnStatus updates the last-seen time for an exchange (P3-5).
|
||||
// RecordConnStatus updates the last-seen time for an exchange.
|
||||
func (d *Dashboard) RecordConnStatus(exchange string) {
|
||||
d.connMu.Lock()
|
||||
d.connMap[exchange] = time.Now()
|
||||
d.connMu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastEvent sends an immediate SSE event (P3-4).
|
||||
// BroadcastEvent sends an immediate SSE event.
|
||||
func (d *Dashboard) BroadcastEvent(event string, data interface{}) {
|
||||
d.hub.Broadcast(event, data)
|
||||
}
|
||||
@@ -659,7 +476,6 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// Try disk first (hot reload)
|
||||
data, err = os.ReadFile("frontend/dist/index.html")
|
||||
if err != nil {
|
||||
// Fall back to embed
|
||||
data, err = staticFS.ReadFile("frontend/dist/index.html")
|
||||
}
|
||||
if err != nil {
|
||||
@@ -672,25 +488,9 @@ func (d *Dashboard) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (d *Dashboard) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
snap := d.store.GetAll()
|
||||
positions := d.trader.ReadSnapshot()
|
||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||
|
||||
// Format exchange funds (snake_case, like SSE)
|
||||
exFunds := d.trader.GetExchangeFunds()
|
||||
exFundsMap := make(map[string]map[string]float64, len(exFunds))
|
||||
for ex, ef := range exFunds {
|
||||
exFundsMap[ex] = map[string]float64{
|
||||
"balance": math.Round(ef.Balance*100) / 100,
|
||||
"total_fee": math.Round(ef.TotalFee*100) / 100,
|
||||
"total_pnl": math.Round(ef.TotalPnl*100) / 100,
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"prices": snap,
|
||||
"positions": len(positions),
|
||||
"stats": map[string]int{"total": total, "converged": converged, "diverged": diverged, "flat": flat},
|
||||
"exchange_funds": exFundsMap,
|
||||
"prices": snap,
|
||||
"coins": len(snap),
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
@@ -704,7 +504,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
for c := range snap {
|
||||
coins = append(coins, c)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{"Binance", "HyperLiquid", "Bitget", "dYdX"}})
|
||||
writeJSON(w, map[string]interface{}{"coins": coins, "exchanges": []string{ExBinance, ExOKX, ExBitget}})
|
||||
return
|
||||
}
|
||||
points := d.history.GetHistory(coin, exchange, 300)
|
||||
@@ -715,7 +515,7 @@ func (d *Dashboard) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleSpreadHistory returns BG↔HL spread history for a coin (P3-2).
|
||||
// handleSpreadHistory returns 3-exchange max spread history for a coin.
|
||||
func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request) {
|
||||
coin := r.URL.Query().Get("coin")
|
||||
if coin == "" {
|
||||
@@ -729,7 +529,7 @@ func (d *Dashboard) handleSpreadHistory(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleConnStatus returns connection health for all exchanges (P3-5).
|
||||
// handleConnStatus returns connection health for all exchanges.
|
||||
func (d *Dashboard) handleConnStatus(w http.ResponseWriter, r *http.Request) {
|
||||
d.connMu.RLock()
|
||||
conns := make(map[string]string)
|
||||
@@ -757,7 +557,6 @@ func (d *Dashboard) handleTrendHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if events == nil {
|
||||
// Fallback to in-memory ring buffer
|
||||
if d.trendDetector != nil {
|
||||
events = d.trendDetector.GetEvents(200)
|
||||
} else {
|
||||
@@ -796,51 +595,25 @@ func (d *Dashboard) handleTrendSignals(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"signals": signals})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTrades(w http.ResponseWriter, r *http.Request) {
|
||||
if d.db == nil {
|
||||
writeJSON(w, map[string]interface{}{"trades": []interface{}{}, "total": 0})
|
||||
return
|
||||
}
|
||||
page := 1
|
||||
limit := 20
|
||||
coin := r.URL.Query().Get("coin")
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := fmt.Sscanf(l, "%d", &limit); err != nil || n != 1 {
|
||||
limit = 20
|
||||
func (d *Dashboard) handleSurgeEvents(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
|
||||
// Try DB first
|
||||
if d.db != nil {
|
||||
events, err := d.db.GetSurgeEvents(limit)
|
||||
if err == nil {
|
||||
writeJSON(w, map[string]interface{}{"events": events, "total": len(events)})
|
||||
return
|
||||
}
|
||||
}
|
||||
trades, total, err := d.db.GetTrades(page, limit, coin)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"trades": trades,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleTradeDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if d.db == nil {
|
||||
http.Error(w, "DB not available", 503)
|
||||
return
|
||||
// Fallback to in-memory
|
||||
if d.surgeDetector != nil {
|
||||
events := d.surgeDetector.GetRecentEvents(limit)
|
||||
writeJSON(w, map[string]interface{}{"events": events, "total": len(events)})
|
||||
} else {
|
||||
writeJSON(w, map[string]interface{}{"events": []interface{}{}, "total": 0})
|
||||
}
|
||||
var id int64
|
||||
if _, err := fmt.Sscanf(r.URL.Path, "/api/trade/%d", &id); err != nil {
|
||||
http.Error(w, "Invalid trade ID", 400)
|
||||
return
|
||||
}
|
||||
trade, orders, err := d.db.GetTradeByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"trade": trade,
|
||||
"orders": orders,
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -875,16 +648,6 @@ func (d *Dashboard) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
d.trader.Stop()
|
||||
writeJSON(w, map[string]string{"status": "stopped", "message": "Trading stopped, positions closing"})
|
||||
}
|
||||
|
||||
func (d *Dashboard) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
d.trader.Start()
|
||||
writeJSON(w, map[string]string{"status": "started", "message": "Trading resumed"})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
|
||||
Reference in New Issue
Block a user