stock-ai-agent/frontend/paper-trading.html
2026-02-07 01:43:24 +08:00

993 lines
36 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>模拟交易 - Tradus</title>
<link rel="stylesheet" href="/static/css/style.css">
<style>
.trading-page {
min-height: 100vh;
background: var(--bg-primary);
padding: 20px;
}
.trading-container {
max-width: 1400px;
min-width: 800px;
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 span {
color: var(--accent);
}
.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 {
width: 100%;
border-collapse: collapse;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.orders-table th,
.orders-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.orders-table th {
background: var(--bg-primary);
color: var(--text-secondary);
font-weight: 400;
font-size: 12px;
text-transform: uppercase;
}
.orders-table td {
color: var(--text-primary);
font-size: 14px;
}
.orders-table tr:hover {
background: var(--bg-tertiary);
}
/* 状态标签 */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 2px;
font-size: 12px;
}
.status-badge.pending {
background: rgba(255, 165, 0, 0.1);
color: orange;
}
.status-badge.open {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
}
.status-badge.closed_tp {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
}
.status-badge.closed_sl {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
}
.status-badge.closed_manual {
background: rgba(255, 165, 0, 0.1);
color: orange;
}
/* 方向标签 */
.side-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 2px;
font-size: 12px;
}
.side-badge.long {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
}
.side-badge.short {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
}
/* 等级标签 */
.grade-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 2px;
font-size: 12px;
font-weight: 500;
}
.grade-badge.A {
background: rgba(255, 215, 0, 0.2);
color: gold;
}
.grade-badge.B {
background: rgba(192, 192, 192, 0.2);
color: silver;
}
.grade-badge.C {
background: rgba(205, 127, 50, 0.2);
color: #cd7f32;
}
/* 盈亏显示 */
.pnl {
font-weight: 500;
}
.pnl.positive {
color: #00ff41;
}
.pnl.negative {
color: #ff4444;
}
/* 按等级统计 */
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-top: 30px;
}
.grade-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.grade-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.grade-card-title {
font-size: 16px;
font-weight: 500;
}
.grade-card-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.grade-stat-row {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.grade-stat-label {
color: var(--text-secondary);
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* 监控状态 */
.monitor-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.monitor-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4444;
}
.monitor-dot.running {
background: #00ff41;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 价格显示 */
.price-list {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.price-item {
background: var(--bg-tertiary);
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.price-item .symbol {
color: var(--text-secondary);
margin-right: 8px;
}
.price-item .price {
color: var(--accent);
font-family: monospace;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* 重置按钮 */
.reset-btn {
padding: 8px 16px;
background: transparent;
border: 1px solid #ff4444;
color: #ff4444;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.reset-btn:hover {
background: rgba(255, 68, 68, 0.1);
}
/* 操作按钮 */
.action-btn {
padding: 4px 8px;
background: transparent;
border: 1px solid var(--border-bright);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.action-btn.danger:hover {
border-color: #ff4444;
color: #ff4444;
}
/* 持仓汇总 */
.position-summary {
display: flex;
gap: 24px;
padding: 16px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 16px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-label {
font-size: 12px;
color: var(--text-secondary);
}
.summary-value {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
}
.summary-value.positive {
color: #00ff41;
}
.summary-value.negative {
color: #ff4444;
}
/* 现价样式 */
.current-price {
font-family: monospace;
font-weight: 500;
}
.current-price.price-up {
color: #00ff41;
}
.current-price.price-down {
color: #ff4444;
}
/* 警告价格(接近止损) */
.warning-price {
color: #ff4444;
font-weight: 500;
}
/* 成功价格(接近止盈) */
.success-price {
color: #00ff41;
font-weight: 500;
}
/* 盈亏小字 */
.pnl small {
font-size: 11px;
opacity: 0.8;
}
</style>
</head>
<body>
<div id="app">
<div class="trading-page">
<div class="trading-container">
<!-- 固定顶部区域 -->
<div class="sticky-header">
<!-- 头部 -->
<div class="trading-header">
<h1 class="trading-title">模拟交易 <span>Paper Trading</span></h1>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="monitor-status">
<div class="monitor-dot" :class="{ running: monitorRunning }"></div>
<span>{{ monitorRunning ? '监控中' : '未启动' }}</span>
</div>
<button class="refresh-btn" @click="manualRefresh">刷新数据</button>
<button class="reset-btn" @click="resetData">重置数据</button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">总交易数</div>
<div class="stat-value">{{ stats.total_trades }}</div>
</div>
<div class="stat-card">
<div class="stat-label">胜率</div>
<div class="stat-value">{{ stats.win_rate.toFixed(1) }}%</div>
</div>
<div class="stat-card">
<div class="stat-label">总盈亏</div>
<div class="stat-value" :class="stats.total_pnl >= 0 ? 'positive' : 'negative'">
${{ stats.total_pnl.toFixed(2) }}
</div>
</div>
<div class="stat-card">
<div class="stat-label">盈亏比</div>
<div class="stat-value">{{ stats.profit_factor === Infinity ? '∞' : stats.profit_factor.toFixed(2) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">平均盈利</div>
<div class="stat-value positive">${{ stats.average_win.toFixed(2) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">平均亏损</div>
<div class="stat-value negative">${{ stats.average_loss.toFixed(2) }}</div>
</div>
</div>
<!-- 实时价格 -->
<div v-if="Object.keys(latestPrices).length > 0" style="margin-bottom: 15px;">
<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>
</div>
<!-- 标签页 -->
<div class="tabs">
<button class="tab" :class="{ active: activeTab === 'active' }" @click="activeTab = 'active'">
活跃订单 ({{ activeOrders.length }})
</button>
<button class="tab" :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
历史订单
</button>
<button class="tab" :class="{ active: activeTab === 'stats' }" @click="activeTab = 'stats'">
详细统计
</button>
</div>
<!-- 活跃订单 -->
<div v-if="activeTab === 'active'">
<!-- 持仓汇总 -->
<div v-if="activeOrders.length > 0" class="position-summary">
<div class="summary-item">
<span class="summary-label">持仓数量</span>
<span class="summary-value">{{ activeOrders.length }}</span>
</div>
<div class="summary-item">
<span class="summary-label">总仓位</span>
<span class="summary-value">${{ totalPosition.toFixed(2) }}</span>
</div>
<div class="summary-item">
<span class="summary-label">浮动盈亏</span>
<span class="summary-value" :class="totalUnrealizedPnl >= 0 ? 'positive' : 'negative'">
{{ totalUnrealizedPnl >= 0 ? '+' : '' }}${{ totalUnrealizedPnl.toFixed(2) }}
({{ totalUnrealizedPnlPercent >= 0 ? '+' : '' }}{{ totalUnrealizedPnlPercent.toFixed(2) }}%)
</span>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="activeOrders.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p>暂无活跃订单</p>
</div>
<table v-else class="orders-table">
<thead>
<tr>
<th>订单ID</th>
<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 activeOrders" :key="order.order_id">
<td>{{ order.order_id.slice(-12) }}</td>
<td>{{ order.symbol }}</td>
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
<td>
<span class="status-badge" :class="order.status">
{{ order.status === 'pending' ? '⏳ 挂单' : '✅ 持仓' }}
</span>
</td>
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
<td>${{ order.entry_price?.toLocaleString() }}</td>
<td>
<span class="current-price" :class="getPriceChangeClass(order)">
${{ getCurrentPrice(order.symbol)?.toLocaleString() || '-' }}
</span>
</td>
<td>
<template v-if="order.status === 'open'">
<span class="pnl" :class="getUnrealizedPnl(order).pnl >= 0 ? 'positive' : 'negative'">
{{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).percent.toFixed(2) }}%
<br>
<small>(${{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).pnl.toFixed(2) }})</small>
</span>
</template>
<template v-else>
<span style="color: var(--text-secondary);">等待入场</span>
</template>
</td>
<td>
<span :class="isNearStopLoss(order) ? 'warning-price' : ''">
${{ order.stop_loss?.toLocaleString() }}
</span>
</td>
<td>
<span :class="isNearTakeProfit(order) ? 'success-price' : ''">
${{ order.take_profit?.toLocaleString() }}
</span>
</td>
<td>${{ order.quantity }}</td>
<td>{{ formatTime(order.status === 'open' ? order.opened_at : order.created_at) }}</td>
<td>
<button class="action-btn danger" @click="closeOrder(order)">
{{ order.status === 'pending' ? '取消' : '平仓' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 历史订单 -->
<div v-if="activeTab === 'history'">
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="historyOrders.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p>暂无历史订单</p>
</div>
<table v-else class="orders-table">
<thead>
<tr>
<th>订单ID</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 historyOrders" :key="order.order_id">
<td>{{ order.order_id.slice(-12) }}</td>
<td>{{ order.symbol }}</td>
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
<td>${{ order.filled_price?.toLocaleString() }}</td>
<td>${{ order.exit_price?.toLocaleString() }}</td>
<td>
<span class="pnl" :class="order.pnl_amount >= 0 ? 'positive' : 'negative'">
{{ order.pnl_percent >= 0 ? '+' : '' }}{{ order.pnl_percent?.toFixed(2) }}%
(${{ order.pnl_amount >= 0 ? '+' : '' }}{{ order.pnl_amount?.toFixed(2) }})
</span>
</td>
<td><span class="status-badge" :class="order.status">{{ formatStatus(order.status) }}</span></td>
<td>{{ formatTime(order.closed_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 详细统计 -->
<div v-if="activeTab === 'stats'">
<h3 style="color: var(--text-primary); font-weight: 300; margin-bottom: 16px;">按信号等级统计</h3>
<div class="grade-stats">
<div class="grade-card" v-for="(data, grade) in stats.by_grade" :key="grade">
<div class="grade-card-header">
<span class="grade-card-title">
<span class="grade-badge" :class="grade">{{ grade }}</span> 级信号
</span>
</div>
<div class="grade-card-stats">
<div class="grade-stat-row">
<span class="grade-stat-label">交易数</span>
<span>{{ data.count }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">胜率</span>
<span>{{ data.win_rate.toFixed(1) }}%</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">总盈亏</span>
<span :class="data.total_pnl >= 0 ? 'positive' : 'negative'">
${{ data.total_pnl.toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
<h3 style="color: var(--text-primary); font-weight: 300; margin: 30px 0 16px;">按交易对统计</h3>
<div class="grade-stats">
<div class="grade-card" v-for="(data, symbol) in stats.by_symbol" :key="symbol">
<div class="grade-card-header">
<span class="grade-card-title">{{ symbol }}</span>
</div>
<div class="grade-card-stats">
<div class="grade-stat-row">
<span class="grade-stat-label">交易数</span>
<span>{{ data.count }}</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">胜率</span>
<span>{{ data.win_rate.toFixed(1) }}%</span>
</div>
<div class="grade-stat-row">
<span class="grade-stat-label">总盈亏</span>
<span :class="data.total_pnl >= 0 ? 'positive' : 'negative'">
${{ data.total_pnl.toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
activeTab: 'active',
loading: true, // 只在首次加载时为 true
activeOrders: [],
historyOrders: [],
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,
by_grade: {},
by_symbol: {}
},
monitorRunning: false,
latestPrices: {},
refreshInterval: null,
isFirstLoad: true
};
},
mounted() {
this.refreshData();
// 每3秒自动刷新静默刷新不显示 loading
this.refreshInterval = setInterval(() => {
this.silentRefresh();
}, 3000);
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
// 手动刷新(显示 loading
async manualRefresh() {
this.loading = true;
await this.refreshData();
},
// 静默刷新(不显示 loading
async silentRefresh() {
await this.refreshData();
},
async refreshData() {
try {
await Promise.all([
this.fetchActiveOrders(),
this.fetchHistoryOrders(),
this.fetchStatistics(),
this.fetchMonitorStatus()
]);
} catch (e) {
console.error('刷新数据失败:', e);
} finally {
this.loading = false;
this.isFirstLoad = false;
}
},
async resetData() {
if (!confirm('确定要重置所有模拟交易数据吗?\n\n此操作将删除所有订单记录不可恢复')) {
return;
}
try {
const response = await fetch('/api/paper-trading/reset', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
alert(data.message);
this.refreshData();
} else {
alert('重置失败: ' + (data.detail || '未知错误'));
}
} catch (e) {
alert('重置失败: ' + e.message);
}
},
async fetchActiveOrders() {
const response = await fetch('/api/paper-trading/orders/active');
const data = await response.json();
if (data.success) {
this.activeOrders = data.orders;
}
},
async fetchHistoryOrders() {
const response = await fetch('/api/paper-trading/orders?status=closed&limit=50');
const data = await response.json();
if (data.success) {
this.historyOrders = data.orders;
}
},
async fetchStatistics() {
const response = await fetch('/api/paper-trading/statistics');
const data = await response.json();
if (data.success) {
this.stats = data.statistics;
}
},
async fetchMonitorStatus() {
const response = await fetch('/api/paper-trading/monitor/status');
const data = await response.json();
if (data.success) {
this.monitorRunning = data.running;
this.latestPrices = data.latest_prices || {};
}
},
async closeOrder(order) {
const price = this.latestPrices[order.symbol];
if (!price) {
alert('无法获取当前价格');
return;
}
if (!confirm(`确定要以 $${price.toLocaleString()} 平仓吗?`)) {
return;
}
try {
const response = await fetch(`/api/paper-trading/orders/${order.order_id}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exit_price: price })
});
const data = await response.json();
if (data.success) {
alert(`平仓成功!盈亏: ${data.result.pnl_percent.toFixed(2)}%`);
this.refreshData();
} else {
alert('平仓失败: ' + (data.detail || '未知错误'));
}
} catch (e) {
alert('平仓失败: ' + e.message);
}
},
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': '持仓中',
'closed_tp': '止盈',
'closed_sl': '止损',
'closed_manual': '手动平仓'
};
return map[status] || status;
},
// 获取当前价格
getCurrentPrice(symbol) {
return this.latestPrices[symbol] || null;
},
// 计算未实现盈亏
getUnrealizedPnl(order) {
const currentPrice = this.getCurrentPrice(order.symbol);
if (!currentPrice || !order.entry_price) {
return { pnl: 0, percent: 0 };
}
let pnlPercent;
if (order.side === 'long') {
pnlPercent = ((currentPrice - order.entry_price) / order.entry_price) * 100;
} else {
pnlPercent = ((order.entry_price - currentPrice) / order.entry_price) * 100;
}
const pnlAmount = (order.quantity || 0) * pnlPercent / 100;
return {
pnl: pnlAmount,
percent: pnlPercent
};
},
// 获取价格变化样式类
getPriceChangeClass(order) {
const currentPrice = this.getCurrentPrice(order.symbol);
if (!currentPrice || !order.entry_price) return '';
if (order.side === 'long') {
return currentPrice >= order.entry_price ? 'price-up' : 'price-down';
} else {
return currentPrice <= order.entry_price ? 'price-up' : 'price-down';
}
},
// 检查是否接近止损(距离止损 < 1%
isNearStopLoss(order) {
const currentPrice = this.getCurrentPrice(order.symbol);
if (!currentPrice || !order.stop_loss) return false;
const distance = Math.abs(currentPrice - order.stop_loss) / currentPrice;
return distance < 0.01;
},
// 检查是否接近止盈(距离止盈 < 1%
isNearTakeProfit(order) {
const currentPrice = this.getCurrentPrice(order.symbol);
if (!currentPrice || !order.take_profit) return false;
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
return distance < 0.01;
}
},
computed: {
// 总仓位
totalPosition() {
return this.activeOrders.reduce((sum, order) => sum + (order.quantity || 0), 0);
},
// 总浮动盈亏
totalUnrealizedPnl() {
return this.activeOrders.reduce((sum, order) => {
return sum + this.getUnrealizedPnl(order).pnl;
}, 0);
},
// 总浮动盈亏百分比
totalUnrealizedPnlPercent() {
if (this.totalPosition === 0) return 0;
return (this.totalUnrealizedPnl / this.totalPosition) * 100;
}
}
}).mount('#app');
</script>
</body>
</html>