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

713 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
<!-- 警告横幅 - 只在API未配置时显示 -->
<div class="warning-banner" v-if="!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">
Bitget API 密钥未配置,请在配置文件中设置 BITGET_API_KEY 和 BITGET_API_SECRET
</div>
</div>
<!-- 实盘交易未启用提示 -->
<div class="warning-banner" v-else-if="!serviceEnabled && apiConfigured" style="background: rgba(255, 200, 0, 0.1); border-color: #ffc800;">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style="color: #ffc800;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="warning-banner-text" style="color: #ffc800;">
实盘交易服务未启用REAL_TRADING_ENABLED=false仅可查看账户数据无法执行交易
</div>
</div>
<!-- 账户信息 - API 配置后即可显示 -->
<div class="account-info" v-if="apiConfigured">
<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 && serviceEnabled">
<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>