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
-40
File diff suppressed because one or more lines are too long
+40
File diff suppressed because one or more lines are too long
+1
-1
@@ -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}
|
||||
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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