stock-ai-agent/frontend/trading.html
2026-03-30 11:34:15 +08:00

1236 lines
53 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 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);
}
/* Real-time Prices */
.price-section {
margin-bottom: 24px;
padding: 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
}
.price-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.price-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.price-item .symbol {
font-weight: 600;
color: var(--text-primary);
}
.price-item .price {
font-size: 16px;
font-weight: 700;
color: var(--primary);
}
/* Core Metrics Grid */
.core-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.core-metric-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 24px;
text-align: center;
}
.core-metric-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.core-metric-value {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
.core-metric-value.positive {
color: var(--success);
}
.core-metric-value.negative {
color: var(--error);
}
@media (max-width: 768px) {
.metrics-grid, .stat-grid, .grade-stats, .core-metrics-grid, .price-list {
grid-template-columns: 1fr;
}
}
/* Admin Dropdown */
.admin-dropdown {
position: relative;
}
.admin-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 200px;
z-index: 1000;
}
.admin-menu-item {
display: block;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
text-align: left;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.admin-menu-item:hover {
background: var(--bg-secondary);
}
.admin-menu-item:first-child {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.admin-menu-item:last-child {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.pending-stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.pending-main {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.4;
}
.pending-sub {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.pending-sub.text-success,
.pending-sub.text-error {
font-weight: 600;
}
.pending-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pending-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border);
font-size: 11px;
color: var(--text-secondary);
line-height: 1.4;
white-space: nowrap;
}
.pending-signal {
min-width: 140px;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<!-- Header -->
<div class="page-header">
<div>
<div class="page-title" @click="handleTitleClick" style="cursor: pointer;">
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>
<!-- 管理员菜单 -->
<div class="admin-dropdown" v-if="adminMode">
<button class="btn btn-secondary" @click="toggleAdminMenu">管理 ▾</button>
<div class="admin-menu" v-if="showAdminMenu">
<button class="admin-menu-item" @click="toggleAdminMode">
🔓 关闭管理员模式
</button>
</div>
</div>
</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" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
${{ stats.total_pnl ? stats.total_pnl.toLocaleString() : '0' }}
</div>
<div class="metric-sub">{{ stats.total_trades || 0 }} 笔交易</div>
</div>
<div class="metric-card">
<div class="metric-label">收益率</div>
<div class="metric-value" :class="stats.total_pnl_percent >= 0 ? 'positive' : 'negative'">
{{ stats.total_pnl_percent >= 0 ? '+' : '' }}{{ stats.total_pnl_percent ? stats.total_pnl_percent.toFixed(2) : '0.00' }}%
</div>
<div class="metric-sub">初始资金: $10,000</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>
<!-- Real-time Prices -->
<div v-if="Object.keys(latestPrices).length > 0" class="price-section">
<div class="stat-label" style="margin-bottom: 8px;">实时价格</div>
<div class="price-list">
<div class="price-item" v-for="(price, symbol) in latestPrices" :key="symbol">
<span class="symbol">{{ symbol }}</span>
<span class="price">${{ price.toLocaleString() }}</span>
</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')">
历史订单
</button>
<button class="tab" :class="{ active: currentTab === 'stats' }" @click="switchTab('stats')">
详细统计
</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 === '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>
<!-- 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>
<th>盈亏比例</th>
<th v-if="adminMode">操作</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.side === 'long' ? 'badge-success' : 'badge-error'">
{{ order.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>{{ formatNumber(order.quantity, 4) }}</td>
<td>{{ formatCurrency(order.display_entry_price) }}</td>
<td>{{ formatCurrency(order.current_price) }}</td>
<td>{{ formatCurrency(order.stop_loss) }}</td>
<td>{{ formatCurrency(order.take_profit) }}</td>
<td>{{ order.leverage || 0 }}x</td>
<td>{{ formatCurrency(order.margin) }}</td>
<td :class="order.unrealized_pnl >= 0 ? 'text-success' : 'text-error'">
{{ formatSignedCurrency(order.unrealized_pnl) }}
</td>
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
{{ formatSignedPercent(order.pnl_percent) }}
</td>
<td v-if="adminMode">
<button class="btn btn-danger btn-small" @click="closeOrder(order)">平仓</button>
<button v-if="adminMode" class="btn btn-secondary btn-small" @click="deleteOrder(order)" style="margin-left: 4px;">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pending Orders Table -->
<div v-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 v-if="adminMode">操作</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.side === 'long' ? 'badge-success' : 'badge-error'">
{{ order.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>
<div class="pending-stack">
<div class="pending-main">{{ formatNumber(order.quantity, 4) }}</div>
<div class="pending-sub">杠杆 {{ order.leverage || 0 }}x</div>
</div>
</td>
<td>
<div class="pending-stack">
<div class="pending-main">{{ formatCurrency(order.display_entry_price) }}</div>
<div class="pending-sub">现价 {{ formatCurrency(order.current_price) }}</div>
<div class="pending-sub" :class="order.distance_class">{{ order.distance_text }}</div>
</div>
</td>
<td>
<div class="pending-stack">
<div class="pending-main">{{ formatCurrency(order.stop_loss) }}</div>
<div class="pending-sub text-error">止损 {{ formatOptionalSignedPercent(order.stop_loss_percent) }}</div>
<div class="pending-main">{{ formatCurrency(order.take_profit) }}</div>
<div class="pending-sub text-success">止盈 {{ formatOptionalSignedPercent(order.take_profit_percent) }}</div>
<div class="pending-sub">盈亏比 {{ order.risk_reward_text }}</div>
</div>
</td>
<td>
<div class="pending-stack">
<div class="pending-main">{{ formatCurrency(order.margin) }}</div>
<div class="pending-sub">保证金</div>
<div class="pending-main">{{ formatCurrency(order.expected_position_value) }}</div>
<div class="pending-sub">名义仓位</div>
</div>
</td>
<td>
<div class="pending-stack pending-signal">
<div class="pending-main">{{ order.signal_grade_text }}</div>
<div class="pending-chip-row">
<span v-if="order.signal_type_text" class="pending-chip">{{ order.signal_type_text }}</span>
<span v-if="order.confidence_text" class="pending-chip">{{ order.confidence_text }}</span>
<span v-if="order.entry_type_text" class="pending-chip">{{ order.entry_type_text }}</span>
</div>
</div>
</td>
<td>{{ formatTime(order.created_at) }}</td>
<td v-if="adminMode">
<button class="btn btn-danger btn-small" @click="cancelOrder(order)">撤单</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Order History Table -->
<div v-if="currentTab === 'history'" 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 v-if="adminMode">操作</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.side === 'long' ? 'badge-success' : 'badge-error'">
{{ order.side === 'long' ? '做多' : '做空' }}
</span>
</td>
<td>{{ order.quantity ? order.quantity.toFixed(4) : '0.0000' }}</td>
<td>{{ order.entry_price ? '$' + order.entry_price.toFixed(2) : '$0.00' }}</td>
<td>{{ order.exit_price ? '$' + order.exit_price.toFixed(2) : '$0.00' }}</td>
<td :class="order.pnl_amount >= 0 ? 'text-success' : 'text-error'">
{{ order.pnl_amount >= 0 ? '+' : '' }}${{ order.pnl_amount ? order.pnl_amount.toFixed(2) : '0.00' }}
</td>
<td :class="order.pnl_percent >= 0 ? 'text-success' : 'text-error'">
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent ? order.pnl_percent.toFixed(2) : '0.00' }}%
</td>
<td>
<span class="badge" :class="getStatusBadgeClass(order.status)">
{{ getStatusText(order.status) }}
</span>
</td>
<td>{{ formatTime(order.closed_at) }}</td>
<td v-if="adminMode">
<button class="btn btn-danger btn-small" @click="deleteOrder(order)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Detailed Statistics Tab -->
<div v-if="currentTab === 'stats'" class="table-container">
<!-- Core Metrics -->
<div class="core-metrics-grid">
<div class="core-metric-card">
<div class="core-metric-label">累计收益率</div>
<div class="core-metric-value" :class="stats.total_pnl_percent >= 0 ? 'positive' : 'negative'">
{{ stats.total_pnl_percent >= 0 ? '+' : '' }}{{ stats.total_pnl_percent ? stats.total_pnl_percent.toFixed(2) : '0.00' }}%
</div>
</div>
<div class="core-metric-card">
<div class="core-metric-label">总盈亏</div>
<div class="core-metric-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
{{ stats.total_pnl >= 0 ? '+' : '' }}${{ stats.total_pnl ? stats.total_pnl.toFixed(2) : '0.00' }}
</div>
</div>
<div class="core-metric-card">
<div class="core-metric-label">胜率</div>
<div class="core-metric-value positive">
{{ stats.win_rate ? stats.win_rate.toFixed(1) : '0.0' }}%
</div>
</div>
<div class="core-metric-card">
<div class="core-metric-label">盈亏比</div>
<div class="core-metric-value">
{{ stats.profit_factor === Infinity ? '∞' : (stats.profit_factor ? stats.profit_factor.toFixed(2) : '0.00') }}
</div>
</div>
</div>
<!-- Grade Stats -->
<div class="grade-stats" style="margin-top: 24px;">
<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 negative">{{ stats.max_drawdown ? stats.max_drawdown.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>
</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,
total_pnl_percent: 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: [],
adminMode: false,
showAdminMenu: false,
adminPassword: '223388',
titleClickCount: 0,
titleClickTimer: null,
refreshInterval: null,
latestPrices: {}
};
},
computed: {
openPositions() {
return this.orders
.filter(order => order.status === 'open')
.map(order => {
const displayEntryPrice = this.getDisplayEntryPrice(order);
const currentPrice = this.resolveOrderCurrentPrice(order);
const pnlPercent = this.calculateOpenOrderPnlPercent(order, currentPrice, displayEntryPrice);
const unrealizedPnl = this.calculateOpenOrderPnlAmount(order, pnlPercent);
return {
...order,
display_entry_price: displayEntryPrice,
current_price: currentPrice || null,
pnl_percent: pnlPercent,
unrealized_pnl: unrealizedPnl
};
});
},
pendingOrders() {
return this.orders
.filter(order => order.status === 'pending')
.map(order => {
const entryPrice = Number(order.entry_price || 0);
const currentPrice = this.resolveOrderCurrentPrice(order);
const stopLossPercent = this.calculateOrderTargetPercent(order.side, entryPrice, order.stop_loss);
const takeProfitPercent = this.calculateOrderTargetPercent(order.side, entryPrice, order.take_profit);
const margin = Number(order.margin || 0);
const leverage = Number(order.leverage || 0);
return {
...order,
display_entry_price: entryPrice,
current_price: currentPrice || null,
distance_percent: this.calculatePendingDistancePercent(currentPrice, entryPrice),
distance_text: this.getPendingDistanceText(currentPrice, entryPrice),
distance_class: this.getPendingDistanceClass(currentPrice, entryPrice),
stop_loss_percent: stopLossPercent,
take_profit_percent: takeProfitPercent,
risk_reward_ratio: this.calculateRiskRewardRatio(stopLossPercent, takeProfitPercent),
risk_reward_text: this.formatRiskRewardRatio(stopLossPercent, takeProfitPercent),
expected_position_value: margin > 0 && leverage > 0 ? margin * leverage : 0,
signal_grade_text: order.signal_grade || '-',
signal_type_text: this.getSignalTypeText(order.signal_type),
confidence_text: this.formatConfidence(order.confidence),
entry_type_text: this.getEntryTypeText(order.entry_type)
};
});
},
orderHistory() {
return this.orders.filter(order => {
const status = typeof order.status === 'string' ? order.status : '';
return status.startsWith('closed') || status === 'cancelled';
});
}
},
methods: {
async switchTab(tab) {
this.currentTab = tab;
},
async refreshData() {
this.loading = true;
try {
await Promise.all([
this.fetchAccountStatus(),
this.fetchStatistics(),
this.fetchOrders(),
this.fetchLatestPrices()
]);
} catch (e) {
console.error('刷新数据失败:', e);
} finally {
this.loading = false;
}
},
async silentRefresh() {
try {
await Promise.all([
this.fetchAccountStatus(),
this.fetchStatistics(),
this.fetchOrders(),
this.fetchLatestPrices()
]);
} catch (e) {
console.error('静默刷新失败:', e);
}
},
async fetchLatestPrices() {
try {
const response = await axios.get('/api/trading/monitor/status');
if (response.data.success && response.data.latest_prices) {
this.latestPrices = response.data.latest_prices;
}
} catch (error) {
console.error('获取实时价格失败:', error);
}
},
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');
if (response.data.success) {
this.orders = response.data.orders || [];
}
} catch (error) {
console.error('获取订单失败:', error);
}
},
async closeOrder(order) {
if (!confirm('确定要平仓吗?')) return;
try {
const exitPrice = this.resolveOrderCurrentPrice(order) || this.getDisplayEntryPrice(order);
const response = await axios.post(`/api/trading/orders/${order.order_id}/close`, {
exit_price: exitPrice
});
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 deleteOrder(order) {
if (!confirm('确定要删除该订单吗?此操作不可恢复!')) return;
try {
const response = await axios.delete(`/api/trading/orders/${order.order_id}`);
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'
});
},
formatCurrency(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return '-';
return `$${number.toFixed(2)}`;
},
formatSignedCurrency(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '$0.00';
return `${number >= 0 ? '+' : '-'}$${Math.abs(number).toFixed(2)}`;
},
formatSignedPercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '0.00%';
return `${number >= 0 ? '+' : '-'}${Math.abs(number).toFixed(2)}%`;
},
formatOptionalSignedPercent(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return this.formatSignedPercent(number);
},
formatNumber(value, digits = 2) {
const number = Number(value);
if (!Number.isFinite(number)) return (0).toFixed(digits);
return number.toFixed(digits);
},
formatConfidence(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return '';
const percent = number <= 1 ? number * 100 : number;
return `置信度 ${percent.toFixed(1)}%`;
},
getDisplayEntryPrice(order) {
const price = Number(order.filled_price || order.entry_price || 0);
return Number.isFinite(price) ? price : 0;
},
resolveOrderCurrentPrice(order) {
const latest = Number(this.latestPrices?.[order.symbol]);
if (Number.isFinite(latest) && latest > 0) {
return latest;
}
const current = Number(order.current_price);
if (Number.isFinite(current) && current > 0) {
return current;
}
return 0;
},
calculateOpenOrderPnlPercent(order, currentPrice, entryPrice) {
if (!currentPrice || !entryPrice) {
return Number(order.pnl_percent || 0);
}
if (order.side === 'long') {
return ((currentPrice - entryPrice) / entryPrice) * 100;
}
if (order.side === 'short') {
return ((entryPrice - currentPrice) / entryPrice) * 100;
}
return Number(order.pnl_percent || 0);
},
calculateOpenOrderPnlAmount(order, pnlPercent) {
const positionValue = Number(order.quantity || 0);
if (!Number.isFinite(positionValue) || positionValue <= 0) {
return Number(order.unrealized_pnl || 0);
}
return positionValue * pnlPercent / 100;
},
calculatePendingDistancePercent(currentPrice, entryPrice) {
const current = Number(currentPrice);
const entry = Number(entryPrice);
if (!Number.isFinite(current) || current <= 0 || !Number.isFinite(entry) || entry <= 0) {
return null;
}
return ((entry - current) / current) * 100;
},
getPendingDistanceText(currentPrice, entryPrice) {
const distancePercent = this.calculatePendingDistancePercent(currentPrice, entryPrice);
if (!Number.isFinite(distancePercent)) {
return '等待触发';
}
if (Math.abs(distancePercent) < 0.05) {
return '接近触发';
}
const direction = distancePercent > 0 ? '需上涨触发' : '需回落触发';
return `${direction} ${this.formatPercent(Math.abs(distancePercent))}`;
},
getPendingDistanceClass(currentPrice, entryPrice) {
const distancePercent = this.calculatePendingDistancePercent(currentPrice, entryPrice);
if (!Number.isFinite(distancePercent)) {
return '';
}
if (Math.abs(distancePercent) < 0.35) {
return 'text-success';
}
if (Math.abs(distancePercent) < 1) {
return '';
}
return 'text-error';
},
calculateOrderTargetPercent(side, entryPrice, targetPrice) {
const entry = Number(entryPrice);
const target = Number(targetPrice);
if (!Number.isFinite(entry) || entry <= 0 || !Number.isFinite(target) || target <= 0) {
return null;
}
if (side === 'long') {
return ((target - entry) / entry) * 100;
}
if (side === 'short') {
return ((entry - target) / entry) * 100;
}
return null;
},
calculateRiskRewardRatio(stopLossPercent, takeProfitPercent) {
const stop = Number(stopLossPercent);
const take = Number(takeProfitPercent);
if (!Number.isFinite(stop) || !Number.isFinite(take) || stop >= 0 || take <= 0) {
return null;
}
return take / Math.abs(stop);
},
formatRiskRewardRatio(stopLossPercent, takeProfitPercent) {
const ratio = this.calculateRiskRewardRatio(stopLossPercent, takeProfitPercent);
if (!Number.isFinite(ratio) || ratio <= 0) {
return '-';
}
return `1:${ratio.toFixed(2)}`;
},
formatPercent(value, digits = 2) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return `${number.toFixed(digits)}%`;
},
getSignalTypeText(signalType) {
const map = {
short_term: '日内',
medium_term: '中线',
long_term: '趋势',
swing: '波段'
};
return map[signalType] || signalType || '';
},
getEntryTypeText(entryType) {
const map = {
market: '市价',
limit: '限价',
pending: '挂单'
};
return map[entryType] || entryType || '';
},
getCloseReason(reason) {
const map = {
'manual': '手动',
'stop_loss': '止损',
'take_profit': '止盈',
'timeout': '超时',
'signal': '信号'
};
return map[reason] || reason || '-';
},
handleTitleClick() {
this.titleClickCount++;
if (this.titleClickTimer) {
clearTimeout(this.titleClickTimer);
}
this.titleClickTimer = setTimeout(() => {
this.titleClickCount = 0;
}, 2000);
if (this.titleClickCount === 3) {
this.titleClickCount = 0;
this.promptAdminMode();
}
},
promptAdminMode() {
if (this.adminMode) {
this.adminMode = false;
this.showAdminMenu = false;
alert('管理员模式已关闭');
} else {
const password = prompt('请输入管理员密码:');
if (password === this.adminPassword) {
this.adminMode = true;
alert('管理员模式已开启');
} else if (password !== null) {
alert('密码错误');
}
}
},
toggleAdminMenu() {
this.showAdminMenu = !this.showAdminMenu;
},
toggleAdminMode() {
this.promptAdminMode();
},
getStatusBadgeClass(status) {
const classMap = {
'closed_tp': 'badge-success',
'closed_sl': 'badge-error',
'closed_be': 'badge-warning',
'closed_ts': 'badge-success',
'closed_manual': 'badge-info',
'cancelled': 'badge-secondary'
};
return classMap[status] || 'badge-secondary';
},
getStatusText(status) {
const textMap = {
'pending': '挂单中',
'open': '持仓中',
'closed_tp': '止盈平仓',
'closed_sl': '止损平仓',
'closed_be': '保本平仓',
'closed_ts': '移动止盈',
'closed_manual': '手动平仓',
'cancelled': '已取消'
};
return textMap[status] || status;
}
},
mounted() {
this.refreshData();
// 每3秒自动刷新静默刷新不显示 loading
this.refreshInterval = setInterval(() => {
this.silentRefresh();
}, 3000);
// 点击外部关闭管理菜单
document.addEventListener('click', (e) => {
if (this.showAdminMenu && !e.target.closest('.admin-dropdown')) {
this.showAdminMenu = false;
}
});
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}).mount('#app');
</script>
</body>
</html>