feat(web): trade detail modal with prices, fees, timestamps

- Click any trade row in history table to open detail modal
- Modal shows 6 sections: 概览, 时间, 价差, 手续费, 多仓, 空仓
- Entries and exits displayed with 6 decimal precision
- Fee entry/exit and total fee displayed
- Open/close timestamps with full date-time format
- Duration, scale count, total amount, exit reason
- Orders sub-table if available
- Escape key and overlay click to close
This commit is contained in:
jackyu66git
2026-05-03 18:59:28 +08:00
parent 02b74f1ec0
commit 505137bcb0
6 changed files with 229 additions and 4 deletions
+17
View File
@@ -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
}
+1 -1
View File
@@ -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())
}
+12 -2
View File
@@ -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 {
+115 -1
View File
@@ -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 `<tr>
return `<tr class="trade-row" data-id="${t.ID}" onclick="openTradeDetail(${t.ID})">
<td class="text-dim">${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
<td><strong>${t.Coin}</strong></td>
<td>${t.Direction}</td>
@@ -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 = '<div class="loading">加载中...</div>';
fetch('/api/trade/' + id)
.then(r => r.json())
.then(data => {
const t = data.trade;
if (!t || !t.ID) {
body.innerHTML = '<div class="loading text-red">交易数据加载失败</div>';
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 = `<div class="detail-grid">
<div class="detail-section">
<h3>概览</h3>
<div class="detail-row"><span class="label">币种</span><span class="value"><strong>${t.Coin}</strong>/USDT</span></div>
<div class="detail-row"><span class="label">方向</span><span class="value">${t.Direction || '-'}</span></div>
<div class="detail-row"><span class="label">状态</span><span class="value">${t.Status === 'closed' ? '已平仓' : t.Status}</span></div>
<div class="detail-row"><span class="label">加仓次数</span><span class="value">${t.ScaleCount || 0} 次</span></div>
<div class="detail-row"><span class="label">总规模</span><span class="value">$${(t.AmountUSD || 0).toFixed(0)}</span></div>
</div>
<div class="detail-section">
<h3>时间</h3>
<div class="detail-row"><span class="label">开仓</span><span class="value">${opened.toLocaleString('zh-CN', { hour12: false })}</span></div>
<div class="detail-row"><span class="label">平仓</span><span class="value">${closed ? closed.toLocaleString('zh-CN', { hour12: false }) : '-'}</span></div>
<div class="detail-row"><span class="label">持仓时长</span><span class="value">${dur}</span></div>
</div>
<div class="detail-section">
<h3>价差</h3>
<div class="detail-row"><span class="label">入场价差</span><span class="value">${t.EntrySpread != null ? t.EntrySpread.toFixed(4) + '%' : '-'}</span></div>
<div class="detail-row"><span class="label">出场价差</span><span class="value">${t.ExitSpread != null ? t.ExitSpread.toFixed(4) + '%' : '-'}</span></div>
<div class="detail-row"><span class="label">收敛情况</span><span class="value ${t.Convergence === '价差收敛' ? 'text-green' : t.Convergence === '价差发散' ? 'text-red' : ''}">${t.Convergence || '-'}</span></div>
<div class="detail-row"><span class="label">平仓原因</span><span class="value">${t.ExitReason || '-'}</span></div>
</div>
<div class="detail-section">
<h3>手续费</h3>
<div class="detail-row"><span class="label">开仓费</span><span class="value">${feeEntry}</span></div>
<div class="detail-row"><span class="label">平仓费</span><span class="value">${feeExit}</span></div>
<div class="detail-row"><span class="label">总手续费</span><span class="value">${totalFee}</span></div>
</div>
<div class="detail-section">
<h3>多仓 ${t.LongExchange || '-'}</h3>
<div class="detail-row"><span class="label">入场价</span><span class="value">$${le}</span></div>
<div class="detail-row"><span class="label">出场价</span><span class="value">$${lx}</span></div>
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.LongPnl > 0 ? 'text-green' : t.LongPnl < 0 ? 'text-red' : ''}">${lpnl}</span></div>
</div>
<div class="detail-section">
<h3>空仓 ${t.ShortExchange || '-'}</h3>
<div class="detail-row"><span class="label">入场价</span><span class="value">$${se}</span></div>
<div class="detail-row"><span class="label">出场价</span><span class="value">$${sx}</span></div>
<div class="detail-row"><span class="label">盈亏</span><span class="value ${t.ShortPnl > 0 ? 'text-green' : t.ShortPnl < 0 ? 'text-red' : ''}">${spnl}</span></div>
</div>
<div class="detail-section detail-section-full">
<h3>净收益</h3>
<div class="detail-row" style="font-size:16px"><span class="label">总计</span><span class="value ${pnlCls}" style="font-weight:700">${t.NetPnl != null ? t.NetPnl.toFixed(4) + '%' : '-'}</span></div>
</div>
</div>`;
// 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 `<tr><td>${typeLabel}</td><td>${o.Side === 'buy' ? '买' : '卖'}</td><td>${o.Exchange}</td><td>$${o.Price ? o.Price.toFixed(6) : '-'}</td><td>${o.Size || '-'}</td><td>${o.Fee ? o.Fee.toFixed(4) + '%' : '-'}</td><td>${o.OrderID ? o.OrderID.substring(0, 12) + '...' : '-'}</td></tr>`;
}).join('');
body.innerHTML += `<div class="detail-section detail-section-full" style="border-top:1px solid var(--border)">
<h3>订单明细 (${data.orders.length})</h3>
<table class="detail-orders">
<thead><tr><th>类型</th><th>方向</th><th>交易所</th><th>价格</th><th>数量</th><th>手续费</th><th>订单ID</th></tr></thead>
<tbody>${ordersHtml}</tbody>
</table>
</div>`;
}
})
.catch(err => {
body.innerHTML = '<div class="loading text-red">加载失败: ' + err.message + '</div>';
});
}
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();
+13
View File
@@ -116,6 +116,19 @@
</div>
</section>
</div>
<!-- Trade Detail Modal -->
<div id="trade-modal" class="modal-overlay" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h2>📋 交易详情</h2>
<button class="modal-close" onclick="closeTradeDetail()"></button>
</div>
<div id="trade-detail-body">
<div class="loading">加载中...</div>
</div>
</div>
</div>
</div>
<script src="/static/app.js"></script>
+71
View File
@@ -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; }