stock-ai-agent/frontend/real-trading.html
2026-02-23 00:04:23 +08:00

704 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>实盘交易 | Tradus Auto Trading</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
html, body {
overflow-x: hidden;
max-width: 100vw;
}
#app {
height: auto;
display: block;
align-items: initial;
justify-content: initial;
padding: 0;
overflow-x: hidden;
}
.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 .real-badge {
display: inline-block;
background: #ff4444;
color: white;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
margin-left: 10px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.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: 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-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.orders-table {
width: 100%;
border-collapse: collapse;
}
.orders-table th,
.orders-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.orders-table th {
background: rgba(255, 255, 255, 0.05);
color: var(--text-secondary);
font-size: 12px;
font-weight: normal;
text-transform: uppercase;
}
.orders-table td {
color: var(--text-primary);
font-size: 13px;
}
.orders-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.orders-table tr:last-child td {
border-bottom: none;
}
.side-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
}
.side-long {
background: rgba(0, 255, 65, 0.2);
color: #00ff41;
}
.side-short {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.grade-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: bold;
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
.grade-A { background: rgba(0, 255, 65, 0.2); color: #00ff41; }
.grade-B { background: rgba(100, 200, 255, 0.2); color: #64c8ff; }
.grade-C { background: rgba(255, 200, 0, 0.2); color: #ffc800; }
.grade-D { background: rgba(255, 68, 68, 0.2); color: #ff4444; }
.pnl-positive {
color: #00ff41;
}
.pnl-negative {
color: #ff4444;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
}
.status-open {
background: rgba(0, 255, 65, 0.2);
color: #00ff41;
}
.status-pending {
background: rgba(255, 200, 0, 0.2);
color: #ffc800;
}
.status-closed {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
.close-btn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-size: 12px;
cursor: pointer;
border-radius: 3px;
transition: all 0.2s;
}
.close-btn:hover {
background: var(--accent);
color: var(--bg-primary);
}
.empty-state {
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.loading-state {
padding: 40px;
text-align: center;
color: var(--text-secondary);
}
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.warning-banner {
background: rgba(255, 68, 68, 0.1);
border: 1px solid #ff4444;
border-radius: 4px;
padding: 12px 16px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.warning-banner svg {
width: 20px;
height: 20px;
color: #ff4444;
flex-shrink: 0;
}
.warning-banner-text {
color: #ff4444;
font-size: 13px;
}
.account-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.account-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.account-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.account-value {
font-size: 20px;
font-weight: 300;
color: var(--text-primary);
}
.account-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
}
</style>
</head>
<body>
<div id="app">
<div class="trading-page">
<div class="trading-container">
<div class="sticky-header">
<div class="trading-header">
<div class="trading-title">
实盘交易
<span class="real-badge">LIVE</span>
</div>
<button class="refresh-btn" @click="refreshData">
刷新
</button>
</div>
</div>
<!-- 警告横幅 -->
<div class="warning-banner" v-if="!serviceEnabled || !apiConfigured">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="warning-banner-text">
<template v-if="!serviceEnabled">实盘交易服务未启用,请在配置中设置 REAL_TRADING_ENABLED=true</template>
<template v-else-if="!apiConfigured">Bitget API 密钥未配置,请检查配置文件</template>
</div>
</div>
<!-- 账户信息 -->
<div class="account-info" v-if="serviceEnabled">
<div class="account-card">
<div class="account-label">账户余额</div>
<div class="account-value">${{ account.current_balance ? account.current_balance.toLocaleString() : '0' }}</div>
<div class="account-sub">可用: ${{ account.available ? account.available.toLocaleString() : '0' }}</div>
</div>
<div class="account-card">
<div class="account-label">已用保证金</div>
<div class="account-value">${{ account.used_margin ? account.used_margin.toLocaleString() : '0' }}</div>
</div>
<div class="account-card">
<div class="account-label">持仓价值</div>
<div class="account-value">${{ account.total_position_value ? account.total_position_value.toLocaleString() : '0' }}</div>
</div>
<div class="account-card" v-if="stats">
<div class="account-label">总盈亏</div>
<div class="account-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
${{ stats.total_pnl ? stats.total_pnl.toLocaleString() : '0' }}
</div>
<div class="account-sub">{{ stats.total_trades || 0 }} 笔交易</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid" v-if="stats">
<div class="stat-card">
<div class="stat-label">胜率</div>
<div class="stat-value" :class="stats.win_rate >= 50 ? 'positive' : 'negative'">
{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0' }}%
</div>
</div>
<div class="stat-card">
<div class="stat-label">盈利交易</div>
<div class="stat-value positive">
{{ stats.winning_trades || 0 }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">亏损交易</div>
<div class="stat-value negative">
{{ stats.losing_trades || 0 }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">活跃订单</div>
<div class="stat-value">
{{ activeOrders.length }}
</div>
</div>
</div>
<!-- 标签页 -->
<div class="tabs">
<button
class="tab"
:class="{ active: currentTab === 'active' }"
@click="currentTab = 'active'">
活跃订单 ({{ activeOrders.length }})
</button>
<button
class="tab"
:class="{ active: currentTab === 'history' }"
@click="currentTab = 'history'">
历史订单
</button>
<button
class="tab"
:class="{ active: currentTab === 'positions' }"
@click="currentTab = 'positions'">
交易所持仓
</button>
</div>
<!-- 订单列表 -->
<div class="orders-table-container">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p style="margin-top: 12px;">加载中...</p>
</div>
<div v-else-if="currentTab === 'active' && activeOrders.length === 0" class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>暂无活跃订单</p>
</div>
<div v-else-if="currentTab === 'history' && historyOrders.length === 0" class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>暂无历史订单</p>
</div>
<div v-else-if="currentTab === 'positions' && exchangePositions.length === 0" class="empty-state">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>暂无持仓</p>
</div>
<table class="orders-table" v-else>
<thead>
<tr>
<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 displayOrders" :key="order.order_id">
<td><strong>{{ order.symbol }}</strong></td>
<td>
<span class="side-badge" :class="'side-' + order.side">
{{ order.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>
<span class="grade-badge" :class="'grade-' + (order.signal_grade || 'D')">
{{ order.signal_grade || 'D' }}
</span>
</td>
<td>${{ order.entry_price ? order.entry_price.toLocaleString() : '-' }}</td>
<td>${{ order.current_price ? order.current_price.toLocaleString() : '-' }}</td>
<td>${{ order.quantity ? order.quantity.toLocaleString() : '-' }}</td>
<td>{{ order.leverage || 1 }}x</td>
<td>${{ order.stop_loss ? order.stop_loss.toLocaleString() : '-' }}</td>
<td>${{ order.take_profit ? order.take_profit.toLocaleString() : '-' }}</td>
<td>
<span v-if="order.pnl !== undefined" :class="order.pnl >= 0 ? 'pnl-positive' : 'pnl-negative'">
{{ order.pnl >= 0 ? '+' : '' }}${{ order.pnl.toFixed(2) }}
</span>
<span v-else>-</span>
</td>
<td>
<span class="status-badge" :class="'status-' + order.status">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ formatTime(order.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
currentTab: 'active',
loading: false,
serviceEnabled: false,
apiConfigured: false,
useTestnet: true,
account: {
current_balance: 0,
available: 0,
used_margin: 0,
total_position_value: 0
},
stats: null,
activeOrders: [],
historyOrders: [],
exchangePositions: [],
autoRefreshInterval: null
};
},
computed: {
displayOrders() {
if (this.currentTab === 'active') return this.activeOrders;
if (this.currentTab === 'history') return this.historyOrders;
if (this.currentTab === 'positions') return this.exchangePositions;
return [];
}
},
methods: {
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchServiceStatus(),
this.fetchAccountStatus(),
this.fetchStats(),
this.fetchActiveOrders(),
this.fetchHistoryOrders(),
this.fetchExchangePositions()
]);
} finally {
this.loading = false;
}
},
async fetchServiceStatus() {
try {
const response = await axios.get('/api/real-trading/status');
if (response.data.success) {
const status = response.data.status;
this.serviceEnabled = status.enabled;
this.apiConfigured = status.api_configured;
this.useTestnet = status.use_testnet;
if (status.account) {
this.account = status.account;
}
}
} catch (error) {
console.error('获取服务状态失败:', error);
}
},
async fetchAccountStatus() {
try {
const response = await axios.get('/api/real-trading/account');
if (response.data.success) {
this.account = response.data.account;
}
} catch (error) {
console.error('获取账户状态失败:', error);
}
},
async fetchStats() {
try {
const response = await axios.get('/api/real-trading/stats');
if (response.data.success) {
this.stats = response.data.stats;
}
} catch (error) {
console.error('获取统计数据失败:', error);
}
},
async fetchActiveOrders() {
try {
const response = await axios.get('/api/real-trading/orders/active');
if (response.data.success) {
this.activeOrders = response.data.orders;
}
} catch (error) {
console.error('获取活跃订单失败:', error);
}
},
async fetchHistoryOrders() {
try {
const response = await axios.get('/api/real-trading/orders?status=closed&limit=50');
if (response.data.success) {
this.historyOrders = response.data.orders;
}
} catch (error) {
console.error('获取历史订单失败:', error);
}
},
async fetchExchangePositions() {
try {
const response = await axios.get('/api/real-trading/positions');
if (response.data.success) {
this.exchangePositions = response.data.positions;
}
} catch (error) {
console.error('获取交易所持仓失败:', error);
}
},
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': '持仓中',
'pending': '挂单中',
'closed': '已平仓',
'cancelled': '已取消'
};
return map[status] || status;
}
},
mounted() {
this.refreshData();
// 每30秒自动刷新
this.autoRefreshInterval = setInterval(() => {
this.refreshData();
}, 30000);
},
beforeUnmount() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
}
}
}).mount('#app');
</script>
</body>
</html>