""" HPLL-DataNode — Super Space Gộp HPLL-L4 (WebSocket Collector) + HPLL-Transformer (ETL Processor) vào 1 Space. Thu thập toàn bộ dữ liệu Hyperliquid real-time và push vào HF Dataset. """ import asyncio import websockets import json import pandas as pd import re import gc import os import sys import time import threading import requests from datetime import datetime from http.server import HTTPServer, BaseHTTPRequestHandler from huggingface_hub import HfApi, CommitOperationAdd, CommitOperationDelete, hf_hub_download # ═══════════════════════════════════════════════════════════════ # CONFIG # ═══════════════════════════════════════════════════════════════ HF_TOKEN = os.environ.get("HF_TOKEN") DATASET_ID = os.environ.get("HF_DATASET_ID", "gionuibk/hyperliquidL2Book-v2") DATA_DIR = "/data" CACHE_DIR = "/data/cache" SELF_URL = "https://gionuibk-hpll-datanode.hf.space" WS_URI = "wss://api.hyperliquid.xyz/ws" FLUSH_INTERVAL_S = 600 # flush raw data every 10 min (RAM -> SSD -> HF commit) ETL_INTERVAL_S = 3600 # run ETL every 60 min to reduce Hugging Face API rate limits os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True) if not HF_TOKEN: print("ERROR: HF_TOKEN not set!", flush=True) sys.exit(1) api = HfApi(token=HF_TOKEN) # ═══════════════════════════════════════════════════════════════ # SHARED STATE # ═══════════════════════════════════════════════════════════════ BUFFER_LOCK = threading.Lock() GLOBAL_BUFFERS = { "l2book": [], "trades": [], "candles": [], "liquidations": [], "webdata": [], } STATS = { "start_time": time.time(), "last_ws_msg": time.time(), "l2book_msg": 0, "trades_msg": 0, "candles_msg": 0, "webdata_msg": 0, "liquidations_detected":0, "flush_count": 0, "etl_count": 0, "errors": 0, "ws_status": "Connecting", } def log(msg, tag="DataNode"): ts = datetime.now().strftime("%H:%M:%S") print(f"[{tag}][{ts}] {msg}", flush=True) # ═══════════════════════════════════════════════════════════════ # HEALTH SERVER # ═══════════════════════════════════════════════════════════════ class HealthHandler(BaseHTTPRequestHandler): def do_GET(self): uptime = int(time.time() - STATS["start_time"]) payload = { "service": "HPLL-DataNode", "version": "2.0-merged", "status": "running", "ws_status": STATS["ws_status"], "uptime_s": uptime, "l2book_msg": STATS["l2book_msg"], "trades_msg": STATS["trades_msg"], "candles_msg": STATS["candles_msg"], "webdata_msg": STATS["webdata_msg"], "liquidations":STATS["liquidations_detected"], "flush_count": STATS["flush_count"], "etl_count": STATS["etl_count"], "errors": STATS["errors"], } # Check if client wants JSON if self.path in ["/api", "/json"] or "application/json" in self.headers.get("Accept", ""): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(payload).encode()) return # Render styled HTML dashboard self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() html = f""" Aetheris HPLL-DataNode

Aetheris HPLL-DataNode

WebSocket Data Collector running smoothly.

Uptime
{uptime//3600}h {(uptime%3600)//60}m
WS Connection
{payload['ws_status']}
L2 Book Messages
{payload['l2book_msg']:,}
Trades Messages
{payload['trades_msg']:,}
Candles Messages
{payload['candles_msg']:,}
Dataset Flushes
{payload['flush_count']}
""" self.wfile.write(html.encode('utf-8')) def log_message(self, *args): pass def start_health_server(): server = HTTPServer(("0.0.0.0", 7860), HealthHandler) log("Health server started on :7860") server.serve_forever() threading.Thread(target=start_health_server, daemon=True).start() # ═══════════════════════════════════════════════════════════════ # SELF KEEPALIVE (ping every 90s để HF không sleep Space) # ═══════════════════════════════════════════════════════════════ def start_keepalive(): log("Keepalive started (90s interval)") while True: time.sleep(90) try: r = requests.get(SELF_URL, timeout=15) log(f"🏓 Self-ping: HTTP {r.status_code}") except Exception as e: log(f"⚠️ Self-ping fail: {e}") threading.Thread(target=start_keepalive, daemon=True).start() # ═══════════════════════════════════════════════════════════════ # WATCHDOG — restart container nếu không có data 3 phút # ═══════════════════════════════════════════════════════════════ def start_watchdog(): log("Watchdog started (timeout=180s)") while True: time.sleep(60) silence = time.time() - STATS["last_ws_msg"] if silence > 180: log(f"CRITICAL: No WS data for {int(silence)}s — exiting to force restart") sys.exit(1) uptime = int(time.time() - STATS["start_time"]) log(f"❤️ Uptime: {uptime//3600}h{(uptime%3600)//60}m | WS: {STATS['ws_status']} | " f"L2:{STATS['l2book_msg']} T:{STATS['trades_msg']} C:{STATS['candles_msg']}") threading.Thread(target=start_watchdog, daemon=True).start() # ═══════════════════════════════════════════════════════════════ # MODULE 1 — COLLECTOR (WebSocket → Buffer) # ═══════════════════════════════════════════════════════════════ def get_all_coins(): try: resp = requests.post( "https://api.hyperliquid.xyz/info", headers={"Content-Type": "application/json"}, json={"type": "meta"}, timeout=15 ) perps = [a["name"] for a in resp.json().get("universe", [])] log(f"Universe: {len(perps)} perp coins") return perps except Exception as e: log(f"Failed to fetch coin list: {e} — using fallback") return ["BTC", "ETH", "SOL", "DOGE", "AVAX", "XRP", "LINK", "ARB", "OP", "SEI"] async def drain_messages(ws, timeout=0.05): drained = 0 while True: try: msg_txt = await asyncio.wait_for(ws.recv(), timeout=timeout) STATS["last_ws_msg"] = time.time() _process_message(msg_txt) drained += 1 except asyncio.TimeoutError: break return drained def _process_message(msg_txt): try: msg = json.loads(msg_txt) channel = msg.get("channel") data = msg.get("data") if not data: return now = datetime.now() coin = None buf = None if channel == "l2Book": coin = data.get("coin") buf = "l2book" STATS["l2book_msg"] += 1 elif channel == "trades": if isinstance(data, list) and data: coin = data[0].get("coin") # Detect liquidations for t in data: if t.get("liquidation") or "liq" in str(t.get("users", [])).lower(): with BUFFER_LOCK: GLOBAL_BUFFERS["liquidations"].append( {"timestamp": now, "coin": coin, "payload": msg_txt} ) STATS["liquidations_detected"] += 1 break buf = "trades" STATS["trades_msg"] += 1 elif channel == "candle": coin = data.get("s") buf = "candles" STATS["candles_msg"] += 1 elif channel == "webData2": coin = "ALL" buf = "webdata" STATS["webdata_msg"] += 1 if buf and coin: with BUFFER_LOCK: GLOBAL_BUFFERS[buf].append( {"timestamp": now, "coin": coin, "payload": msg_txt} ) except Exception: pass async def subscribe_batch(ws, coins): await ws.send(json.dumps({ "method": "subscribe", "subscription": {"type": "webData2", "user": "0x0000000000000000000000000000000000000000"} })) CHUNK = 10 for i in range(0, len(coins), CHUNK): batch = coins[i:i+CHUNK] for coin in batch: await ws.send(json.dumps({"method": "subscribe", "subscription": {"type": "l2Book", "coin": coin}})) await ws.send(json.dumps({"method": "subscribe", "subscription": {"type": "trades", "coin": coin}})) await ws.send(json.dumps({"method": "subscribe", "subscription": {"type": "candle", "coin": coin, "interval": "1m"}})) drained = await drain_messages(ws) log(f"Subscribed {min(i+CHUNK, len(coins))}/{len(coins)} coins (drained {drained})") await asyncio.sleep(0.3) async def collector_loop(): while True: try: coins = get_all_coins() STATS["ws_status"] = "Connecting" async with websockets.connect(WS_URI, ping_interval=30, ping_timeout=60, max_size=None) as ws: STATS["ws_status"] = "Connected" await subscribe_batch(ws, coins) log(f"✅ Collecting from {len(coins)} coins...") while True: msg_txt = await ws.recv() STATS["last_ws_msg"] = time.time() _process_message(msg_txt) except Exception as e: STATS["ws_status"] = "Disconnected" STATS["errors"] += 1 log(f"WS error: {e} — reconnecting in 5s") await asyncio.sleep(5) # ═══════════════════════════════════════════════════════════════ # MODULE 2 — RAW FLUSH (Buffer → HF Dataset raw parquet) # ═══════════════════════════════════════════════════════════════ def _make_commit_op(data, dtype): if not data: return None try: ts = int(datetime.now().timestamp()) now = datetime.now() year = now.strftime("%Y") month = now.strftime("%m") day = now.strftime("%d") folder = f"l4_{dtype}/year={year}/month={month}/day={day}" fname = f"l4_{dtype}_{ts}.parquet" local = os.path.join(DATA_DIR, fname) pd.DataFrame(data).to_parquet(local, compression="snappy", index=False) with open(local, "rb") as f: content = f.read() os.remove(local) return CommitOperationAdd(path_in_repo=f"data/{folder}/{fname}", path_or_fileobj=content) except Exception as e: log(f"Prepare op failed ({dtype}): {e}") STATS["errors"] += 1 return None async def flush_loop(): """Flush raw buffers to HF dataset every FLUSH_INTERVAL_S seconds.""" while True: await asyncio.sleep(FLUSH_INTERVAL_S) ops = [] labels = [] with BUFFER_LOCK: for dtype, buf in GLOBAL_BUFFERS.items(): if buf: chunk = list(buf) buf.clear() op = _make_commit_op(chunk, dtype) if op: ops.append(op) labels.append(f"{dtype}:{len(chunk)}") if ops: STATS["flush_count"] += 1 log(f"═══ FLUSH #{STATS['flush_count']} | {', '.join(labels)}") try: await asyncio.to_thread( api.create_commit, repo_id=DATASET_ID, repo_type="dataset", operations=ops, commit_message=f"Auto: Raw flush #{STATS['flush_count']}" ) log("Flush committed ✅") except Exception as e: log(f"Flush commit failed: {e}") if "429" in str(e): await asyncio.sleep(600) # ═══════════════════════════════════════════════════════════════ # MODULE 3 — ETL (Raw parquet → Parsed parquet) # ═══════════════════════════════════════════════════════════════ def _etl_channel(channel, raw_folder, target_folder, all_files): ops = [] raw_files = sorted([f for f in all_files if f.startswith(f"data/{raw_folder}/") and f.endswith(".parquet")]) if not raw_files: return ops # Watermark done = [f for f in all_files if f.startswith(f"data/{target_folder}/") and f.endswith(".parquet")] pat = re.compile(rf"{channel}_(\d+)_(\d+)\.parquet") max_ts = 0.0 for f in done: m = pat.match(f.split("/")[-1]) if m: t = int(m.group(2)) if t > max_ts: max_ts = float(t) new_files = [] for f in raw_files: try: ts = int(f.split("/")[-1].replace(".parquet","").split("_")[-1]) if ts > max_ts: new_files.append(f) except: continue if not new_files: return ops batch = new_files[:3] log(f"ETL [{channel}]: {len(new_files)} new files, processing {len(batch)}", "ETL") records = [] for fp in batch: local = None try: local = hf_hub_download(repo_id=DATASET_ID, filename=fp, repo_type="dataset", local_dir=CACHE_DIR, token=HF_TOKEN) df = pd.read_parquet(local) for _, row in df.iterrows(): try: payload = json.loads(row["payload"]) d = payload.get("data") if channel == "l2book" and d: coin = d.get("coin") ts = d.get("time") lvls = d.get("levels") if lvls and len(lvls) == 2: rec = {"coin": coin, "timestamp": datetime.fromtimestamp(ts/1000) if ts else row["timestamp"], "server_time": ts} for i in range(20): bids, asks = lvls[0], lvls[1] rec[f"bid_px_{i+1}"] = float(bids[i]["px"]) if i < len(bids) else 0.0 rec[f"bid_sz_{i+1}"] = float(bids[i]["sz"]) if i < len(bids) else 0.0 rec[f"ask_px_{i+1}"] = float(asks[i]["px"]) if i < len(asks) else 0.0 rec[f"ask_sz_{i+1}"] = float(asks[i]["sz"]) if i < len(asks) else 0.0 records.append(rec) elif channel == "trades" and isinstance(d, list): for t in d: t["timestamp"] = datetime.fromtimestamp(t.get("time", 0)/1000) records.append(t) elif channel == "candles" and d: records.append({ "coin": d.get("s"), "timestamp": datetime.fromtimestamp(d.get("t",0)/1000), "open": float(d.get("o",0)), "high": float(d.get("h",0)), "low": float(d.get("l",0)), "close": float(d.get("c",0)), "volume": float(d.get("v",0)), "num_trades": d.get("n",0) }) except: continue del df; gc.collect() except Exception as e: log(f"ETL file error {fp}: {e}", "ETL") finally: if local and os.path.exists(local): os.remove(local) if records: tdf = pd.DataFrame(records) mn_ts = int(tdf["timestamp"].min().timestamp()) mx_ts = int(tdf["timestamp"].max().timestamp()) now = datetime.now() year = now.strftime("%Y") month = now.strftime("%m") day = now.strftime("%d") folder = f"{target_folder}/year={year}/month={month}/day={day}" fname = f"{channel}_{mn_ts}_{mx_ts}.parquet" lpath = os.path.join(CACHE_DIR, fname) tdf.to_parquet(lpath, index=False, compression="snappy") with open(lpath, "rb") as f: ops.append(CommitOperationAdd(path_in_repo=f"data/{folder}/{fname}", path_or_fileobj=f.read())) os.remove(lpath) del records, tdf; gc.collect() return ops async def etl_loop(): """Run ETL every ETL_INTERVAL_S seconds.""" await asyncio.sleep(60) # Wait 1 min after start before first ETL while True: try: log("Starting ETL cycle...", "ETL") all_files = await asyncio.to_thread( lambda: list(api.list_repo_files(repo_id=DATASET_ID, repo_type="dataset")) ) all_ops = [] for ch, raw_f, tgt_f in [ ("l2book", "l4_l2book", "l2book"), ("trades", "l4_trades", "trades"), ("candles", "l4_candles", "candles"), ]: ops = await asyncio.to_thread(_etl_channel, ch, raw_f, tgt_f, all_files) all_ops.extend(ops) gc.collect() if all_ops: STATS["etl_count"] += 1 log(f"ETL commit: {len(all_ops)} ops", "ETL") await asyncio.to_thread( api.create_commit, repo_id=DATASET_ID, repo_type="dataset", operations=all_ops, commit_message=f"ETL: Batch #{STATS['etl_count']}" ) log("ETL committed ✅", "ETL") else: log("ETL: No new data.", "ETL") except Exception as e: log(f"ETL cycle error: {e}", "ETL") if "429" in str(e): await asyncio.sleep(600) await asyncio.sleep(ETL_INTERVAL_S) # ═══════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════ async def main(): log("🚀 HPLL-DataNode v2.0 (Collector only) starting...") threading.Thread(target=start_watchdog, daemon=True).start() await asyncio.gather( collector_loop(), flush_loop(), ) if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: pass