|
|
|
@@ -1,24 +1,27 @@
|
|
|
|
|
"""Nav bar BTC/ETH spot tickers vs Beijing 08:00 open (OKX 1D = UTC 00:00)."""
|
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
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, swap ccxt symbol, index instId)
|
|
|
|
|
NAV_SPOT_SPECS = (
|
|
|
|
|
("ETH", "ETH-USDT", "ETHUSDT", "ETH/USDT:USDT", "ETH-USD"),
|
|
|
|
|
("BTC", "BTC-USDT", "BTCUSDT", "BTC/USDT:USDT", "BTC-USD"),
|
|
|
|
|
)
|
|
|
|
|
ANCHOR_SOD_FIELD = "sodUtc0"
|
|
|
|
|
ANCHOR_CANDLE_BAR = "1Dutc"
|
|
|
|
|
ANCHOR_LABEL = "BJ 08:00"
|
|
|
|
|
|
|
|
|
|
_OPEN_CACHE_LOCK = threading.Lock()
|
|
|
|
|
_OPEN_CACHE: dict[str, dict[str, Any]] = {}
|
|
|
|
|
_OPEN_CACHE_TTL_SEC = 120.0
|
|
|
|
|
# (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]:
|
|
|
|
@@ -42,131 +45,74 @@ def session_day_key(now: Optional[datetime] = None, reset_hour: int = 8) -> str:
|
|
|
|
|
return dt.strftime("%Y-%m-%d")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _public_last(ex: Any, inst_id: str, last_key: str = "last") -> Optional[float]:
|
|
|
|
|
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 _safe_float(rows[0].get(last_key))
|
|
|
|
|
return rows[0]
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _public_index_last(ex: Any, inst_id: str) -> Optional[float]:
|
|
|
|
|
try:
|
|
|
|
|
rows = ex.public_get_market_index_tickers({"instId": inst_id}).get("data") or []
|
|
|
|
|
if rows and isinstance(rows[0], dict):
|
|
|
|
|
return _safe_float(rows[0].get("idxPx"))
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ccxt_last(ex: Any, symbol: str) -> Optional[float]:
|
|
|
|
|
try:
|
|
|
|
|
t = ex.fetch_ticker(symbol)
|
|
|
|
|
return _safe_float(t.get("last") or t.get("close"))
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_spot_last(ex: Any, spot_inst: str, swap_sym: str, index_inst: str) -> Optional[float]:
|
|
|
|
|
# 1) 现货 2) 永续(本站常用) 3) 指数
|
|
|
|
|
last = _public_last(ex, spot_inst, "last")
|
|
|
|
|
if last is not None:
|
|
|
|
|
return last
|
|
|
|
|
last = _public_last(ex, f"{spot_inst}-SWAP", "last")
|
|
|
|
|
if last is not None:
|
|
|
|
|
return last
|
|
|
|
|
last = _ccxt_last(ex, swap_sym)
|
|
|
|
|
if last is not None:
|
|
|
|
|
return last
|
|
|
|
|
last = _ccxt_last(ex, spot_inst.replace("-", "/"))
|
|
|
|
|
if last is not None:
|
|
|
|
|
return last
|
|
|
|
|
return _public_index_last(ex, index_inst)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _public_candle_open(ex: Any, inst_id: str, bar: str = "1D") -> Optional[float]:
|
|
|
|
|
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:
|
|
|
|
|
row = rows[0]
|
|
|
|
|
if isinstance(row, (list, tuple)) and len(row) > 1:
|
|
|
|
|
return _safe_float(row[1])
|
|
|
|
|
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 _public_index_candle_open(ex: Any, inst_id: str) -> Optional[float]:
|
|
|
|
|
def _ccxt_last_and_anchor_open(ex: Any, symbol: str) -> tuple[Optional[float], Optional[float]]:
|
|
|
|
|
last = None
|
|
|
|
|
open_px = None
|
|
|
|
|
try:
|
|
|
|
|
rows = ex.public_get_market_index_candles(
|
|
|
|
|
{"instId": inst_id, "bar": "1D", "limit": "1"}
|
|
|
|
|
).get("data") or []
|
|
|
|
|
if rows:
|
|
|
|
|
row = rows[0]
|
|
|
|
|
if isinstance(row, (list, tuple)) and len(row) > 1:
|
|
|
|
|
return _safe_float(row[1])
|
|
|
|
|
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 None
|
|
|
|
|
return last, open_px
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ccxt_day_open(ex: Any, symbol: str) -> Optional[float]:
|
|
|
|
|
try:
|
|
|
|
|
ohlcv = ex.fetch_ohlcv(symbol, timeframe="1d", limit=1) or []
|
|
|
|
|
if ohlcv:
|
|
|
|
|
return _safe_float(ohlcv[-1][1])
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
return None
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
def _fetch_day_open_bj8(
|
|
|
|
|
ex: Any, spot_inst: str, swap_sym: str, index_inst: str
|
|
|
|
|
) -> Optional[float]:
|
|
|
|
|
"""OKX 1D candle open is UTC 00:00 = Beijing 08:00."""
|
|
|
|
|
open_px = _public_candle_open(ex, spot_inst)
|
|
|
|
|
if open_px is not None:
|
|
|
|
|
return open_px
|
|
|
|
|
open_px = _public_candle_open(ex, f"{spot_inst}-SWAP")
|
|
|
|
|
if open_px is not None:
|
|
|
|
|
return open_px
|
|
|
|
|
open_px = _ccxt_day_open(ex, swap_sym)
|
|
|
|
|
if open_px is not None:
|
|
|
|
|
return open_px
|
|
|
|
|
open_px = _ccxt_day_open(ex, spot_inst.replace("-", "/"))
|
|
|
|
|
if open_px is not None:
|
|
|
|
|
return open_px
|
|
|
|
|
return _public_index_candle_open(ex, index_inst)
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cached_day_open(
|
|
|
|
|
ex: Any, cache_key: str, spot_inst: str, swap_sym: str, index_inst: str, day_key: str
|
|
|
|
|
) -> Optional[float]:
|
|
|
|
|
now_ts = time.time()
|
|
|
|
|
with _OPEN_CACHE_LOCK:
|
|
|
|
|
entry = _OPEN_CACHE.get(cache_key)
|
|
|
|
|
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, spot_inst, swap_sym, index_inst)
|
|
|
|
|
if open_px is not None:
|
|
|
|
|
with _OPEN_CACHE_LOCK:
|
|
|
|
|
_OPEN_CACHE[cache_key] = {
|
|
|
|
|
"day_key": day_key,
|
|
|
|
|
"open": open_px,
|
|
|
|
|
"fetched_at": now_ts,
|
|
|
|
|
}
|
|
|
|
|
return 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(
|
|
|
|
@@ -175,15 +121,12 @@ def build_nav_spot_tickers(
|
|
|
|
|
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."""
|
|
|
|
|
"""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, spot_inst, display, swap_sym, index_inst in NAV_SPOT_SPECS:
|
|
|
|
|
last = _fetch_spot_last(exchange, spot_inst, swap_sym, index_inst)
|
|
|
|
|
open_px = _cached_day_open(
|
|
|
|
|
exchange, spot_inst, spot_inst, swap_sym, index_inst, day_key
|
|
|
|
|
)
|
|
|
|
|
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:
|
|
|
|
@@ -192,14 +135,15 @@ def build_nav_spot_tickers(
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
"base": base,
|
|
|
|
|
"inst_id": spot_inst,
|
|
|
|
|
"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": f"BJ {int(reset_hour):02d}:00",
|
|
|
|
|
"anchor": ANCHOR_LABEL,
|
|
|
|
|
"anchor_field": ANCHOR_SOD_FIELD,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return out
|
|
|
|
|