diff --git a/db/trade_repo.go b/db/trade_repo.go index 438fb2a..3b10f7e 100644 --- a/db/trade_repo.go +++ b/db/trade_repo.go @@ -206,3 +206,20 @@ func scanTrades(rows *sql.Rows) ([]TradeRecord, error) { } return trades, rows.Err() } + +// GetClosedStats returns convergence counts from the database. +func (d *DB) GetClosedStats() (converged, diverged, flat, total int, err error) { + if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed'").Scan(&total); err != nil { + return + } + if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差收敛'").Scan(&converged); err != nil { + return + } + if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND convergence='价差发散'").Scan(&diverged); err != nil { + return + } + if err = d.QueryRow("SELECT COUNT(*) FROM trades WHERE status='closed' AND (convergence IS NULL OR convergence NOT IN ('价差收敛','价差发散'))").Scan(&flat); err != nil { + return + } + return +} diff --git a/main.go b/main.go index 5aa1f75..19a9d9a 100644 --- a/main.go +++ b/main.go @@ -183,7 +183,7 @@ func main() { // Profile: warn if any step is slow tickDur := t3.Sub(t0) tickMs := tickDur.Milliseconds() - if tickMs > 100 || t1.Sub(t0) > 50 || t2.Sub(t1) > 50 || t3.Sub(t2) > 50 { + if tickMs > 100 || t1.Sub(t0) > 50*time.Millisecond || t2.Sub(t1) > 50*time.Millisecond || t3.Sub(t2) > 50*time.Millisecond { log.Printf("[Profile] tick=%dms trader=%dms scan=%dms entry=%dms", tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds()) } diff --git a/trader.go b/trader.go index 5ac8f43..fd3e9f9 100644 --- a/trader.go +++ b/trader.go @@ -119,7 +119,10 @@ type Trader struct { mu sync.Mutex positions map[string]*ArbPosition // coin -> position lastTradeTime map[string]time.Time - closedTrades []TradeRecord // history of closed trades + closedTrades []TradeRecord // history of closed trades (current session) + + // Historical stats loaded from DB on startup — combined with session stats in GetClosedStats + dbConverged, dbDiverged, dbFlat, dbTotal int OnTradeEvent func(event string, data interface{}) // P3-4: real-time SSE push @@ -163,6 +166,10 @@ func NewTrader(cfg *Config, database *db.DB) *Trader { // Restore open positions from DB on restart if database != nil { t.restoreOpenPositions() + // Load historical closed trade stats for convergence display + if c, d, f, tot, err := database.GetClosedStats(); err == nil { + t.dbConverged, t.dbDiverged, t.dbFlat, t.dbTotal = c, d, f, tot + } } return t @@ -720,10 +727,13 @@ func weightedAvgPrice(prices []float64, amountPerTrade float64) float64 { return totalCost / totalShares } -// GetClosedStats returns convergence stats from all closed trades. +// GetClosedStats returns convergence stats from all closed trades (DB history + current session). func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) { t.mu.Lock() defer t.mu.Unlock() + // Start with DB historical counts + converged, diverged, flat, total = t.dbConverged, t.dbDiverged, t.dbFlat, t.dbTotal + // Add in-memory session trades for _, tr := range t.closedTrades { total++ switch tr.Convergence { diff --git a/web/static/app.js b/web/static/app.js index 7daf7d1..3669520 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -471,7 +471,7 @@ async function loadTrades() { const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; const convCls = t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : 'text-yellow'; - return ` + return ` ${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'} ${t.Coin} ${t.Direction} @@ -489,6 +489,120 @@ async function loadTrades() { } } +// ---- Trade Detail Modal ---- +function openTradeDetail(id) { + const modal = document.getElementById('trade-modal'); + const body = document.getElementById('trade-detail-body'); + modal.style.display = 'flex'; + body.innerHTML = '
加载中...
'; + + fetch('/api/trade/' + id) + .then(r => r.json()) + .then(data => { + const t = data.trade; + if (!t || !t.ID) { + body.innerHTML = '
交易数据加载失败
'; + return; + } + + const opened = new Date(t.OpenedAt); + const closed = t.ClosedAt ? new Date(t.ClosedAt) : null; + const dur = closed ? Math.round((closed - opened) / 1000) + 's' : '-'; + const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : ''; + + const feeEntry = t.FeeEntry != null ? t.FeeEntry.toFixed(3) + '%' : '-'; + const feeExit = t.FeeExit != null ? t.FeeExit.toFixed(3) + '%' : '-'; + const totalFee = t.FeeEntry != null && t.FeeExit != null + ? (t.FeeEntry + t.FeeExit).toFixed(3) + '%' : '-'; + + const le = t.LongEntry != null ? t.LongEntry.toFixed(6) : '-'; + const lx = t.LongExit != null ? t.LongExit.toFixed(6) : '-'; + const se = t.ShortEntry != null ? t.ShortEntry.toFixed(6) : '-'; + const sx = t.ShortExit != null ? t.ShortExit.toFixed(6) : '-'; + const lpnl = t.LongPnl != null ? t.LongPnl.toFixed(4) + '%' : '-'; + const spnl = t.ShortPnl != null ? t.ShortPnl.toFixed(4) + '%' : '-'; + + body.innerHTML = `
+
+

概览

+
币种${t.Coin}/USDT
+
方向${t.Direction || '-'}
+
状态${t.Status === 'closed' ? '已平仓' : t.Status}
+
加仓次数${t.ScaleCount || 0} 次
+
总规模$${(t.AmountUSD || 0).toFixed(0)}
+
+
+

时间

+
开仓${opened.toLocaleString('zh-CN', { hour12: false })}
+
平仓${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}
+
持仓时长${dur}
+
+
+

价差

+
入场价差${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}
+
出场价差${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}
+
收敛情况${t.Convergence || '-'}
+
平仓原因${t.ExitReason || '-'}
+
+
+

手续费

+
开仓费${feeEntry}
+
平仓费${feeExit}
+
总手续费${totalFee}
+
+
+

多仓 ${t.LongExchange || '-'}

+
入场价$${le}
+
出场价$${lx}
+
盈亏${lpnl}
+
+
+

空仓 ${t.ShortExchange || '-'}

+
入场价$${se}
+
出场价$${sx}
+
盈亏${spnl}
+
+
+

净收益

+
总计${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}
+
+
`; + + // Append orders table if available + if (data.orders && data.orders.length > 0) { + const ordersHtml = data.orders.map(o => { + const typeLabel = o.Type === 'entry' ? '开仓' : o.Type === 'exit' ? '平仓' : o.Type === 'scale' ? '加仓' : o.Type; + return `${typeLabel}${o.Side === 'buy' ? '买' : '卖'}${o.Exchange}$${o.Price ? o.Price.toFixed(6) : '-'}${o.Size || '-'}${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}`; + }).join(''); + body.innerHTML += `
+

订单明细 (${data.orders.length})

+ + + ${ordersHtml} +
类型方向交易所价格数量手续费订单ID
+
`; + } + }) + .catch(err => { + body.innerHTML = '
加载失败: ' + err.message + '
'; + }); +} + +function closeTradeDetail() { + document.getElementById('trade-modal').style.display = 'none'; +} + +// Close modal on overlay click +document.addEventListener('click', function(e) { + const modal = document.getElementById('trade-modal'); + if (e.target === modal) closeTradeDetail(); +}); + +// Close on Escape +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closeTradeDetail(); +}); + // ---- Init ---- function init() { connectSSE(); diff --git a/web/static/index.html b/web/static/index.html index b13e7cc..5adb77b 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -116,6 +116,19 @@ + + + diff --git a/web/static/style.css b/web/static/style.css index 849260f..e511731 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -166,3 +166,74 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); } header { flex-direction: column; gap: 8px; } .stats-row { justify-content: center; } } + +/* Trade Detail Modal */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.7); + z-index: 1000; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 40px 16px; + overflow-y: auto; +} +.modal-content { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + max-width: 700px; + width: 100%; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} +.modal-header h2 { font-size: 16px; margin: 0; padding: 0; border: none; color: var(--text); } +.modal-close { + background: none; + border: none; + color: var(--text-dim); + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; +} +.modal-close:hover { background: rgba(255,255,255,0.1); color: var(--text); } +#trade-detail-body { padding: 0; } +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; +} +.detail-section { + padding: 14px 20px; + border-bottom: 1px solid rgba(48,54,61,0.4); +} +.detail-section:last-child { border-bottom: none; } +.detail-section-full { grid-column: 1 / -1; } +.detail-section h3 { + font-size: 12px; + color: var(--text-dim); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} +.detail-row { + display: flex; + justify-content: space-between; + padding: 3px 0; + font-size: 13px; +} +.detail-row .label { color: var(--text-dim); } +.detail-row .value { font-weight: 500; } +.detail-orders { width: 100%; font-size: 12px; } +.detail-orders th { background: var(--bg); font-size: 10px; } +.detail-orders td { padding: 4px 6px; }