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

754 lines
27 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;
}
.fee-positive {
color: #00ff41;
}
.fee-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 === 'positions' }"
@click="switchTab('positions')">
交易所持仓 ({{ exchangePositions.length }})
</button>
<button
class="tab"
:class="{ active: currentTab === 'orders' }"
@click="switchTab('orders')">
历史订单 ({{ orderHistory.length }})
</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 === '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>
<div v-else-if="currentTab === 'orders' && orderHistory.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 v-if="currentTab === 'positions'">
<th>交易对</th>
<th>方向</th>
<th>持仓量</th>
<th>入场价</th>
<th>标记价</th>
<th>杠杆</th>
<th>保证金</th>
<th>未实现盈亏</th>
<th>盈亏比例</th>
<th>强平价格</th>
</tr>
<tr v-else>
<th>交易对</th>
<th>方向</th>
<th>类型</th>
<th>价格</th>
<th>数量</th>
<th>成交数量</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<!-- 交易所持仓表格 -->
<template v-if="currentTab === 'positions'">
<tr v-for="pos in displayOrders" :key="pos.id || pos.symbol">
<td><strong>{{ formatSymbol(pos.symbol) }}</strong></td>
<td>
<span class="side-badge" :class="pos.side === 'long' ? 'side-long' : 'side-short'">
{{ pos.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>{{ pos.contracts || '0' }}</td>
<td>${{ pos.entryPrice ? parseFloat(pos.entryPrice).toLocaleString() : '-' }}</td>
<td>${{ pos.markPrice ? parseFloat(pos.markPrice).toLocaleString() : '-' }}</td>
<td>{{ pos.leverage }}x</td>
<td>${{ pos.initialMargin ? parseFloat(pos.initialMargin).toFixed(2) : '-' }}</td>
<td :class="parseFloat(pos.unrealizedPnl || 0) >= 0 ? 'pnl-positive' : 'pnl-negative'">
{{ parseFloat(pos.unrealizedPnl || 0) >= 0 ? '+' : '' }}${{ parseFloat(pos.unrealizedPnl || 0).toFixed(2) }}
</td>
<td :class="(pos.percentage || 0) >= 0 ? 'pnl-positive' : 'pnl-negative'">
{{ (pos.percentage || 0).toFixed(2) }}%
</td>
<td>${{ pos.liquidationPrice ? parseFloat(pos.liquidationPrice).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2}) : '-' }}</td>
</tr>
</template>
<!-- 历史订单表格 -->
<template v-else>
</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: 'positions',
loading: false,
serviceEnabled: false,
apiConfigured: false,
useTestnet: true,
account: {
current_balance: 0,
available: 0,
used_margin: 0,
total_position_value: 0
},
stats: null,
orderHistory: [],
exchangePositions: [],
autoRefreshInterval: null
};
},
computed: {
displayOrders() {
if (this.currentTab === 'orders') return this.orderHistory;
if (this.currentTab === 'positions') return this.exchangePositions;
return [];
}
},
methods: {
async switchTab(tab) {
if (this.currentTab === tab) return; // 已经是当前标签,不重新加载
this.currentTab = tab;
// 切换标签时加载对应数据
this.loading = true;
try {
if (tab === 'orders') {
await this.fetchOrderHistory();
}
} finally {
this.loading = false;
}
},
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchServiceStatus(),
this.fetchAccountStatus(),
this.fetchStats(),
this.fetchExchangePositions()
]);
// 根据当前标签页获取对应数据
if (this.currentTab === 'orders') {
await this.fetchOrderHistory();
}
} 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 fetchTradeHistory() {
try {
// 获取成交记录(包含盈亏)
const response = await axios.get('/api/real-trading/orders?status=trades&limit=100');
if (response.data.success) {
this.tradeHistory = response.data.orders;
}
} catch (error) {
console.error('获取成交记录失败:', error);
}
},
async fetchOrderHistory() {
try {
// 获取历史订单
const response = await axios.get('/api/real-trading/orders?status=orders&limit=50');
if (response.data.success) {
this.orderHistory = 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;
},
formatSymbol(symbol) {
// 将 CCXT 格式的交易对转换为简洁格式
// 例如BTC/USDT:USDT -> BTCUSDT
if (!symbol) return '-';
return symbol.replace('/', '').replace(':', '');
},
formatOrderStatus(status) {
const map = {
'open': '挂单中',
'closed': '已成交',
'canceled': '已取消',
'cancelled': '已取消',
'filled': '已成交',
'partially_filled': '部分成交',
'rejected': '已拒绝',
'expired': '已过期'
};
return map[status] || status;
},
getFeeClass(order) {
const fee = parseFloat(order.fee || 0);
if (fee === 0) return '';
// 如果是 taker (fee > 0),显示为负(花费)
// 如果是 maker (fee < 0),显示为正(返还)
return fee > 0 ? 'fee-negative' : 'fee-positive';
}
},
mounted() {
this.refreshData();
// 不自动刷新,用户可以手动点击刷新按钮
},
beforeUnmount() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
}
}
}).mount('#app');
</script>
</body>
</html>