812 lines
42 KiB
HTML
812 lines
42 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>Trading Dashboard</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
|
|
colors: {
|
|
primary: { 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7' },
|
|
success: '#10b981',
|
|
danger: '#ef4444',
|
|
warning: '#f59e0b',
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
<style>
|
|
html, body { font-family: 'Inter', system-ui, sans-serif; background: #0f172a; min-height: 100vh; }
|
|
body { background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); background-attachment: fixed; }
|
|
.glass-card { background: rgba(30, 41, 59, 0.8); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; }
|
|
.stat-card { background: linear-gradient(145deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9)); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 16px; transition: transform 0.2s; }
|
|
.stat-card:hover { transform: translateY(-2px); }
|
|
.text-gradient { background: linear-gradient(135deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
|
.badge { display: inline-flex; align-items: center; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
|
|
.badge-long { background: rgba(16, 185, 129, 0.2); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
|
|
.badge-short { background: rgba(239, 68, 68, 0.2); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); }
|
|
.badge-hold { background: rgba(148, 163, 184, 0.2); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.3); }
|
|
.badge-flat { background: rgba(100, 116, 139, 0.2); color: #64748b; border: 1px solid rgba(100, 116, 139, 0.3); }
|
|
.timeframe-card { background: linear-gradient(145deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.95)); }
|
|
.pulse-dot { animation: pulse 2s infinite; }
|
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
.table-row:hover { background: rgba(255, 255, 255, 0.05); }
|
|
/* 水印样式 */
|
|
.watermark {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 9999;
|
|
overflow: hidden;
|
|
}
|
|
.watermark-text {
|
|
position: absolute;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: rgba(255, 255, 255, 0.06);
|
|
white-space: nowrap;
|
|
transform: rotate(-25deg);
|
|
user-select: none;
|
|
font-family: 'Inter', sans-serif;
|
|
letter-spacing: 2px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="text-slate-100">
|
|
<!-- 水印层 -->
|
|
<div class="watermark" id="watermark"></div>
|
|
|
|
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
|
<!-- Header -->
|
|
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-gradient mb-1">AI Quant Trading</h1>
|
|
<p class="text-slate-400 text-sm">Multi-Symbol • Multi-Timeframe • Powered by Quantitative Analysis & AI</p>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<div id="connection-status" class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-yellow-500/20 text-yellow-400 text-sm">
|
|
<span class="w-2 h-2 rounded-full bg-yellow-400 pulse-dot"></span>
|
|
Connecting...
|
|
</div>
|
|
<div class="text-slate-500 text-sm" id="last-update">--:--:--</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Symbol Selector & Total Summary -->
|
|
<div class="glass-card p-4 mb-6">
|
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
<!-- Symbol Tabs -->
|
|
<div class="flex items-center gap-2" id="symbol-tabs">
|
|
<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium bg-primary-500/20 text-primary-400 border border-primary-500/30" data-symbol="BTCUSDT">
|
|
BTC/USDT
|
|
</button>
|
|
<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium bg-slate-700/50 text-slate-400 border border-slate-600/30 hover:bg-slate-700" data-symbol="ETHUSDT">
|
|
ETH/USDT
|
|
</button>
|
|
</div>
|
|
<div class="flex items-center gap-6">
|
|
<div>
|
|
<div class="text-slate-400 text-xs uppercase">Total Balance</div>
|
|
<div id="total-balance" class="text-2xl font-bold text-white">$60,000.00</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-slate-400 text-xs uppercase">Total Return</div>
|
|
<div id="total-return" class="text-xl font-bold text-slate-400">+0.00%</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-slate-400 text-xs uppercase" id="price-label">BTC Price</div>
|
|
<div id="current-price" class="text-xl font-bold text-white font-mono">$0.00</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Three Timeframes -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
|
|
<!-- Short-term -->
|
|
<div class="timeframe-card glass-card p-4" id="tf-short">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-lg">📈</span>
|
|
<div>
|
|
<div class="font-semibold text-white">Short-term</div>
|
|
<div class="text-xs text-slate-500">5m / 15m / 1h • 10x</div>
|
|
</div>
|
|
</div>
|
|
<span class="badge badge-flat tf-position-badge">FLAT</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Balance</div>
|
|
<div class="text-lg font-bold text-white tf-balance">$10,000.00</div>
|
|
<div class="text-xs tf-return text-slate-400">+0.00%</div>
|
|
</div>
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Stats</div>
|
|
<div class="text-sm text-white"><span class="tf-trades">0</span> trades</div>
|
|
<div class="text-xs text-slate-400">WR: <span class="tf-winrate">0.0</span>%</div>
|
|
</div>
|
|
</div>
|
|
<div class="tf-position-info bg-slate-800/30 rounded-lg p-3 text-center text-slate-500 text-sm">
|
|
No position
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Medium-term -->
|
|
<div class="timeframe-card glass-card p-4" id="tf-medium">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-lg">📊</span>
|
|
<div>
|
|
<div class="font-semibold text-white">Medium-term</div>
|
|
<div class="text-xs text-slate-500">4h / 1d • 10x</div>
|
|
</div>
|
|
</div>
|
|
<span class="badge badge-flat tf-position-badge">FLAT</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Balance</div>
|
|
<div class="text-lg font-bold text-white tf-balance">$10,000.00</div>
|
|
<div class="text-xs tf-return text-slate-400">+0.00%</div>
|
|
</div>
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Stats</div>
|
|
<div class="text-sm text-white"><span class="tf-trades">0</span> trades</div>
|
|
<div class="text-xs text-slate-400">WR: <span class="tf-winrate">0.0</span>%</div>
|
|
</div>
|
|
</div>
|
|
<div class="tf-position-info bg-slate-800/30 rounded-lg p-3 text-center text-slate-500 text-sm">
|
|
No position
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Long-term -->
|
|
<div class="timeframe-card glass-card p-4" id="tf-long">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-lg">📉</span>
|
|
<div>
|
|
<div class="font-semibold text-white">Long-term</div>
|
|
<div class="text-xs text-slate-500">1d / 1w • 10x</div>
|
|
</div>
|
|
</div>
|
|
<span class="badge badge-flat tf-position-badge">FLAT</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Balance</div>
|
|
<div class="text-lg font-bold text-white tf-balance">$10,000.00</div>
|
|
<div class="text-xs tf-return text-slate-400">+0.00%</div>
|
|
</div>
|
|
<div class="bg-slate-800/50 rounded-lg p-3">
|
|
<div class="text-slate-500 text-xs">Stats</div>
|
|
<div class="text-sm text-white"><span class="tf-trades">0</span> trades</div>
|
|
<div class="text-xs text-slate-400">WR: <span class="tf-winrate">0.0</span>%</div>
|
|
</div>
|
|
</div>
|
|
<div class="tf-position-info bg-slate-800/30 rounded-lg p-3 text-center text-slate-500 text-sm">
|
|
No position
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Signals Section -->
|
|
<div class="glass-card p-4 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
|
<span>🤖</span> AI Signal Analysis
|
|
<span class="ml-2 px-2 py-0.5 rounded-full bg-primary-500/20 text-primary-400 text-xs font-medium">Quant + LLM</span>
|
|
</h2>
|
|
<div class="text-slate-500 text-xs" id="signal-time">Signal: --:--:--</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4" id="signals-container">
|
|
<div class="bg-slate-800/50 rounded-lg p-4" id="signal-short">
|
|
<div class="text-slate-400 text-xs uppercase mb-2">Short-term</div>
|
|
<div class="text-slate-500 text-sm">No signal</div>
|
|
</div>
|
|
<div class="bg-slate-800/50 rounded-lg p-4" id="signal-medium">
|
|
<div class="text-slate-400 text-xs uppercase mb-2">Medium-term</div>
|
|
<div class="text-slate-500 text-sm">No signal</div>
|
|
</div>
|
|
<div class="bg-slate-800/50 rounded-lg p-4" id="signal-long">
|
|
<div class="text-slate-400 text-xs uppercase mb-2">Long-term</div>
|
|
<div class="text-slate-500 text-sm">No signal</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trade History -->
|
|
<div class="glass-card p-4">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
|
<span>📝</span> Trade History
|
|
</h2>
|
|
<span id="trade-count" class="text-sm text-slate-500">0 trades</span>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-slate-400 text-xs uppercase border-b border-slate-700/50">
|
|
<th class="px-3 py-2 text-left">Symbol</th>
|
|
<th class="px-3 py-2 text-left">TF</th>
|
|
<th class="px-3 py-2 text-left">Side</th>
|
|
<th class="px-3 py-2 text-right">Entry</th>
|
|
<th class="px-3 py-2 text-right">Exit</th>
|
|
<th class="px-3 py-2 text-right">PnL</th>
|
|
<th class="px-3 py-2 text-left">Reason</th>
|
|
<th class="px-3 py-2 text-left">Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="trades-table" class="divide-y divide-slate-700/30">
|
|
<tr><td colspan="8" class="text-center py-8 text-slate-500">No trades yet</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="mt-6 text-center text-slate-600 text-sm">
|
|
<div class="flex items-center justify-center gap-2 mb-1">
|
|
<span class="w-1.5 h-1.5 rounded-full bg-primary-500 pulse-dot"></span>
|
|
<span>AI-Powered Quantitative Trading System</span>
|
|
</div>
|
|
<div class="text-xs text-slate-700">Technical Analysis • Machine Learning • Real-time Market Data</div>
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
let ws = null;
|
|
let reconnectInterval = null;
|
|
let currentPrices = {}; // 多币种价格 {BTCUSDT: 12345, ETHUSDT: 1234}
|
|
let currentPrice = 0; // 当前选中币种的价格 (向后兼容)
|
|
let lastState = null; // 保存最新状态用于价格更新时重新计算 PnL
|
|
let supportedSymbols = ['BTCUSDT', 'ETHUSDT']; // 支持的币种列表
|
|
let selectedSymbol = 'BTCUSDT'; // 当前选中的币种
|
|
|
|
const TF_NAMES = { short: 'Short', medium: 'Medium', long: 'Long' };
|
|
const SYMBOL_DISPLAY = { BTCUSDT: 'BTC/USDT', ETHUSDT: 'ETH/USDT' };
|
|
|
|
function connectWebSocket() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
|
|
ws.onopen = () => {
|
|
const el = document.getElementById('connection-status');
|
|
el.className = 'flex items-center gap-2 px-3 py-1.5 rounded-full bg-success/20 text-success text-sm';
|
|
el.innerHTML = '<span class="w-2 h-2 rounded-full bg-success"></span> Connected';
|
|
if (reconnectInterval) { clearInterval(reconnectInterval); reconnectInterval = null; }
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
const el = document.getElementById('connection-status');
|
|
el.className = 'flex items-center gap-2 px-3 py-1.5 rounded-full bg-danger/20 text-danger text-sm';
|
|
el.innerHTML = '<span class="w-2 h-2 rounded-full bg-danger pulse-dot"></span> Disconnected';
|
|
if (!reconnectInterval) { reconnectInterval = setInterval(connectWebSocket, 3000); }
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'init') {
|
|
// 更新支持的币种列表
|
|
if (data.supported_symbols) {
|
|
supportedSymbols = data.supported_symbols;
|
|
updateSymbolTabs();
|
|
}
|
|
// 更新多币种价格
|
|
if (data.prices) {
|
|
currentPrices = data.prices;
|
|
currentPrice = currentPrices[selectedSymbol] || 0;
|
|
updatePriceDisplay();
|
|
} else if (data.current_price) {
|
|
currentPrice = data.current_price;
|
|
currentPrices[selectedSymbol] = currentPrice;
|
|
updatePriceDisplay();
|
|
}
|
|
updateSignal(data.signal);
|
|
updateState(data.state);
|
|
}
|
|
else if (data.type === 'price_update') {
|
|
// 多币种实时价格更新
|
|
if (data.prices) {
|
|
currentPrices = data.prices;
|
|
currentPrice = currentPrices[selectedSymbol] || 0;
|
|
updatePriceDisplay();
|
|
} else if (data.current_price) {
|
|
currentPrice = data.current_price;
|
|
currentPrices[selectedSymbol] = currentPrice;
|
|
updatePriceDisplay();
|
|
}
|
|
// 重新计算 PnL
|
|
if (lastState) {
|
|
updateState(lastState);
|
|
}
|
|
}
|
|
else if (data.type === 'state_update') {
|
|
updateState(data.state);
|
|
}
|
|
else if (data.type === 'signal_update') {
|
|
updateSignal(data.signal);
|
|
// 价格更新后,重新计算 PnL
|
|
if (lastState) {
|
|
updateState(lastState);
|
|
}
|
|
}
|
|
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
|
};
|
|
}
|
|
|
|
function updateSymbolTabs() {
|
|
const container = document.getElementById('symbol-tabs');
|
|
container.innerHTML = supportedSymbols.map(sym => {
|
|
const displayName = SYMBOL_DISPLAY[sym] || sym;
|
|
const isSelected = sym === selectedSymbol;
|
|
const activeClass = isSelected
|
|
? 'bg-primary-500/20 text-primary-400 border-primary-500/30'
|
|
: 'bg-slate-700/50 text-slate-400 border-slate-600/30 hover:bg-slate-700';
|
|
return `<button class="symbol-tab px-4 py-2 rounded-lg text-sm font-medium border ${activeClass}" data-symbol="${sym}">${displayName}</button>`;
|
|
}).join('');
|
|
|
|
// 重新绑定点击事件
|
|
container.querySelectorAll('.symbol-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => selectSymbol(btn.dataset.symbol));
|
|
});
|
|
}
|
|
|
|
function selectSymbol(symbol) {
|
|
if (symbol === selectedSymbol) return;
|
|
selectedSymbol = symbol;
|
|
currentPrice = currentPrices[selectedSymbol] || 0;
|
|
|
|
// 更新 tab 样式
|
|
updateSymbolTabs();
|
|
|
|
// 更新价格显示
|
|
updatePriceDisplay();
|
|
|
|
// 重新渲染状态(使用新币种的数据)
|
|
if (lastState) {
|
|
updateState(lastState);
|
|
}
|
|
}
|
|
|
|
function updatePriceDisplay() {
|
|
const price = currentPrices[selectedSymbol] || currentPrice || 0;
|
|
document.getElementById('current-price').textContent = `$${price.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
|
|
const symbolShort = selectedSymbol.replace('USDT', '');
|
|
document.getElementById('price-label').textContent = `${symbolShort} Price`;
|
|
}
|
|
|
|
function updateState(state) {
|
|
if (!state) return;
|
|
|
|
// 保存最新状态用于价格更新时重新计算
|
|
lastState = state;
|
|
|
|
// 支持新的多币种格式和旧格式
|
|
let accounts = null;
|
|
let grandTotalInitial = 0, grandTotalEquity = 0, grandTotalRealizedPnl = 0, grandTotalUnrealizedPnl = 0;
|
|
let allTrades = [];
|
|
|
|
if (state.symbols) {
|
|
// 新的多币种格式
|
|
// 计算所有币种的汇总
|
|
for (const [sym, symData] of Object.entries(state.symbols)) {
|
|
const symAccounts = symData.timeframes || symData;
|
|
const symPrice = currentPrices[sym] || 0;
|
|
|
|
for (const [tf, acc] of Object.entries(symAccounts)) {
|
|
const initial = acc.initial_balance || 0;
|
|
const realizedPnl = acc.realized_pnl || 0;
|
|
const position = acc.position;
|
|
|
|
let unrealizedPnl = 0;
|
|
if (position && position.side && position.side !== 'FLAT' && symPrice > 0) {
|
|
const entryPrice = position.entry_price || 0;
|
|
const size = position.size || 0;
|
|
if (position.side === 'LONG') {
|
|
unrealizedPnl = (symPrice - entryPrice) * size;
|
|
} else {
|
|
unrealizedPnl = (entryPrice - symPrice) * size;
|
|
}
|
|
}
|
|
|
|
grandTotalInitial += initial;
|
|
grandTotalRealizedPnl += realizedPnl;
|
|
grandTotalUnrealizedPnl += unrealizedPnl;
|
|
grandTotalEquity += initial + realizedPnl + unrealizedPnl;
|
|
|
|
// 收集交易记录
|
|
const trades = acc.trades || [];
|
|
trades.forEach(t => { if (!t.symbol) t.symbol = sym; });
|
|
allTrades = allTrades.concat(trades);
|
|
}
|
|
}
|
|
|
|
// 获取当前选中币种的账户用于显示
|
|
const selectedData = state.symbols[selectedSymbol];
|
|
accounts = selectedData ? (selectedData.timeframes || selectedData) : null;
|
|
} else if (state.accounts) {
|
|
// 旧格式
|
|
accounts = state.accounts;
|
|
|
|
for (const [tf, acc] of Object.entries(accounts)) {
|
|
const initial = acc.initial_balance || 0;
|
|
const realizedPnl = acc.realized_pnl || 0;
|
|
const position = acc.position;
|
|
|
|
let unrealizedPnl = 0;
|
|
if (position && position.side && position.side !== 'FLAT' && currentPrice > 0) {
|
|
const entryPrice = position.entry_price || 0;
|
|
const size = position.size || 0;
|
|
if (position.side === 'LONG') {
|
|
unrealizedPnl = (currentPrice - entryPrice) * size;
|
|
} else {
|
|
unrealizedPnl = (entryPrice - currentPrice) * size;
|
|
}
|
|
}
|
|
|
|
grandTotalInitial += initial;
|
|
grandTotalRealizedPnl += realizedPnl;
|
|
grandTotalUnrealizedPnl += unrealizedPnl;
|
|
grandTotalEquity += initial + realizedPnl + unrealizedPnl;
|
|
|
|
allTrades = allTrades.concat(acc.trades || []);
|
|
}
|
|
}
|
|
|
|
// 更新 Total 显示(所有币种汇总)
|
|
const grandTotalReturn = grandTotalInitial > 0 ? (grandTotalEquity - grandTotalInitial) / grandTotalInitial * 100 : 0;
|
|
document.getElementById('total-balance').textContent = `$${grandTotalEquity.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
|
|
const returnEl = document.getElementById('total-return');
|
|
returnEl.textContent = `${grandTotalReturn >= 0 ? '+' : ''}${grandTotalReturn.toFixed(2)}%`;
|
|
returnEl.className = `text-xl font-bold ${grandTotalReturn > 0 ? 'text-success' : grandTotalReturn < 0 ? 'text-danger' : 'text-slate-400'}`;
|
|
|
|
// 更新当前选中币种的各周期卡片
|
|
if (accounts) {
|
|
for (const [tf, acc] of Object.entries(accounts)) {
|
|
updateTimeframeCard(tf, acc);
|
|
}
|
|
}
|
|
|
|
// 更新交易记录
|
|
allTrades.sort((a, b) => (b.exit_time || '').localeCompare(a.exit_time || ''));
|
|
updateTrades(allTrades);
|
|
}
|
|
|
|
function updateTimeframeCard(tf, acc) {
|
|
const card = document.getElementById(`tf-${tf}`);
|
|
if (!card) return;
|
|
|
|
const initial = acc.initial_balance || 0;
|
|
const realizedPnl = acc.realized_pnl || 0;
|
|
const position = acc.position;
|
|
|
|
// 使用当前价格实时计算未实现盈亏
|
|
let unrealizedPnl = 0;
|
|
let unrealizedPct = 0;
|
|
if (position && position.side && position.side !== 'FLAT' && currentPrice > 0) {
|
|
const entryPrice = position.entry_price || 0;
|
|
const size = position.size || 0;
|
|
const margin = position.margin || 0;
|
|
if (position.side === 'LONG') {
|
|
unrealizedPnl = (currentPrice - entryPrice) * size;
|
|
} else {
|
|
unrealizedPnl = (entryPrice - currentPrice) * size;
|
|
}
|
|
unrealizedPct = margin > 0 ? (unrealizedPnl / margin * 100) : 0;
|
|
}
|
|
|
|
// 权益 = 初始本金 + 已实现盈亏 + 未实现盈亏
|
|
const equity = initial + realizedPnl + unrealizedPnl;
|
|
const returnPct = initial > 0 ? (equity - initial) / initial * 100 : 0;
|
|
const stats = acc.stats || {};
|
|
|
|
card.querySelector('.tf-balance').textContent = `$${equity.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
|
|
|
|
const returnEl = card.querySelector('.tf-return');
|
|
returnEl.textContent = `${returnPct >= 0 ? '+' : ''}${returnPct.toFixed(2)}%`;
|
|
returnEl.className = `text-xs tf-return ${returnPct > 0 ? 'text-success' : returnPct < 0 ? 'text-danger' : 'text-slate-400'}`;
|
|
|
|
card.querySelector('.tf-trades').textContent = stats.total_trades || 0;
|
|
card.querySelector('.tf-winrate').textContent = (stats.win_rate || 0).toFixed(1);
|
|
|
|
const badge = card.querySelector('.tf-position-badge');
|
|
const posInfo = card.querySelector('.tf-position-info');
|
|
|
|
if (position && position.side && position.side !== 'FLAT') {
|
|
const isLong = position.side === 'LONG';
|
|
badge.className = `badge ${isLong ? 'badge-long' : 'badge-short'} tf-position-badge`;
|
|
badge.textContent = position.side;
|
|
|
|
const pyramidLevel = position.pyramid_level || 1;
|
|
const pnlColor = unrealizedPnl >= 0 ? 'text-success' : 'text-danger';
|
|
const posMargin = position.margin || 0;
|
|
const posSize = position.size || 0;
|
|
const entryPrice = position.entry_price || 0;
|
|
// 仓位价值 = 持仓数量 * 当前价格
|
|
const positionValue = currentPrice > 0 ? posSize * currentPrice : posSize * entryPrice;
|
|
// 实际杠杆 = 仓位价值 / 保证金
|
|
const actualLeverage = posMargin > 0 ? (positionValue / posMargin).toFixed(1) : '0';
|
|
|
|
// 计算爆仓价格 (假设维持保证金率为 0.5%,即亏损超过 99.5% 保证金时爆仓)
|
|
// LONG: 爆仓价 = 入场价 * (1 - 保证金 / 仓位价值 + 维持保证金率)
|
|
// SHORT: 爆仓价 = 入场价 * (1 + 保证金 / 仓位价值 - 维持保证金率)
|
|
const maintenanceMarginRate = 0.005; // 0.5% 维持保证金率
|
|
const entryValue = posSize * entryPrice;
|
|
let liquidationPrice = 0;
|
|
if (posSize > 0 && entryPrice > 0) {
|
|
if (isLong) {
|
|
// 做多爆仓价 = 入场价 - (保证金 - 维持保证金) / 持仓数量
|
|
liquidationPrice = entryPrice - (posMargin * (1 - maintenanceMarginRate)) / posSize;
|
|
} else {
|
|
// 做空爆仓价 = 入场价 + (保证金 - 维持保证金) / 持仓数量
|
|
liquidationPrice = entryPrice + (posMargin * (1 - maintenanceMarginRate)) / posSize;
|
|
}
|
|
}
|
|
// 确保爆仓价不为负
|
|
liquidationPrice = Math.max(0, liquidationPrice);
|
|
|
|
posInfo.innerHTML = `
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500">Entry <span class="text-slate-600">(L${pyramidLevel}/4)</span></span>
|
|
<span class="text-white font-mono">$${entryPrice.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
|
</div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500">Value / Margin</span>
|
|
<span class="text-white font-mono">$${positionValue.toLocaleString('en-US', {minimumFractionDigits: 0})} / $${posMargin.toLocaleString('en-US', {minimumFractionDigits: 0})} <span class="text-primary-400">(${actualLeverage}x)</span></span>
|
|
</div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500">Liq. Price</span>
|
|
<span class="text-warning font-mono">$${liquidationPrice.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
|
</div>
|
|
<div class="flex justify-between text-xs mb-1">
|
|
<span class="text-slate-500">SL / TP</span>
|
|
<span class="font-mono"><span class="text-danger">$${(position.stop_loss || 0).toLocaleString('en-US', {minimumFractionDigits: 0})}</span> / <span class="text-success">$${(position.take_profit || 0).toLocaleString('en-US', {minimumFractionDigits: 0})}</span></span>
|
|
</div>
|
|
<div class="flex justify-between text-xs pt-1 border-t border-slate-700/50">
|
|
<span class="text-slate-500">PnL</span>
|
|
<span class="${pnlColor} font-mono font-medium">${unrealizedPnl >= 0 ? '+' : ''}$${Math.abs(unrealizedPnl).toFixed(2)} (${unrealizedPct >= 0 ? '+' : ''}${unrealizedPct.toFixed(1)}%)</span>
|
|
</div>
|
|
`;
|
|
posInfo.className = 'tf-position-info bg-slate-800/30 rounded-lg p-3';
|
|
} else {
|
|
badge.className = 'badge badge-flat tf-position-badge';
|
|
badge.textContent = 'FLAT';
|
|
// 显示已实现盈亏
|
|
const realizedColor = realizedPnl >= 0 ? 'text-success' : 'text-danger';
|
|
posInfo.innerHTML = `
|
|
<div class="text-center">
|
|
<div class="text-slate-500 text-xs mb-1">Realized PnL</div>
|
|
<div class="${realizedColor} font-mono text-sm">${realizedPnl >= 0 ? '+' : ''}$${realizedPnl.toFixed(2)}</div>
|
|
</div>
|
|
`;
|
|
posInfo.className = 'tf-position-info bg-slate-800/30 rounded-lg p-3';
|
|
}
|
|
}
|
|
|
|
function updateSignal(signal) {
|
|
if (!signal) return;
|
|
|
|
const agg = signal.aggregated_signal || {};
|
|
const llm = agg.llm_signal || {};
|
|
const price = agg.levels?.current_price || signal.market_analysis?.price || 0;
|
|
const timestamp = agg.timestamp || signal.timestamp;
|
|
|
|
// 更新全局当前价格
|
|
if (price > 0) {
|
|
currentPrice = price;
|
|
}
|
|
|
|
document.getElementById('current-price').textContent = `$${price.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
|
|
|
|
// Update signal time
|
|
if (timestamp) {
|
|
const signalDate = new Date(timestamp);
|
|
const timeStr = signalDate.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false
|
|
});
|
|
document.getElementById('signal-time').textContent = `Signal: ${timeStr}`;
|
|
}
|
|
|
|
const opportunities = llm.opportunities || {};
|
|
const mapping = {
|
|
short: opportunities.short_term_5m_15m_1h || opportunities.intraday,
|
|
medium: opportunities.medium_term_4h_1d || opportunities.swing,
|
|
long: opportunities.long_term_1d_1w,
|
|
};
|
|
|
|
for (const [tf, opp] of Object.entries(mapping)) {
|
|
const el = document.getElementById(`signal-${tf}`);
|
|
if (!el) continue;
|
|
|
|
const tfName = tf === 'short' ? 'Short-term' : tf === 'medium' ? 'Medium-term' : 'Long-term';
|
|
|
|
if (opp && opp.exists && opp.direction) {
|
|
const isLong = opp.direction === 'LONG';
|
|
const confidence = opp.confidence || opp.confidence_score || 0;
|
|
const reasoning = opp.reasoning || '';
|
|
el.innerHTML = `
|
|
<div class="flex items-center justify-between mb-3">
|
|
<span class="text-slate-400 text-xs uppercase">${tfName}</span>
|
|
<div class="flex items-center gap-2">
|
|
${confidence ? `<span class="text-xs text-slate-500">Conf: ${(confidence * 100).toFixed(0)}%</span>` : ''}
|
|
<span class="badge ${isLong ? 'badge-long' : 'badge-short'}">${opp.direction}</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2 text-xs mb-3">
|
|
<div><span class="text-slate-500">Entry</span><div class="text-white font-mono">$${(opp.entry_price || 0).toLocaleString()}</div></div>
|
|
<div><span class="text-slate-500">SL</span><div class="text-danger font-mono">$${(opp.stop_loss || 0).toLocaleString()}</div></div>
|
|
<div><span class="text-slate-500">TP</span><div class="text-success font-mono">$${(opp.take_profit || 0).toLocaleString()}</div></div>
|
|
</div>
|
|
${reasoning ? `<div class="text-xs text-slate-400 border-t border-slate-700/50 pt-2 mt-2 leading-relaxed">${reasoning}</div>` : ''}
|
|
`;
|
|
el.className = `bg-slate-800/50 rounded-lg p-4 border ${isLong ? 'border-success/30' : 'border-danger/30'}`;
|
|
} else {
|
|
const reason = opp?.reasoning || 'No opportunity';
|
|
el.innerHTML = `
|
|
<div class="text-slate-400 text-xs uppercase mb-2">${tfName}</div>
|
|
<div class="text-slate-500 text-sm leading-relaxed">${reason}</div>
|
|
`;
|
|
el.className = 'bg-slate-800/50 rounded-lg p-4';
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateTrades(trades) {
|
|
const tbody = document.getElementById('trades-table');
|
|
document.getElementById('trade-count').textContent = `${trades.length} trades`;
|
|
|
|
if (!trades || trades.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-8 text-slate-500">No trades yet</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = trades.slice(0, 30).map(trade => {
|
|
const pnl = trade.pnl || 0;
|
|
const pnlPct = trade.pnl_pct || 0;
|
|
const isWin = pnl > 0;
|
|
const tfLabel = TF_NAMES[trade.timeframe] || trade.timeframe;
|
|
const symbolDisplay = (trade.symbol || 'BTCUSDT').replace('USDT', '');
|
|
|
|
return `
|
|
<tr class="table-row">
|
|
<td class="px-3 py-2 text-primary-400 text-xs font-medium">${symbolDisplay}</td>
|
|
<td class="px-3 py-2 text-slate-400 text-xs">${tfLabel}</td>
|
|
<td class="px-3 py-2"><span class="badge ${trade.side === 'LONG' ? 'badge-long' : 'badge-short'}">${trade.side}</span></td>
|
|
<td class="px-3 py-2 text-right font-mono text-white">$${(trade.entry_price || 0).toFixed(2)}</td>
|
|
<td class="px-3 py-2 text-right font-mono text-white">$${(trade.exit_price || 0).toFixed(2)}</td>
|
|
<td class="px-3 py-2 text-right">
|
|
<span class="${isWin ? 'text-success' : 'text-danger'} font-mono">${pnl >= 0 ? '+' : ''}$${Math.abs(pnl).toFixed(2)}</span>
|
|
<span class="text-xs ${isWin ? 'text-success/70' : 'text-danger/70'} ml-1">(${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%)</span>
|
|
</td>
|
|
<td class="px-3 py-2 text-slate-400 text-xs">${trade.exit_reason || '-'}</td>
|
|
<td class="px-3 py-2 text-slate-500 text-xs">${formatTime(trade.exit_time)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatTime(isoString) {
|
|
if (!isoString) return '-';
|
|
return new Date(isoString).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
async function loadInitialData() {
|
|
try {
|
|
const [statusRes, signalRes, pricesRes] = await Promise.all([
|
|
fetch('/api/status'),
|
|
fetch('/api/signal'),
|
|
fetch('/api/prices'),
|
|
]);
|
|
const status = await statusRes.json();
|
|
const signal = await signalRes.json();
|
|
const pricesData = await pricesRes.json();
|
|
|
|
// 更新支持的币种列表
|
|
if (status.supported_symbols) {
|
|
supportedSymbols = status.supported_symbols;
|
|
updateSymbolTabs();
|
|
} else if (pricesData.supported_symbols) {
|
|
supportedSymbols = pricesData.supported_symbols;
|
|
updateSymbolTabs();
|
|
}
|
|
|
|
// 更新多币种价格
|
|
if (pricesData.prices) {
|
|
currentPrices = pricesData.prices;
|
|
currentPrice = currentPrices[selectedSymbol] || 0;
|
|
updatePriceDisplay();
|
|
}
|
|
|
|
// 处理信号 (新格式或旧格式)
|
|
if (signal.symbols) {
|
|
// 新的多币种格式
|
|
const selectedSignal = signal.symbols[selectedSymbol];
|
|
if (selectedSignal) {
|
|
updateSignal({ aggregated_signal: { llm_signal: { opportunities: selectedSignal.opportunities }, levels: { current_price: selectedSignal.current_price }, timestamp: selectedSignal.timestamp } });
|
|
}
|
|
} else {
|
|
// 旧格式
|
|
updateSignal({ aggregated_signal: { llm_signal: { opportunities: signal.opportunities }, levels: { current_price: signal.current_price }, timestamp: signal.timestamp } });
|
|
}
|
|
|
|
// 更新状态 (新格式或旧格式)
|
|
if (status.symbols) {
|
|
// 新的多币种格式
|
|
updateState({ symbols: status.symbols });
|
|
} else if (status.timeframes) {
|
|
// 旧格式
|
|
const state = { accounts: {} };
|
|
for (const [tf, data] of Object.entries(status.timeframes)) {
|
|
state.accounts[tf] = {
|
|
initial_balance: data.initial_balance,
|
|
realized_pnl: data.realized_pnl || 0,
|
|
equity: data.equity || data.initial_balance,
|
|
available_balance: data.available_balance,
|
|
used_margin: data.used_margin,
|
|
leverage: data.leverage,
|
|
position: data.position,
|
|
stats: data.stats,
|
|
trades: [],
|
|
};
|
|
}
|
|
updateState(state);
|
|
}
|
|
|
|
// Load trades
|
|
const tradesRes = await fetch('/api/trades?limit=50');
|
|
const tradesData = await tradesRes.json();
|
|
updateTrades(tradesData.trades || []);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading initial data:', error);
|
|
}
|
|
}
|
|
|
|
// 生成水印
|
|
function generateWatermark() {
|
|
const watermarkContainer = document.getElementById('watermark');
|
|
const text = '龙哥AI Trading Lab';
|
|
const cols = Math.ceil(window.innerWidth / 250);
|
|
const rows = Math.ceil(window.innerHeight / 150);
|
|
|
|
watermarkContainer.innerHTML = '';
|
|
|
|
for (let i = 0; i < rows; i++) {
|
|
for (let j = 0; j < cols; j++) {
|
|
const span = document.createElement('span');
|
|
span.className = 'watermark-text';
|
|
span.textContent = text;
|
|
span.style.left = (j * 250 + (i % 2) * 100) + 'px';
|
|
span.style.top = (i * 150) + 'px';
|
|
watermarkContainer.appendChild(span);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
generateWatermark();
|
|
loadInitialData();
|
|
connectWebSocket();
|
|
});
|
|
|
|
window.addEventListener('resize', generateWatermark);
|
|
</script>
|
|
</body>
|
|
</html>
|