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

748 lines
28 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>XClaw AI | Hyperliquid</title>
<link rel="stylesheet" href="/static/css/style.css">
<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=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<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 {
min-height: 100vh;
}
.hyperliquid-page {
min-height: 100vh;
background: transparent;
}
.paper-badge {
display: inline-block;
background: linear-gradient(135deg, #8fd7ff, #63e6be);
color: #071018;
padding: 5px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
margin-left: 12px;
box-shadow: 0 10px 24px rgba(49, 132, 189, 0.22);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.btn[disabled] {
opacity: 0.7;
cursor: not-allowed;
}
.btn-spinner {
width: 14px;
height: 14px;
border-width: 2px;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.status-enabled {
background: var(--success-light);
color: var(--success);
}
.status-disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
transition: all 0.2s;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.stat-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.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;
}
.stat-value {
font-size: var(--font-2xl);
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--space-xs);
font-family: "IBM Plex Mono", monospace;
}
.stat-value.positive {
color: var(--success);
}
.stat-value.negative {
color: var(--error);
}
.stat-value.warning {
color: var(--warning);
}
.stat-sub {
font-size: var(--font-sm);
color: var(--text-tertiary);
}
.content-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-lg);
}
.panel {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
transition: all 0.2s;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.panel:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: rgba(10, 20, 31, 0.76);
}
.panel-title {
font-size: var(--font-lg);
font-weight: 600;
color: var(--text-primary);
}
.panel-count {
font-size: var(--font-sm);
color: var(--text-secondary);
}
.panel-body {
padding: 0;
}
.list-item {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: rgba(126, 200, 255, 0.05);
}
.item-left {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
min-width: 0;
}
.symbol {
font-size: var(--font-md);
font-weight: 600;
color: var(--text-primary);
min-width: 96px;
}
.item-details {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
font-size: var(--font-sm);
color: var(--text-secondary);
}
.item-detail span {
color: var(--text-primary);
font-weight: 600;
}
.item-right {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.pnl {
font-size: var(--font-base);
font-weight: 600;
}
.pnl.positive {
color: var(--success);
}
.pnl.negative {
color: var(--error);
}
.side-badge {
display: inline-flex;
align-items: center;
margin-top: 8px;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.side-badge.long {
background: var(--success-light);
color: var(--success);
}
.side-badge.short {
background: var(--error-light);
color: var(--error);
}
.order-type-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: 600;
}
.order-entry {
background: var(--info-light);
color: var(--info);
}
.order-tp {
background: var(--success-light);
color: var(--success);
}
.order-sl {
background: var(--error-light);
color: var(--error);
}
.wallet-link {
color: var(--primary);
text-decoration: none;
font-size: 14px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.wallet-link:hover {
color: var(--primary-dark);
}
.wallet-icon {
font-size: 16px;
}
.empty-state {
padding: 56px 24px;
text-align: center;
color: var(--text-secondary);
}
.empty-state p + p {
margin-top: 8px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.subtle-note {
font-size: 11px;
color: var(--text-secondary);
}
.order-section-title {
padding: 12px 24px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
background: rgba(10, 20, 31, 0.76);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.order-section-title:first-child {
border-top: none;
}
@media (max-width: 1024px) {
.content-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.header-actions {
width: 100%;
}
.header-actions .btn {
flex: 1;
justify-content: center;
}
.list-item {
flex-direction: column;
align-items: stretch;
}
.item-left,
.item-right {
width: 100%;
}
.item-right {
align-items: flex-start;
text-align: left;
}
}
</style>
</head>
<body>
<div id="app">
<div class="hyperliquid-page">
<div class="container">
<div class="page-header">
<div>
<div class="page-title">
XClaw
<span class="paper-badge">Hyperliquid</span>
</div>
<div class="page-subtitle">Hyperliquid 实盘账户、持仓与挂单监控面板</div>
</div>
<div class="header-actions">
<span class="status-badge" :class="enabled ? 'status-enabled' : 'status-disabled'">
{{ enabled ? '已启用' : '未启用' }}
</span>
<button class="btn btn-secondary" @click="refreshData" :disabled="loading">
<span v-if="loading" class="spinner btn-spinner"></span>
{{ loading ? '刷新中...' : '刷新' }}
</button>
</div>
</div>
<div v-if="!enabled" class="empty-state">
<div class="empty-icon">🔒</div>
<p>Hyperliquid 实盘交易未启用</p>
<p>请在 <code>.env</code> 文件中设置 <code>hyperliquid_trading_enabled=true</code></p>
</div>
<div v-else>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">钱包地址</div>
<div style="margin-top: 6px;">
<a :href="getHyperbotUrl(account.wallet_address)" target="_blank" class="wallet-link">
<span class="wallet-icon">🔗</span>
<span>{{ formatWalletAddress(account.wallet_address) }}</span>
</a>
</div>
<div class="stat-sub">Hyperliquid Bot</div>
</div>
<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 class="subtle-note">
TP: ${{ formatNumber(pos.take_profit) }} | SL: ${{ formatNumber(pos.stop_loss) }}
</div>
<button class="btn btn-danger btn-small" @click="closePosition(pos.symbol)">
平仓
</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="btn btn-danger btn-small" @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 class="subtle-note">自动执行</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,
wallet_address: null
},
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);
}
},
formatWalletAddress(address) {
if (!address) return 'N/A';
// 显示前6位和后4位中间用...省略
if (address.length <= 10) return address;
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
},
getHyperbotUrl(address) {
if (!address) return '#';
// Hyperbot 链接格式
return `https://hyperbot.network/trader/${address}`;
}
},
async mounted() {
await this.refreshData();
// 自动刷新每30秒
setInterval(() => {
this.refreshData();
}, 30000);
}
}).mount('#app');
</script>
</body>
</html>