stock-ai-agent/frontend/paper-trading.html
2026-02-06 23:35:16 +08:00

728 lines
26 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>
.trading-page {
min-height: 100vh;
background: var(--bg-primary);
padding: 20px;
}
.trading-container {
max-width: 1400px;
min-width: 800px;
margin: 0 auto;
}
.trading-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.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);
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 30px;
}
.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.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);
}
/* 操作按钮 */
.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;
}
</style>
</head>
<body>
<div id="app">
<div class="trading-page">
<div class="trading-container">
<!-- 头部 -->
<div class="trading-header">
<h1 class="trading-title">模拟交易 <span>Paper Trading</span></h1>
<div style="display: flex; align-items: center; gap: 20px;">
<div class="monitor-status">
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
</div>
<button class="refresh-btn" @click="refreshData">刷新数据</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: 20px;">
<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 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="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>
</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="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
<td>${{ order.entry_price?.toLocaleString() }}</td>
<td>${{ order.stop_loss?.toLocaleString() }}</td>
<td>${{ order.take_profit?.toLocaleString() }}</td>
<td>${{ order.quantity }}</td>
<td>{{ formatTime(order.opened_at) }}</td>
<td>
<button class="action-btn danger" @click="closeOrder(order)">平仓</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: false,
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
};
},
mounted() {
this.refreshData();
// 每10秒自动刷新
this.refreshInterval = setInterval(() => {
this.refreshData();
}, 10000);
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchActiveOrders(),
this.fetchHistoryOrders(),
this.fetchStatistics(),
this.fetchMonitorStatus()
]);
} catch (e) {
console.error('刷新数据失败:', e);
} finally {
this.loading = 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;
}
}
}).mount('#app');
</script>
</body>
</html>