feat: 趋势过滤信号记录系统 + 实时涨跌方向判断

- 新增 TrendFilter 信号记录(enter/exit),按完整信号和高分信号两档分类
- 信号持久化到 data/trend_signals_cache.json,开机自动恢复
- 新增 /api/trend-signals API + SSE trend_signal 实时广播
- 前端新增完整信号卡片和高分信号卡片,移除旧趋势检测卡片
- 评分加入 1h 涨跌方向和实时 drift 惩罚,下跌币不触发信号
- OKX 交易所支持(累积变动、动量、趋势检测)
- 修复 trend_filter.go 编译错误

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-05-06 22:24:59 +08:00
co-authored by Claude Opus 4.6
parent 73dac50a36
commit 559d7bb870
14 changed files with 1316 additions and 260 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exchange Monitor Dashboard</title>
<script type="module" crossorigin src="/static/assets/index-Dr4jUtK1.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DsdSIpuQ.css">
<script type="module" crossorigin src="/static/assets/index-B9OruCsy.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CDE5zNyv.css">
</head>
<body>
<div id="root"></div>
+20
View File
@@ -242,3 +242,23 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
.trend-confirmed:hover td { background: rgba(63, 185, 80, 0.15) !important; }
.trend-exhausting { background: rgba(139, 148, 158, 0.05); }
.trend-exhausting:hover td { background: rgba(139, 148, 158, 0.1) !important; }
/* Trend Filter Card */
.text-orange { color: var(--yellow); }
#trend-filter-card { grid-column: 1 / -1; }
#trend-filter-table td { font-variant-numeric: tabular-nums; }
.filter-pass td { background: rgba(63, 185, 80, 0.06); }
.filter-pass:hover td { background: rgba(63, 185, 80, 0.12) !important; }
/* blue text for categories */
.text-blue { color: #58a6ff; }
/* Trend Signal Card */
#trend-signal-card { grid-column: 1 / -1; }
#trend-signal-table td { font-variant-numeric: tabular-nums; }
#high-score-card { grid-column: 1 / -1; }
#high-score-table td { font-variant-numeric: tabular-nums; }
.signal-enter td { background: rgba(63, 185, 80, 0.08); }
.signal-enter:hover td { background: rgba(63, 185, 80, 0.15) !important; }
.signal-exit td { background: rgba(139, 148, 158, 0.05); }
.signal-exit:hover td { background: rgba(139, 148, 158, 0.1) !important; }
+218 -167
View File
@@ -28,9 +28,10 @@ export default function App() {
const [trades, setTrades] = useState([])
const [momentum, setMomentum] = useState([])
const [trendData, setTrendData] = useState([])
const [trendHistory, setTrendHistory] = useState([])
const [cmData, setCmData] = useState([])
const [cmHistory, setCmHistory] = useState([])
const [trendFilter, setTrendFilter] = useState([])
const [trendSignals, setTrendSignals] = useState([])
const priceCacheRef = useRef({})
// Clock
@@ -83,6 +84,13 @@ export default function App() {
case 'cumulative':
setCmData(msg.data || [])
break
case 'trend_filter':
setTrendFilter(msg.data || [])
break
case 'trend_signal':
// Prepend new signal to list
setTrendSignals(prev => [msg.data, ...prev].slice(0, 100))
break
case 'stats':
setStats(msg.data || {})
if (msg.data && msg.data.blacklist) {
@@ -118,23 +126,6 @@ export default function App() {
return () => clearInterval(id)
}, [loadTrades])
// Load trend history
const loadTrendHistory = useCallback(async () => {
try {
const resp = await fetch('/api/trend-history')
const data = await resp.json()
setTrendHistory(data.events || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadTrendHistory()
const id = setInterval(loadTrendHistory, 5000)
return () => clearInterval(id)
}, [loadTrendHistory])
// Load cumulative history
const loadCmHistory = useCallback(async () => {
try {
@@ -152,6 +143,23 @@ export default function App() {
return () => clearInterval(id)
}, [loadCmHistory])
// Load trend signals from API
const loadTrendSignals = useCallback(async () => {
try {
const resp = await fetch('/api/trend-signals')
const data = await resp.json()
if (data.signals) setTrendSignals(data.signals)
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadTrendSignals()
const id = setInterval(loadTrendSignals, 5000)
return () => clearInterval(id)
}, [loadTrendSignals])
// Handle prices
function handlePrices(data) {
if (!data || data.length === 0) return
@@ -210,6 +218,21 @@ export default function App() {
</header>
<div className="grid">
{/* Trend Filter (K-line quiet + EMA52) — 最优先 */}
<TrendFilterCard filterData={trendFilter} />
{/* Full Signal Records (FreshAnomaly + Score >= 70) */}
<FullSignalCard signals={trendSignals} />
{/* High Score Signal Records (Score >= 90) */}
<HighScoreCard signals={trendSignals} />
{/* Cumulative Change (1min consensus) */}
<CmCard data={cmData} />
{/* Cumulative History */}
<CmHistoryCard history={cmHistory} />
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
@@ -219,18 +242,6 @@ export default function App() {
{/* Momentum Scanner */}
<MomentumCard momentum={momentum} />
{/* Trend Detection */}
<TrendCard trend={trendData} />
{/* Trend History */}
<TrendHistoryCard history={trendHistory} />
{/* Cumulative Change (1min consensus) */}
<CmCard data={cmData} />
{/* Cumulative History */}
<CmHistoryCard history={cmHistory} />
{/* ---- 交易相关 ---- */}
{/* Stats Summary */}
@@ -958,87 +969,6 @@ function MomentumCard({ momentum }) {
)
}
// ============ Trend Detection Card ============
function TrendCard({ trend }) {
function stateLabel(state) {
switch (state) {
case 'alert': return '⚠ 异动'
case 'confirmed': return '🚀 趋势'
case 'exhausting': return '🔄 衰减'
default: return state
}
}
function stateClass(state) {
switch (state) {
case 'alert': return 'trend-alert'
case 'confirmed': return 'trend-confirmed'
case 'exhausting': return 'trend-exhausting'
default: return ''
}
}
function dirIcon(dir) {
return dir === 'up' ? '\u2191' : '\u2193'
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="trend-card">
<h2>📈 趋势检测 (价格异动)</h2>
<div className="table-wrap" style={{ maxHeight: 300 }}>
<table id="trend-table">
<thead>
<tr>
<th>币种</th>
<th>状态</th>
<th>方向</th>
<th>异动分</th>
<th>波动率</th>
<th>一致数</th>
<th>BG 15s</th>
<th>HL 15s</th>
<th>BN 15s</th>
<th>OKX 15s</th>
<th>时长</th>
</tr>
</thead>
<tbody>
{trend.length === 0 ? (
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
等待检测数据... (需要至少 3 个交易所数据)
</td></tr>
) : trend.slice(0, 30).map(entry => (
<tr key={entry.coin} className={stateClass(entry.state)}>
<td><strong>{entry.coin}</strong></td>
<td className="trend-state">{stateLabel(entry.state)}</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:18}}>{dirIcon(entry.direction)}</td>
<td className="text-right" style={{fontWeight:700}}>{(entry.anomaly_score || 0).toFixed(1)}σ</td>
<td className="text-right">{(entry.volatility || 0).toFixed(4)}%</td>
<td className="text-right">{entry.ex_changes || 0}/4</td>
<td className={'text-right ' + changeClass(entry.bg_change)}>{entry.bg_change != null ? entry.bg_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_change)}>{entry.hl_change != null ? entry.hl_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_change)}>{entry.bn_change != null ? entry.bn_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_change)}>{entry.okx_change != null ? entry.okx_change.toFixed(3) + '%' : '-'}</td>
<td className="text-dim">{entry.duration || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ Trend History Card ============
// ============ Cumulative Change Card (1min consensus) ============
function CmCard({ data }) {
function stateLabel(state) {
@@ -1087,11 +1017,16 @@ function CmCard({ data }) {
<th>HL 5m</th>
<th>BN 5m</th>
<th>OKX 5m</th>
<th colSpan={4} style={{borderLeft:'2px solid var(--border)'}}>1h 趋势</th>
<th>BG 1h</th>
<th>HL 1h</th>
<th>BN 1h</th>
<th>OKX 1h</th>
</tr>
</thead>
<tbody>
{!data || data.length === 0 ? (
<tr><td colSpan="14" className="text-dim" style={{textAlign:'center',padding:20}}>
<tr><td colSpan="19" className="text-dim" style={{textAlign:'center',padding:20}}>
等待累积数据... (需要至少 1 分钟数据)
</td></tr>
) : data.slice(0, 30).map(entry => (
@@ -1110,6 +1045,11 @@ function CmCard({ data }) {
<td className={'text-right ' + changeClass(entry.hl_5m)}>{entry.hl_5m != null ? entry.hl_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_5m)}>{entry.bn_5m != null ? entry.bn_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_5m)}>{entry.okx_5m != null ? entry.okx_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + dirClass(entry.direction)} style={{fontWeight:600,borderLeft:'2px solid var(--border)'}}>{(entry.avg_1h || 0).toFixed(2)}%</td>
<td className={'text-right ' + changeClass(entry.bg_1h)}>{entry.bg_1h != null ? entry.bg_1h.toFixed(2) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_1h)}>{entry.hl_1h != null ? entry.hl_1h.toFixed(2) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_1h)}>{entry.bn_1h != null ? entry.bn_1h.toFixed(2) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_1h)}>{entry.okx_1h != null ? entry.okx_1h.toFixed(2) + '%' : '-'}</td>
</tr>
))}
</tbody>
@@ -1201,78 +1141,85 @@ function CmHistoryCard({ history }) {
)
}
function TrendHistoryCard({ history }) {
function stateLabel(state) {
switch (state) {
case 'alert': return '⚠ 异动'
case 'confirmed': return '🚀 趋势'
case 'exhausting': return '🔄 衰减'
case 'idle': return '✓ 结束'
default: return state
function TrendFilterCard({ filterData }) {
const passing = filterData.filter(f => f.passes_filter).length
const highScore = filterData.filter(f => f.signal_score >= 80).length
const midScore = filterData.filter(f => f.signal_score >= 50 && f.signal_score < 80).length
const anomalyCount = filterData.filter(f => f.fresh_anomaly).length
// Build header description
let desc = `高分${highScore} 中分${midScore}`
if (anomalyCount > 0) {
desc += ` | ${anomalyCount}币异动中`
if (passing > 0) {
desc += `${passing}通过!`
}
} else {
desc += ' | 等待异动信号'
}
function stateClass(state) {
switch (state) {
case 'alert': return 'text-yellow'
case 'confirmed': return 'text-green'
case 'exhausting': return 'text-dim'
case 'idle': return 'text-dim'
default: return ''
}
function scoreClass(s) {
if (s == null) return ''
if (s >= 80) return 'text-green'
if (s >= 50) return 'text-yellow'
return 'text-dim'
}
function dirIcon(dir) {
return dir === 'up' ? '\u2191' : '\u2193'
function volClass(r) {
if (r == null || r <= 1.5) return ''
if (r > 3.0) return 'text-red'
return 'text-orange'
}
function dirClass(dir) {
return dir === 'up' ? 'text-green' : 'text-red'
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
function slopeClass(s) {
if (s == null || s === 0) return ''
return s > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="trend-history-card">
<h2>📋 趋势事件记录</h2>
<section className="card card-wide" id="trend-filter-card">
<h2>趋势过滤 ({passing}通过 / {filterData.length}) <span className="text-dim" style={{fontSize:12,fontWeight:400}}>{desc}</span></h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="trend-history-table">
<table id="trend-filter-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>转换</th>
<th>方向</th>
<th>异动分</th>
<th>波动率</th>
<th>一致</th>
<th>BG</th>
<th>HL</th>
<th>BN</th>
<th>OKX</th>
<th>分数</th>
<th>24h范围</th>
<th>基线</th>
<th>1h范围</th>
<th>成交量比</th>
<th>1h变化</th>
<th>EMA52</th>
<th>EMA斜率</th>
<th>现价</th>
<th>&gt; EMA</th>
<th>安静24h</th>
<th>安静1h</th>
<th>异动</th>
<th>更新于</th>
</tr>
</thead>
<tbody>
{history.length === 0 ? (
<tr><td colSpan="11" className="text-dim" style={{textAlign:'center',padding:20}}>
暂无趋势事件记录
</td></tr>
) : history.slice(0, 100).map((ev, i) => (
<tr key={(ev.timestamp || ev.id || i) + '-' + i}>
<td className="text-dim">{ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : (ev.created_at ? new Date(ev.created_at).toLocaleTimeString('zh-CN', { hour12: false }) : '-')}</td>
<td><strong>{ev.coin}</strong></td>
<td className={stateClass(ev.new_state)}>{ev.prev_state} {stateLabel(ev.new_state)}</td>
<td className={dirClass(ev.direction)} style={{textAlign:'center',fontSize:16}}>{dirIcon(ev.direction)}</td>
<td className="text-right">{(ev.z_score || 0).toFixed(1)}σ</td>
<td className="text-right">{(ev.volatility || 0).toFixed(4)}%</td>
<td className="text-right">{ev.ex_agree || 0}/{ev.ex_total || 0}</td>
<td className={'text-right ' + changeClass(ev.bg_change)}>{ev.bg_change != null ? ev.bg_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_change)}>{ev.hl_change != null ? ev.hl_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_change)}>{ev.bn_change != null ? ev.bn_change.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_change)}>{ev.okx_change != null ? ev.okx_change.toFixed(3) + '%' : '-'}</td>
{filterData.length === 0 ? (
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>等待K线数据...</td></tr>
) : filterData.map(entry => (
<tr key={entry.coin} className={entry.passes_filter ? 'filter-pass' : ''}>
<td><strong>{entry.coin}</strong></td>
<td className={'text-right ' + scoreClass(entry.signal_score)} style={{fontWeight:700}}>{entry.signal_score != null ? entry.signal_score.toFixed(0) : '-'}</td>
<td className={'text-right ' + (entry.quiet_24h ? 'text-green' : '')}>{entry.range_24h != null ? entry.range_24h.toFixed(2) + '%' : '-'}</td>
<td className="text-right text-dim">{entry.vol_baseline != null ? entry.vol_baseline.toFixed(2) + '%' : '-'}</td>
<td className={'text-right ' + (entry.quiet_1h ? 'text-green' : '')}>{entry.range_1h != null ? entry.range_1h.toFixed(2) + '%' : '-'}</td>
<td className={'text-right ' + volClass(entry.volume_ratio)}>{entry.volume_ratio != null ? entry.volume_ratio.toFixed(2) + 'x' : '-'}</td>
<td className={'text-right ' + (entry.change_1h > 0 ? 'text-green' : entry.change_1h < 0 ? 'text-red' : '')}>{entry.change_1h != null ? (entry.change_1h > 0 ? '+' : '') + entry.change_1h.toFixed(2) + '%' : '-'}</td>
<td className="text-right">{entry.ema_52 ? entry.ema_52.toFixed(4) : '-'}</td>
<td className={'text-right ' + slopeClass(entry.ema_slope)}>{entry.ema_slope != null ? (entry.ema_slope > 0 ? '+' : '') + entry.ema_slope.toFixed(3) + '%' : '-'}</td>
<td className="text-right">{entry.current_price ? entry.current_price.toFixed(4) : '-'}</td>
<td className={entry.price_above_ema ? 'text-green' : 'text-red'}>{entry.price_above_ema != null ? (entry.price_above_ema ? '↑' : '') : '-'}</td>
<td className={entry.quiet_24h ? 'text-green' : 'text-dim'}>{entry.quiet_24h != null ? (entry.quiet_24h ? '' : '✗') : '-'}</td>
<td className={entry.quiet_1h ? 'text-green' : 'text-dim'}>{entry.quiet_1h != null ? (entry.quiet_1h ? '' : '✗') : '-'}</td>
<td className={entry.fresh_anomaly ? 'text-orange' : 'text-dim'}>{entry.fresh_anomaly != null ? (entry.fresh_anomaly ? '⚠' : '-') : '-'}</td>
<td className="text-dim">{entry.last_updated ? new Date(entry.last_updated).toLocaleTimeString('zh-CN', {hour12:false}) : '-'}</td>
</tr>
))}
</tbody>
@@ -1281,3 +1228,107 @@ function TrendHistoryCard({ history }) {
</section>
)
}
// ============ Full Signal Records Card (FreshAnomaly + Score >= 70) ============
function FullSignalCard({ signals }) {
const filtered = signals.filter(s => s.category === 'full')
const enterCount = filtered.filter(s => s.type === 'enter').length
return (
<section className="card card-wide" id="trend-signal-card">
<h2>完整信号 (异动+分数70) {enterCount > 0 && <span className="text-green" style={{fontSize:12,fontWeight:400,marginLeft:8}}>{enterCount}</span>}</h2>
<div className="table-wrap" style={{ maxHeight: 350 }}>
<table id="trend-signal-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>类型</th>
<th>分数</th>
<th>价格</th>
<th>成交量比</th>
<th>EMA斜率</th>
<th>趋势状态</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan="8" className="text-dim" style={{textAlign:'center',padding:20}}>
等待完整信号... (FreshAnomaly + 分数70)
</td></tr>
) : filtered.slice(0, 50).map((s, i) => {
const rowClass = s.type === 'enter' ? 'signal-enter' : 'signal-exit'
const ts = s.timestamp ? new Date(s.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-'
return (
<tr key={'full-' + s.timestamp + '-' + s.coin + '-' + i} className={rowClass}>
<td className="text-dim">{ts}</td>
<td><strong>{s.coin}</strong></td>
<td className={s.type === 'enter' ? 'text-green' : 'text-dim'} style={{fontWeight:600}}>
{s.type === 'enter' ? '开' : '关'}
</td>
<td className="text-right" style={{fontWeight:700}}>{s.signal_score != null ? s.signal_score.toFixed(0) : '-'}</td>
<td className="text-right">{s.price ? s.price.toFixed(4) : '-'}</td>
<td className="text-right">{s.volume_ratio != null ? s.volume_ratio.toFixed(2) + 'x' : '-'}</td>
<td className="text-right">{s.ema_slope != null ? (s.ema_slope > 0 ? '+' : '') + s.ema_slope.toFixed(3) + '%' : '-'}</td>
<td className="text-dim">{s.state || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
// ============ High Score Signal Records Card (Score >= 90) ============
function HighScoreCard({ signals }) {
const filtered = signals.filter(s => s.category === 'high')
const enterCount = filtered.filter(s => s.type === 'enter').length
return (
<section className="card card-wide" id="high-score-card">
<h2>高分信号 (分数90) {enterCount > 0 && <span className="text-green" style={{fontSize:12,fontWeight:400,marginLeft:8}}>{enterCount}</span>}</h2>
<div className="table-wrap" style={{ maxHeight: 350 }}>
<table id="high-score-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>类型</th>
<th>分数</th>
<th>价格</th>
<th>成交量比</th>
<th>EMA斜率</th>
<th>趋势状态</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan="8" className="text-dim" style={{textAlign:'center',padding:20}}>
等待高分信号... (分数90)
</td></tr>
) : filtered.slice(0, 50).map((s, i) => {
const rowClass = s.type === 'enter' ? 'signal-enter' : 'signal-exit'
const ts = s.timestamp ? new Date(s.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : '-'
return (
<tr key={'high-' + s.timestamp + '-' + s.coin + '-' + i} className={rowClass}>
<td className="text-dim">{ts}</td>
<td><strong>{s.coin}</strong></td>
<td className={s.type === 'enter' ? 'text-green' : 'text-dim'} style={{fontWeight:600}}>
{s.type === 'enter' ? '开' : '关'}
</td>
<td className="text-right" style={{fontWeight:700}}>{s.signal_score != null ? s.signal_score.toFixed(0) : '-'}</td>
<td className="text-right">{s.price ? s.price.toFixed(4) : '-'}</td>
<td className="text-right">{s.volume_ratio != null ? s.volume_ratio.toFixed(2) + 'x' : '-'}</td>
<td className="text-right">{s.ema_slope != null ? (s.ema_slope > 0 ? '+' : '') + s.ema_slope.toFixed(3) + '%' : '-'}</td>
<td className="text-dim">{s.state || '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}