Align header title and ETH/BTC quotes with crypto_okx.
Match fullwidth title separator, page spacing, and move live tickers into the nav right column using the same spot-ticker UI/API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7134,34 +7134,59 @@ def options_main_page():
|
|||||||
return render_main_page("options")
|
return render_main_page("options")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/header-quotes")
|
@app.route("/api/nav_spot_tickers")
|
||||||
@login_required
|
@login_required
|
||||||
def api_header_quotes():
|
def api_nav_spot_tickers():
|
||||||
"""顶栏 ETH/BTC 报价(公共 ticker last + 24h 涨跌)."""
|
"""BTC/ETH 最新价,相对北京时间 08:00(OKX sodUtc0)涨跌 — 与 crypto_okx 同款."""
|
||||||
from lib.exchange.okx_public_http_lib import fetch_swap_ticker
|
try:
|
||||||
|
from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers
|
||||||
|
|
||||||
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"):
|
for base in ("ETH", "BTC"):
|
||||||
item: dict = {"base": base, "symbol": f"{base}USDT"}
|
|
||||||
try:
|
try:
|
||||||
row = fetch_swap_ticker(base)
|
row = fetch_swap_ticker(base)
|
||||||
last = float(row.get("last") or row.get("markPx") or 0)
|
last = float(row.get("last") or row.get("markPx") or 0) or None
|
||||||
open24 = float(row.get("open24h") or row.get("sodUtc0") or 0)
|
open_px = float(row.get("sodUtc0") or row.get("open24h") or 0) or None
|
||||||
change = (last - open24) if open24 > 0 else 0.0
|
change = (last - open_px) if last is not None and open_px else None
|
||||||
pct = (change / open24 * 100.0) if open24 > 0 else 0.0
|
pct = (change / open_px * 100.0) if change is not None and open_px else None
|
||||||
item.update(
|
rebuilt.append(
|
||||||
{
|
{
|
||||||
"last": last,
|
"base": base,
|
||||||
"open24h": open24,
|
"inst_id": f"{base}-USDT-SWAP",
|
||||||
"change": change,
|
"symbol": f"{base}USDT",
|
||||||
"pct": pct,
|
"last": round(last, 8) if last is not None else None,
|
||||||
"mark": float(row.get("markPx") or last or 0) or 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:
|
except Exception as e:
|
||||||
item["error"] = str(e)[:160]
|
return jsonify({"ok": False, "msg": str(e) or "行情读取失败", "tickers": []}), 502
|
||||||
quotes.append(item)
|
|
||||||
return jsonify({"ok": True, "quotes": quotes})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/account_snapshot")
|
@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_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")
|
_OPTIONS_SETTINGS_JS = os.path.join(_REPO_STATIC_DIR, "options_settings.js")
|
||||||
_HEDGE_PLAN_JS = os.path.join(_REPO_STATIC_DIR, "hedge_plan.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")
|
@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")
|
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
@app.route("/static/header_live_quotes.js")
|
@app.route("/static/nav_spot_tickers.js")
|
||||||
def static_header_live_quotes_js():
|
def static_nav_spot_tickers_js():
|
||||||
if not os.path.isfile(_HEADER_LIVE_QUOTES_JS):
|
if not os.path.isfile(_NAV_SPOT_TICKERS_JS):
|
||||||
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
|
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")
|
@app.route("/static/form_submit_guard.js")
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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-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-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}
|
.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}
|
body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 12px}
|
||||||
.container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)}
|
/* 与 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{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}
|
.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}
|
.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{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}
|
.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}
|
.stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch}
|
||||||
|
|||||||
@@ -53,22 +53,43 @@ html[data-theme="light"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-nav {
|
.top-nav {
|
||||||
display: flex !important;
|
display: grid !important;
|
||||||
flex-wrap: nowrap !important;
|
grid-template-columns: 1fr !important;
|
||||||
justify-content: flex-start !important;
|
gap: 8px !important;
|
||||||
align-items: stretch;
|
margin-bottom: 12px !important;
|
||||||
overflow-x: auto !important;
|
padding: 2px 2px 6px;
|
||||||
overflow-y: hidden;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-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;
|
-webkit-overflow-scrolling: touch;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
gap: 6px !important;
|
gap: 6px;
|
||||||
margin-bottom: 12px !important;
|
|
||||||
padding: 2px 2px 6px;
|
|
||||||
scroll-padding-inline: 10px;
|
scroll-padding-inline: 10px;
|
||||||
touch-action: pan-x;
|
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 {
|
.top-nav::-webkit-scrollbar {
|
||||||
@@ -561,126 +582,125 @@ html[data-theme="light"] .theme-toggle-btn.is-active {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶栏品牌:加密货币 | OKX 对冲 + 右上角 ETH/BTC 报价 */
|
/* 导航右侧 ETH/BTC 报价 — 与 crypto_okx 同款 */
|
||||||
.header.header--brand {
|
.top-nav-links > a {
|
||||||
position: relative;
|
flex: 0 0 auto;
|
||||||
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;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-spot-tickers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.header-live-quotes .hlq-icon {
|
|
||||||
width: 14px;
|
.nav-spot-row {
|
||||||
height: 14px;
|
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%;
|
border-radius: 50%;
|
||||||
flex: 0 0 auto;
|
display: inline-flex;
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
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;
|
.nav-spot-sym {
|
||||||
min-width: 58px;
|
color: var(--inst-text, #e8ecff);
|
||||||
}
|
|
||||||
.header-live-quotes .hlq-last {
|
|
||||||
color: #e8ecff;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 64px;
|
letter-spacing: 0.01em;
|
||||||
text-align: right;
|
|
||||||
}
|
}
|
||||||
.header-live-quotes .hlq-chg,
|
|
||||||
.header-live-quotes .hlq-pct {
|
.nav-spot-last,
|
||||||
min-width: 48px;
|
.nav-spot-chg,
|
||||||
|
.nav-spot-pct {
|
||||||
text-align: right;
|
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;
|
color: #9aa3bf;
|
||||||
}
|
}
|
||||||
.header-live-quotes .hlq-row.is-up .hlq-chg,
|
|
||||||
.header-live-quotes .hlq-row.is-up .hlq-pct {
|
html[data-theme="light"] .nav-spot-row {
|
||||||
color: #3dd68c;
|
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 {
|
html[data-theme="light"] .nav-spot-row:hover {
|
||||||
color: #ff8a8a;
|
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"] .nav-spot--up .nav-spot-last,
|
||||||
html[data-theme="light"] .header.header--brand .brand-product {
|
html[data-theme="light"] .nav-spot--up .nav-spot-chg,
|
||||||
color: #142232;
|
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 {
|
html[data-theme="light"] .nav-spot--down .nav-spot-last,
|
||||||
color: #5a6f85;
|
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: 1100px) {
|
||||||
}
|
.nav-spot-row {
|
||||||
@media (max-width: 720px) {
|
grid-template-columns: 3px 16px minmax(4em, auto) minmax(4.2em, auto) minmax(3.2em, auto) minmax(3.4em, auto);
|
||||||
.header.header--brand {
|
column-gap: 5px;
|
||||||
padding: 0 8px;
|
font-size: 0.8rem;
|
||||||
min-height: 0;
|
padding: 2px 7px 2px 4px;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -79,7 +79,7 @@ def install_instance_theme_static(app) -> None:
|
|||||||
"account_ledger.js": "application/javascript; charset=utf-8",
|
"account_ledger.js": "application/javascript; charset=utf-8",
|
||||||
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
"options_expiry_countdown.js": "application/javascript; charset=utf-8",
|
||||||
"options_panel.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",
|
"hedge_plan.js": "application/javascript; charset=utf-8",
|
||||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ def pwa_app_name(exchange_key: str) -> str:
|
|||||||
ex = (exchange_key or "").strip().lower()
|
ex = (exchange_key or "").strip().lower()
|
||||||
return {
|
return {
|
||||||
"binance": "Binance 交易系统",
|
"binance": "Binance 交易系统",
|
||||||
"okx": "加密货币 | OKX 对冲",
|
"okx": "加密货币|OKX 对冲",
|
||||||
"gate": "Gate 交易系统",
|
"gate": "Gate 交易系统",
|
||||||
}.get(ex, "交易系统")
|
}.get(ex, "交易系统")
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<script src="/static/autofill_guard.js?v=1"></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/instance_theme_early.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
<link rel="stylesheet" href="/static/instance_page.css?v=27">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=122">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=123">
|
||||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<script src="/static/open_submit_gate.js?v=1"></script>
|
<script src="/static/open_submit_gate.js?v=1"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
{% include 'instance_brand_header.html' %}
|
{% include 'instance_brand_header.html' %}
|
||||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||||
|
<div class="top-nav-links">
|
||||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||||
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">{% if hedge_plan_show_options_options %}期期对冲{% else %}永期对冲{% endif %}</a>
|
<a href="/hedge-plan" data-embed-tab="hedge_plan" class="{% if initial_tab == 'hedge_plan' %}active{% endif %}">{% if hedge_plan_show_options_options %}期期对冲{% else %}永期对冲{% endif %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
|
</div>
|
||||||
|
{% include 'nav_spot_tickers.html' %}
|
||||||
</nav>
|
</nav>
|
||||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||||
|
|
||||||
@@ -138,6 +141,6 @@ window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
|||||||
<script src="/static/instance_live.js?v=6"></script>
|
<script src="/static/instance_live.js?v=6"></script>
|
||||||
<script src="/static/instance_embed.js?v=32"></script>
|
<script src="/static/instance_embed.js?v=32"></script>
|
||||||
<script src="/static/instance_mobile_nav.js?v=3"></script>
|
<script src="/static/instance_mobile_nav.js?v=3"></script>
|
||||||
<script src="/static/header_live_quotes.js?v=1"></script>
|
<script src="/static/nav_spot_tickers.js?v=1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ pwa_app_name }}</title>
|
<title>{{ pwa_app_name }}</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=13">
|
<link rel="stylesheet" href="/static/instance_page.css?v=27">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=122">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=123">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -152,6 +152,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
{% include 'instance_brand_header.html' %}
|
{% include 'instance_brand_header.html' %}
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
|
<div class="top-nav-links">
|
||||||
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
{% if hedge_plan_nav_visible and display.show_nav_hedge_plan %}
|
||||||
<a href="/hedge-plan" class="{% if page == 'hedge_plan' %}active{% endif %}">{% if hedge_plan_show_options_options %}期期对冲{% else %}永期对冲{% endif %}</a>
|
<a href="/hedge-plan" class="{% if page == 'hedge_plan' %}active{% endif %}">{% if hedge_plan_show_options_options %}期期对冲{% else %}永期对冲{% endif %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -169,6 +170,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% include 'nav_spot_tickers.html' %}
|
||||||
|
</div>
|
||||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||||
|
|
||||||
{% include 'instance_header_panel.html' %}
|
{% include 'instance_header_panel.html' %}
|
||||||
@@ -2089,6 +2092,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/instance_settings_prefs.js?v=22"></script>
|
<script src="/static/instance_settings_prefs.js?v=22"></script>
|
||||||
<script src="/static/header_live_quotes.js?v=1"></script>
|
<script src="/static/nav_spot_tickers.js?v=1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,24 +1,4 @@
|
|||||||
{# 顶栏品牌标题 + 右上角 ETH/BTC 报价(与期权仓同款) #}
|
{# 顶栏品牌标题 — 与 crypto_okx 同款字体/间距(全角分隔符) #}
|
||||||
<div class="header header--brand">
|
<div class="header">
|
||||||
<h1 class="brand-title">
|
<h1>加密货币|OKX 对冲</h1>
|
||||||
<span class="brand-prefix">加密货币</span>
|
|
||||||
<span class="brand-sep" aria-hidden="true">|</span>
|
|
||||||
<span class="brand-product">OKX 对冲</span>
|
|
||||||
</h1>
|
|
||||||
<div class="header-live-quotes" id="header-live-quotes" aria-label="ETH/BTC 行情">
|
|
||||||
<div class="hlq-row" data-base="ETH">
|
|
||||||
<span class="hlq-icon hlq-icon--eth" aria-hidden="true"></span>
|
|
||||||
<span class="hlq-sym">ETHUSDT</span>
|
|
||||||
<span class="hlq-last" data-hlq="last">—</span>
|
|
||||||
<span class="hlq-chg" data-hlq="chg">—</span>
|
|
||||||
<span class="hlq-pct" data-hlq="pct">—</span>
|
|
||||||
</div>
|
|
||||||
<div class="hlq-row" data-base="BTC">
|
|
||||||
<span class="hlq-icon hlq-icon--btc" aria-hidden="true"></span>
|
|
||||||
<span class="hlq-sym">BTCUSDT</span>
|
|
||||||
<span class="hlq-last" data-hlq="last">—</span>
|
|
||||||
<span class="hlq-chg" data-hlq="chg">—</span>
|
|
||||||
<span class="hlq-pct" data-hlq="pct">—</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{# 导航右侧 BTC/ETH 报价(相对北京 08:00 开盘) — 与 crypto_okx 同款 #}
|
||||||
|
<div class="top-nav-spot-tickers" id="nav-spot-tickers" aria-label="BTC ETH 报价" title="涨跌相对北京时间 08:00 开盘(OKX sodUtc0)">
|
||||||
|
<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 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user