Spaces:
Paused
Paused
zt p commited on
Commit ·
537797a
1
Parent(s): 6efd179
Aggregate core market metrics
Browse files- app/core/dashboard.py +65 -20
- app/monitors/crypto_price.py +14 -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()"
|
| 579 |
-
<button class="primary" onclick="saveFormConfig()"
|
| 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>
|
| 645 |
-
<strong id="
|
| 646 |
-
<small id="
|
| 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",
|
|
|
|
|
|
|
| 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",
|
| 1289 |
-
? formatPercent(
|
| 1290 |
: "--");
|
| 1291 |
setText("coreFuturesSymbol", core.enable_futures
|
| 1292 |
-
? `
|
| 1293 |
: "合约未启用");
|
| 1294 |
-
setText("coreOpenInterest", coreSnap.openInterest ? formatNumber(coreSnap.openInterest, 2) : "--");
|
| 1295 |
-
setText("coreOpenInterestChange",
|
|
|
|
|
|
|
| 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 |
-
|
| 1302 |
-
setText("
|
| 1303 |
-
setText("
|
|
|
|
| 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("
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|