stock-ai-agent/frontend/trading.html
2026-03-29 11:39:40 +08:00

645 lines
28 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>XClaw AI</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=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Page-Specific Styles -->
<style>
.paper-badge {
display: inline-block;
background: var(--primary);
color: white;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
margin-left: 12px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.metric-card, .stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
transition: all 0.2s;
}
.metric-card:hover, .stat-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.metric-label, .stat-label {
font-size: var(--font-sm);
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-sm);
font-weight: 500;
}
.metric-value, .stat-value {
font-size: var(--font-2xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-xs);
font-family: 'Inter', monospace;
}
.metric-sub {
font-size: var(--font-sm);
color: var(--text-tertiary);
}
.metric-value.positive, .stat-value.positive {
color: var(--success);
}
.metric-value.negative, .stat-value.negative {
color: var(--error);
}
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--space-lg);
margin-top: var(--space-xl);
}
.grade-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.grade-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--border);
}
.grade-card-title {
font-size: var(--font-md);
font-weight: 600;
color: var(--text-primary);
}
.grade-card-stats {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.grade-stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) 0;
}
.grade-stat-label {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.grade-stat-value {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.grade-stat-value.positive {
color: var(--success);
}
.grade-stat-value.negative {
color: var(--error);
}
@media (max-width: 768px) {
.metrics-grid, .stat-grid, .grade-stats {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<!-- Header -->
<div class="page-header">
<div>
<div class="page-title">
XClaw
<span class="paper-badge">AI</span>
</div>
<div class="page-subtitle">XClaw AI Auto Trading</div>
</div>
<div class="header-actions">
<button class="btn btn-secondary" @click="refreshData">刷新</button>
<button class="btn btn-primary" @click="sendReport" :disabled="sendingReport">
{{ sendingReport ? '发送中...' : '发送报告' }}
</button>
<button class="btn btn-danger" @click="resetAccount" v-if="currentTab === 'positions' && openPositions.length > 0">
重置账户
</button>
</div>
</div>
<!-- Account Info -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">账户余额</div>
<div class="metric-value">${{ account.current_balance ? account.current_balance.toLocaleString() : '0' }}</div>
<div class="metric-sub">可用: ${{ account.available_margin ? account.available_margin.toLocaleString() : '0' }}</div>
</div>
<div class="metric-card">
<div class="metric-label">已用保证金</div>
<div class="metric-value">${{ account.used_margin ? account.used_margin.toLocaleString() : '0' }}</div>
</div>
<div class="metric-card">
<div class="metric-label">持仓价值</div>
<div class="metric-value">${{ account.total_position_value ? account.total_position_value.toLocaleString() : '0' }}</div>
</div>
<div class="metric-card">
<div class="metric-label">已实现盈亏</div>
<div class="metric-value" :class="account.realized_pnl >= 0 ? 'positive' : 'negative'">
${{ account.realized_pnl ? account.realized_pnl.toLocaleString() : '0' }}
</div>
<div class="metric-sub">{{ stats.total_trades || 0 }} 笔交易</div>
</div>
</div>
<!-- Stats Grid -->
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">胜率</div>
<div class="stat-value positive">{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0.0' }}%</div>
</div>
<div class="stat-card">
<div class="stat-label">盈亏比</div>
<div class="stat-value">{{ stats.profit_factor === Infinity ? '∞' : (stats.profit_factor ? stats.profit_factor.toFixed(2) : '0.00') }}</div>
</div>
<div class="stat-card">
<div class="stat-label">最大回撤</div>
<div class="stat-value" :class="stats.max_drawdown <= 0 ? 'negative' : 'positive'">
{{ stats.max_drawdown ? stats.max_drawdown.toFixed(2) : '0.00' }}%
</div>
</div>
<div class="stat-card">
<div class="stat-label">总杠杆率</div>
<div class="stat-value">{{ account.current_total_leverage ? account.current_total_leverage.toFixed(1) : '0.0' }}x / {{ account.max_total_leverage || 10 }}x</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{ active: currentTab === 'positions' }" @click="switchTab('positions')">
当前持仓 ({{ openPositions.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'pending' }" @click="switchTab('pending')">
挂单中 ({{ pendingOrders.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'history' }" @click="switchTab('history')">
历史订单 ({{ 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' && openPositions.length === 0" class="empty-state">
<p>暂无持仓</p>
</div>
<div v-else-if="currentTab === 'pending' && pendingOrders.length === 0" class="empty-state">
<p>暂无挂单</p>
</div>
<div v-else-if="currentTab === 'history' && orderHistory.length === 0" class="empty-state">
<p>暂无历史订单</p>
</div>
<div v-else>
<!-- Open Positions Table -->
<div v-if="currentTab === 'positions'" class="table-container">
<table>
<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="order in openPositions" :key="order.order_id">
<td><strong>{{ order.symbol }}</strong></td>
<td>
<span class="badge" :class="order.is_long ? 'badge-success' : 'badge-error'">
{{ order.is_long ? '做多' : '做空' }}
</span>
</td>
<td>{{ order.quantity.toFixed(4) }}</td>
<td>${{ order.entry_price.toFixed(2) }}</td>
<td>${{ order.current_price ? order.current_price.toFixed(2) : '-' }}</td>
<td>{{ order.leverage }}x</td>
<td>${{ order.margin.toFixed(2) }}</td>
<td :class="order.unrealized_pnl >= 0 ? 'text-success' : 'text-error'">
{{ order.unrealized_pnl >= 0 ? '+' : '' }}${{ order.unrealized_pnl.toFixed(2) }}
</td>
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent.toFixed(2) }}%
</td>
<td>
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pending Orders Table -->
<div v-else-if="currentTab === 'pending'" class="table-container">
<table>
<thead>
<tr>
<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 pendingOrders" :key="order.order_id">
<td><strong>{{ order.symbol }}</strong></td>
<td>
<span class="badge" :class="order.is_long ? 'badge-success' : 'badge-error'">
{{ order.is_long ? '做多' : '做空' }}
</span>
</td>
<td>{{ order.quantity.toFixed(4) }}</td>
<td>${{ order.entry_price.toFixed(2) }}</td>
<td>{{ order.leverage }}x</td>
<td>{{ order.stop_loss ? '$' + order.stop_loss.toFixed(2) : '-' }}</td>
<td>{{ order.take_profit ? '$' + order.take_profit.toFixed(2) : '-' }}</td>
<td>{{ formatTime(order.created_at) }}</td>
<td>
<button class="btn btn-danger btn-small" @click="cancelOrder(order)">撤单</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Order History Table -->
<div v-else class="table-container">
<table>
<thead>
<tr>
<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 orderHistory" :key="order.order_id">
<td><strong>{{ order.symbol }}</strong></td>
<td>
<span class="badge" :class="order.is_long ? 'badge-success' : 'badge-error'">
{{ order.is_long ? '做多' : '做空' }}
</span>
</td>
<td>{{ order.quantity.toFixed(4) }}</td>
<td>${{ order.entry_price.toFixed(2) }}</td>
<td>${{ order.exit_price.toFixed(2) }}</td>
<td :class="order.realized_pnl >= 0 ? 'text-success' : 'text-error'">
{{ order.realized_pnl >= 0 ? '+' : '' }}${{ order.realized_pnl.toFixed(2) }}
</td>
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent.toFixed(2) }}%
</td>
<td>{{ getCloseReason(order.close_reason) }}</td>
<td>{{ formatTime(order.closed_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Detailed Stats (Only show in history tab) -->
<div v-if="currentTab === 'history' && orderHistory.length > 0" class="grade-stats">
<div class="grade-card">
<div class="grade-card-header">
<span class="grade-card-title">交易详情</span>
</div>
<div class="grade-card-stats">
<div class="grade-stat-row">
<span class="grade-stat-label">总交易数</span>
<span class="grade-stat-value">{{ stats.total_trades || 0 }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">盈利交易</span>
<span class="grade-stat-value positive">{{ stats.winning_trades || 0 }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">亏损交易</span>
<span class="grade-stat-value negative">{{ stats.losing_trades || 0 }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">最佳交易</span>
<span class="grade-stat-value positive">{{ stats.best_trade ? stats.best_trade.toFixed(2) : '0.00' }}%</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">最差交易</span>
<span class="grade-stat-value negative">{{ stats.worst_trade ? stats.worst_trade.toFixed(2) : '0.00' }}%</span>
</div>
</div>
</div>
<div class="grade-card">
<div class="grade-card-header">
<span class="grade-card-title">收益分析</span>
</div>
<div class="grade-card-stats">
<div class="grade-stat-row">
<span class="grade-stat-label">平均盈利</span>
<span class="grade-stat-value positive">${{ stats.average_win ? stats.average_win.toFixed(2) : '0.00' }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">平均亏损</span>
<span class="grade-stat-value negative">${{ stats.average_loss ? stats.average_loss.toFixed(2) : '0.00' }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">收益率</span>
<span class="grade-stat-value" :class="stats.return_percent >= 0 ? 'positive' : 'negative'">
{{ stats.return_percent >= 0 ? '+' : '' }}{{ stats.return_percent ? stats.return_percent.toFixed(2) : '0.00' }}%
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.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,
sendingReport: false,
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,
max_drawdown: 0,
best_trade: 0,
worst_trade: 0,
return_percent: 0
},
account: {
initial_balance: 10000,
current_balance: 10000,
used_margin: 0,
available_margin: 10000,
leverage: 10,
margin_per_order: 1000,
active_orders: 0,
max_orders: 10,
available_orders: 10,
total_position_value: 0,
margin_ratio: 0,
realized_pnl: 0,
current_total_leverage: 0,
max_total_leverage: 10
},
orders: []
};
},
computed: {
openPositions() {
return this.orders.filter(o => o.status === 'open');
},
pendingOrders() {
return this.orders.filter(o => o.status === 'pending');
},
orderHistory() {
return this.orders.filter(o => o.status === 'closed');
}
},
methods: {
async switchTab(tab) {
this.currentTab = tab;
},
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchAccountStatus(),
this.fetchStatistics(),
this.fetchOrders()
]);
} catch (e) {
console.error('刷新数据失败:', e);
} finally {
this.loading = false;
}
},
async fetchAccountStatus() {
try {
const response = await axios.get('/api/trading/account');
if (response.data.success) {
this.account = response.data.account;
}
} catch (error) {
console.error('获取账户状态失败:', error);
}
},
async fetchStatistics() {
try {
const response = await axios.get('/api/trading/statistics');
if (response.data.success) {
this.stats = response.data.statistics;
}
} catch (error) {
console.error('获取统计数据失败:', error);
}
},
async fetchOrders() {
try {
const response = await axios.get('/api/trading/orders');
console.log('API Response:', response.data);
if (response.data.success) {
this.orders = response.data.orders || [];
console.log('Orders loaded:', this.orders.length);
console.log('Orders data:', this.orders);
console.log('Open positions:', this.orders.filter(o => o.status === 'open'));
console.log('Pending orders:', this.orders.filter(o => o.status === 'pending'));
console.log('Order history:', this.orders.filter(o => o.status === 'closed'));
}
} catch (error) {
console.error('获取订单失败:', error);
}
},
async closeOrder(order) {
if (!confirm('确定要平仓吗?')) return;
try {
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`);
if (response.data.success) {
await this.refreshData();
alert('平仓成功');
} else {
alert('平仓失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('平仓失败:', error);
alert('平仓失败: ' + (error.response?.data?.detail || error.message));
}
},
async cancelOrder(order) {
if (!confirm('确定要撤单吗?')) return;
try {
const response = await axios.post(`/api/trading/orders/${order.order_id}/cancel`);
if (response.data.success) {
await this.refreshData();
alert('撤单成功');
} else {
alert('撤单失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('撤单失败:', error);
alert('撤单失败: ' + (error.response?.data?.detail || error.message));
}
},
async resetAccount() {
if (!confirm('确定要重置账户吗?这将清除所有持仓和订单!')) return;
try {
const response = await axios.post('/api/trading/account/reset');
if (response.data.success) {
await this.refreshData();
alert('账户重置成功');
} else {
alert('重置失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('重置账户失败:', error);
alert('重置失败: ' + (error.response?.data?.detail || error.message));
}
},
async sendReport() {
this.sendingReport = true;
try {
const response = await axios.post('/api/trading/report?hours=4&send_telegram=true');
if (response.data.success) {
alert('报告已发送');
} else {
alert('发送失败: ' + (response.data.message || '未知错误'));
}
} catch (error) {
console.error('发送报告失败:', error);
alert('发送失败: ' + (error.response?.data?.detail || error.message));
} finally {
this.sendingReport = false;
}
},
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'
});
},
getCloseReason(reason) {
const map = {
'manual': '手动',
'stop_loss': '止损',
'take_profit': '止盈',
'timeout': '超时',
'signal': '信号'
};
return map[reason] || reason || '-';
}
},
mounted() {
this.refreshData();
}
}).mount('#app');
</script>
</body>
</html>