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

743 lines
23 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>交易信号 - XClaw</title>
<!-- Global Styles -->
<link rel="stylesheet" href="/static/css/style.css">
<!-- Fonts -->
<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">
<!-- Page-Specific Styles -->
<style>
.signals-page {
max-width: 1400px;
margin: 0 auto;
padding: 32px 24px;
}
/* Header */
.signals-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding: 22px 24px;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.signals-title {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.live-dot {
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refresh-btn {
padding: 10px 20px;
background: rgba(126, 200, 255, 0.08);
border: 1px solid var(--border);
border-radius: 14px;
color: var(--primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(14px);
}
.refresh-btn:hover {
background: rgba(126, 200, 255, 0.14);
border-color: var(--primary);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
box-shadow: var(--shadow-soft);
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--primary);
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
.stat-value.positive {
color: var(--success);
}
.stat-value.negative {
color: var(--error);
}
/* Grade Stats */
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.grade-stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-soft);
}
.grade-stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.grade-stat-title {
font-size: 14px;
color: var(--text-primary);
}
.grade-stat-count {
font-size: 20px;
font-weight: 700;
color: var(--primary);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
}
.tab {
padding: 14px 24px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Signals Grid */
.signals-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
}
.signal-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
transition: all 0.2s;
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.signal-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow-md);
}
.signal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.signal-symbol-group {
display: flex;
align-items: center;
gap: 12px;
}
.signal-symbol {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.signal-type-badge {
font-size: 11px;
padding: 3px 8px;
background: rgba(128, 169, 202, 0.12);
color: var(--text-secondary);
border-radius: 999px;
border: 1px solid var(--border);
}
.signal-action-group {
display: flex;
align-items: center;
gap: 12px;
}
.signal-action {
padding: 6px 16px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
}
.signal-action.buy {
background: var(--success-light);
color: var(--success);
}
.signal-action.sell {
background: var(--error-light);
color: var(--error);
}
.signal-action.hold {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.signal-grade {
display: inline-block;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
}
.signal-grade.A {
background: var(--warning-light);
color: var(--warning);
}
.signal-grade.B {
background: rgba(126, 200, 255, 0.12);
color: var(--primary);
}
.signal-grade.C {
background: rgba(255, 184, 77, 0.16);
color: var(--warning);
}
.signal-grade.D {
background: var(--error-light);
color: var(--error);
}
/* Confidence */
.confidence-section {
margin-bottom: 16px;
}
.confidence-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.confidence-value {
color: var(--primary);
font-weight: 600;
}
.confidence-bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, #7ec8ff, #63e6be);
transition: width 0.3s;
}
/* Price Section */
.price-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 12px 0;
margin-bottom: 12px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.price-item {
text-align: center;
}
.price-label {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.price-value {
font-size: 14px;
color: var(--text-primary);
font-family: "IBM Plex Mono", monospace;
}
/* Signal Details */
.signal-details {
margin-bottom: 12px;
}
.detail-row {
display: flex;
margin-bottom: 6px;
font-size: 13px;
}
.detail-label {
color: var(--text-secondary);
min-width: 70px;
}
.detail-value {
color: var(--text-primary);
flex: 1;
}
/* Reason */
.signal-reason {
background: rgba(10, 20, 31, 0.72);
border-radius: var(--radius-sm);
padding: 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
}
.reason-label {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.reason-text {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
/* Time */
.signal-time {
font-size: 11px;
color: var(--text-tertiary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.signals-page {
padding: 20px 16px;
}
.signals-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.signals-title {
font-size: 22px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
padding: 14px;
}
.stat-value {
font-size: 22px;
}
.tabs {
overflow-x: auto;
}
.tab {
padding: 10px 16px;
font-size: 13px;
white-space: nowrap;
}
.signals-grid {
grid-template-columns: 1fr;
}
.price-section {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="signals-page">
<!-- Header -->
<div class="signals-header">
<h1 class="signals-title">
交易信号中心
<span class="live-dot"></span>
</h1>
<button class="refresh-btn" @click="loadSignals">刷新</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{ active: currentTab === 'crypto' }" @click="switchTab('crypto')">
加密货币 ({{ cryptoSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'stock' }" @click="switchTab('stock')">
美股 ({{ stockSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'all' }" @click="switchTab('all')">
全部信号 ({{ allSignals.length }})
</button>
</div>
<!-- Signals List -->
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="currentSignals.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<p>暂无信号</p>
</div>
<div v-else class="signals-grid">
<div v-for="signal in currentSignals" :key="signal.id" class="signal-card">
<!-- Header -->
<div class="signal-header">
<div class="signal-symbol-group">
<span class="signal-symbol">{{ signal.symbol }}</span>
<span class="signal-type-badge">{{ signal.signal_type === 'crypto' ? '加密货币' : '美股' }}</span>
</div>
<div class="signal-action-group">
<span class="signal-action" :class="signal.action">
{{ signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '持有' }}
</span>
<span class="signal-grade" :class="signal.grade">{{ signal.grade }}级</span>
</div>
</div>
<!-- Confidence -->
<div class="confidence-section">
<div class="confidence-label">
<span>置信度</span>
<span class="confidence-value">{{ signal.confidence }}%</span>
</div>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: signal.confidence + '%' }"></div>
</div>
</div>
<!-- Price Section -->
<div class="price-section" v-if="getEntryPrice(signal) || signal.stop_loss || signal.take_profit">
<div class="price-item" v-if="getEntryPrice(signal)">
<div class="price-label">{{ getEntryPriceLabel(signal) }}</div>
<div class="price-value">${{ getEntryPrice(signal).toFixed(2) }}</div>
</div>
<div class="price-item" v-if="signal.stop_loss">
<div class="price-label">止损</div>
<div class="price-value">${{ signal.stop_loss?.toFixed(2) }}</div>
</div>
<div class="price-item" v-if="signal.take_profit">
<div class="price-label">止盈</div>
<div class="price-value">${{ signal.take_profit?.toFixed(2) }}</div>
</div>
</div>
<!-- Details -->
<div class="signal-details" v-if="signal.signal_type_detail || signal.entry_type || signal.position_size || signal.current_price">
<div class="detail-row" v-if="signal.current_price">
<span class="detail-label">当前价:</span>
<span class="detail-value">${{ signal.current_price?.toFixed(2) }}</span>
</div>
<div class="detail-row" v-if="signal.signal_type_detail">
<span class="detail-label">周期:</span>
<span class="detail-value">{{ getSignalTypeText(signal.signal_type_detail) }}</span>
</div>
<div class="detail-row" v-if="signal.entry_type">
<span class="detail-label">入场:</span>
<span class="detail-value">{{ getEntryTypeText(signal.entry_type) }}</span>
</div>
<div class="detail-row" v-if="signal.position_size">
<span class="detail-label">仓位:</span>
<span class="detail-value">{{ getPositionSizeText(signal.position_size) }}</span>
</div>
<div class="detail-row" v-if="signal.news_sentiment">
<span class="detail-label">情绪:</span>
<span class="detail-value">{{ getNewsSentimentText(signal.news_sentiment) }}</span>
</div>
</div>
<!-- Reason -->
<div class="signal-reason" v-if="getCombinedReason(signal)">
<div class="reason-label">分析理由</div>
<div class="reason-text">{{ getCombinedReason(signal) }}</div>
</div>
<!-- Time -->
<div class="signal-time">
{{ formatTime(signal.timestamp || signal.created_at) }}
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
currentTab: 'crypto',
loading: true,
cryptoSignals: [],
stockSignals: [],
allSignals: [],
stats: {
crypto: { total: 0, buy: 0, sell: 0, recent_24h: 0 },
stock: { total: 0, buy: 0, sell: 0, recent_24h: 0 },
grades: {},
total: 0
},
refreshInterval: null
};
},
computed: {
currentSignals() {
if (this.currentTab === 'crypto') return this.cryptoSignals;
if (this.currentTab === 'stock') return this.stockSignals;
return this.allSignals;
}
},
methods: {
switchTab(tab) {
this.currentTab = tab;
},
async loadSignals() {
this.loading = true;
try {
await Promise.all([
this.loadCryptoSignals(),
this.loadStockSignals(),
this.loadStats()
]);
} catch (e) {
console.error('加载信号失败:', e);
} finally {
this.loading = false;
}
},
async loadCryptoSignals() {
const response = await fetch('/api/signals/crypto?limit=50&days=7');
const data = await response.json();
if (data.success) {
this.cryptoSignals = data.signals || [];
}
},
async loadStockSignals() {
const response = await fetch('/api/signals/stock?limit=50&days=7');
const data = await response.json();
if (data.success) {
this.stockSignals = data.signals || [];
}
},
async loadStats() {
const response = await fetch('/api/signals/stats?days=7');
const data = await response.json();
if (data.success) {
this.stats = {
crypto: data.crypto || { total: 0, buy: 0, sell: 0, recent_24h: 0 },
stock: data.stock || { total: 0, buy: 0, sell: 0, recent_24h: 0 },
grades: data.grades || {},
total: data.total || 0
};
}
},
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'
});
},
getSignalTypeText(type) {
const map = {
'short_term': '短期',
'medium_term': '中期',
'long_term': '长期'
};
return map[type] || type;
},
getEntryTypeText(type) {
const map = {
'market': '市价',
'limit': '限价'
};
return map[type] || type;
},
getPositionSizeText(size) {
const map = {
'light': '轻仓',
'medium': '中仓',
'heavy': '重仓'
};
return map[size] || size;
},
getEntryPrice(signal) {
if (signal.entry_type === 'limit' && signal.entry_zone) {
return signal.entry_zone;
}
return signal.entry_price;
},
getEntryPriceLabel(signal) {
if (signal.entry_type === 'limit' && signal.entry_zone) {
return '挂单价';
}
return '入场价';
},
getNewsSentimentText(sentiment) {
const map = {
'bullish': '看涨',
'bearish': '看跌',
'neutral': '中性',
'positive': '积极',
'negative': '消极'
};
return map[sentiment] || sentiment;
},
getCombinedReason(signal) {
return signal.analysis_summary || signal.reason || '';
}
},
mounted() {
this.loadSignals();
this.refreshInterval = setInterval(() => {
this.loadSignals();
}, 30000);
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}).mount('#app');
</script>
</body>
</html>