diff --git a/app.py b/app.py index 5ef8d3c..1e503e7 100644 --- a/app.py +++ b/app.py @@ -7134,34 +7134,59 @@ def options_main_page(): return render_main_page("options") -@app.route("/api/header-quotes") +@app.route("/api/nav_spot_tickers") @login_required -def api_header_quotes(): - """顶栏 ETH/BTC 报价(公共 ticker last + 24h 涨跌).""" - from lib.exchange.okx_public_http_lib import fetch_swap_ticker +def api_nav_spot_tickers(): + """BTC/ETH 最新价,相对北京时间 08:00(OKX sodUtc0)涨跌 — 与 crypto_okx 同款.""" + try: + from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers - quotes = [] - for base in ("ETH", "BTC"): - item: dict = {"base": base, "symbol": f"{base}USDT"} - try: - row = fetch_swap_ticker(base) - last = float(row.get("last") or row.get("markPx") or 0) - open24 = float(row.get("open24h") or row.get("sodUtc0") or 0) - change = (last - open24) if open24 > 0 else 0.0 - pct = (change / open24 * 100.0) if open24 > 0 else 0.0 - item.update( - { - "last": last, - "open24h": open24, - "change": change, - "pct": pct, - "mark": float(row.get("markPx") or last or 0) or None, - } - ) - except Exception as e: - item["error"] = str(e)[:160] - quotes.append(item) - return jsonify({"ok": True, "quotes": quotes}) + tickers = build_nav_spot_tickers( + exchange, + reset_hour=TRADING_DAY_RESET_HOUR, + now_fn=lambda: datetime.now(APP_TZ), + ) + # 公共 HTTP 回退:ccxt/代理异常时仍能出顶栏价 + if not any((t or {}).get("last") is not None for t in tickers): + from lib.exchange.okx_public_http_lib import fetch_swap_ticker + from lib.market.nav_spot_tickers_lib import session_day_key + + day_key = session_day_key(datetime.now(APP_TZ), reset_hour=TRADING_DAY_RESET_HOUR) + rebuilt = [] + for base in ("ETH", "BTC"): + try: + row = fetch_swap_ticker(base) + last = float(row.get("last") or row.get("markPx") or 0) or None + open_px = float(row.get("sodUtc0") or row.get("open24h") or 0) or None + change = (last - open_px) if last is not None and open_px else None + pct = (change / open_px * 100.0) if change is not None and open_px else None + rebuilt.append( + { + "base": base, + "inst_id": f"{base}-USDT-SWAP", + "symbol": f"{base}USDT", + "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(pct, 4) if pct is not None else None, + "session_day": day_key, + "anchor": "BJ 08:00", + "anchor_field": "sodUtc0", + } + ) + except Exception: + rebuilt.append({"base": base, "symbol": f"{base}USDT", "last": None}) + if any((t or {}).get("last") is not None for t in rebuilt): + tickers = rebuilt + 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/account_snapshot") @@ -9438,7 +9463,7 @@ _OPTIONS_PANEL_JS = os.path.join(_REPO_STATIC_DIR, "options_panel.js") _OPTIONS_EXPIRY_COUNTDOWN_JS = os.path.join(_REPO_STATIC_DIR, "options_expiry_countdown.js") _OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js") _HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.js") -_HEADER_LIVE_QUOTES_JS = os.path.join(_REPO_STATIC_DIR, "header_live_quotes.js") +_NAV_SPOT_TICKERS_JS = os.path.join(_REPO_STATIC_DIR, "nav_spot_tickers.js") @app.route("/static/ai_review_render.js") @@ -9448,11 +9473,11 @@ def static_ai_review_render_js(): return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8") -@app.route("/static/header_live_quotes.js") -def static_header_live_quotes_js(): - if not os.path.isfile(_HEADER_LIVE_QUOTES_JS): +@app.route("/static/nav_spot_tickers.js") +def static_nav_spot_tickers_js(): + if not os.path.isfile(_NAV_SPOT_TICKERS_JS): return Response("not found", status=404, mimetype="text/plain; charset=utf-8") - return send_file(_HEADER_LIVE_QUOTES_JS, mimetype="application/javascript; charset=utf-8") + return send_file(_NAV_SPOT_TICKERS_JS, mimetype="application/javascript; charset=utf-8") @app.route("/static/form_submit_guard.js") diff --git a/lib/common/static/header_live_quotes.js b/lib/common/static/header_live_quotes.js deleted file mode 100644 index fe606d3..0000000 --- a/lib/common/static/header_live_quotes.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * 顶栏右上角 ETH/BTC 实时报价(公共 ticker). - */ -(function (global) { - const BASES = ["ETH", "BTC"]; - - function $(id) { - return document.getElementById(id); - } - - function refreshMs() { - const raw = document.body && document.body.getAttribute("data-price-refresh-ms"); - const n = Number(raw); - return n > 2000 ? n : 5000; - } - - function fmtLast(base, v) { - if (v == null || Number.isNaN(Number(v))) return "—"; - const n = Number(v); - if (base === "BTC") { - return n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); - } - return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - } - - function fmtChg(base, v) { - if (v == null || Number.isNaN(Number(v))) return "—"; - const n = Number(v); - const abs = Math.abs(n); - const body = - base === "BTC" - ? abs.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) - : abs.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); - if (n > 0) return "+" + body; - if (n < 0) return "-" + body; - return body; - } - - function fmtPct(v) { - if (v == null || Number.isNaN(Number(v))) return "—"; - const n = Number(v); - const body = Math.abs(n).toFixed(2) + "%"; - if (n > 0) return "+" + body; - if (n < 0) return "-" + body; - return body; - } - - function paintRow(row, q) { - if (!row || !q) return; - const last = row.querySelector('[data-hlq="last"]'); - const chg = row.querySelector('[data-hlq="chg"]'); - const pct = row.querySelector('[data-hlq="pct"]'); - const base = row.getAttribute("data-base") || ""; - if (last) last.textContent = fmtLast(base, q.last); - if (chg) chg.textContent = fmtChg(base, q.change); - if (pct) pct.textContent = fmtPct(q.pct); - const up = Number(q.change) > 0 || Number(q.pct) > 0; - const down = Number(q.change) < 0 || Number(q.pct) < 0; - row.classList.toggle("is-up", up); - row.classList.toggle("is-down", down); - } - - async function fetchQuotes() { - const r = await fetch("/api/header-quotes", { - credentials: "same-origin", - cache: "no-store", - }); - const d = await r.json(); - if (!d || !d.ok) throw new Error((d && d.msg) || "行情失败"); - return d.quotes || []; - } - - async function tick() { - const host = $("header-live-quotes"); - if (!host) return; - try { - const quotes = await fetchQuotes(); - BASES.forEach(function (base) { - const row = host.querySelector('.hlq-row[data-base="' + base + '"]'); - const q = quotes.find(function (x) { - return String((x && x.base) || "").toUpperCase() === base; - }); - if (row && q && !q.error) paintRow(row, q); - }); - } catch (_) { - /* 静默:保持上次有效行情 */ - } - } - - let timer = null; - function boot() { - if (!$("header-live-quotes")) return; - void tick(); - if (timer) clearInterval(timer); - timer = setInterval(function () { - void tick(); - }, refreshMs()); - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", boot); - } else { - boot(); - } - - global.HeaderLiveQuotes = { refresh: tick, boot: boot }; -})(window); diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index c0db29f..a33840e 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -3,13 +3,25 @@ .order-entry-model-row select.order-entry-category{min-width:4.8em;max-width:6.5em} .order-entry-model-row select.order-entry-model-sub{min-width:7em;max-width:10rem} .order-leverage-hint{font-size:.78rem;color:#cfd3ef;white-space:nowrap;align-self:center} - body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px} - .container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)} + body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px} + /* 与 crypto_okx 同款:普通屏铺满;embed 壳限宽 */ + .container{width:100%;max-width:none;margin:0 auto;padding:0 clamp(6px,1vw,14px)} + body[data-embed-shell="1"] .container{ + width:100%; + max-width:min(1800px, 100%); + margin-left:auto; + margin-right:auto; + padding:0 clamp(6px,1vw,14px); + } .header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} - .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} + .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25;font-weight:700} .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;align-items:center;min-width:0} + .top-nav-links > a{flex:0 0 auto;white-space:nowrap} + .top-nav > a{justify-self:center;align-self:center;width:max-content;max-width:100%} + .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} diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 5c45a6a..d8c6fd1 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -53,22 +53,43 @@ html[data-theme="light"] { } .top-nav { - display: flex !important; - flex-wrap: nowrap !important; - justify-content: flex-start !important; - align-items: stretch; - overflow-x: auto !important; - overflow-y: hidden; + display: grid !important; + grid-template-columns: 1fr !important; + gap: 8px !important; + margin-bottom: 12px !important; + padding: 2px 2px 6px; width: 100%; max-width: 100%; + overflow: visible !important; + } + + .top-nav-links { + grid-column: 1 !important; + 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 { + grid-column: 1 !important; + justify-self: stretch; + align-self: stretch; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; } .top-nav::-webkit-scrollbar { @@ -561,126 +582,125 @@ html[data-theme="light"] .theme-toggle-btn.is-active { margin-top: 6px; } -/* 顶栏品牌:加密货币 | OKX 对冲 + 右上角 ETH/BTC 报价 */ -.header.header--brand { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 52px; - margin-bottom: 10px; - padding: 0 168px 0 12px; - box-sizing: border-box; -} -.header.header--brand .brand-title { - margin: 0; - font-size: 1.7rem; - font-weight: 700; - color: #e8ecff; - text-align: center; - line-height: 1.25; - letter-spacing: 0.02em; -} -.header.header--brand .brand-prefix { - color: #e8ecff; -} -.header.header--brand .brand-sep { - display: inline-block; - margin: 0 0.45em; - color: #9aa3bf; - font-weight: 500; -} -.header.header--brand .brand-product { - color: #e8ecff; -} -.header-live-quotes { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 3px; - z-index: 2; - pointer-events: none; -} -.header-live-quotes .hlq-row { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.72rem; - line-height: 1.2; - color: #c5cce0; +/* 导航右侧 ETH/BTC 报价 — 与 crypto_okx 同款 */ +.top-nav-links > a { + flex: 0 0 auto; white-space: nowrap; +} + +.top-nav-spot-tickers { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; font-variant-numeric: tabular-nums; } -.header-live-quotes .hlq-icon { - width: 14px; - height: 14px; + +.nav-spot-row { + display: grid; + grid-template-columns: 3px 18px minmax(4.6em, auto) minmax(5em, auto) minmax(3.6em, auto) minmax(3.8em, auto); + align-items: center; + column-gap: 7px; + padding: 3px 9px 3px 5px; + border: 1px solid transparent; + border-radius: 6px; + background: rgba(16, 22, 36, 0.72); + line-height: 1.2; + white-space: nowrap; + font-size: 0.88rem; +} + +.nav-spot-row:hover { + border-color: #3a4568; +} + +.nav-spot-ribbon { + width: 3px; + height: 16px; + border-radius: 1px 2px 2px 1px; + background: #e24b6a; + justify-self: center; +} + +.nav-spot-icon { + width: 18px; + height: 18px; border-radius: 50%; - flex: 0 0 auto; - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 700; + color: #fff; } -.header-live-quotes .hlq-icon--eth { - background: radial-gradient(circle at 35% 30%, #8ad0ff, #3c7cff 55%, #1a3a8a); + +.nav-spot-icon--eth { + background: #627eea; } -.header-live-quotes .hlq-icon--btc { - background: radial-gradient(circle at 35% 30%, #ffd27a, #f0a028 55%, #a65a00); + +.nav-spot-icon--btc { + background: #f2a900; + color: #1a1200; } -.header-live-quotes .hlq-sym { - color: #9aa3bf; - min-width: 58px; -} -.header-live-quotes .hlq-last { - color: #e8ecff; + +.nav-spot-sym { + color: var(--inst-text, #e8ecff); font-weight: 600; - min-width: 64px; - text-align: right; + letter-spacing: 0.01em; } -.header-live-quotes .hlq-chg, -.header-live-quotes .hlq-pct { - min-width: 48px; + +.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; } -.header-live-quotes .hlq-row.is-up .hlq-chg, -.header-live-quotes .hlq-row.is-up .hlq-pct { - color: #3dd68c; + +html[data-theme="light"] .nav-spot-row { + background: rgba(255, 255, 255, 0.85); + border-color: #d5e0ec; } -.header-live-quotes .hlq-row.is-down .hlq-chg, -.header-live-quotes .hlq-row.is-down .hlq-pct { - color: #ff8a8a; + +html[data-theme="light"] .nav-spot-row:hover { + border-color: #9bb4cc; } -html[data-theme="light"] .header.header--brand .brand-title, -html[data-theme="light"] .header.header--brand .brand-prefix, -html[data-theme="light"] .header.header--brand .brand-product { - color: #142232; + +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"] .header.header--brand .brand-sep, -html[data-theme="light"] .header-live-quotes .hlq-sym { - color: #5a6f85; + +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; } -html[data-theme="light"] .header-live-quotes .hlq-last { - color: #142232; -} -@media (max-width: 720px) { - .header.header--brand { - padding: 0 8px; - min-height: 0; - align-items: flex-start; - } - .header.header--brand .brand-title { - font-size: 1.05rem !important; - text-align: left; - } - .header-live-quotes { - position: static; - transform: none; - margin-top: 6px; - align-items: flex-start; - width: 100%; + +@media (max-width: 1100px) { + .nav-spot-row { + grid-template-columns: 3px 16px minmax(4em, auto) minmax(4.2em, auto) minmax(3.2em, auto) minmax(3.4em, auto); + column-gap: 5px; + font-size: 0.8rem; + padding: 2px 7px 2px 4px; } } diff --git a/lib/common/static/nav_spot_tickers.js b/lib/common/static/nav_spot_tickers.js new file mode 100644 index 0000000..a34c09b --- /dev/null +++ b/lib/common/static/nav_spot_tickers.js @@ -0,0 +1,120 @@ +/** + * 导航栏 BTC/ETH 报价:相对北京 08:00 开盘涨跌(与 crypto_okx 同款). + */ +(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) + " · 北京 08:00 (sodUtc0)" + : "北京 08:00 (sodUtc0)"; + 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); diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index 73d8f41..d7d058d 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -79,7 +79,7 @@ def install_instance_theme_static(app) -> None: "account_ledger.js": "application/javascript; charset=utf-8", "options_expiry_countdown.js": "application/javascript; charset=utf-8", "options_panel.js": "application/javascript; charset=utf-8", - "header_live_quotes.js": "application/javascript; charset=utf-8", + "nav_spot_tickers.js": "application/javascript; charset=utf-8", "hedge_plan.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8", diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index e6523e1..ced343b 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -215,7 +215,7 @@ def pwa_app_name(exchange_key: str) -> str: ex = (exchange_key or "").strip().lower() return { "binance": "Binance 交易系统", - "okx": "加密货币 | OKX 对冲", + "okx": "加密货币|OKX 对冲", "gate": "Gate 交易系统", }.get(ex, "交易系统") diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 25ad49b..9708f3a 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,8 +7,8 @@ - - + + @@ -29,6 +29,7 @@
{% include 'instance_brand_header.html' %} @@ -138,6 +141,6 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; - + diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 620d653..5fbd947 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -18,8 +18,8 @@ {{ pwa_app_name }} - - + + {% include 'instance_brand_header.html' %}
+ + {% include 'nav_spot_tickers.html' %}
{% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} @@ -2089,6 +2092,6 @@ document.addEventListener("DOMContentLoaded", function () { {% endif %} - + \ No newline at end of file diff --git a/lib/instance/templates/instance_brand_header.html b/lib/instance/templates/instance_brand_header.html index 89b4b27..5acf1a2 100644 --- a/lib/instance/templates/instance_brand_header.html +++ b/lib/instance/templates/instance_brand_header.html @@ -1,24 +1,4 @@ -{# 顶栏品牌标题 + 右上角 ETH/BTC 报价(与期权仓同款) #} -
-

- 加密货币 - - OKX 对冲 -

-
-
- - ETHUSDT - — - — - — -
-
- - BTCUSDT - — - — - — -
-
+{# 顶栏品牌标题 — 与 crypto_okx 同款字体/间距(全角分隔符) #} +
+

加密货币|OKX 对冲

diff --git a/lib/instance/templates/nav_spot_tickers.html b/lib/instance/templates/nav_spot_tickers.html new file mode 100644 index 0000000..9957685 --- /dev/null +++ b/lib/instance/templates/nav_spot_tickers.html @@ -0,0 +1,19 @@ +{# 导航右侧 BTC/ETH 报价(相对北京 08:00 开盘) — 与 crypto_okx 同款 #} + diff --git a/lib/market/__init__.py b/lib/market/__init__.py new file mode 100644 index 0000000..af0744a --- /dev/null +++ b/lib/market/__init__.py @@ -0,0 +1 @@ + diff --git a/lib/market/nav_spot_tickers_lib.py b/lib/market/nav_spot_tickers_lib.py new file mode 100644 index 0000000..a6a2491 --- /dev/null +++ b/lib/market/nav_spot_tickers_lib.py @@ -0,0 +1,149 @@ +"""Nav bar BTC/ETH tickers vs Beijing 08:00 open. + +OKX: + sodUtc0 / candle 1Dutc = UTC+0 open = Beijing 08:00 ← use this + sodUtc8 / candle 1D = UTC+8 open = Beijing 00:00 (default OKX daily K) +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +BJ = ZoneInfo("Asia/Shanghai") + +ANCHOR_SOD_FIELD = "sodUtc0" +ANCHOR_CANDLE_BAR = "1Dutc" +ANCHOR_LABEL = "BJ 08:00" + +# (base, display, swap instId, spot instId, ccxt swap symbol) +NAV_SPOT_SPECS = ( + ("ETH", "ETHUSDT", "ETH-USDT-SWAP", "ETH-USDT", "ETH/USDT:USDT"), + ("BTC", "BTCUSDT", "BTC-USDT-SWAP", "BTC-USDT", "BTC/USDT:USDT"), +) + + +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 _ticker_row(ex: Any, inst_id: str) -> Optional[dict[str, Any]]: + try: + rows = ex.public_get_market_ticker({"instId": inst_id}).get("data") or [] + if rows and isinstance(rows[0], dict): + return rows[0] + except Exception: + pass + return None + + +def _candle_open(ex: Any, inst_id: str, bar: str) -> Optional[float]: + """OKX candles: newest first; [ts, o, h, l, c, ...].""" + try: + rows = ex.public_get_market_candles( + {"instId": inst_id, "bar": bar, "limit": "1"} + ).get("data") or [] + if rows and isinstance(rows[0], (list, tuple)) and len(rows[0]) > 1: + return _safe_float(rows[0][1]) + except Exception: + pass + return None + + +def _ccxt_last_and_anchor_open(ex: Any, symbol: str) -> tuple[Optional[float], Optional[float]]: + last = None + open_px = None + try: + t = ex.fetch_ticker(symbol) or {} + last = _safe_float(t.get("last") or t.get("close")) + info = t.get("info") if isinstance(t.get("info"), dict) else {} + open_px = _safe_float(info.get(ANCHOR_SOD_FIELD)) + except Exception: + pass + return last, open_px + + +def _fetch_last_and_open( + ex: Any, swap_inst: str, spot_inst: str, swap_sym: str +) -> tuple[Optional[float], Optional[float]]: + """Same instrument last + Beijing 08:00 open. Prefer USDT SWAP.""" + for inst in (swap_inst, spot_inst): + row = _ticker_row(ex, inst) + if not row: + continue + last = _safe_float(row.get("last")) + open_px = _safe_float(row.get(ANCHOR_SOD_FIELD)) + if open_px is None: + open_px = _candle_open(ex, inst, ANCHOR_CANDLE_BAR) + if last is not None and open_px is not None: + return last, open_px + + last, open_px = _ccxt_last_and_anchor_open(ex, swap_sym) + if last is not None and open_px is not None: + return last, open_px + + last, open_px = _ccxt_last_and_anchor_open(ex, spot_inst.replace("-", "/")) + if last is not None and open_px is not None: + return last, open_px + + for inst, sym in ((swap_inst, swap_sym), (spot_inst, spot_inst.replace("-", "/"))): + row = _ticker_row(ex, inst) + last = _safe_float(row.get("last")) if row else None + if last is None: + last, _ = _ccxt_last_and_anchor_open(ex, sym) + open_px = _candle_open(ex, inst, ANCHOR_CANDLE_BAR) + if last is not None and open_px is not None: + return last, open_px + return None, None + + +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 last/change vs Beijing 08:00 open (sodUtc0).""" + 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, display, swap_inst, spot_inst, swap_sym in NAV_SPOT_SPECS: + last, open_px = _fetch_last_and_open(exchange, swap_inst, spot_inst, swap_sym) + 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": swap_inst, + "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": ANCHOR_LABEL, + "anchor_field": ANCHOR_SOD_FIELD, + } + ) + return out