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:
co-authored by
Claude Opus 4.6
parent
047571921e
commit
b7767c95ae
@@ -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
@@ -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>BG→HL净利</th><th>HL→BG净利</th></tr>
|
||||
<tr><th>币种</th><th>HyperLiquid</th><th>Bitget</th><th>Binance</th><th>OKX</th><th>毛价差</th><th>BG→HL净利</th><th>HL→BG净利</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user