1049 lines
38 KiB
HTML
1049 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>模拟交易 - Tradus</title>
|
||
<link rel="stylesheet" href="/static/css/style.css">
|
||
<style>
|
||
/* 覆盖全局 #app 样式 */
|
||
#app {
|
||
height: auto;
|
||
display: block;
|
||
align-items: initial;
|
||
justify-content: initial;
|
||
padding: 0;
|
||
}
|
||
|
||
.trading-page {
|
||
min-height: 100vh;
|
||
background: var(--bg-primary);
|
||
padding: 20px;
|
||
}
|
||
|
||
.trading-container {
|
||
max-width: 1400px;
|
||
min-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
/* 固定顶部区域 */
|
||
.sticky-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
background: var(--bg-primary);
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
.trading-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding: 10px 0 20px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg-primary);
|
||
}
|
||
|
||
.trading-title {
|
||
font-size: 24px;
|
||
font-weight: 300;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.trading-title span {
|
||
color: var(--accent);
|
||
}
|
||
|
||
.refresh-btn {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 1px solid var(--accent);
|
||
color: var(--accent);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: var(--accent);
|
||
color: var(--bg-primary);
|
||
}
|
||
|
||
/* 发送报告按钮 */
|
||
.report-btn {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 1px solid #1da1f2;
|
||
color: #1da1f2;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.report-btn:hover {
|
||
background: rgba(29, 161, 242, 0.1);
|
||
}
|
||
|
||
.report-btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: 300;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.stat-value.positive {
|
||
color: #00ff41;
|
||
}
|
||
|
||
.stat-value.negative {
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 标签页 */
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.tab {
|
||
padding: 12px 24px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.tab.active {
|
||
color: var(--accent);
|
||
border-bottom-color: var(--accent);
|
||
}
|
||
|
||
/* 表格 */
|
||
.orders-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.orders-table th,
|
||
.orders-table td {
|
||
padding: 12px 16px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.orders-table th {
|
||
background: var(--bg-primary);
|
||
color: var(--text-secondary);
|
||
font-weight: 400;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.orders-table td {
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.orders-table tr:hover {
|
||
background: var(--bg-tertiary);
|
||
}
|
||
|
||
/* 状态标签 */
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 2px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.status-badge.pending {
|
||
background: rgba(255, 165, 0, 0.1);
|
||
color: orange;
|
||
}
|
||
|
||
.status-badge.open {
|
||
background: rgba(0, 255, 65, 0.1);
|
||
color: #00ff41;
|
||
}
|
||
|
||
.status-badge.closed_tp {
|
||
background: rgba(0, 255, 65, 0.1);
|
||
color: #00ff41;
|
||
}
|
||
|
||
.status-badge.closed_sl {
|
||
background: rgba(255, 68, 68, 0.1);
|
||
color: #ff4444;
|
||
}
|
||
|
||
.status-badge.closed_manual {
|
||
background: rgba(255, 165, 0, 0.1);
|
||
color: orange;
|
||
}
|
||
|
||
/* 方向标签 */
|
||
.side-badge {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
border-radius: 2px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.side-badge.long {
|
||
background: rgba(0, 255, 65, 0.1);
|
||
color: #00ff41;
|
||
}
|
||
|
||
.side-badge.short {
|
||
background: rgba(255, 68, 68, 0.1);
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 等级标签 */
|
||
.grade-badge {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
border-radius: 2px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.grade-badge.A {
|
||
background: rgba(255, 215, 0, 0.2);
|
||
color: gold;
|
||
}
|
||
|
||
.grade-badge.B {
|
||
background: rgba(192, 192, 192, 0.2);
|
||
color: silver;
|
||
}
|
||
|
||
.grade-badge.C {
|
||
background: rgba(205, 127, 50, 0.2);
|
||
color: #cd7f32;
|
||
}
|
||
|
||
/* 盈亏显示 */
|
||
.pnl {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.pnl.positive {
|
||
color: #00ff41;
|
||
}
|
||
|
||
.pnl.negative {
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 按等级统计 */
|
||
.grade-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 16px;
|
||
margin-top: 30px;
|
||
}
|
||
|
||
.grade-card {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.grade-card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.grade-card-title {
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.grade-card-stats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.grade-stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.grade-stat-label {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.empty-state svg {
|
||
width: 48px;
|
||
height: 48px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
/* 监控状态 */
|
||
.monitor-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.monitor-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: #ff4444;
|
||
}
|
||
|
||
.monitor-dot.running {
|
||
background: #00ff41;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
/* 价格显示 */
|
||
.price-list {
|
||
display: flex;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.price-item {
|
||
background: var(--bg-tertiary);
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.price-item .symbol {
|
||
color: var(--text-secondary);
|
||
margin-right: 8px;
|
||
}
|
||
|
||
.price-item .price {
|
||
color: var(--accent);
|
||
font-family: monospace;
|
||
}
|
||
|
||
/* 加载状态 */
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* 重置按钮 */
|
||
.reset-btn {
|
||
padding: 8px 16px;
|
||
background: transparent;
|
||
border: 1px solid #ff4444;
|
||
color: #ff4444;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.reset-btn:hover {
|
||
background: rgba(255, 68, 68, 0.1);
|
||
}
|
||
|
||
/* 操作按钮 */
|
||
.action-btn {
|
||
padding: 4px 8px;
|
||
background: transparent;
|
||
border: 1px solid var(--border-bright);
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.action-btn.danger:hover {
|
||
border-color: #ff4444;
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 持仓汇总 */
|
||
.position-summary {
|
||
display: flex;
|
||
gap: 24px;
|
||
padding: 16px 20px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.summary-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.summary-label {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.summary-value {
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.summary-value.positive {
|
||
color: #00ff41;
|
||
}
|
||
|
||
.summary-value.negative {
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 现价样式 */
|
||
.current-price {
|
||
font-family: monospace;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.current-price.price-up {
|
||
color: #00ff41;
|
||
}
|
||
|
||
.current-price.price-down {
|
||
color: #ff4444;
|
||
}
|
||
|
||
/* 警告价格(接近止损) */
|
||
.warning-price {
|
||
color: #ff4444;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 成功价格(接近止盈) */
|
||
.success-price {
|
||
color: #00ff41;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* 盈亏小字 */
|
||
.pnl small {
|
||
font-size: 11px;
|
||
opacity: 0.8;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div class="trading-page">
|
||
<div class="trading-container">
|
||
<!-- 固定顶部区域 -->
|
||
<div class="sticky-header">
|
||
<!-- 头部 -->
|
||
<div class="trading-header">
|
||
<h1 class="trading-title">模拟交易 <span>Paper Trading</span></h1>
|
||
<div style="display: flex; align-items: center; gap: 12px;">
|
||
<div class="monitor-status">
|
||
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
|
||
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
|
||
</div>
|
||
<button class="report-btn" @click="sendReport" :disabled="sendingReport">
|
||
{{ sendingReport ? '发送中...' : '发送报告' }}
|
||
</button>
|
||
<button class="refresh-btn" @click="manualRefresh">刷新数据</button>
|
||
<button class="reset-btn" @click="resetData">重置数据</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">总交易数</div>
|
||
<div class="stat-value">{{ stats.total_trades }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">胜率</div>
|
||
<div class="stat-value">{{ stats.win_rate.toFixed(1) }}%</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">总盈亏</div>
|
||
<div class="stat-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
|
||
${{ stats.total_pnl.toFixed(2) }}
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">盈亏比</div>
|
||
<div class="stat-value">{{ stats.profit_factor === Infinity ? '∞' : stats.profit_factor.toFixed(2) }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">平均盈利</div>
|
||
<div class="stat-value positive">${{ stats.average_win.toFixed(2) }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">平均亏损</div>
|
||
<div class="stat-value negative">${{ stats.average_loss.toFixed(2) }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 实时价格 -->
|
||
<div v-if="Object.keys(latestPrices).length > 0" style="margin-bottom: 15px;">
|
||
<div class="stat-label" style="margin-bottom: 8px;">实时价格</div>
|
||
<div class="price-list">
|
||
<div class="price-item" v-for="(price, symbol) in latestPrices" :key="symbol">
|
||
<span class="symbol">{{ symbol }}</span>
|
||
<span class="price">${{ price.toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签页 -->
|
||
<div class="tabs">
|
||
<button class="tab" :class="{ active: activeTab === 'active' }" @click="activeTab = 'active'">
|
||
活跃订单 ({{ activeOrders.length }})
|
||
</button>
|
||
<button class="tab" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
|
||
历史订单
|
||
</button>
|
||
<button class="tab" :class="{ active: activeTab === 'stats' }" @click="activeTab = 'stats'">
|
||
详细统计
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 活跃订单 -->
|
||
<div v-if="activeTab === 'active'">
|
||
<!-- 持仓汇总 -->
|
||
<div v-if="activeOrders.length > 0" class="position-summary">
|
||
<div class="summary-item">
|
||
<span class="summary-label">持仓数量</span>
|
||
<span class="summary-value">{{ activeOrders.length }}</span>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">总仓位</span>
|
||
<span class="summary-value">${{ totalPosition.toFixed(2) }}</span>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">浮动盈亏</span>
|
||
<span class="summary-value" :class="totalUnrealizedPnl >= 0 ? 'positive' : 'negative'">
|
||
{{ totalUnrealizedPnl >= 0 ? '+' : '' }}${{ totalUnrealizedPnl.toFixed(2) }}
|
||
({{ totalUnrealizedPnlPercent >= 0 ? '+' : '' }}{{ totalUnrealizedPnlPercent.toFixed(2) }}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="loading">加载中...</div>
|
||
<div v-else-if="activeOrders.length === 0" class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||
</svg>
|
||
<p>暂无活跃订单</p>
|
||
</div>
|
||
<table v-else class="orders-table">
|
||
<thead>
|
||
<tr>
|
||
<th>订单ID</th>
|
||
<th>交易对</th>
|
||
<th>方向</th>
|
||
<th>状态</th>
|
||
<th>等级</th>
|
||
<th>入场价</th>
|
||
<th>现价</th>
|
||
<th>浮动盈亏</th>
|
||
<th>止损</th>
|
||
<th>止盈</th>
|
||
<th>仓位</th>
|
||
<th>时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="order in activeOrders" :key="order.order_id">
|
||
<td>{{ order.order_id.slice(-12) }}</td>
|
||
<td>{{ order.symbol }}</td>
|
||
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
|
||
<td>
|
||
<span class="status-badge" :class="order.status">
|
||
{{ order.status === 'pending' ? '⏳ 挂单' : '✅ 持仓' }}
|
||
</span>
|
||
</td>
|
||
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
|
||
<td>${{ order.entry_price?.toLocaleString() }}</td>
|
||
<td>
|
||
<span class="current-price" :class="getPriceChangeClass(order)">
|
||
${{ getCurrentPrice(order.symbol)?.toLocaleString() || '-' }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<template v-if="order.status === 'open'">
|
||
<span class="pnl" :class="getUnrealizedPnl(order).pnl >= 0 ? 'positive' : 'negative'">
|
||
{{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).percent.toFixed(2) }}%
|
||
<br>
|
||
<small>(${{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).pnl.toFixed(2) }})</small>
|
||
</span>
|
||
</template>
|
||
<template v-else>
|
||
<span style="color: var(--text-secondary);">等待入场</span>
|
||
</template>
|
||
</td>
|
||
<td>
|
||
<span :class="isNearStopLoss(order) ? 'warning-price' : ''">
|
||
${{ order.stop_loss?.toLocaleString() }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<span :class="isNearTakeProfit(order) ? 'success-price' : ''">
|
||
${{ order.take_profit?.toLocaleString() }}
|
||
</span>
|
||
</td>
|
||
<td>${{ order.quantity }}</td>
|
||
<td>{{ formatTime(order.status === 'open' ? order.opened_at : order.created_at) }}</td>
|
||
<td>
|
||
<button class="action-btn danger" @click="closeOrder(order)">
|
||
{{ order.status === 'pending' ? '取消' : '平仓' }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 历史订单 -->
|
||
<div v-if="activeTab === 'history'">
|
||
<div v-if="loading" class="loading">加载中...</div>
|
||
<div v-else-if="historyOrders.length === 0" class="empty-state">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||
</svg>
|
||
<p>暂无历史订单</p>
|
||
</div>
|
||
<table v-else class="orders-table">
|
||
<thead>
|
||
<tr>
|
||
<th>订单ID</th>
|
||
<th>交易对</th>
|
||
<th>方向</th>
|
||
<th>等级</th>
|
||
<th>入场价</th>
|
||
<th>出场价</th>
|
||
<th>盈亏</th>
|
||
<th>状态</th>
|
||
<th>平仓时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="order in historyOrders" :key="order.order_id">
|
||
<td>{{ order.order_id.slice(-12) }}</td>
|
||
<td>{{ order.symbol }}</td>
|
||
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
|
||
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
|
||
<td>${{ order.filled_price?.toLocaleString() }}</td>
|
||
<td>${{ order.exit_price?.toLocaleString() }}</td>
|
||
<td>
|
||
<span class="pnl" :class="order.pnl_amount >= 0 ? 'positive' : 'negative'">
|
||
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent?.toFixed(2) }}%
|
||
(${{ order.pnl_amount >= 0 ? '+' : '' }}{{ order.pnl_amount?.toFixed(2) }})
|
||
</span>
|
||
</td>
|
||
<td><span class="status-badge" :class="order.status">{{ formatStatus(order.status) }}</span></td>
|
||
<td>{{ formatTime(order.closed_at) }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 详细统计 -->
|
||
<div v-if="activeTab === 'stats'">
|
||
<h3 style="color: var(--text-primary); font-weight: 300; margin-bottom: 16px;">按信号等级统计</h3>
|
||
<div class="grade-stats">
|
||
<div class="grade-card" v-for="(data, grade) in stats.by_grade" :key="grade">
|
||
<div class="grade-card-header">
|
||
<span class="grade-card-title">
|
||
<span class="grade-badge" :class="grade">{{ grade }}</span> 级信号
|
||
</span>
|
||
</div>
|
||
<div class="grade-card-stats">
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">交易数</span>
|
||
<span>{{ data.count }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">胜率</span>
|
||
<span>{{ data.win_rate.toFixed(1) }}%</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">总盈亏</span>
|
||
<span :class="data.total_pnl >= 0 ? 'positive' : 'negative'">
|
||
${{ data.total_pnl.toFixed(2) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 style="color: var(--text-primary); font-weight: 300; margin: 30px 0 16px;">按交易对统计</h3>
|
||
<div class="grade-stats">
|
||
<div class="grade-card" v-for="(data, symbol) in stats.by_symbol" :key="symbol">
|
||
<div class="grade-card-header">
|
||
<span class="grade-card-title">{{ symbol }}</span>
|
||
</div>
|
||
<div class="grade-card-stats">
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">交易数</span>
|
||
<span>{{ data.count }}</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">胜率</span>
|
||
<span>{{ data.win_rate.toFixed(1) }}%</span>
|
||
</div>
|
||
<div class="grade-stat-row">
|
||
<span class="grade-stat-label">总盈亏</span>
|
||
<span :class="data.total_pnl >= 0 ? 'positive' : 'negative'">
|
||
${{ data.total_pnl.toFixed(2) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||
<script>
|
||
const { createApp } = Vue;
|
||
|
||
createApp({
|
||
data() {
|
||
return {
|
||
activeTab: 'active',
|
||
loading: true, // 只在首次加载时为 true
|
||
activeOrders: [],
|
||
historyOrders: [],
|
||
stats: {
|
||
total_trades: 0,
|
||
winning_trades: 0,
|
||
losing_trades: 0,
|
||
win_rate: 0,
|
||
total_pnl: 0,
|
||
average_win: 0,
|
||
average_loss: 0,
|
||
profit_factor: 0,
|
||
by_grade: {},
|
||
by_symbol: {}
|
||
},
|
||
monitorRunning: false,
|
||
latestPrices: {},
|
||
refreshInterval: null,
|
||
isFirstLoad: true,
|
||
sendingReport: false
|
||
};
|
||
},
|
||
mounted() {
|
||
this.refreshData();
|
||
// 每3秒自动刷新(静默刷新,不显示 loading)
|
||
this.refreshInterval = setInterval(() => {
|
||
this.silentRefresh();
|
||
}, 3000);
|
||
},
|
||
beforeUnmount() {
|
||
if (this.refreshInterval) {
|
||
clearInterval(this.refreshInterval);
|
||
}
|
||
},
|
||
methods: {
|
||
// 手动刷新(显示 loading)
|
||
async manualRefresh() {
|
||
this.loading = true;
|
||
await this.refreshData();
|
||
},
|
||
|
||
// 静默刷新(不显示 loading)
|
||
async silentRefresh() {
|
||
await this.refreshData();
|
||
},
|
||
|
||
async refreshData() {
|
||
try {
|
||
await Promise.all([
|
||
this.fetchActiveOrders(),
|
||
this.fetchHistoryOrders(),
|
||
this.fetchStatistics(),
|
||
this.fetchMonitorStatus()
|
||
]);
|
||
} catch (e) {
|
||
console.error('刷新数据失败:', e);
|
||
} finally {
|
||
this.loading = false;
|
||
this.isFirstLoad = false;
|
||
}
|
||
},
|
||
|
||
async resetData() {
|
||
if (!confirm('确定要重置所有模拟交易数据吗?\n\n此操作将删除所有订单记录,不可恢复!')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/paper-trading/reset', {
|
||
method: 'POST'
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
alert(data.message);
|
||
this.refreshData();
|
||
} else {
|
||
alert('重置失败: ' + (data.detail || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('重置失败: ' + e.message);
|
||
}
|
||
},
|
||
|
||
async sendReport() {
|
||
this.sendingReport = true;
|
||
try {
|
||
const response = await fetch('/api/paper-trading/report?hours=4&send_telegram=true', {
|
||
method: 'POST'
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
if (data.telegram_sent) {
|
||
alert('报告已发送到 Telegram Channel');
|
||
} else {
|
||
alert('报告生成成功,但 Telegram 发送失败(可能未配置)');
|
||
}
|
||
} else {
|
||
alert('发送失败: ' + (data.detail || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('发送失败: ' + e.message);
|
||
} finally {
|
||
this.sendingReport = false;
|
||
}
|
||
},
|
||
|
||
async fetchActiveOrders() {
|
||
const response = await fetch('/api/paper-trading/orders/active');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.activeOrders = data.orders;
|
||
}
|
||
},
|
||
|
||
async fetchHistoryOrders() {
|
||
const response = await fetch('/api/paper-trading/orders?status=closed&limit=50');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.historyOrders = data.orders;
|
||
}
|
||
},
|
||
|
||
async fetchStatistics() {
|
||
const response = await fetch('/api/paper-trading/statistics');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.stats = data.statistics;
|
||
}
|
||
},
|
||
|
||
async fetchMonitorStatus() {
|
||
const response = await fetch('/api/paper-trading/monitor/status');
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
this.monitorRunning = data.running;
|
||
this.latestPrices = data.latest_prices || {};
|
||
}
|
||
},
|
||
|
||
async closeOrder(order) {
|
||
const price = this.latestPrices[order.symbol];
|
||
if (!price) {
|
||
alert('无法获取当前价格');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`确定要以 $${price.toLocaleString()} 平仓吗?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/paper-trading/orders/${order.order_id}/close`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ exit_price: price })
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
alert(`平仓成功!盈亏: ${data.result.pnl_percent.toFixed(2)}%`);
|
||
this.refreshData();
|
||
} else {
|
||
alert('平仓失败: ' + (data.detail || '未知错误'));
|
||
}
|
||
} catch (e) {
|
||
alert('平仓失败: ' + e.message);
|
||
}
|
||
},
|
||
|
||
formatTime(timeStr) {
|
||
if (!timeStr) return '-';
|
||
const date = new Date(timeStr);
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
},
|
||
|
||
formatStatus(status) {
|
||
const map = {
|
||
'open': '持仓中',
|
||
'closed_tp': '止盈',
|
||
'closed_sl': '止损',
|
||
'closed_manual': '手动平仓'
|
||
};
|
||
return map[status] || status;
|
||
},
|
||
|
||
// 获取当前价格
|
||
getCurrentPrice(symbol) {
|
||
return this.latestPrices[symbol] || null;
|
||
},
|
||
|
||
// 计算未实现盈亏
|
||
getUnrealizedPnl(order) {
|
||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||
if (!currentPrice || !order.entry_price) {
|
||
return { pnl: 0, percent: 0 };
|
||
}
|
||
|
||
let pnlPercent;
|
||
if (order.side === 'long') {
|
||
pnlPercent = ((currentPrice - order.entry_price) / order.entry_price) * 100;
|
||
} else {
|
||
pnlPercent = ((order.entry_price - currentPrice) / order.entry_price) * 100;
|
||
}
|
||
|
||
const pnlAmount = (order.quantity || 0) * pnlPercent / 100;
|
||
|
||
return {
|
||
pnl: pnlAmount,
|
||
percent: pnlPercent
|
||
};
|
||
},
|
||
|
||
// 获取价格变化样式类
|
||
getPriceChangeClass(order) {
|
||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||
if (!currentPrice || !order.entry_price) return '';
|
||
|
||
if (order.side === 'long') {
|
||
return currentPrice >= order.entry_price ? 'price-up' : 'price-down';
|
||
} else {
|
||
return currentPrice <= order.entry_price ? 'price-up' : 'price-down';
|
||
}
|
||
},
|
||
|
||
// 检查是否接近止损(距离止损 < 1%)
|
||
isNearStopLoss(order) {
|
||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||
if (!currentPrice || !order.stop_loss) return false;
|
||
|
||
const distance = Math.abs(currentPrice - order.stop_loss) / currentPrice;
|
||
return distance < 0.01;
|
||
},
|
||
|
||
// 检查是否接近止盈(距离止盈 < 1%)
|
||
isNearTakeProfit(order) {
|
||
const currentPrice = this.getCurrentPrice(order.symbol);
|
||
if (!currentPrice || !order.take_profit) return false;
|
||
|
||
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
|
||
return distance < 0.01;
|
||
}
|
||
},
|
||
computed: {
|
||
// 总仓位
|
||
totalPosition() {
|
||
return this.activeOrders.reduce((sum, order) => sum + (order.quantity || 0), 0);
|
||
},
|
||
|
||
// 总浮动盈亏
|
||
totalUnrealizedPnl() {
|
||
return this.activeOrders.reduce((sum, order) => {
|
||
return sum + this.getUnrealizedPnl(order).pnl;
|
||
}, 0);
|
||
},
|
||
|
||
// 总浮动盈亏百分比
|
||
totalUnrealizedPnlPercent() {
|
||
if (this.totalPosition === 0) return 0;
|
||
return (this.totalUnrealizedPnl / this.totalPosition) * 100;
|
||
}
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|