tradusai/web/static/index.html
2025-12-09 18:13:35 +08:00

572 lines
30 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">BTC/USDT Perpetual • 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>
<!-- Total Summary -->
<div class="glass-card p-4 mb-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<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">$30,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">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">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="7" 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;
const TF_NAMES = { short: 'Short', medium: 'Medium', long: 'Long' };
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') { updateState(data.state); updateSignal(data.signal); }
else if (data.type === 'state_update') { updateState(data.state); }
else if (data.type === 'signal_update') { updateSignal(data.signal); }
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
};
}
function updateState(state) {
if (!state || !state.accounts) return;
const accounts = state.accounts;
let totalInitial = 0, totalEquity = 0, totalRealizedPnl = 0, totalUnrealizedPnl = 0;
for (const [tf, acc] of Object.entries(accounts)) {
const initial = acc.initial_balance || 0;
const realizedPnl = acc.realized_pnl || 0;
// 兼容旧数据
const equity = acc.equity || (acc.balance || initial + realizedPnl);
const unrealizedPnl = acc.position?.unrealized_pnl || 0;
totalInitial += initial;
totalRealizedPnl += realizedPnl;
totalUnrealizedPnl += unrealizedPnl;
totalEquity += equity + unrealizedPnl;
updateTimeframeCard(tf, acc);
}
const totalReturn = totalInitial > 0 ? (totalEquity - totalInitial) / totalInitial * 100 : 0;
document.getElementById('total-balance').textContent = `$${totalEquity.toLocaleString('en-US', {minimumFractionDigits: 2})}`;
const returnEl = document.getElementById('total-return');
returnEl.textContent = `${totalReturn >= 0 ? '+' : ''}${totalReturn.toFixed(2)}%`;
returnEl.className = `text-xl font-bold ${totalReturn > 0 ? 'text-success' : totalReturn < 0 ? 'text-danger' : 'text-slate-400'}`;
// Collect all trades
let allTrades = [];
for (const acc of Object.values(accounts)) {
allTrades = allTrades.concat(acc.trades || []);
}
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;
const unrealizedPnl = position?.unrealized_pnl || 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 unrealized = position.unrealized_pnl || 0;
const unrealizedPct = position.unrealized_pnl_pct || 0;
const pyramidLevel = position.pyramid_level || 1;
const pnlColor = unrealized >= 0 ? 'text-success' : 'text-danger';
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">$${(position.entry_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
</div>
<div class="flex justify-between text-xs mb-1">
<span class="text-slate-500">Stop Loss</span>
<span class="text-danger font-mono">$${(position.stop_loss || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
</div>
<div class="flex justify-between text-xs mb-1">
<span class="text-slate-500">Take Profit</span>
<span class="text-success font-mono">$${(position.take_profit || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</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">${unrealized >= 0 ? '+' : ''}$${Math.abs(unrealized).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;
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="7" 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;
return `
<tr class="table-row">
<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] = await Promise.all([
fetch('/api/status'),
fetch('/api/signal'),
]);
const status = await statusRes.json();
const signal = await signalRes.json();
// Convert API response to state format
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);
}
updateSignal({ aggregated_signal: { llm_signal: { opportunities: signal.opportunities }, levels: { current_price: signal.current_price } } });
// 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>