stock-ai-agent/frontend/signals.html
2026-02-24 22:02:37 +08:00

838 lines
27 KiB
HTML
Raw Permalink 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>交易信号 - Stock Agent</title>
<link rel="stylesheet" href="/static/css/style.css">
<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;
}
.signals-page {
min-height: 100vh;
background: var(--bg-primary);
padding: 20px;
}
.signals-container {
max-width: 1400px;
min-width: 1200px;
margin: 0 auto;
}
/* 固定顶部区域 */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-primary);
padding-bottom: 10px;
}
.signals-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);
}
.signals-title {
font-size: 24px;
font-weight: 300;
color: var(--text-primary);
}
.signals-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);
}
/* 统计卡片 */
.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: #00ff41;
}
.stat-value.negative {
color: #ff4444;
}
/* 标签页 */
.tabs {
display: flex;
gap: 0;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tab {
padding: 12px 24px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* 信号卡片网格 */
.signals-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 16px;
}
.signal-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
transition: all 0.2s;
}
.signal-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(0, 200, 150, 0.1);
}
.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: 20px;
font-weight: 500;
color: var(--text-primary);
}
.signal-type-badge {
font-size: 11px;
padding: 3px 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 2px;
}
.signal-action-group {
display: flex;
align-items: center;
gap: 12px;
}
.signal-action {
padding: 6px 16px;
border-radius: 2px;
font-size: 14px;
font-weight: 500;
}
.signal-action.buy {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
}
.signal-action.sell {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
}
.signal-action.hold {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
.signal-grade {
display: inline-block;
padding: 4px 10px;
border-radius: 2px;
font-size: 12px;
font-weight: 500;
}
.signal-grade.A {
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 179, 0, 0.2));
color: gold;
}
.signal-grade.B {
background: linear-gradient(135deg, rgba(192, 192, 192, 0.2), rgba(160, 160, 160, 0.2));
color: silver;
}
.signal-grade.C {
background: linear-gradient(135deg, rgba(205, 127, 50, 0.2), rgba(160, 82, 45, 0.2));
color: #cd7f32;
}
.signal-grade.D {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
}
/* 置信度条 */
.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(--accent);
font-weight: 500;
}
.confidence-bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent) 0%, #00ff41 100%);
transition: width 0.3s;
}
/* 价格信息 */
.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: monospace;
}
/* 信号详情 */
.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;
}
/* 分析理由 */
.signal-reason {
background: var(--bg-tertiary);
border-radius: 4px;
padding: 12px;
margin-bottom: 12px;
}
.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;
}
/* 时间戳 */
.signal-time {
font-size: 11px;
color: var(--text-secondary);
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.signals-page {
padding: 10px;
overflow-x: hidden;
}
.signals-container {
min-width: auto;
max-width: 100%;
overflow-x: hidden;
}
/* 取消顶部固定 */
.sticky-header {
position: static;
}
.signals-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.signals-title {
font-size: 18px;
}
.signals-title span {
display: block;
font-size: 14px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-card {
padding: 12px;
min-width: 0;
}
.stat-value {
font-size: 16px;
word-break: break-all;
}
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
padding: 10px 16px;
font-size: 13px;
white-space: nowrap;
}
.signals-grid {
grid-template-columns: 1fr;
}
.price-section {
grid-template-columns: 1fr;
gap: 8px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.signals-title {
font-size: 16px;
}
.stat-value {
font-size: 16px;
}
}
/* 等级统计 */
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.grade-stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.grade-stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.grade-stat-title {
font-size: 14px;
color: var(--text-primary);
}
.grade-stat-count {
font-size: 20px;
font-weight: 300;
color: var(--accent);
}
.grade-stat-details {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.grade-stat-row {
display: flex;
justify-content: space-between;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div id="app">
<div class="signals-page">
<div class="signals-container">
<!-- 固定顶部区域 -->
<div class="sticky-header">
<!-- 头部 -->
<div class="signals-header">
<h1 class="signals-title">交易信号中心 <span>| Trading Signals</span></h1>
<button class="refresh-btn" @click="loadSignals">刷新</button>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">加密货币信号</div>
<div class="stat-value">{{ stats.crypto.total }}</div>
<div class="stat-label" style="margin-top: 6px;">最近24小时: {{ stats.crypto.recent_24h }}</div>
</div>
<div class="stat-card">
<div class="stat-label">美股信号</div>
<div class="stat-value">{{ stats.stock.total }}</div>
<div class="stat-label" style="margin-top: 6px;">最近24小时: {{ stats.stock.recent_24h }}</div>
</div>
<div class="stat-card">
<div class="stat-label">总信号数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">买入信号</div>
<div class="stat-value positive">{{ stats.crypto.buy + stats.stock.buy }}</div>
</div>
<div class="stat-card">
<div class="stat-label">卖出信号</div>
<div class="stat-value negative">{{ stats.crypto.sell + stats.stock.sell }}</div>
</div>
</div>
<!-- 等级统计 -->
<div class="grade-stats" v-if="Object.keys(stats.grades).length > 0">
<div class="grade-stat-card" v-for="(count, grade) in stats.grades" :key="grade">
<div class="grade-stat-header">
<span class="grade-stat-title">
<span class="signal-grade" :class="grade">{{ grade }}</span> 级信号
</span>
<span class="grade-stat-count">{{ count }}</span>
</div>
</div>
</div>
</div>
<!-- 标签页 -->
<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>
<!-- 信号列表 -->
<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" :class="signal.action">
<!-- 信号头部 -->
<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>
<!-- 置信度 -->
<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>
<!-- 价格信息 -->
<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>
<!-- 信号详情 -->
<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 和 analysis_summary -->
<div class="signal-reason" v-if="getCombinedReason(signal)">
<div class="reason-label">分析理由</div>
<div class="reason-text">{{ getCombinedReason(signal) }}</div>
</div>
<!-- 时间戳 -->
<div class="signal-time">
{{ formatTime(signal.timestamp || signal.created_at) }}
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/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;
},
// 格式化价位数据
formatLevels(levels) {
if (Array.isArray(levels)) {
return levels.map(l => '$' + l.toFixed(2)).join(', ');
} else if (typeof levels === 'number') {
return '$' + levels.toFixed(2);
} else if (typeof levels === 'string') {
return levels;
}
return '';
},
// 合并理由和分析摘要
getCombinedReason(signal) {
// 优先使用 analysis_summary如果不存在则使用 reason
return signal.analysis_summary || signal.reason || '';
}
},
mounted() {
this.loadSignals();
// 每30秒自动刷新
this.refreshInterval = setInterval(() => {
this.loadSignals();
}, 30000);
},
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}).mount('#app');
</script>
</body>
</html>