zt p commited on
Commit
537797a
·
1 Parent(s): 6efd179

Aggregate core market metrics

Browse files
Files changed (3) hide show
  1. app/core/dashboard.py +65 -20
  2. app/monitors/crypto_price.py +14 -3
  3. coinpush.py +249 -0
app/core/dashboard.py CHANGED
@@ -328,6 +328,16 @@ def render_dashboard():
328
  button.primary:hover { background: #1f2937; }
329
  button.danger { color: var(--err); }
330
  button.small { padding: 5px 8px; min-height: 30px; }
 
 
 
 
 
 
 
 
 
 
331
  pre {
332
  margin: 0;
333
  white-space: pre-wrap;
@@ -575,8 +585,8 @@ def render_dashboard():
575
  </div>
576
  <div class="toolbar">
577
  <span id="updated" class="muted">等待刷新</span>
578
- <button onclick="loadStatus()">刷新状态</button>
579
- <button class="primary" onclick="saveFormConfig()">保存参数</button>
580
  <span id="topMessage" class="message"></span>
581
  </div>
582
  </header>
@@ -616,7 +626,7 @@ def render_dashboard():
616
  </div>
617
  <div class="market-detail-grid">
618
  <div class="market-tile">
619
- <span>24h 成交额</span>
620
  <strong id="coreVolume24h">--</strong>
621
  <small id="coreVolume5m">近 5m --</small>
622
  </div>
@@ -631,7 +641,7 @@ def render_dashboard():
631
  <small id="coreFuturesSymbol">合约未启用</small>
632
  </div>
633
  <div class="market-tile">
634
- <span>合约持仓量</span>
635
  <strong id="coreOpenInterest">--</strong>
636
  <small id="coreOpenInterestChange">变化 --</small>
637
  </div>
@@ -641,9 +651,9 @@ def render_dashboard():
641
  <small id="coreFlowMeta">等待样本</small>
642
  </div>
643
  <div class="market-tile">
644
- <span>监控状态</span>
645
- <strong id="coreMonitorState">--</strong>
646
- <small id="coreMonitorMeta">等待数据</small>
647
  </div>
648
  </div>
649
  </div>
@@ -985,6 +995,7 @@ def render_dashboard():
985
  let currentConfig = null;
986
  let currentCoin = null;
987
  let activePanel = "global";
 
988
 
989
  const globalFields = [
990
  ["poll_interval_sec", "number"],
@@ -1105,6 +1116,29 @@ def render_dashboard():
1105
  return "等待首次成功";
1106
  }
1107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108
  function formatDateTime(value) {
1109
  if (!value) return "--";
1110
  const date = new Date(value);
@@ -1257,6 +1291,10 @@ def render_dashboard():
1257
  const coreSnap = core.snapshot || {};
1258
  const btcSnap = summary?.btc?.snapshot || {};
1259
  const coreMonitor = core.monitor || null;
 
 
 
 
1260
  const oiChange = coreSnap.openInterestChange || {};
1261
  const flowBits = [];
1262
  if (Number.isFinite(Number(coreSnap.volumeRatio5m))) {
@@ -1279,28 +1317,33 @@ def render_dashboard():
1279
  setText("btcMeta", btcSnap.priceChange?.["1h"] !== undefined
1280
  ? `1h ${formatSignedPercent(btcSnap.priceChange["1h"], 2)}`
1281
  : "等待数据");
1282
- setText("coreVolume24h", formatUsd(coreSnap.quoteVolume24h));
1283
- setText("coreVolume5m", `近 5m ${formatUsd(coreSnap.volume5m)} · ${coreSnap.volumeRatio5m ? "x" + formatNumber(coreSnap.volumeRatio5m, 1) : "--"}`);
 
 
1284
  setText("coreRange24h", coreSnap.high24 && coreSnap.low24
1285
  ? `${formatPrice(coreSnap.low24)} - ${formatPrice(coreSnap.high24)}`
1286
  : "--");
1287
- setText("coreRsi", `RSI ${formatNumber(coreSnap.rsi, 1)}`);
1288
- setText("coreFunding", coreSnap.funding !== null && coreSnap.funding !== undefined
1289
- ? formatPercent(coreSnap.funding, 4)
1290
  : "--");
1291
  setText("coreFuturesSymbol", core.enable_futures
1292
- ? `合约 ${core.futures_symbol || "--"}`
1293
  : "合约未启用");
1294
- setText("coreOpenInterest", coreSnap.openInterest ? formatNumber(coreSnap.openInterest, 2) : "--");
1295
- setText("coreOpenInterestChange", oiChange["15m"] !== undefined
 
 
1296
  ? `15m ${formatSignedPercent(oiChange["15m"], 1)}`
1297
  : "等待持仓样本");
1298
  setText("coreFlow", flowBits[0] || "--");
1299
  setText("coreFlowMeta", flowBits.slice(1).join(";") || "等待资金异动样本");
1300
  const displayStatus = !isEnabled(coreMonitor) ? "disabled" : (isPaused(coreMonitor) ? "paused" : coreMonitor?.status);
1301
- setText("coreMonitorState", statusText(displayStatus));
1302
- setText("coreMonitorMeta", formatMonitorMeta(coreMonitor));
1303
- setText("coreUpdated", coreSnap.updatedAt ? `更新 ${formatDateTime(coreSnap.updatedAt)}` : "等待行情");
 
1304
  }
1305
 
1306
  function renderStrategySummary(monitors) {
@@ -1391,7 +1434,7 @@ def render_dashboard():
1391
  const data = await res.json();
1392
  const monitors = data.monitors || [];
1393
  const alerts = data.alerts || [];
1394
- const events = data.events || [];
1395
  const errorCount = monitors.filter((m) => m.status === "error" && !isPaused(m)).length;
1396
 
1397
  document.getElementById("platformState").textContent = data.started ? "运行中" : "未启动";
@@ -1469,7 +1512,9 @@ def render_dashboard():
1469
  renderCoinForm();
1470
  renderExchangeForm();
1471
  syncFormToJson();
1472
- setMessage("配置已加载", "ok");
 
 
1473
  }
1474
 
1475
  function renderGlobalForm() {
 
328
  button.primary:hover { background: #1f2937; }
329
  button.danger { color: var(--err); }
330
  button.small { padding: 5px 8px; min-height: 30px; }
331
+ .icon-button {
332
+ width: 36px;
333
+ min-width: 36px;
334
+ padding: 0;
335
+ display: inline-flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ font-size: 18px;
339
+ line-height: 1;
340
+ }
341
  pre {
342
  margin: 0;
343
  white-space: pre-wrap;
 
585
  </div>
586
  <div class="toolbar">
587
  <span id="updated" class="muted">等待刷新</span>
588
+ <button class="icon-button" onclick="loadStatus()" title="刷新状态" aria-label="刷新状态">↻</button>
589
+ <button class="icon-button primary" onclick="saveFormConfig()" title="保存参数" aria-label="保存参数">✓</button>
590
  <span id="topMessage" class="message"></span>
591
  </div>
592
  </header>
 
626
  </div>
627
  <div class="market-detail-grid">
628
  <div class="market-tile">
629
+ <span>聚合 24h 成交额</span>
630
  <strong id="coreVolume24h">--</strong>
631
  <small id="coreVolume5m">近 5m --</small>
632
  </div>
 
641
  <small id="coreFuturesSymbol">合约未启用</small>
642
  </div>
643
  <div class="market-tile">
644
+ <span>聚合合约持仓量</span>
645
  <strong id="coreOpenInterest">--</strong>
646
  <small id="coreOpenInterestChange">变化 --</small>
647
  </div>
 
651
  <small id="coreFlowMeta">等待样本</small>
652
  </div>
653
  <div class="market-tile">
654
+ <span>数据覆盖</span>
655
+ <strong id="coreCoverage">--</strong>
656
+ <small id="coreCoverageMeta">等待来源</small>
657
  </div>
658
  </div>
659
  </div>
 
995
  let currentConfig = null;
996
  let currentCoin = null;
997
  let activePanel = "global";
998
+ let uiEvents = [];
999
 
1000
  const globalFields = [
1001
  ["poll_interval_sec", "number"],
 
1116
  return "等待首次成功";
1117
  }
1118
 
1119
+ function sourceNames(sources, limit = 3) {
1120
+ const list = Array.isArray(sources) ? sources : [];
1121
+ if (!list.length) return "--";
1122
+ const names = list.slice(0, limit).map((item) => item.name).filter(Boolean);
1123
+ const extra = list.length > limit ? ` +${list.length - limit}` : "";
1124
+ return names.join(" / ") + extra;
1125
+ }
1126
+
1127
+ function sourceCountLabel(count, total) {
1128
+ const n = Number(count || 0);
1129
+ return `${n}/${total}`;
1130
+ }
1131
+
1132
+ function addUiEvent(message, level = "info") {
1133
+ uiEvents.unshift({
1134
+ created_at: new Date().toISOString(),
1135
+ monitor_id: "dashboard",
1136
+ level,
1137
+ message
1138
+ });
1139
+ uiEvents = uiEvents.slice(0, 12);
1140
+ }
1141
+
1142
  function formatDateTime(value) {
1143
  if (!value) return "--";
1144
  const date = new Date(value);
 
1291
  const coreSnap = core.snapshot || {};
1292
  const btcSnap = summary?.btc?.snapshot || {};
1293
  const coreMonitor = core.monitor || null;
1294
+ const aggregate = core.aggregate || {};
1295
+ const aggVolume = aggregate.volume24h || {};
1296
+ const aggOi = aggregate.openInterest || {};
1297
+ const aggFunding = aggregate.fundingRate || {};
1298
  const oiChange = coreSnap.openInterestChange || {};
1299
  const flowBits = [];
1300
  if (Number.isFinite(Number(coreSnap.volumeRatio5m))) {
 
1317
  setText("btcMeta", btcSnap.priceChange?.["1h"] !== undefined
1318
  ? `1h ${formatSignedPercent(btcSnap.priceChange["1h"], 2)}`
1319
  : "等待数据");
1320
+ setText("coreVolume24h", formatUsd(aggVolume.total || coreSnap.quoteVolume24h));
1321
+ setText("coreVolume5m", aggVolume.sourceCount
1322
+ ? `${sourceCountLabel(aggVolume.sourceCount, 11)} 来源 · ${sourceNames(aggVolume.sources)}`
1323
+ : `近 5m ${formatUsd(coreSnap.volume5m)} · ${coreSnap.volumeRatio5m ? "x" + formatNumber(coreSnap.volumeRatio5m, 1) : "--"}`);
1324
  setText("coreRange24h", coreSnap.high24 && coreSnap.low24
1325
  ? `${formatPrice(coreSnap.low24)} - ${formatPrice(coreSnap.high24)}`
1326
  : "--");
1327
+ setText("coreRsi", `Binance 24h 区间 · RSI ${formatNumber(coreSnap.rsi, 1)}`);
1328
+ setText("coreFunding", aggFunding.value !== null && aggFunding.value !== undefined
1329
+ ? formatPercent(aggFunding.value, 4)
1330
  : "--");
1331
  setText("coreFuturesSymbol", core.enable_futures
1332
+ ? `Binance Futures · ${aggFunding.symbol || core.futures_symbol || "--"}`
1333
  : "合约未启用");
1334
+ setText("coreOpenInterest", aggOi.total ? formatUsd(aggOi.total) : (coreSnap.openInterest ? formatNumber(coreSnap.openInterest, 2) : "--"));
1335
+ setText("coreOpenInterestChange", aggOi.sourceCount
1336
+ ? `${sourceCountLabel(aggOi.sourceCount, 6)} 来源 · ${sourceNames(aggOi.sources)}`
1337
+ : oiChange["15m"] !== undefined
1338
  ? `15m ${formatSignedPercent(oiChange["15m"], 1)}`
1339
  : "等待持仓样本");
1340
  setText("coreFlow", flowBits[0] || "--");
1341
  setText("coreFlowMeta", flowBits.slice(1).join(";") || "等待资金异动样本");
1342
  const displayStatus = !isEnabled(coreMonitor) ? "disabled" : (isPaused(coreMonitor) ? "paused" : coreMonitor?.status);
1343
+ const statusMeta = `${statusText(displayStatus)} · ${formatMonitorMeta(coreMonitor)}`;
1344
+ setText("coreCoverage", `成交 ${aggVolume.sourceCount || 0} · OI ${aggOi.sourceCount || 0}`);
1345
+ setText("coreCoverageMeta", `成交额覆盖 11 所;持仓覆盖 6 所`);
1346
+ setText("coreUpdated", coreSnap.updatedAt ? `更新 ${formatDateTime(coreSnap.updatedAt)} · ${statusMeta}` : `等待行情 · ${statusMeta}`);
1347
  }
1348
 
1349
  function renderStrategySummary(monitors) {
 
1434
  const data = await res.json();
1435
  const monitors = data.monitors || [];
1436
  const alerts = data.alerts || [];
1437
+ const events = [...uiEvents, ...(data.events || [])];
1438
  const errorCount = monitors.filter((m) => m.status === "error" && !isPaused(m)).length;
1439
 
1440
  document.getElementById("platformState").textContent = data.started ? "运行中" : "未启动";
 
1512
  renderCoinForm();
1513
  renderExchangeForm();
1514
  syncFormToJson();
1515
+ setMessage("");
1516
+ addUiEvent("配置已加载");
1517
+ await loadStatus();
1518
  }
1519
 
1520
  function renderGlobalForm() {
app/monitors/crypto_price.py CHANGED
@@ -52,6 +52,12 @@ class CryptoPriceMonitor(BaseMonitor):
52
  self.alpha_api = legacy.BinanceAlphaAPI(self.log)
53
  self.bitget = legacy.BitgetAPI(self.log)
54
  self.okx = legacy.OKXAPI(self.log)
 
 
 
 
 
 
55
  self.okx_adapter = legacy.OKXMarketAdapter(self.okx, self.log)
56
  self.binance_futures_adapter = legacy.BinanceFuturesMarketAdapter(self.api)
57
  self.binance_futures_with_fallback = legacy.FallbackMarketAdapter(
@@ -201,10 +207,11 @@ class CryptoPriceMonitor(BaseMonitor):
201
  "GRAM" if "GRAM" in coins else (next(iter(coins), None)),
202
  )
203
 
204
- def market(name):
205
  if not name:
206
  return None
207
  coin = coins.get(name, {})
 
208
  monitor = self.store.get_monitor(coin_monitor_id(name))
209
  return {
210
  "name": name,
@@ -212,12 +219,16 @@ class CryptoPriceMonitor(BaseMonitor):
212
  "futures_symbol": coin.get("futures_symbol"),
213
  "data_source": coin.get("data_source", "binance"),
214
  "enable_futures": bool(coin.get("enable_futures")),
215
- "snapshot": snapshots.get(name),
 
 
 
 
216
  "monitor": monitor,
217
  }
218
 
219
  return {
220
- "core": market(core_name),
221
  "btc": market("BTC") if "BTC" in coins else None,
222
  }
223
 
 
52
  self.alpha_api = legacy.BinanceAlphaAPI(self.log)
53
  self.bitget = legacy.BitgetAPI(self.log)
54
  self.okx = legacy.OKXAPI(self.log)
55
+ self.core_aggregator = legacy.CoreMarketAggregator(
56
+ self.api,
57
+ self.okx,
58
+ self.bitget,
59
+ self.log,
60
+ )
61
  self.okx_adapter = legacy.OKXMarketAdapter(self.okx, self.log)
62
  self.binance_futures_adapter = legacy.BinanceFuturesMarketAdapter(self.api)
63
  self.binance_futures_with_fallback = legacy.FallbackMarketAdapter(
 
207
  "GRAM" if "GRAM" in coins else (next(iter(coins), None)),
208
  )
209
 
210
+ def market(name, include_aggregate=False):
211
  if not name:
212
  return None
213
  coin = coins.get(name, {})
214
+ snapshot = snapshots.get(name)
215
  monitor = self.store.get_monitor(coin_monitor_id(name))
216
  return {
217
  "name": name,
 
219
  "futures_symbol": coin.get("futures_symbol"),
220
  "data_source": coin.get("data_source", "binance"),
221
  "enable_futures": bool(coin.get("enable_futures")),
222
+ "snapshot": snapshot,
223
+ "aggregate": (
224
+ self.core_aggregator.aggregate(name, coin, snapshot)
225
+ if include_aggregate and snapshot else None
226
+ ),
227
  "monitor": monitor,
228
  }
229
 
230
  return {
231
+ "core": market(core_name, include_aggregate=True),
232
  "btc": market("BTC") if "BTC" in coins else None,
233
  }
234
 
coinpush.py CHANGED
@@ -79,6 +79,15 @@ OKX_PUBLIC_CACHE_TTL_SEC = int(os.getenv("OKX_PUBLIC_CACHE_TTL_SEC", "120"))
79
  OKX_HISTORY_CACHE_TTL_SEC = int(os.getenv("OKX_HISTORY_CACHE_TTL_SEC", "300"))
80
  OKX_429_COOLDOWN_SEC = int(os.getenv("OKX_429_COOLDOWN_SEC", "120"))
81
  OKX_ERROR_LOG_COOLDOWN_SEC = int(os.getenv("OKX_ERROR_LOG_COOLDOWN_SEC", "300"))
 
 
 
 
 
 
 
 
 
82
 
83
  # 合约功能全局开关(默认关闭)。币安合约接口/强平流对 US 区域可能返回 451。
84
  # 该开关控制现货主行情币种的合约指标与强平 websocket;若标的本身以 Binance
@@ -1309,6 +1318,246 @@ class OKXMarketAdapter:
1309
  return [{"fundingRate": x["fundingRate"]} for x in data if "fundingRate" in x]
1310
 
1311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1312
  class FallbackMarketAdapter:
1313
  """主行情源不可用时透明切到备用行情源,返回格式保持 BinanceAPI 兼容。"""
1314
 
 
79
  OKX_HISTORY_CACHE_TTL_SEC = int(os.getenv("OKX_HISTORY_CACHE_TTL_SEC", "300"))
80
  OKX_429_COOLDOWN_SEC = int(os.getenv("OKX_429_COOLDOWN_SEC", "120"))
81
  OKX_ERROR_LOG_COOLDOWN_SEC = int(os.getenv("OKX_ERROR_LOG_COOLDOWN_SEC", "300"))
82
+ CORE_AGG_CACHE_TTL_SEC = int(os.getenv("CORE_MARKET_AGG_CACHE_TTL_SEC", "120"))
83
+ BYBIT_BASE = os.getenv("BYBIT_BASE", "https://api.bybit.com")
84
+ GATE_BASE = os.getenv("GATE_BASE", "https://api.gateio.ws/api/v4")
85
+ KUCOIN_BASE = os.getenv("KUCOIN_BASE", "https://api.kucoin.com")
86
+ MEXC_BASE = os.getenv("MEXC_BASE", "https://api.mexc.com")
87
+ KRAKEN_BASE = os.getenv("KRAKEN_BASE", "https://api.kraken.com")
88
+ UPBIT_BASE = os.getenv("UPBIT_BASE", "https://api.upbit.com")
89
+ HYPERLIQUID_BASE = os.getenv("HYPERLIQUID_BASE", "https://api.hyperliquid.xyz")
90
+ ASTER_FAPI_BASE = os.getenv("ASTER_FAPI_BASE", "https://fapi.asterdex.com")
91
 
92
  # 合约功能全局开关(默认关闭)。币安合约接口/强平流对 US 区域可能返回 451。
93
  # 该开关控制现货主行情币种的合约指标与强平 websocket;若标的本身以 Binance
 
1318
  return [{"fundingRate": x["fundingRate"]} for x in data if "fundingRate" in x]
1319
 
1320
 
1321
+ class CoreMarketAggregator:
1322
+ """Best-effort cross-exchange metrics for the dashboard core asset."""
1323
+
1324
+ def __init__(self, binance_api, okx_api, bitget_api, logger, ttl=CORE_AGG_CACHE_TTL_SEC):
1325
+ self.binance = binance_api
1326
+ self.okx = okx_api
1327
+ self.bitget = bitget_api
1328
+ self.logger = logger
1329
+ self.ttl = max(30, int(ttl))
1330
+ self.session = requests.Session()
1331
+ self.session.headers.update(REQ_HEADERS)
1332
+ self.cache = {}
1333
+ self.last_error_log = {}
1334
+
1335
+ @staticmethod
1336
+ def _asset(symbol, name=None):
1337
+ text = str(symbol or name or "").strip().upper()
1338
+ for suffix in ("-USDT-SWAP", "-USDT", "_USDT", "USDT", "USD"):
1339
+ if text.endswith(suffix):
1340
+ text = text[: -len(suffix)]
1341
+ break
1342
+ return text.replace("-", "").replace("_", "") or str(name or "").upper()
1343
+
1344
+ @staticmethod
1345
+ def _num(value):
1346
+ try:
1347
+ n = float(value)
1348
+ if n == n and n not in (float("inf"), float("-inf")):
1349
+ return n
1350
+ except (TypeError, ValueError):
1351
+ return None
1352
+ return None
1353
+
1354
+ @staticmethod
1355
+ def _source(name, value, market="spot", note=None):
1356
+ try:
1357
+ value = float(value)
1358
+ except (TypeError, ValueError):
1359
+ return None
1360
+ if value <= 0:
1361
+ return None
1362
+ out = {"name": name, "value": value, "market": market}
1363
+ if note:
1364
+ out["note"] = note
1365
+ return out
1366
+
1367
+ def _log_error_once(self, key, msg, cooldown=600):
1368
+ now = time.time()
1369
+ last = self.last_error_log.get(key, 0)
1370
+ if now - last >= cooldown:
1371
+ self.last_error_log[key] = now
1372
+ self.logger(msg)
1373
+
1374
+ def _get_json(self, base, path, params=None, method="GET", body=None, timeout=6):
1375
+ url = base.rstrip("/") + path
1376
+ try:
1377
+ if method == "POST":
1378
+ resp = self.session.post(url, json=body or {}, timeout=timeout)
1379
+ else:
1380
+ resp = self.session.get(url, params=params, timeout=timeout)
1381
+ if resp.status_code != 200:
1382
+ return None
1383
+ return resp.json()
1384
+ except Exception as exc:
1385
+ self._log_error_once((base, path, "exception"), f"⚠️ 聚合行情 {base}{path} 异常: {exc}")
1386
+ return None
1387
+
1388
+ def aggregate(self, coin_name, coin_cfg, snapshot=None):
1389
+ symbol = (coin_cfg or {}).get("symbol")
1390
+ futures_symbol = (coin_cfg or {}).get("futures_symbol") or symbol
1391
+ asset = self._asset(futures_symbol or symbol, coin_name)
1392
+ cache_key = (asset, str(symbol or ""), str(futures_symbol or ""))
1393
+ cached = self.cache.get(cache_key)
1394
+ now = time.time()
1395
+ if cached and now - cached["ts"] < self.ttl:
1396
+ return copy.deepcopy(cached["value"])
1397
+
1398
+ reference_price = self._num((snapshot or {}).get("price"))
1399
+ value = {
1400
+ "asset": asset,
1401
+ "volume24h": self._volume24h(asset, symbol, snapshot),
1402
+ "openInterest": self._open_interest(asset, futures_symbol, reference_price),
1403
+ "fundingRate": self._binance_funding(futures_symbol or f"{asset}USDT"),
1404
+ "updatedAt": datetime.now(timezone.utc).isoformat(),
1405
+ }
1406
+ self.cache[cache_key] = {"ts": now, "value": copy.deepcopy(value)}
1407
+ return value
1408
+
1409
+ def _volume24h(self, asset, symbol, snapshot):
1410
+ sources = []
1411
+
1412
+ def add(name, value, market="spot", note=None):
1413
+ item = self._source(name, value, market, note)
1414
+ if item:
1415
+ sources.append(item)
1416
+
1417
+ spot_symbol = f"{asset}USDT"
1418
+ add("Binance", (snapshot or {}).get("quoteVolume24h"), "primary")
1419
+
1420
+ okx = self.okx.ticker(f"{asset}-USDT")
1421
+ if okx:
1422
+ add("OKX", okx.get("qvol"), "spot")
1423
+
1424
+ bitget = self.bitget.spot_ticker(spot_symbol)
1425
+ if bitget:
1426
+ add("Bitget", bitget.get("qvol"), "spot")
1427
+
1428
+ hl = self._hyperliquid_ctx(asset)
1429
+ if hl:
1430
+ add("Hyperliquid", hl.get("dayNtlVlm"), "perp")
1431
+
1432
+ aster = self._get_json(ASTER_FAPI_BASE, "/fapi/v1/ticker/24hr", {"symbol": spot_symbol})
1433
+ if isinstance(aster, dict):
1434
+ add("Aster", aster.get("quoteVolume"), "perp")
1435
+
1436
+ bybit = self._bybit_ticker("spot", spot_symbol)
1437
+ if bybit:
1438
+ add("Bybit", bybit.get("turnover24h"), "spot")
1439
+
1440
+ gate = self._get_json(GATE_BASE, "/spot/tickers", {"currency_pair": f"{asset}_USDT"})
1441
+ if isinstance(gate, list) and gate:
1442
+ add("Gate", gate[0].get("quote_volume"), "spot")
1443
+
1444
+ kucoin = self._get_json(KUCOIN_BASE, "/api/v1/market/stats", {"symbol": f"{asset}-USDT"})
1445
+ if isinstance(kucoin, dict) and str(kucoin.get("code")) == "200000":
1446
+ add("KuCoin", (kucoin.get("data") or {}).get("volValue"), "spot")
1447
+
1448
+ mexc = self._get_json(MEXC_BASE, "/api/v3/ticker/24hr", {"symbol": spot_symbol})
1449
+ if isinstance(mexc, dict):
1450
+ add("MEXC", mexc.get("quoteVolume"), "spot")
1451
+
1452
+ kraken_pair = f"{'XBT' if asset == 'BTC' else asset}USDT"
1453
+ kraken = self._get_json(KRAKEN_BASE, "/0/public/Ticker", {"pair": kraken_pair})
1454
+ if isinstance(kraken, dict) and not kraken.get("error"):
1455
+ result = kraken.get("result") or {}
1456
+ if result:
1457
+ row = next(iter(result.values()))
1458
+ close = self._num((row.get("c") or [None])[0])
1459
+ vol = self._num((row.get("v") or [None, None])[1])
1460
+ if close and vol:
1461
+ add("Kraken", close * vol, "spot")
1462
+
1463
+ upbit = self._get_json(UPBIT_BASE, "/v1/ticker", {"markets": f"USDT-{asset}"})
1464
+ if isinstance(upbit, list) and upbit:
1465
+ add("Upbit", upbit[0].get("acc_trade_price_24h"), "spot")
1466
+
1467
+ sources.sort(key=lambda item: item["value"], reverse=True)
1468
+ return {
1469
+ "total": sum(item["value"] for item in sources),
1470
+ "sources": sources,
1471
+ "sourceCount": len(sources),
1472
+ }
1473
+
1474
+ def _open_interest(self, asset, futures_symbol, reference_price=None):
1475
+ sources = []
1476
+ symbol = f"{asset}USDT"
1477
+
1478
+ def add(name, value, market="perp", note=None):
1479
+ item = self._source(name, value, market, note)
1480
+ if item:
1481
+ sources.append(item)
1482
+
1483
+ bi = self.binance.open_interest(futures_symbol or symbol)
1484
+ bi_oi = self._num((bi or {}).get("openInterest"))
1485
+ if bi_oi and reference_price:
1486
+ add("Binance", bi_oi * reference_price, "perp")
1487
+
1488
+ bybit = self._bybit_ticker("linear", symbol)
1489
+ if bybit:
1490
+ add("Bybit", bybit.get("openInterestValue"), "perp")
1491
+
1492
+ okx = self.okx.open_interest(f"{asset}-USDT-SWAP")
1493
+ if okx:
1494
+ add("OKX", okx.get("oi_usd"), "perp")
1495
+
1496
+ bitget = self._get_json(
1497
+ BITGET_BASE,
1498
+ "/api/v2/mix/market/ticker",
1499
+ {"symbol": symbol, "productType": "USDT-FUTURES"},
1500
+ )
1501
+ data = (bitget or {}).get("data")
1502
+ row = data[0] if isinstance(data, list) and data else (data if isinstance(data, dict) else None)
1503
+ if row:
1504
+ oi = self._num(row.get("holdingAmount"))
1505
+ price = self._num(row.get("lastPr")) or reference_price
1506
+ if oi and price:
1507
+ add("Bitget", oi * price, "perp")
1508
+
1509
+ hl = self._hyperliquid_ctx(asset)
1510
+ if hl:
1511
+ oi = self._num(hl.get("openInterest"))
1512
+ mark = self._num(hl.get("markPx")) or reference_price
1513
+ if oi and mark:
1514
+ add("Hyperliquid", oi * mark, "perp")
1515
+
1516
+ aster_oi = self._get_json(ASTER_FAPI_BASE, "/fapi/v1/openInterest", {"symbol": symbol})
1517
+ aster_mark = self._get_json(ASTER_FAPI_BASE, "/fapi/v1/premiumIndex", {"symbol": symbol})
1518
+ if isinstance(aster_oi, dict):
1519
+ oi = self._num(aster_oi.get("openInterest"))
1520
+ mark = self._num((aster_mark or {}).get("markPrice")) or reference_price
1521
+ if oi and mark:
1522
+ add("Aster", oi * mark, "perp")
1523
+
1524
+ sources.sort(key=lambda item: item["value"], reverse=True)
1525
+ return {
1526
+ "total": sum(item["value"] for item in sources),
1527
+ "sources": sources,
1528
+ "sourceCount": len(sources),
1529
+ }
1530
+
1531
+ def _binance_funding(self, symbol):
1532
+ data = self.binance.premium_index(symbol)
1533
+ rate = self._num((data or {}).get("lastFundingRate"))
1534
+ if rate is None:
1535
+ return None
1536
+ return {
1537
+ "symbol": symbol,
1538
+ "value": rate * 100,
1539
+ "source": "Binance Futures",
1540
+ }
1541
+
1542
+ def _bybit_ticker(self, category, symbol):
1543
+ data = self._get_json(BYBIT_BASE, "/v5/market/tickers", {"category": category, "symbol": symbol})
1544
+ if not isinstance(data, dict) or str(data.get("retCode")) != "0":
1545
+ return None
1546
+ rows = ((data.get("result") or {}).get("list") or [])
1547
+ return rows[0] if rows else None
1548
+
1549
+ def _hyperliquid_ctx(self, asset):
1550
+ data = self._get_json(HYPERLIQUID_BASE, "/info", method="POST", body={"type": "metaAndAssetCtxs"})
1551
+ if not isinstance(data, list) or len(data) < 2:
1552
+ return None
1553
+ universe = ((data[0] or {}).get("universe") or [])
1554
+ contexts = data[1] or []
1555
+ for idx, item in enumerate(universe):
1556
+ if str(item.get("name", "")).upper() == asset and idx < len(contexts):
1557
+ return contexts[idx]
1558
+ return None
1559
+
1560
+
1561
  class FallbackMarketAdapter:
1562
  """主行情源不可用时透明切到备用行情源,返回格式保持 BinanceAPI 兼容。"""
1563