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})
+
+ | 类型 | 方向 | 交易所 | 价格 | 数量 | 手续费 | 订单ID |
+ ${ordersHtml}
+
+
`;
+ }
+ })
+ .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; }