feat: 添加OKX行情接入+趋势检测+累积变动系统+界面重构

- 新增OKX WebSocket行情连接器,扩展4交易所价格监控
- 新增z-score趋势检测引擎(TrendDetector),识别价格异动/趋势启动
- 新增累积变动跟踪(CumulativeTracker),基于1min/5min多交易所共识
- 趋势事件和累积变动事件持久化到SQLite
- 新增Binance/OKX动量检测字段,扩展前端动量卡片至15列
- 迁移至macOS(darwin-arm64),更新前端依赖
- Dashboard网格重构:非交易卡片置顶,交易卡片置底
- TrackedCoin添加OK字段,添加ExBinance/ExOKX常量
- 前端新增趋势检测卡片、趋势历史卡片、累积变动卡片

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jackyu66git
2026-05-06 13:26:05 +08:00
co-authored by Claude Opus 4.6
parent 047571921e
commit b7767c95ae
24 changed files with 2705 additions and 273 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.stats-row{display:flex;gap:16px;flex-wrap:wrap}.stat{display:flex;flex-direction:column;align-items:center;min-width:60px}.stat label{font-size:11px;color:var(--text-dim);margin-bottom:2px}.stat span{font-size:20px;font-weight:700}.pct-green{color:var(--green)}.pct-red{color:var(--red)}.pct-gray{color:var(--text-dim)}.pct-yellow{color:var(--yellow)}.pct-blue{color:var(--blue)}#conn-detail{font-size:11px;white-space:nowrap}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.trade-row{cursor:pointer}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}.stats-row{justify-content:center}}#bl-body{display:flex;gap:8px;flex-wrap:wrap}.bl-item{background:#f851491a;border:1px solid rgba(248,81,73,.3);border-radius:4px;padding:4px 10px;font-size:12px;color:var(--red);cursor:default}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}.modal-content{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;box-shadow:0 8px 32px #00000080}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;margin:0;padding:0;border:none;color:var(--text)}.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1}.modal-close:hover{background:#ffffff1a;color:var(--text)}#trade-detail-body{padding:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}.detail-section{padding:14px 20px;border-bottom:1px solid rgba(48,54,61,.4)}.detail-section:last-child{border-bottom:none}.detail-section-full{grid-column:1 / -1}.detail-section h3{font-size:12px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}.detail-row{display:flex;justify-content:space-between;padding:3px 0;font-size:13px}.detail-row .label{color:var(--text-dim)}.detail-row .value{font-weight:500}.detail-orders{width:100%;font-size:12px}.detail-orders th{background:var(--bg);font-size:10px}.detail-orders td{padding:4px 6px}
:root{--bg: #0d1117;--card: #161b22;--border: #30363d;--text: #c9d1d9;--text-dim: #8b949e;--accent: #58a6ff;--green: #3fb950;--red: #f85149;--yellow: #d29922;--blue: #58a6ff}*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;min-height:100vh}#app{max-width:1440px;margin:0 auto;padding:16px}header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;background:var(--card);border:1px solid var(--border);border-radius:8px;margin-bottom:16px}header h1{font-size:18px;font-weight:600}.header-meta{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-dim)}.sep{color:var(--border)}.status-offline{color:var(--red)}.status-online{color:var(--green)}.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.card-wide{grid-column:1 / -1}.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:12px}.card h2{font-size:14px;font-weight:600;color:var(--text-dim);margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}.stats-row{display:flex;gap:16px;flex-wrap:wrap}.stat{display:flex;flex-direction:column;align-items:center;min-width:60px}.stat label{font-size:11px;color:var(--text-dim);margin-bottom:2px}.stat span{font-size:20px;font-weight:700}.pct-green{color:var(--green)}.pct-red{color:var(--red)}.pct-gray{color:var(--text-dim)}.pct-yellow{color:var(--yellow)}.pct-blue{color:var(--blue)}#conn-detail{font-size:11px;white-space:nowrap}.table-wrap{overflow-x:auto;max-height:320px;overflow-y:auto}table{width:100%;border-collapse:collapse;font-size:13px}th{text-align:left;padding:6px 8px;color:var(--text-dim);font-weight:500;font-size:11px;text-transform:uppercase;letter-spacing:.5px;position:sticky;top:0;background:var(--card);border-bottom:1px solid var(--border)}td{padding:5px 8px;border-bottom:1px solid rgba(48,54,61,.5);white-space:nowrap}tr:hover td{background:#58a6ff0d}.trade-row{cursor:pointer}.loading{text-align:center;color:var(--text-dim);padding:20px!important}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-yellow{color:var(--yellow)}.text-dim{color:var(--text-dim)}.text-right{text-align:right}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#484f58}@media (max-width: 768px){.grid{grid-template-columns:1fr}header{flex-direction:column;gap:8px}.stats-row{justify-content:center}}#bl-body{display:flex;gap:8px;flex-wrap:wrap}.bl-item{background:#f851491a;border:1px solid rgba(248,81,73,.3);border-radius:4px;padding:4px 10px;font-size:12px;color:var(--red);cursor:default}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:#000000b3;z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:40px 16px;overflow-y:auto}.modal-content{background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;box-shadow:0 8px 32px #00000080}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border)}.modal-header h2{font-size:16px;margin:0;padding:0;border:none;color:var(--text)}.modal-close{background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;padding:4px 8px;border-radius:4px;line-height:1}.modal-close:hover{background:#ffffff1a;color:var(--text)}#trade-detail-body{padding:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}.detail-section{padding:14px 20px;border-bottom:1px solid rgba(48,54,61,.4)}.detail-section:last-child{border-bottom:none}.detail-section-full{grid-column:1 / -1}.detail-section h3{font-size:12px;color:var(--text-dim);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}.detail-row{display:flex;justify-content:space-between;padding:3px 0;font-size:13px}.detail-row .label{color:var(--text-dim)}.detail-row .value{font-weight:500}.detail-orders{width:100%;font-size:12px}.detail-orders th{background:var(--bg);font-size:10px}.detail-orders td{padding:4px 6px}#momentum-card{grid-column:1 / -1}#momentum-table th{cursor:pointer;-webkit-user-select:none;user-select:none}#momentum-table th:hover{color:var(--accent)}#momentum-table td{font-variant-numeric:tabular-nums}#trend-card{grid-column:1 / -1}#trend-table th{-webkit-user-select:none;user-select:none}#trend-table td{font-variant-numeric:tabular-nums}.trend-state{font-weight:600;font-size:12px}.trend-alert{background:#d299220d}.trend-alert:hover td{background:#d299221a!important}.trend-confirmed{background:#3fb95014}.trend-confirmed:hover td{background:#3fb95026!important}.trend-exhausting{background:#8b949e0d}.trend-exhausting:hover td{background:#8b949e1a!important}
+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-D_JzXaOQ.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-Bh5bnFYE.css">
<script type="module" crossorigin src="/static/assets/index-Dr4jUtK1.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DsdSIpuQ.css">
</head>
<body>
<div id="root"></div>
+18
View File
@@ -224,3 +224,21 @@ tr:hover td { background: rgba(88, 166, 255, 0.05); }
.detail-orders { width: 100%; font-size: 12px; }
.detail-orders th { background: var(--bg); font-size: 10px; }
.detail-orders td { padding: 4px 6px; }
/* Momentum Card */
#momentum-card { grid-column: 1 / -1; }
#momentum-table th { cursor: pointer; user-select: none; }
#momentum-table th:hover { color: var(--accent); }
#momentum-table td { font-variant-numeric: tabular-nums; }
/* Trend Card */
#trend-card { grid-column: 1 / -1; }
#trend-table th { user-select: none; }
#trend-table td { font-variant-numeric: tabular-nums; }
.trend-state { font-weight: 600; font-size: 12px; }
.trend-alert { background: rgba(210, 153, 34, 0.05); }
.trend-alert:hover td { background: rgba(210, 153, 34, 0.1) !important; }
.trend-confirmed { background: rgba(63, 185, 80, 0.08); }
.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; }
+520 -9
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
const EXCHANGES = ['HyperLiquid', 'Bitget']
const EXCHANGES = ['HyperLiquid', 'Bitget', 'Binance', 'OKX']
function formatPrice(p) {
if (p == null || p <= 0) return '-'
@@ -26,6 +26,11 @@ export default function App() {
const [blacklist, setBlacklist] = useState([])
const [stats, setStats] = useState({})
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 priceCacheRef = useRef({})
// Clock
@@ -69,6 +74,15 @@ export default function App() {
case 'blacklist':
setBlacklist(msg.data || [])
break
case 'momentum':
setMomentum(msg.data || [])
break
case 'trend':
setTrendData(msg.data || [])
break
case 'cumulative':
setCmData(msg.data || [])
break
case 'stats':
setStats(msg.data || {})
if (msg.data && msg.data.blacklist) {
@@ -104,6 +118,40 @@ 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 {
const resp = await fetch('/api/cm-history')
const data = await resp.json()
setCmHistory(data.events || [])
} catch (err) {
// ignore
}
}, [])
useEffect(() => {
loadCmHistory()
const id = setInterval(loadCmHistory, 5000)
return () => clearInterval(id)
}, [loadCmHistory])
// Handle prices
function handlePrices(data) {
if (!data || data.length === 0) return
@@ -162,6 +210,29 @@ export default function App() {
</header>
<div className="grid">
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* 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 */}
<StatsCard stats={stats} />
@@ -171,12 +242,6 @@ export default function App() {
{/* PnL Growth Chart */}
<PnlChart />
{/* Price Table */}
<PriceTable coins={coins} prices={prices} getPrevPrice={getPrevPrice} priceClass={priceClass} pricesAge={pricesAge} />
{/* Arbitrage Opportunities */}
<ArbTable opps={opps} />
{/* Recent Trades */}
<TradesCard trades={trades} onRefresh={loadTrades} />
@@ -333,11 +398,11 @@ function PriceTable({ coins, prices, getPrevPrice, priceClass, pricesAge }) {
<div className="table-wrap">
<table id="price-table">
<thead>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>毛价差</th><th>BGHL净利</th><th>HLBG净利</th></tr>
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>Binance</th><th>OKX</th><th>毛价差</th><th>BGHL净利</th><th>HLBG净利</th></tr>
</thead>
<tbody id="price-body">
{coins.length === 0 ? (
<tr><td colSpan="6" className="loading">等待数据...</td></tr>
<tr><td colSpan="8" className="loading">等待数据...</td></tr>
) : coins.map(coin => {
const row = prices.find(p => p.coin === coin)
if (!row) {
@@ -770,3 +835,449 @@ function PnlChart() {
</section>
)
}
// ============ Momentum Scanner Card ============
function MomentumCard({ momentum }) {
const [sortCol, setSortCol] = useState('score')
const [sortDir, setSortDir] = useState('desc')
function toggleSort(col) {
if (sortCol === col) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortCol(col)
setSortDir('desc')
}
}
function sortArrow(col) {
if (sortCol !== col) return ''
return sortDir === 'asc' ? ' ▲' : ' ▼'
}
const sorted = [...momentum].sort((a, b) => {
let va, vb
switch (sortCol) {
case 'coin': va = a.coin; vb = b.coin; break
case 'bg_1s': va = a.bg_1s || 0; vb = b.bg_1s || 0; break
case 'bg_5s': va = a.bg_5s || 0; vb = b.bg_5s || 0; break
case 'bg_15s': va = a.bg_15s || 0; vb = b.bg_15s || 0; break
case 'hl_1s': va = a.hl_1s || 0; vb = b.hl_1s || 0; break
case 'hl_5s': va = a.hl_5s || 0; vb = b.hl_5s || 0; break
case 'hl_15s': va = a.hl_15s || 0; vb = b.hl_15s || 0; break
case 'bn_1s': va = a.bn_1s || 0; vb = b.bn_1s || 0; break
case 'bn_5s': va = a.bn_5s || 0; vb = b.bn_5s || 0; break
case 'bn_15s': va = a.bn_15s || 0; vb = b.bn_15s || 0; break
case 'okx_1s': va = a.okx_1s || 0; vb = b.okx_1s || 0; break
case 'okx_5s': va = a.okx_5s || 0; vb = b.okx_5s || 0; break
case 'okx_15s': va = a.okx_15s || 0; vb = b.okx_15s || 0; break
default: va = a.score || 0; vb = b.score || 0
}
if (typeof va === 'string') {
return sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va)
}
return sortDir === 'asc' ? va - vb : vb - va
})
function dirIcon(dir) {
switch (dir) {
case 'up': return '\u2191'
case 'down': return '\u2193'
case 'flat': return '\u2192'
case 'mixed': return '\u2195'
default: return '-'
}
}
function dirClass(dir) {
switch (dir) {
case 'up': return 'text-green'
case 'down': return 'text-red'
case 'mixed': return 'text-yellow'
default: return ''
}
}
function changeClass(val) {
if (val == null || val === 0) return ''
return val > 0 ? 'text-green' : 'text-red'
}
return (
<section className="card card-wide" id="momentum-card">
<h2> 动量扫描 (价格变动%)</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="momentum-table">
<thead>
<tr>
<th onClick={() => toggleSort('coin')} style={{cursor:'pointer'}}>币种{sortArrow('coin')}</th>
<th onClick={() => toggleSort('score')} style={{cursor:'pointer'}}>分数{sortArrow('score')}</th>
<th>方向</th>
<th onClick={() => toggleSort('bg_1s')} style={{cursor:'pointer'}}>BG 1s{sortArrow('bg_1s')}</th>
<th onClick={() => toggleSort('bg_5s')} style={{cursor:'pointer'}}>BG 5s{sortArrow('bg_5s')}</th>
<th onClick={() => toggleSort('bg_15s')} style={{cursor:'pointer'}}>BG 15s{sortArrow('bg_15s')}</th>
<th onClick={() => toggleSort('hl_1s')} style={{cursor:'pointer'}}>HL 1s{sortArrow('hl_1s')}</th>
<th onClick={() => toggleSort('hl_5s')} style={{cursor:'pointer'}}>HL 5s{sortArrow('hl_5s')}</th>
<th onClick={() => toggleSort('hl_15s')} style={{cursor:'pointer'}}>HL 15s{sortArrow('hl_15s')}</th>
<th onClick={() => toggleSort('bn_1s')} style={{cursor:'pointer'}}>BN 1s{sortArrow('bn_1s')}</th>
<th onClick={() => toggleSort('bn_5s')} style={{cursor:'pointer'}}>BN 5s{sortArrow('bn_5s')}</th>
<th onClick={() => toggleSort('bn_15s')} style={{cursor:'pointer'}}>BN 15s{sortArrow('bn_15s')}</th>
<th onClick={() => toggleSort('okx_1s')} style={{cursor:'pointer'}}>OKX 1s{sortArrow('okx_1s')}</th>
<th onClick={() => toggleSort('okx_5s')} style={{cursor:'pointer'}}>OKX 5s{sortArrow('okx_5s')}</th>
<th onClick={() => toggleSort('okx_15s')} style={{cursor:'pointer'}}>OKX 15s{sortArrow('okx_15s')}</th>
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>
正在收集动量数据... (需要至少 15 秒数据)
</td></tr>
) : sorted.slice(0, 50).map(entry => (
<tr key={entry.coin}>
<td><strong>{entry.coin}</strong></td>
<td className="text-right" style={{fontWeight:700}}>{entry.score.toFixed(4)}%</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:18}}>{dirIcon(entry.direction)}</td>
<td className={'text-right ' + changeClass(entry.bg_1s)}>{entry.bg_1s != null ? entry.bg_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_5s)}>{entry.bg_5s != null ? entry.bg_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_15s)}>{entry.bg_15s != null ? entry.bg_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_1s)}>{entry.hl_1s != null ? entry.hl_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_5s)}>{entry.hl_5s != null ? entry.hl_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_15s)}>{entry.hl_15s != null ? entry.hl_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_1s)}>{entry.bn_1s != null ? entry.bn_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_5s)}>{entry.bn_5s != null ? entry.bn_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_15s)}>{entry.bn_15s != null ? entry.bn_15s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_1s)}>{entry.okx_1s != null ? entry.okx_1s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_5s)}>{entry.okx_5s != null ? entry.okx_5s.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_15s)}>{entry.okx_15s != null ? entry.okx_15s.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ 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) {
switch (state) {
case 'rising': return '↑ 上涨'
case 'falling': return '↓ 下跌'
default: return ' 中性'
}
}
function stateClass(state) {
switch (state) {
case 'rising': return 'text-green'
case 'falling': return 'text-red'
default: return 'text-dim'
}
}
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="cm-card">
<h2>📊 累积变动 (1min 共识)</h2>
<div className="table-wrap" style={{ maxHeight: 300 }}>
<table id="cm-table">
<thead>
<tr>
<th>币种</th>
<th>状态</th>
<th>方向</th>
<th>分数</th>
<th>均值%</th>
<th>一致</th>
<th>BG 1m</th>
<th>HL 1m</th>
<th>BN 1m</th>
<th>OKX 1m</th>
<th>BG 5m</th>
<th>HL 5m</th>
<th>BN 5m</th>
<th>OKX 5m</th>
</tr>
</thead>
<tbody>
{!data || data.length === 0 ? (
<tr><td colSpan="14" className="text-dim" style={{textAlign:'center',padding:20}}>
等待累积数据... (需要至少 1 分钟数据)
</td></tr>
) : data.slice(0, 30).map(entry => (
<tr key={entry.coin} className={stateClass(entry.state)}>
<td><strong>{entry.coin}</strong></td>
<td>{stateLabel(entry.state)}</td>
<td className={dirClass(entry.direction)} style={{textAlign:'center',fontSize:16}}>{entry.direction === 'up' ? '↑' : '↓'}</td>
<td className="text-right" style={{fontWeight:700}}>{(entry.score || 0).toFixed(2)}</td>
<td className="text-right">{(entry.avg_change || 0).toFixed(3)}%</td>
<td className="text-right">{entry.ex_agree || 0}/{entry.ex_total || 0}</td>
<td className={'text-right ' + changeClass(entry.bg_1m)}>{entry.bg_1m != null ? entry.bg_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.hl_1m)}>{entry.hl_1m != null ? entry.hl_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bn_1m)}>{entry.bn_1m != null ? entry.bn_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.okx_1m)}>{entry.okx_1m != null ? entry.okx_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(entry.bg_5m)}>{entry.bg_5m != null ? entry.bg_5m.toFixed(3) + '%' : '-'}</td>
<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>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// ============ Cumulative Change History Card ============
function CmHistoryCard({ history }) {
function stateLabel(state) {
switch (state) {
case 'rising': return '↑ 上涨'
case 'falling': return '↓ 下跌'
default: return ' 中性'
}
}
function stateClass(state) {
switch (state) {
case 'rising': return 'text-green'
case 'falling': return 'text-red'
default: return 'text-dim'
}
}
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="cm-history-card">
<h2>📋 累积变动事件记录</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="cm-history-table">
<thead>
<tr>
<th>时间</th>
<th>币种</th>
<th>转换</th>
<th>方向</th>
<th>分数</th>
<th>均值%</th>
<th>一致</th>
<th>BG 1m</th>
<th>HL 1m</th>
<th>BN 1m</th>
<th>OKX 1m</th>
<th>BG 5m</th>
<th>HL 5m</th>
<th>BN 5m</th>
<th>OKX 5m</th>
</tr>
</thead>
<tbody>
{history.length === 0 ? (
<tr><td colSpan="15" className="text-dim" style={{textAlign:'center',padding:20}}>
暂无累积变动事件记录
</td></tr>
) : history.slice(0, 100).map((ev, i) => (
<tr key={(ev.id || i) + '-cm'}>
<td className="text-dim">{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}}>{ev.direction === 'up' ? '↑' : '↓'}</td>
<td className="text-right">{(ev.score || 0).toFixed(2)}</td>
<td className="text-right">{(ev.avg_change || 0).toFixed(3)}%</td>
<td className="text-right">{ev.ex_agree || 0}/{ev.ex_total || 0}</td>
<td className={'text-right ' + changeClass(ev.bg_1m)}>{ev.bg_1m != null ? ev.bg_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_1m)}>{ev.hl_1m != null ? ev.hl_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_1m)}>{ev.bn_1m != null ? ev.bn_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_1m)}>{ev.okx_1m != null ? ev.okx_1m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bg_5m)}>{ev.bg_5m != null ? ev.bg_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.hl_5m)}>{ev.hl_5m != null ? ev.hl_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.bn_5m)}>{ev.bn_5m != null ? ev.bn_5m.toFixed(3) + '%' : '-'}</td>
<td className={'text-right ' + changeClass(ev.okx_5m)}>{ev.okx_5m != null ? ev.okx_5m.toFixed(3) + '%' : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
function TrendHistoryCard({ history }) {
function stateLabel(state) {
switch (state) {
case 'alert': return '⚠ 异动'
case 'confirmed': return '🚀 趋势'
case 'exhausting': return '🔄 衰减'
case 'idle': return '✓ 结束'
default: return state
}
}
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 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-history-card">
<h2>📋 趋势事件记录</h2>
<div className="table-wrap" style={{ maxHeight: 400 }}>
<table id="trend-history-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>
</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>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}