From 1e4a3f3b3712bf0d76871ae6094fd22ddbf59690 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sun, 3 May 2026 21:03:40 +0800 Subject: [PATCH] Add detailed PnL and duration stats to dashboard --- config.go | 87 ++++++++++++++++++++++++++++++++++++++----- config.json | 10 +++++ dashboard.go | 14 +++++++ trader.go | 56 ++++++++++++++++++++++++++++ web/static/app.js | 11 ++++++ web/static/index.html | 9 +++++ 6 files changed, 177 insertions(+), 10 deletions(-) create mode 100644 config.json diff --git a/config.go b/config.go index 636c397..923ad4a 100644 --- a/config.go +++ b/config.go @@ -1,10 +1,13 @@ package main import ( + "encoding/json" "os" "strconv" ) +// Config holds all system configuration. +// Priority: .env vars > config.json > code defaults. type Config struct { TelegramBotToken string TelegramChatID string @@ -14,7 +17,7 @@ type Config struct { // Automated trading TradeEnabled bool - TradeThreshold float64 // minimum profit % to execute trade (>0.15%) + TradeThreshold float64 // minimum profit % to execute trade TradeAmountUSD float64 // amount per trade in USDT TradeCooldownMs int // ms between trades of same coin @@ -32,7 +35,24 @@ type Config struct { HLAddress string // wallet address } +// jsonConfig maps config.json fields (non-secret defaults checked into git). +type jsonConfig struct { + TestMode bool `json:"test_mode"` + TradeEnabled bool `json:"trade_enabled"` + ArbThreshold float64 `json:"arb_threshold"` + ScanIntervalMs int `json:"scan_interval_ms"` + TradeThreshold float64 `json:"trade_threshold"` + TradeAmountUSD float64 `json:"trade_amount_usd"` + TradeCooldownMs int `json:"trade_cooldown_ms"` + AlertCooldownSec int `json:"alert_cooldown_sec"` + MockSlippagePct float64 `json:"mock_slippage_pct"` +} + func LoadConfig() *Config { + // 1. Load config.json defaults + jsonCfg := loadJSONConfig() + + // 2. .env vars override config.json getEnv := func(key, def string) string { if v := os.Getenv(key); v != "" { return v @@ -61,17 +81,17 @@ func LoadConfig() *Config { return &Config{ TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""), TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""), - AlertCooldownSec: 300, - ArbThreshold: 0.03, - ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", 500)), + AlertCooldownSec: int(getFloat("ALERT_COOLDOWN_SEC", float64(jsonCfg.AlertCooldownSec))), + ArbThreshold: getFloat("ARB_THRESHOLD", jsonCfg.ArbThreshold), + ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", float64(jsonCfg.ScanIntervalMs))), - TradeEnabled: getBool("TRADE_ENABLED", false), - TradeThreshold: getFloat("TRADE_THRESHOLD", 0.15), - TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", 10), - TradeCooldownMs: 30000, + TradeEnabled: getBool("TRADE_ENABLED", jsonCfg.TradeEnabled), + TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold), + TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD), + TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))), - TestMode: getBool("TEST_MODE", false), - MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", 0.005), + TestMode: getBool("TEST_MODE", jsonCfg.TestMode), + MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct), BitgetAPIKey: getEnv("BITGET_API_KEY", ""), BitgetAPISecret: getEnv("BITGET_API_SECRET", ""), @@ -81,3 +101,50 @@ func LoadConfig() *Config { HLAddress: getEnv("HL_ADDRESS", ""), } } + +func loadJSONConfig() jsonConfig { + def := jsonConfig{ + ArbThreshold: 0.03, + ScanIntervalMs: 500, + TradeThreshold: 0.15, + TradeAmountUSD: 10, + TradeCooldownMs: 30000, + AlertCooldownSec: 300, + MockSlippagePct: 0.005, + } + + data, err := os.ReadFile("config.json") + if err != nil { + return def // file not found, use code defaults + } + + var cfg jsonConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return def + } + + // Only override if the JSON file actually set the field + if cfg.ArbThreshold != 0 { + def.ArbThreshold = cfg.ArbThreshold + } + if cfg.ScanIntervalMs != 0 { + def.ScanIntervalMs = cfg.ScanIntervalMs + } + if cfg.TradeThreshold != 0 { + def.TradeThreshold = cfg.TradeThreshold + } + if cfg.TradeAmountUSD != 0 { + def.TradeAmountUSD = cfg.TradeAmountUSD + } + if cfg.TradeCooldownMs != 0 { + def.TradeCooldownMs = cfg.TradeCooldownMs + } + if cfg.AlertCooldownSec != 0 { + def.AlertCooldownSec = cfg.AlertCooldownSec + } + if cfg.MockSlippagePct != 0 { + def.MockSlippagePct = cfg.MockSlippagePct + } + + return def +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..e0019da --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "test_mode": true, + "arb_threshold": 0.03, + "scan_interval_ms": 200, + "trade_threshold": 0.1, + "trade_amount_usd": 5, + "trade_cooldown_ms": 30000, + "alert_cooldown_sec": 300, + "mock_slippage_pct": 0.005 +} diff --git a/dashboard.go b/dashboard.go index 2434cc0..3d6586d 100644 --- a/dashboard.go +++ b/dashboard.go @@ -360,6 +360,7 @@ func (d *Dashboard) broadcastLoop() { // 4. Stats + connection status (P3-5) converged, diverged, flat, total := d.trader.GetClosedStats() + detail := d.trader.GetDetailedStats() stats := map[string]interface{}{ "total_trades": total, "converged": converged, @@ -367,6 +368,19 @@ func (d *Dashboard) broadcastLoop() { "flat": flat, "open_positions": len(positions), "coins": len(prices), + + // Detailed PnL & duration stats (session only) + "detail": map[string]interface{}{ + "total_pnl": detail.TotalPnlPct, + "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 diff --git a/trader.go b/trader.go index f7cf14a..6c7a82a 100644 --- a/trader.go +++ b/trader.go @@ -753,6 +753,62 @@ func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) { return } +// GetDetailedStats returns comprehensive trading statistics. +type DetailedStats struct { + TotalTrades int `json:"total_trades"` + TotalPnlPct float64 `json:"total_pnl_pct"` + 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"` +} + +func (t *Trader) GetDetailedStats() DetailedStats { + t.mu.Lock() + defer t.mu.Unlock() + ds := DetailedStats{} + if len(t.closedTrades) == 0 { + return ds + } + var totalDur time.Duration + ds.MaxLossPct = 1e9 // sentinel + for _, tr := range t.closedTrades { + ds.TotalTrades++ + ds.TotalPnlPct += tr.PnlPct + 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.AvgPnlPct = ds.TotalPnlPct / float64(ds.TotalTrades) + 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 +} + // GetClosedTrades returns the full closed trade history. func (t *Trader) GetClosedTrades() []TradeRecord { t.mu.Lock() diff --git a/web/static/app.js b/web/static/app.js index 70c1446..c6ddf63 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -206,6 +206,17 @@ eventHandlers.stats = (stats) => { els.statPos.textContent = stats.open_positions || 0; els.statCoins.textContent = stats.coins || 0; + // Detailed PnL stats + if (stats.detail) { + const d = stats.detail; + $('stat-total-pnl').textContent = (d.total_pnl != null) ? d.total_pnl.toFixed(2) + '%' : '—'; + $('stat-avg-pnl').textContent = (d.avg_pnl != null) ? d.avg_pnl.toFixed(2) + '%' : '—'; + $('stat-win-rate').textContent = (d.win_rate != null) ? d.win_rate.toFixed(1) + '%' : '—'; + $('stat-max-profit').textContent = (d.max_profit != null) ? '+' + d.max_profit.toFixed(2) + '%' : '—'; + $('stat-max-loss').textContent = (d.max_loss != null) ? d.max_loss.toFixed(2) + '%' : '—'; + $('stat-avg-dur').textContent = d.avg_dur || '—'; + } + // Connection status dots if (stats.connections) { const dots = Object.entries(stats.connections).map(([ex, status]) => { diff --git a/web/static/index.html b/web/static/index.html index 5adb77b..43e0d09 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -31,6 +31,15 @@
0
+ +
+
+
+
+
+
+
+