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
+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>
)
}