Add detailed PnL and duration stats to dashboard
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Config holds all system configuration.
|
||||||
|
// Priority: .env vars > config.json > code defaults.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
TelegramBotToken string
|
TelegramBotToken string
|
||||||
TelegramChatID string
|
TelegramChatID string
|
||||||
@@ -14,7 +17,7 @@ type Config struct {
|
|||||||
|
|
||||||
// Automated trading
|
// Automated trading
|
||||||
TradeEnabled bool
|
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
|
TradeAmountUSD float64 // amount per trade in USDT
|
||||||
TradeCooldownMs int // ms between trades of same coin
|
TradeCooldownMs int // ms between trades of same coin
|
||||||
|
|
||||||
@@ -32,7 +35,24 @@ type Config struct {
|
|||||||
HLAddress string // wallet address
|
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 {
|
func LoadConfig() *Config {
|
||||||
|
// 1. Load config.json defaults
|
||||||
|
jsonCfg := loadJSONConfig()
|
||||||
|
|
||||||
|
// 2. .env vars override config.json
|
||||||
getEnv := func(key, def string) string {
|
getEnv := func(key, def string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
@@ -61,17 +81,17 @@ func LoadConfig() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
|
||||||
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""),
|
TelegramChatID: getEnv("TELEGRAM_CHAT_ID", ""),
|
||||||
AlertCooldownSec: 300,
|
AlertCooldownSec: int(getFloat("ALERT_COOLDOWN_SEC", float64(jsonCfg.AlertCooldownSec))),
|
||||||
ArbThreshold: 0.03,
|
ArbThreshold: getFloat("ARB_THRESHOLD", jsonCfg.ArbThreshold),
|
||||||
ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", 500)),
|
ScanIntervalMs: int(getFloat("SCAN_INTERVAL_MS", float64(jsonCfg.ScanIntervalMs))),
|
||||||
|
|
||||||
TradeEnabled: getBool("TRADE_ENABLED", false),
|
TradeEnabled: getBool("TRADE_ENABLED", jsonCfg.TradeEnabled),
|
||||||
TradeThreshold: getFloat("TRADE_THRESHOLD", 0.15),
|
TradeThreshold: getFloat("TRADE_THRESHOLD", jsonCfg.TradeThreshold),
|
||||||
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", 10),
|
TradeAmountUSD: getFloat("TRADE_AMOUNT_USD", jsonCfg.TradeAmountUSD),
|
||||||
TradeCooldownMs: 30000,
|
TradeCooldownMs: int(getFloat("TRADE_COOLDOWN_MS", float64(jsonCfg.TradeCooldownMs))),
|
||||||
|
|
||||||
TestMode: getBool("TEST_MODE", false),
|
TestMode: getBool("TEST_MODE", jsonCfg.TestMode),
|
||||||
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", 0.005),
|
MockSlippagePct: getFloat("MOCK_SLIPPAGE_PCT", jsonCfg.MockSlippagePct),
|
||||||
|
|
||||||
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
BitgetAPIKey: getEnv("BITGET_API_KEY", ""),
|
||||||
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
BitgetAPISecret: getEnv("BITGET_API_SECRET", ""),
|
||||||
@@ -81,3 +101,50 @@ func LoadConfig() *Config {
|
|||||||
HLAddress: getEnv("HL_ADDRESS", ""),
|
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
|
||||||
|
}
|
||||||
|
|||||||
+10
@@ -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
|
||||||
|
}
|
||||||
@@ -360,6 +360,7 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
|
|
||||||
// 4. Stats + connection status (P3-5)
|
// 4. Stats + connection status (P3-5)
|
||||||
converged, diverged, flat, total := d.trader.GetClosedStats()
|
converged, diverged, flat, total := d.trader.GetClosedStats()
|
||||||
|
detail := d.trader.GetDetailedStats()
|
||||||
stats := map[string]interface{}{
|
stats := map[string]interface{}{
|
||||||
"total_trades": total,
|
"total_trades": total,
|
||||||
"converged": converged,
|
"converged": converged,
|
||||||
@@ -367,6 +368,19 @@ func (d *Dashboard) broadcastLoop() {
|
|||||||
"flat": flat,
|
"flat": flat,
|
||||||
"open_positions": len(positions),
|
"open_positions": len(positions),
|
||||||
"coins": len(prices),
|
"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
|
// Connection status
|
||||||
|
|||||||
@@ -753,6 +753,62 @@ func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
|||||||
return
|
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.
|
// GetClosedTrades returns the full closed trade history.
|
||||||
func (t *Trader) GetClosedTrades() []TradeRecord {
|
func (t *Trader) GetClosedTrades() []TradeRecord {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|||||||
@@ -206,6 +206,17 @@ eventHandlers.stats = (stats) => {
|
|||||||
els.statPos.textContent = stats.open_positions || 0;
|
els.statPos.textContent = stats.open_positions || 0;
|
||||||
els.statCoins.textContent = stats.coins || 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
|
// Connection status dots
|
||||||
if (stats.connections) {
|
if (stats.connections) {
|
||||||
const dots = Object.entries(stats.connections).map(([ex, status]) => {
|
const dots = Object.entries(stats.connections).map(([ex, status]) => {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@
|
|||||||
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
|
<div class="stat"><label>币种</label><span id="stat-coins" class="pct-blue">0</span></div>
|
||||||
<div class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
|
<div class="stat" id="conn-stats"><label>连接</label><span id="conn-detail"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Detailed PnL stats -->
|
||||||
|
<div class="stats-row detail-stats" style="margin-top:4px;font-size:12px;opacity:0.85">
|
||||||
|
<div class="stat"><label>总PnL</label><span id="stat-total-pnl">—</span></div>
|
||||||
|
<div class="stat"><label>平均PnL</label><span id="stat-avg-pnl">—</span></div>
|
||||||
|
<div class="stat"><label>胜率</label><span id="stat-win-rate">—</span></div>
|
||||||
|
<div class="stat"><label>最多盈利</label><span id="stat-max-profit">—</span></div>
|
||||||
|
<div class="stat"><label>最多亏损</label><span id="stat-max-loss">—</span></div>
|
||||||
|
<div class="stat"><label>平均持仓</label><span id="stat-avg-dur">—</span></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Price Table -->
|
<!-- Price Table -->
|
||||||
|
|||||||
Reference in New Issue
Block a user