stock-ai-agent/frontend/hyperliquid.html
2026-03-22 11:55:16 +08:00

743 lines
27 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, maximum-scale=1.0, user-scalable=no">
<title>Hyperliquid 交易监控 | AI Agent</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<style>
/* 防止横向滚动 */
html, body {
overflow-x: hidden;
max-width: 100vw;
}
/* 覆盖全局 #app 样式 */
#app {
height: auto;
display: block;
align-items: initial;
justify-content: initial;
padding: 0;
overflow-x: hidden;
}
.trading-page {
min-height: 100vh;
background: var(--bg-primary);
padding: 20px;
}
.trading-container {
max-width: 1400px;
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);
user-select: none;
cursor: default;
}
.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);
}
.refresh-btn.loading {
opacity: 0.6;
cursor: wait;
}
/* 状态标签 */
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-enabled {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
border: 1px solid rgba(0, 255, 65, 0.3);
}
.status-disabled {
background: rgba(128, 128, 128, 0.1);
color: var(--text-secondary);
border: 1px solid var(--border);
}
/* 统计卡片 */
.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: var(--accent);
}
.stat-value.negative {
color: #ef4444;
}
.stat-value.warning {
color: #f59e0b;
}
.stat-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
}
/* 内容区域 */
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
/* 面板 */
.panel {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-primary);
}
.panel-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.panel-count {
font-size: 12px;
color: var(--text-secondary);
}
.panel-body {
padding: 0;
}
/* 列表项 */
.list-item {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: var(--bg-tertiary);
}
.item-left {
display: flex;
align-items: center;
gap: 16px;
}
.symbol {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
min-width: 100px;
}
.item-details {
display: flex;
gap: 20px;
font-size: 13px;
color: var(--text-secondary);
}
.item-detail span {
color: var(--text-primary);
}
.item-right {
text-align: right;
}
.pnl {
font-size: 14px;
font-weight: 600;
}
.pnl.positive {
color: var(--accent);
}
.pnl.negative {
color: #ef4444;
}
/* 方向标签 */
.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;
}
/* 订单类型标签 */
.order-type-badge {
padding: 2px 8px;
border-radius: 2px;
font-size: 10px;
font-weight: 600;
}
.order-entry {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.order-tp {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
}
.order-sl {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
}
/* 空状态 */
.empty-state {
padding: 60px 20px;
text-align: center;
color: var(--text-secondary);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* 操作按钮 */
.action-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-tertiary);
border-color: var(--text-secondary);
}
.action-btn.danger {
border-color: #ef4444;
color: #ef4444;
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
/* 加载动画 */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
/* 订单分类标题 */
.order-section-title {
padding: 12px 20px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-primary);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.order-section-title:first-child {
border-top: none;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.content-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.stat-value {
font-size: 16px;
}
.stat-label {
font-size: 10px;
}
.symbol {
min-width: 80px;
font-size: 14px;
}
.item-details {
flex-direction: column;
gap: 4px;
font-size: 11px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="trading-page">
<div class="trading-container">
<!-- 头部 -->
<div class="trading-header">
<div class="trading-title">🔥 Hyperliquid 交易监控</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span class="status-badge" :class="enabled ? 'status-enabled' : 'status-disabled'">
{{ enabled ? '已启用' : '未启用' }}
</span>
<button class="refresh-btn" @click="refreshData" :class="{ loading: loading }">
<span v-if="loading" class="spinner"></span>
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
</div>
<!-- 未启用提示 -->
<div v-if="!enabled" class="empty-state">
<div class="empty-icon">🔒</div>
<p>Hyperliquid 实盘交易未启用</p>
<p style="font-size: 13px; margin-top: 8px;">请在 .env 文件中设置 hyperliquid_trading_enabled=true</p>
</div>
<!-- 已启用内容 -->
<div v-else>
<!-- 账户信息 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">账户价值</div>
<div class="stat-value">${{ formatNumber(account.account_value) }}</div>
<div class="stat-sub">可用: ${{ formatNumber(account.available_balance) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">总持仓价值</div>
<div class="stat-value">${{ formatNumber(account.total_position_value) }}</div>
<div class="stat-sub">{{ positions.length }} 个持仓</div>
</div>
<div class="stat-card">
<div class="stat-label">总杠杆率</div>
<div class="stat-value" :class="getLeverageClass(account.current_leverage)">
{{ account.current_leverage?.toFixed(1) || '0.0' }}x
</div>
<div class="stat-sub">
上限: {{ account.max_leverage }}x
<span v-if="leverageUtilization > 80" style="color: #f59e0b; margin-left: 8px;">
⚠️
</span>
</div>
</div>
<div class="stat-card">
<div class="stat-label">回撤</div>
<div class="stat-value" :class="getDrawdownClass(account.drawdown)">
{{ account.drawdown?.toFixed(1) || '0.0' }}%
</div>
<div class="stat-sub">熔断线: {{ account.circuit_breaker_threshold?.toFixed(0) }}%</div>
</div>
<div class="stat-card">
<div class="stat-label">已用保证金</div>
<div class="stat-value">${{ formatNumber(account.total_margin_used) }}</div>
</div>
<div class="stat-card">
<div class="stat-label">挂单数量</div>
<div class="stat-value">{{ orders.entry_orders + orders.tp_sl_orders }}</div>
<div class="stat-sub">
开仓: {{ orders.entry_orders }} |
TP/SL: {{ orders.tp_sl_orders }}
</div>
</div>
</div>
<!-- 持仓和挂单 -->
<div class="content-grid">
<!-- 持仓列表 -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">📈 持仓 ({{ positions.length }})</div>
</div>
<div class="panel-body">
<div v-if="positions.length === 0" class="empty-state">
<div class="empty-icon">📊</div>
<p>暂无持仓</p>
</div>
<div v-else>
<div v-for="pos in positions" :key="pos.symbol" class="list-item">
<div class="item-left">
<div>
<div class="symbol">{{ pos.symbol }}</div>
<span class="side-badge" :class="pos.side === 'long' ? 'long' : 'short'">
{{ pos.side === 'long' ? '做多' : '做空' }}
</span>
</div>
<div class="item-details">
<div class="item-detail">
数量: <span>{{ pos.size?.toFixed(6) }}</span>
</div>
<div class="item-detail">
入场价: <span>${{ formatNumber(pos.entry_price) }}</span>
</div>
<div class="item-detail">
杠杆: <span>{{ pos.leverage }}x</span>
</div>
</div>
</div>
<div class="item-right">
<div class="pnl" :class="getPnLClass(pos.unrealized_pnl)">
{{ pos.unrealized_pnl >= 0 ? '+' : '' }}${{ formatNumber(pos.unrealized_pnl) }}
</div>
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 4px;">
TP: ${{ formatNumber(pos.take_profit) }} | SL: ${{ formatNumber(pos.stop_loss) }}
</div>
<button class="action-btn danger" @click="closePosition(pos.symbol)" style="margin-top: 8px;">
平仓
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 挂单列表 -->
<div class="panel">
<div class="panel-header">
<div class="panel-title">📋 挂单 ({{ orders.entry_orders + orders.tp_sl_orders }})</div>
</div>
<div class="panel-body">
<div v-if="orders.entry_orders + orders.tp_sl_orders === 0" class="empty-state">
<div class="empty-icon">📋</div>
<p>暂无挂单</p>
</div>
<div v-else>
<!-- 入场单 -->
<div v-if="entryOrdersList.length > 0">
<div class="order-section-title">
入场单 ({{ entryOrdersList.length }})
</div>
<div v-for="order in entryOrdersList" :key="order.order_id" class="list-item">
<div class="item-left">
<div class="symbol">{{ order.symbol }}</div>
<div class="item-details">
<div class="item-detail">
{{ order.side === 'B' ? '买入' : '卖出' }}
</div>
<div class="item-detail">
@ ${{ formatNumber(order.price) }}
</div>
<div class="item-detail">
{{ order.size }}
</div>
</div>
</div>
<div class="item-right">
<button class="action-btn danger" @click="cancelOrder(order.symbol)">
取消
</button>
</div>
</div>
</div>
<!-- 止盈止损单 -->
<div v-if="tpSlOrdersList.length > 0">
<div class="order-section-title">
止盈止损 ({{ tpSlOrdersList.length }})
</div>
<div v-for="order in tpSlOrdersList" :key="order.order_id" class="list-item">
<div class="item-left">
<div class="symbol">{{ order.symbol }}</div>
<div class="item-details">
<div class="item-detail">
<span class="order-type-badge order-tp" v-if="order.is_tp">TP</span>
<span class="order-type-badge order-sl" v-if="order.is_sl">SL</span>
</div>
<div class="item-detail">
@ ${{ formatNumber(order.price) }}
</div>
<div class="item-detail">
{{ order.size }}
</div>
</div>
</div>
<div class="item-right">
<span style="font-size: 11px; color: var(--text-secondary);">自动执行</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
loading: false,
enabled: false,
account: {
account_value: 0,
available_balance: 0,
total_margin_used: 0,
total_position_value: 0,
current_leverage: 0,
max_leverage: 10,
drawdown: 0,
circuit_breaker_threshold: 10
},
positions: [],
orders: {
entry_orders: 0,
tp_sl_orders: 0,
all: []
}
}
},
computed: {
leverageUtilization() {
if (!this.account.max_leverage) return 0;
return (this.account.current_leverage / this.account.max_leverage) * 100;
},
entryOrdersList() {
return this.orders.all.filter(o => !o.is_reduce_only);
},
tpSlOrdersList() {
return this.orders.all.filter(o => o.is_reduce_only);
}
},
methods: {
async refreshData() {
this.loading = true;
try {
// 并发获取所有数据
const [accountRes, positionsRes, ordersRes] = await Promise.all([
fetch('/api/hyperliquid/account').then(r => r.json()),
fetch('/api/hyperliquid/positions').then(r => r.json()),
fetch('/api/hyperliquid/orders').then(r => r.json())
]);
// 处理账户数据
if (accountRes.success && accountRes.data) {
this.enabled = accountRes.enabled;
this.account = accountRes.data;
} else {
this.enabled = accountRes.enabled;
}
// 处理持仓数据
if (positionsRes.success && positionsRes.positions) {
this.positions = positionsRes.positions;
}
// 处理订单数据
if (ordersRes.success) {
this.orders = {
entry_orders: ordersRes.counts.entry_orders,
tp_sl_orders: ordersRes.counts.tp_sl_orders,
all: [...ordersRes.entry_orders, ...ordersRes.tp_sl_orders]
};
}
} catch (error) {
console.error('刷新数据失败:', error);
} finally {
this.loading = false;
}
},
formatNumber(num) {
if (num === null || num === undefined) return '0.00';
return parseFloat(num).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
},
getLeverageClass(leverage) {
if (leverage >= 8) return 'warning';
if (leverage >= 9) return 'negative';
return '';
},
getDrawdownClass(drawdown) {
if (drawdown >= 8) return 'warning';
if (drawdown >= 9) return 'negative';
if (drawdown < 0) return 'positive';
return '';
},
getPnLClass(pnl) {
if (pnl > 0) return 'positive';
if (pnl < 0) return 'negative';
return '';
},
async closePosition(symbol) {
if (!confirm(`确定要平掉 ${symbol} 的持仓吗?`)) return;
try {
const response = await fetch(`/api/hyperliquid/positions/close?symbol=${symbol}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('平仓成功');
await this.refreshData();
} else {
alert('平仓失败: ' + result.message);
}
} catch (error) {
alert('平仓失败: ' + error.message);
}
},
async cancelOrder(symbol) {
if (!confirm(`确定要取消 ${symbol} 的所有挂单吗?`)) return;
try {
const response = await fetch(`/api/hyperliquid/orders/cancel?symbol=${symbol}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
alert('取消成功');
await this.refreshData();
} else {
alert('取消失败: ' + result.message);
}
} catch (error) {
alert('取消失败: ' + error.message);
}
}
},
async mounted() {
await this.refreshData();
// 自动刷新每30秒
setInterval(() => {
this.refreshData();
}, 30000);
}
}).mount('#app');
</script>
</body>
</html>