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:
@@ -206,3 +206,20 @@ func scanTrades(rows *sql.Rows) ([]TradeRecord, error) {
|
|||||||
}
|
}
|
||||||
return trades, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ func main() {
|
|||||||
// Profile: warn if any step is slow
|
// Profile: warn if any step is slow
|
||||||
tickDur := t3.Sub(t0)
|
tickDur := t3.Sub(t0)
|
||||||
tickMs := tickDur.Milliseconds()
|
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",
|
log.Printf("[Profile] tick=%dms trader=%dms scan=%dms entry=%dms",
|
||||||
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
tickMs, t1.Sub(t0).Milliseconds(), t2.Sub(t1).Milliseconds(), t3.Sub(t2).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ type Trader struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
positions map[string]*ArbPosition // coin -> position
|
positions map[string]*ArbPosition // coin -> position
|
||||||
lastTradeTime map[string]time.Time
|
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
|
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
|
// Restore open positions from DB on restart
|
||||||
if database != nil {
|
if database != nil {
|
||||||
t.restoreOpenPositions()
|
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
|
return t
|
||||||
@@ -720,10 +727,13 @@ func weightedAvgPrice(prices []float64, amountPerTrade float64) float64 {
|
|||||||
return totalCost / totalShares
|
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) {
|
func (t *Trader) GetClosedStats() (converged, diverged, flat, total int) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
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 {
|
for _, tr := range t.closedTrades {
|
||||||
total++
|
total++
|
||||||
switch tr.Convergence {
|
switch tr.Convergence {
|
||||||
|
|||||||
+115
-1
@@ -471,7 +471,7 @@ async function loadTrades() {
|
|||||||
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
|
const pnlCls = t.NetPnl > 0 ? 'text-green' : t.NetPnl < 0 ? 'text-red' : '';
|
||||||
const convCls = t.Convergence === '价差收敛' ? 'text-green' :
|
const convCls = t.Convergence === '价差收敛' ? 'text-green' :
|
||||||
t.Convergence === '价差发散' ? 'text-red' : 'text-yellow';
|
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 class="text-dim">${t.ClosedAt ? new Date(t.ClosedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '-'}</td>
|
||||||
<td><strong>${t.Coin}</strong></td>
|
<td><strong>${t.Coin}</strong></td>
|
||||||
<td>${t.Direction}</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 ----
|
// ---- Init ----
|
||||||
function init() {
|
function init() {
|
||||||
connectSSE();
|
connectSSE();
|
||||||
|
|||||||
@@ -116,6 +116,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|||||||
@@ -166,3 +166,74 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
|
|||||||
header { flex-direction: column; gap: 8px; }
|
header { flex-direction: column; gap: 8px; }
|
||||||
.stats-row { justify-content: center; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user