"""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