stock-ai-agent/frontend/real-trading.html
2026-04-22 11:03:24 +08:00

789 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">
<title>实盘交易 - XClaw</title>
<!-- Global Styles -->
<link rel="stylesheet" href="/static/css/style.css">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Page-Specific Styles -->
<style>
.trading-page {
max-width: 1400px;
margin: 0 auto;
padding: 32px 24px;
}
/* Header */
.trading-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding: 22px 24px;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.trading-title {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.live-badge {
display: inline-block;
background: rgba(255, 111, 97, 0.16);
color: var(--error);
padding: 5px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
border: 1px solid rgba(255, 111, 97, 0.22);
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
/* Auto Trading Toggle */
.auto-trading-toggle {
display: flex;
align-items: center;
gap: 12px;
}
.toggle-label {
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.switch {
position: relative;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 26px;
border: 1px solid var(--border);
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 4px;
bottom: 3px;
background-color: var(--text-primary);
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.24);
}
input:checked + .slider {
background-color: var(--success);
border-color: var(--success);
}
input:checked + .slider:before {
transform: translateX(24px);
}
.switch:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status-text {
font-size: 13px;
font-weight: 600;
min-width: 60px;
}
.status-text.enabled {
color: var(--success);
}
.status-text.disabled {
color: var(--error);
}
.refresh-btn {
padding: 10px 20px;
background: rgba(126, 200, 255, 0.08);
border: 1px solid var(--border);
border-radius: 14px;
color: var(--primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(14px);
}
.refresh-btn:hover {
background: rgba(126, 200, 255, 0.14);
border-color: var(--primary);
}
/* Warning Banner */
.warning-banner {
background: var(--error-light);
border: 1px solid var(--error);
border-radius: var(--radius-md);
padding: 16px 20px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow-soft);
}
.warning-banner.info {
background: var(--warning-light);
border-color: var(--warning);
}
.warning-banner svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.warning-banner-text {
font-size: 14px;
color: var(--error);
font-weight: 500;
}
.warning-banner.info .warning-banner-text {
color: var(--warning);
}
/* Account Info */
.account-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.account-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.account-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.account-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.account-sub {
font-size: 12px;
color: var(--text-tertiary);
}
.account-value.positive {
color: var(--success);
}
.account-value.negative {
color: var(--error);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
.tab {
padding: 14px 24px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Table */
.table-container {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: var(--bg-tertiary);
padding: 14px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 14px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background: var(--bg-secondary);
}
/* Badges */
.side-badge {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.side-long {
background: var(--success-light);
color: var(--success);
}
.side-short {
background: var(--error-light);
color: var(--error);
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.status-open {
background: var(--success-light);
color: var(--success);
}
.status-pending {
background: var(--warning-light);
color: var(--warning);
}
.status-closed {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.pnl-positive {
color: var(--success);
font-weight: 600;
}
.pnl-negative {
color: var(--error);
font-weight: 600;
}
/* Loading & Empty States */
.loading-state, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.loading-state svg, .empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid var(--bg-tertiary);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.trading-page {
padding: 20px 16px;
}
.trading-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.trading-title {
font-size: 22px;
}
.account-info {
grid-template-columns: 1fr;
}
.tabs {
overflow-x: auto;
}
.tab {
padding: 10px 16px;
font-size: 13px;
white-space: nowrap;
}
.table-container {
overflow-x: auto;
}
table {
min-width: 800px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="trading-page">
<!-- Header -->
<div class="trading-header">
<div class="trading-title">
实盘交易
<span class="live-badge">LIVE</span>
</div>
<div class="header-actions">
<!-- Auto Trading Toggle -->
<div class="auto-trading-toggle" v-if="apiConfigured">
<span class="toggle-label">自动交易</span>
<label class="switch">
<input type="checkbox"
v-model="autoTradingEnabled"
@change="toggleAutoTrading"
:disabled="!serviceEnabled || switching">
<span class="slider"></span>
</label>
<span class="status-text" :class="{ enabled: autoTradingEnabled, disabled: !autoTradingEnabled }">
{{ autoTradingEnabled ? '已启用' : '已禁用' }}
</span>
</div>
<button class="refresh-btn" @click="refreshData">刷新</button>
</div>
</div>
<!-- Warning Banners -->
<div class="warning-banner" v-if="!apiConfigured">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style="color: var(--error)">
<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 info" v-else-if="!serviceEnabled && apiConfigured">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" style="color: var(--warning)">
<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">
实盘交易服务未启用REAL_TRADING_ENABLED=false仅可查看账户数据无法执行交易
</div>
</div>
<!-- Account Info -->
<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>
<!-- Tabs -->
<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>
<!-- Content -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p style="margin-top: 16px; font-size: 14px;">加载中...</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="1.5" 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="1.5" 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 class="table-container">
<!-- Positions Table -->
<table v-if="currentTab === 'positions'">
<thead>
<tr>
<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="pos in exchangePositions" :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>
</tbody>
</table>
<!-- Orders Table -->
<table v-else>
<thead>
<tr>
<th>交易对</th>
<th>方向</th>
<th>类型</th>
<th>价格</th>
<th>数量</th>
<th>成交数量</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orderHistory" :key="order.id">
<td><strong>{{ formatSymbol(order.symbol) }}</strong></td>
<td>
<span class="side-badge" :class="order.side === 'buy' ? 'side-long' : 'side-short'">
{{ order.side === 'buy' ? '做多' : '做空' }}
</span>
</td>
<td>
<span class="status-badge" :class="{
'status-open': order.status === 'open',
'status-pending': order.status === 'pending' || order.status === 'partially_filled',
'status-closed': order.status === 'closed' || order.status === 'filled'
}">
{{ formatOrderStatus(order.status) }}
</span>
</td>
<td>${{ order.price ? parseFloat(order.price).toLocaleString() : '-' }}</td>
<td>{{ order.amount || order.filled || '0' }}</td>
<td>{{ order.filled || '0' }}</td>
<td>{{ order.datetime ? formatTime(order.datetime) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
currentTab: 'positions',
loading: false,
serviceEnabled: false,
apiConfigured: false,
useTestnet: true,
autoTradingEnabled: false,
switching: false,
account: {
current_balance: 0,
available: 0,
used_margin: 0,
total_position_value: 0
},
orderHistory: [],
exchangePositions: [],
stats: null
};
},
methods: {
async switchTab(tab) {
if (this.currentTab === tab) return;
this.currentTab = tab;
if (tab === 'orders') {
this.loading = true;
try {
await this.fetchOrderHistory();
} finally {
this.loading = false;
}
}
},
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchServiceStatus(),
this.fetchAccountStatus(),
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;
this.autoTradingEnabled = status.auto_trading_enabled || false;
if (status.account) {
this.account = status.account;
}
}
} catch (error) {
console.error('获取服务状态失败:', error);
}
},
async toggleAutoTrading() {
if (this.switching) return;
this.switching = true;
try {
const response = await axios.post('/api/real-trading/auto-trading', null, {
params: { enabled: this.autoTradingEnabled }
});
if (response.data.success) {
await this.fetchServiceStatus();
alert(response.data.message);
} else {
this.autoTradingEnabled = !this.autoTradingEnabled;
alert('设置失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('设置自动交易失败:', error);
this.autoTradingEnabled = !this.autoTradingEnabled;
alert('设置失败: ' + (error.response?.data?.detail || error.message));
} finally {
this.switching = false;
}
},
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 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);
this.orderHistory = [];
}
},
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);
this.exchangePositions = [];
}
},
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'
});
},
formatSymbol(symbol) {
if (!symbol) return '-';
return symbol.replace('/', '').replace(':', '');
},
formatOrderStatus(status) {
const map = {
'open': '挂单中',
'closed': '已成交',
'canceled': '已取消',
'cancelled': '已取消',
'filled': '已成交',
'partially_filled': '部分成交',
'rejected': '已拒绝',
'expired': '已过期'
};
return map[status] || status;
}
},
mounted() {
this.refreshData();
}
}).mount('#app');
</script>
</body>
</html>