Add BTC/ETH spot tickers on nav, anchored to Beijing 08:00 open.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5660,6 +5660,29 @@ def api_settings_open_guard():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/nav_spot_tickers")
|
||||
@login_required
|
||||
def api_nav_spot_tickers():
|
||||
"""BTC/ETH 现货最新价,相对北京时间 08:00(OKX 日K开盘)涨跌."""
|
||||
try:
|
||||
from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers
|
||||
|
||||
tickers = build_nav_spot_tickers(
|
||||
exchange,
|
||||
reset_hour=TRADING_DAY_RESET_HOUR,
|
||||
now_fn=lambda: datetime.now(APP_TZ),
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"updated_at": app_now_str(),
|
||||
"tickers": tickers,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e) or "行情读取失败", "tickers": []}), 502
|
||||
|
||||
|
||||
@app.route("/api/price_snapshot")
|
||||
@login_required
|
||||
def api_price_snapshot():
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||
.top-nav{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:10px 12px;margin-bottom:12px}
|
||||
.top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-width:0}
|
||||
.top-nav-spot-tickers{grid-column:3;justify-self:end;display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
|
||||
|
||||
@@ -54,21 +54,43 @@ html[data-theme="light"] {
|
||||
|
||||
.top-nav {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: flex-start !important;
|
||||
align-items: stretch;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
align-items: stretch !important;
|
||||
overflow: visible !important;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
gap: 8px !important;
|
||||
margin-bottom: 12px !important;
|
||||
padding: 2px 2px 6px;
|
||||
}
|
||||
|
||||
.top-nav-links {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
gap: 6px !important;
|
||||
margin-bottom: 12px !important;
|
||||
padding: 2px 2px 6px;
|
||||
gap: 6px;
|
||||
scroll-padding-inline: 10px;
|
||||
touch-action: pan-x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-nav-links::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-nav-spot-tickers {
|
||||
justify-self: stretch;
|
||||
align-self: stretch;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.top-nav::-webkit-scrollbar {
|
||||
@@ -633,6 +655,131 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* 导航右侧 BTC/ETH 现货(相对北京 08:00 开盘) */
|
||||
.top-nav-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-nav-spot-tickers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.nav-spot-row {
|
||||
display: grid;
|
||||
grid-template-columns: 3px 16px minmax(4.2em, auto) minmax(4.5em, auto) minmax(3.2em, auto) minmax(3.4em, auto);
|
||||
align-items: center;
|
||||
column-gap: 6px;
|
||||
padding: 2px 8px 2px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: rgba(16, 22, 36, 0.72);
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.nav-spot-row:hover {
|
||||
border-color: #3a4568;
|
||||
}
|
||||
|
||||
.nav-spot-ribbon {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 1px 2px 2px 1px;
|
||||
background: #e24b6a;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.nav-spot-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.nav-spot-icon--eth {
|
||||
background: #627eea;
|
||||
}
|
||||
|
||||
.nav-spot-icon--btc {
|
||||
background: #f2a900;
|
||||
color: #1a1200;
|
||||
}
|
||||
|
||||
.nav-spot-sym {
|
||||
color: var(--inst-text, #e8ecff);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.nav-spot-last,
|
||||
.nav-spot-chg,
|
||||
.nav-spot-pct {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-spot--up .nav-spot-last,
|
||||
.nav-spot--up .nav-spot-chg,
|
||||
.nav-spot--up .nav-spot-pct {
|
||||
color: #2dd4a8;
|
||||
}
|
||||
|
||||
.nav-spot--down .nav-spot-last,
|
||||
.nav-spot--down .nav-spot-chg,
|
||||
.nav-spot--down .nav-spot-pct {
|
||||
color: #ff6b7a;
|
||||
}
|
||||
|
||||
.nav-spot--flat .nav-spot-last,
|
||||
.nav-spot--flat .nav-spot-chg,
|
||||
.nav-spot--flat .nav-spot-pct {
|
||||
color: #9aa3bf;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .nav-spot-row {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-color: #d5e0ec;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .nav-spot-row:hover {
|
||||
border-color: #9bb4cc;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .nav-spot--up .nav-spot-last,
|
||||
html[data-theme="light"] .nav-spot--up .nav-spot-chg,
|
||||
html[data-theme="light"] .nav-spot--up .nav-spot-pct {
|
||||
color: #0a8f6c;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .nav-spot--down .nav-spot-last,
|
||||
html[data-theme="light"] .nav-spot--down .nav-spot-chg,
|
||||
html[data-theme="light"] .nav-spot--down .nav-spot-pct {
|
||||
color: #d12b3a;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.nav-spot-row {
|
||||
grid-template-columns: 3px 14px minmax(3.6em, auto) minmax(3.8em, auto) minmax(2.8em, auto) minmax(3em, auto);
|
||||
column-gap: 4px;
|
||||
font-size: 0.66rem;
|
||||
padding: 2px 6px 2px 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.instance-header-stats-wrap {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
|
||||
@@ -262,7 +262,8 @@
|
||||
const mq = window.matchMedia("(max-width: 720px)");
|
||||
|
||||
function scrollActiveTab(nav) {
|
||||
const active = nav.querySelector("a.active");
|
||||
const scroller = nav.querySelector(".top-nav-links") || nav;
|
||||
const active = scroller.querySelector("a.active");
|
||||
if (!active) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 导航栏 BTC/ETH 现货报价:相对北京 08:00 开盘涨跌.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_POLL_MS = 5000;
|
||||
let timer = null;
|
||||
let inflight = false;
|
||||
|
||||
function pollMs() {
|
||||
const raw =
|
||||
(document.body && document.body.getAttribute("data-price-refresh-ms")) || "";
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS;
|
||||
}
|
||||
|
||||
function fmtPrice(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return "—";
|
||||
const abs = Math.abs(v);
|
||||
let digits = 2;
|
||||
if (abs >= 10000) digits = 1;
|
||||
else if (abs >= 1000) digits = 2;
|
||||
else if (abs >= 100) digits = 2;
|
||||
else if (abs >= 1) digits = 3;
|
||||
else digits = 4;
|
||||
try {
|
||||
return v.toLocaleString("en-US", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
} catch (_) {
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSigned(n, isPct) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return "—";
|
||||
const abs = Math.abs(v);
|
||||
let digits = isPct ? 2 : abs >= 100 ? 1 : 2;
|
||||
if (!isPct && abs < 1) digits = 3;
|
||||
const body = abs.toLocaleString("en-US", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
const sign = v > 0 ? "" : v < 0 ? "-" : "";
|
||||
return sign + body + (isPct ? "%" : "");
|
||||
}
|
||||
|
||||
function dirClass(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v === 0) return "nav-spot--flat";
|
||||
return v > 0 ? "nav-spot--up" : "nav-spot--down";
|
||||
}
|
||||
|
||||
function paintRow(row, t) {
|
||||
if (!row || !t) return;
|
||||
const lastEl = row.querySelector('[data-field="last"]');
|
||||
const chgEl = row.querySelector('[data-field="change"]');
|
||||
const pctEl = row.querySelector('[data-field="change_pct"]');
|
||||
const dir = dirClass(t.change);
|
||||
row.classList.remove("nav-spot--up", "nav-spot--down", "nav-spot--flat");
|
||||
row.classList.add(dir);
|
||||
if (lastEl) lastEl.textContent = fmtPrice(t.last);
|
||||
if (chgEl) chgEl.textContent = fmtSigned(t.change, false);
|
||||
if (pctEl) pctEl.textContent = fmtSigned(t.change_pct, true);
|
||||
const openHint =
|
||||
t.open != null
|
||||
? "开盘 " + fmtPrice(t.open) + " (" + (t.anchor || "BJ 08:00") + ")"
|
||||
: t.anchor || "BJ 08:00";
|
||||
row.title = (t.symbol || "") + " " + openHint;
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
const root = document.getElementById("nav-spot-tickers");
|
||||
if (!root) return;
|
||||
const list = (data && data.tickers) || [];
|
||||
list.forEach(function (t) {
|
||||
const base = (t.base || "").toUpperCase();
|
||||
if (!base) return;
|
||||
const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]');
|
||||
paintRow(row, t);
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const root = document.getElementById("nav-spot-tickers");
|
||||
if (!root || inflight) return;
|
||||
inflight = true;
|
||||
fetch("/api/nav_spot_tickers")
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (data && data.ok !== false) apply(data);
|
||||
})
|
||||
.catch(function () {})
|
||||
.finally(function () {
|
||||
inflight = false;
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!document.getElementById("nav-spot-tickers")) return;
|
||||
refresh();
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(refresh, pollMs());
|
||||
}
|
||||
|
||||
global.refreshNavSpotTickers = refresh;
|
||||
global.startNavSpotTickers = start;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window);
|
||||
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=50"></script>
|
||||
<script src="/static/instance_theme.js?v=51"></script>
|
||||
<script src="/static/autofill_guard.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=17">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=121">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=18">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=122">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
@@ -38,6 +38,7 @@
|
||||
<h1>加密货币|OKX 期权与对冲</h1>
|
||||
</div>
|
||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||
<div class="top-nav-links">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if initial_tab == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if initial_tab == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
@@ -58,6 +59,8 @@
|
||||
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
||||
{% endif %}
|
||||
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
||||
</div>
|
||||
{% include 'nav_spot_tickers.html' %}
|
||||
</nav>
|
||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||
|
||||
@@ -146,6 +149,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
<script src="/static/instance_stats.js?v=5"></script>
|
||||
{% include 'embed_boot_scripts.html' %}
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/nav_spot_tickers.js?v=1"></script>
|
||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||
<script src="/static/account_ledger.js?v=1"></script>
|
||||
<script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<script src="/static/instance_theme.js?v=50"></script>
|
||||
<script src="/static/instance_theme.js?v=51"></script>
|
||||
<script src="/static/autofill_guard.js?v=1"></script>
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
@@ -20,8 +20,8 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png?v=2">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<title>{{ pwa_app_name }}</title>
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=17">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=121">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=18">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=122">
|
||||
|
||||
</head>
|
||||
<body
|
||||
@@ -156,6 +156,7 @@
|
||||
<h1>加密货币|OKX 期权与对冲</h1>
|
||||
</div>
|
||||
<div class="top-nav">
|
||||
<div class="top-nav-links">
|
||||
<a href="/dashboard" data-embed-tab="dashboard" class="{% if page == 'dashboard' %}active{% endif %}"{% if not display.show_nav_dashboard %} style="display:none"{% endif %}>数据看板</a>
|
||||
<a href="/account_ledger" data-embed-tab="account_ledger" class="{% if page == 'account_ledger' %}active{% endif %}"{% if not display.show_nav_account_ledger %} style="display:none"{% endif %}>账户流水</a>
|
||||
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}"{% if not display.show_nav_key_monitor %} style="display:none"{% endif %}>关键位监控</a>
|
||||
@@ -176,6 +177,8 @@
|
||||
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
|
||||
{% endif %}
|
||||
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||
</div>
|
||||
{% include 'nav_spot_tickers.html' %}
|
||||
</div>
|
||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||
|
||||
@@ -1827,6 +1830,7 @@ tickOrderHoldDurations();
|
||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||
</script>
|
||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/static/nav_spot_tickers.js?v=1"></script>
|
||||
<script src="/static/instance_dashboard.js?v=5"></script>
|
||||
<script src="/static/account_ledger.js?v=1"></script>
|
||||
<script>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{# 导航右侧 BTC/ETH 现货报价(相对北京 08:00 开盘) #}
|
||||
<div class="top-nav-spot-tickers" id="nav-spot-tickers" aria-label="BTC ETH 现货报价" title="相对今日北京时间 08:00 开盘价">
|
||||
<div class="nav-spot-row" data-base="ETH">
|
||||
<span class="nav-spot-ribbon" aria-hidden="true"></span>
|
||||
<span class="nav-spot-icon nav-spot-icon--eth" aria-hidden="true">Ξ</span>
|
||||
<span class="nav-spot-sym">ETHUSDT</span>
|
||||
<span class="nav-spot-last" data-field="last">—</span>
|
||||
<span class="nav-spot-chg" data-field="change">—</span>
|
||||
<span class="nav-spot-pct" data-field="change_pct">—</span>
|
||||
</div>
|
||||
<div class="nav-spot-row" data-base="BTC">
|
||||
<span class="nav-spot-ribbon" aria-hidden="true"></span>
|
||||
<span class="nav-spot-icon nav-spot-icon--btc" aria-hidden="true">₿</span>
|
||||
<span class="nav-spot-sym">BTCUSDT</span>
|
||||
<span class="nav-spot-last" data-field="last">—</span>
|
||||
<span class="nav-spot-chg" data-field="change">—</span>
|
||||
<span class="nav-spot-pct" data-field="change_pct">—</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Nav bar BTC/ETH spot tickers vs Beijing 08:00 open (OKX 1D = UTC 00:00)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
BJ = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
# (base, OKX spot instId, display symbol)
|
||||
NAV_SPOT_SPECS = (
|
||||
("ETH", "ETH-USDT", "ETHUSDT"),
|
||||
("BTC", "BTC-USDT", "BTCUSDT"),
|
||||
)
|
||||
|
||||
_OPEN_CACHE_LOCK = threading.Lock()
|
||||
_OPEN_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_OPEN_CACHE_TTL_SEC = 120.0
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def session_day_key(now: Optional[datetime] = None, reset_hour: int = 8) -> str:
|
||||
"""Trading day key: before reset_hour BJ belongs to previous calendar day."""
|
||||
dt = now or datetime.now(BJ)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=BJ)
|
||||
else:
|
||||
dt = dt.astimezone(BJ)
|
||||
if dt.hour < int(reset_hour):
|
||||
dt = dt - timedelta(days=1)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _fetch_spot_last(ex: Any, inst_id: str) -> Optional[float]:
|
||||
try:
|
||||
rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or []
|
||||
if rows and isinstance(rows[0], dict):
|
||||
return _safe_float(rows[0].get("last"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# ccxt spot id: BTC-USDT → BTC/USDT
|
||||
sym = inst_id.replace("-", "/")
|
||||
t = ex.fetch_ticker(sym)
|
||||
return _safe_float(t.get("last"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_day_open_bj8(ex: Any, inst_id: str) -> Optional[float]:
|
||||
"""OKX 1D candle open is UTC 00:00 = Beijing 08:00."""
|
||||
try:
|
||||
rows = ex.public_get_market_candles(
|
||||
{"instId": inst_id, "bar": "1D", "limit": "1"}
|
||||
).get("data") or []
|
||||
if rows:
|
||||
row = rows[0]
|
||||
# OKX: [ts, o, h, l, c, vol, volCcy, volCcyQuote, confirm]
|
||||
if isinstance(row, (list, tuple)) and len(row) > 1:
|
||||
return _safe_float(row[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sym = inst_id.replace("-", "/")
|
||||
ohlcv = ex.fetch_ohlcv(sym, timeframe="1d", limit=1) or []
|
||||
if ohlcv:
|
||||
return _safe_float(ohlcv[-1][1])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _cached_day_open(ex: Any, inst_id: str, day_key: str) -> Optional[float]:
|
||||
now_ts = time.time()
|
||||
with _OPEN_CACHE_LOCK:
|
||||
entry = _OPEN_CACHE.get(inst_id)
|
||||
if (
|
||||
entry
|
||||
and entry.get("day_key") == day_key
|
||||
and entry.get("open") is not None
|
||||
and now_ts - float(entry.get("fetched_at") or 0) < _OPEN_CACHE_TTL_SEC
|
||||
):
|
||||
return _safe_float(entry["open"])
|
||||
|
||||
open_px = _fetch_day_open_bj8(ex, inst_id)
|
||||
if open_px is not None:
|
||||
with _OPEN_CACHE_LOCK:
|
||||
_OPEN_CACHE[inst_id] = {
|
||||
"day_key": day_key,
|
||||
"open": open_px,
|
||||
"fetched_at": now_ts,
|
||||
}
|
||||
return open_px
|
||||
|
||||
|
||||
def build_nav_spot_tickers(
|
||||
exchange: Any,
|
||||
*,
|
||||
reset_hour: int = 8,
|
||||
now_fn: Optional[Callable[[], datetime]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return ETH then BTC spot last/change vs Beijing 08:00 open."""
|
||||
now = now_fn() if now_fn else datetime.now(BJ)
|
||||
day_key = session_day_key(now, reset_hour=reset_hour)
|
||||
out: list[dict[str, Any]] = []
|
||||
for base, inst_id, display in NAV_SPOT_SPECS:
|
||||
last = _fetch_spot_last(exchange, inst_id)
|
||||
open_px = _cached_day_open(exchange, inst_id, day_key)
|
||||
change = None
|
||||
change_pct = None
|
||||
if last is not None and open_px is not None and open_px != 0:
|
||||
change = last - open_px
|
||||
change_pct = (change / open_px) * 100.0
|
||||
out.append(
|
||||
{
|
||||
"base": base,
|
||||
"inst_id": inst_id,
|
||||
"symbol": display,
|
||||
"last": round(last, 8) if last is not None else None,
|
||||
"open": round(open_px, 8) if open_px is not None else None,
|
||||
"change": round(change, 8) if change is not None else None,
|
||||
"change_pct": round(change_pct, 4) if change_pct is not None else None,
|
||||
"session_day": day_key,
|
||||
"anchor": f"BJ {int(reset_hour):02d}:00",
|
||||
}
|
||||
)
|
||||
return out
|
||||
Reference in New Issue
Block a user