#!/usr/bin/env python3 # SPDX-License-Identifier: MIT # # f13_atom_compensatory_close.py # ---------------------------------------------------------------------------- # Ad-hoc, operator-authorized compensatory close for the ATOM SHORT residual # (force-c3760f497e) on HyperLiquid TESTNET. # # Reuses FLIGHT's modules BY IMPORT ONLY — it does NOT edit, create, or move # any file under /root/uv-wt/f13-hl (the live v20r1 kernel tree). It builds the # same HlVenueAdapter(FLIGHT uses, loads the SAME sealed keystore # (/root/.hl_creds/hl_testnet.cred via HlWallet.from_keystore), and submits a # real signed /exchange order through the SAME HlHttpClient the kernel uses. # # MANEUVER (operator 2026-08-26: "market-making compensatory ATOM order to lift # the position above the limit and THEN close it", "you pick most profitable / # least costly"): # 1. MAKER leg -> reduce-only BUY LIMIT @ best bid (posts liquidity = MM, # 1.5 bp if lifted). This is the "compensatory lift". # 2. CROSS leg -> if the thin testnet book leaves remainder unfilled after # --cross-delay s, submit a reduce-only BUY MARKET (taker, 4.5 bp) to # GUARANTEE the close. (Mirrors v20r1's own maker->TTL-market exit.) # # SAFETY GATES (fail-closed; doctrine: verify-before-fire): # * Hard TESTNET gate: HlExecClientConfig(environment=TESTNET). mainnet is not # a code path here — validate_mainnet_opt_in() rejects it. The adapter # additionally refuses untraceable intents on mainnet (_submit_async fence). # * Canonical intent_id from order_identity.new_intent_id() -> `u-f13<29hex>`, # so the 2026-08-12 provenance fence emits NO warning and never risks the # 2026-08-11 "stray intent froze the account 9h" class of mistake. # * Signing key is loaded ONLY via the kernel's own HlWallet.from_keystore # (sealed .cred). If it isn't loadable in this context the tool exits before # any /exchange call. The private key is never read/echoed by this script. # * Default = DRY-RUN. Posting requires BOTH `--execute` AND `--live-testnet-order` # (double opt-in on a script that can move live testnet capital). # * WIRE-ORDER GATING: before any POST, the script prints the EXACT wire order # (_order_action_for) and ABORTS unless it is reduce_only=True, is_buy=True # (BUY to close a SHORT), orderType=LIMIT (maker) — so a wrong side/size can # never fire. # * Rate limits respected (>=1s between /info reads; cross-legged with the # kernel's own ~30/hr firehose). # * Does NOT touch PID 790060. Separate signed session on the same key. # # USAGE: # python3 prod/ops/f13_atom_compensatory_close.py # DRY-RUN (default) # python3 prod/ops/f13_atom_compensatory_close.py --execute --live-testnet-order # python3 prod/ops/f13_atom_compensatory_close.py --help # python3 prod/ops/f13_atom_compensatory_close.py --qty 1.0 --lift-px 1.50 # close a slice @ maker # # ENV: run from /mnt/dolphinng5_predict or import path; requires the sealed # /root/.hl_creds/hl_testnet.cred to be loadable in the run context (the boot # namespace). Reads the kernel's HL tree at /root/uv-wt/f13-hl (read-only). from __future__ import annotations import argparse import sys import time import json import asyncio import secrets import traceback from datetime import datetime, timezone F13_ROOT = "/root/uv-wt/f13-hl" # FLIGHT kernel tree (import only; never edited) if F13_ROOT not in sys.path: sys.path.insert(0, F13_ROOT) from prod.hl.config import HlExecClientConfig, HlEnvironment from prod.clean_arch.dita_v2.order_identity import new_intent_id, is_canonical_intent_id from prod.clean_arch.dita_v2.contracts import KernelIntent, KernelCommandType, TradeSide FEE_MAKER_BP = 1.5 # seeded HL testnet maker (runner.log 16:21:08) FEE_TAKER_BP = 4.5 # seeded HL testnet taker RATE_SAFE_SLEEP = 1.0 # >=1s between /info reads (HL testnet limit ~120/min) # Public testnet signer address (printed in the boot banner; NOT a secret). # Used only for the unsigned /info clearinghouseState(user=...) read. SIGNER_ADDR = "0x6af790574E5E9EA067FB4Ce7458B429039cE3f96" def log(msg: str, *, warn: bool = False) -> None: print(f"{'!! ' if warn else '>> '}{msg}", flush=True) # --------------------------------------------------------------------------- # # Venue / credentials (TESTNET-gated, fail-closed on key load) # # --------------------------------------------------------------------------- # def make_testnet_cfg() -> HlExecClientConfig: """Build the HL exec config — hard-locked to TESTNET.""" cfg = HlExecClientConfig( environment=HlEnvironment.TESTNET, # never MAINNET in this tool keystore_name="hl_testnet", keystore_dir="/root/.hl_creds", ) cfg.validate_mainnet_opt_in() # raises if not testnet return cfg def build_venue(cfg: HlExecClientConfig): """Construct the venue adapter; this is where the sealed keystore is loaded.""" from prod.clean_arch.dita_v2.hl_venue import HlVenueAdapter venue = HlVenueAdapter(config=cfg) # HlWallet.from_keystore(...) here _addr = getattr(venue._wallet, "address", None) addr = _addr() if callable(_addr) else _addr log(f"HlVenueAdapter up on {cfg.resolve_urls()['http']} (testnet) | signer {addr}") rc = venue._run(venue.connect()) # instruments + http session warm-up log(f"connect() -> {'OK' if rc else 'FALSE (venue REST may be degraded; reads may fail)'}") return venue def _info(venue, payload: dict, retries: int = 5, backoff: float = 2.0): """Unsigned /info POST with transient (5xx) retry. 4xx/422 = hard fail.""" last = None for _ in range(retries): time.sleep(RATE_SAFE_SLEEP) try: return venue._run(venue._http.info_post(payload)) except Exception as e: # noqa: BLE001 last = e s = str(e) if any(c in s for c in ("502", "503", "504")) or "timeout" in s.lower(): log(f"/info transient ({type(e).__name__}); retrying...", warn=True) time.sleep(backoff); backoff *= 1.5 continue raise raise RuntimeError(f"/info failed after {retries} retries: {last}") def get_atom_position(venue) -> dict: state = _info(venue, {"type": "clearinghouseState", "user": SIGNER_ADDR}) for r in (state or {}).get("assetPositions", []) or []: p = (r or {}).get("position", {}) or {} if (p.get("coin") or "").upper() == "ATOM": return p log("No ATOM position on venue (maybe already closed).", warn=True) return {} def get_atom_mid(venue, coin: str = "ATOM") -> float: """All-mids (robust) — used when the testnet book is thin/empty.""" m = _info(venue, {"type": "allMids"}) try: return float((m or {}).get(coin, 0.0)) except Exception: return 0.0 def get_bid(venue, coin: str = "ATOM") -> float: """Best bid from l2Book; falls back to mid if the book is drained (thin testnet).""" b = _info(venue, {"type": "l2Book", "coin": coin}) bids = (b or {}).get("bids") or [] if bids: return float(bids[0][0]) mid = get_atom_mid(venue, coin) log(f"l2Book {coin} empty (thin testnet) — falling back to allMids mid for pricing.", warn=True) return mid # --------------------------------------------------------------------------- # # Intent + wire-order gated construction # # --------------------------------------------------------------------------- # def new_trade_id() -> str: return f"force-{secrets.token_hex(14)}" # 29-hex trade_id, force- namespace def build_intent(asset: str, side: TradeSide, action: KernelCommandType, size: float, px: float, px_for_limit: float, order_type: str, trade_id: str, reason: str, tif: str | None = None) -> KernelIntent: # EXIT => reduce_only=True automatically (_order_action_for flips is_buy). # For a true post-only MAKER (resting liquidity / MM) an EXIT-LIMIT must # carry _time_in_force="Alo"; the adapter default stamps EXIT->Ioc (taker). # For a taker cross, MARKET -> Ioc implicitly. tif is set only for LIMIT. md = {"reason": reason, "op": "f13_compensatory_close"} if order_type.upper() == "LIMIT" and tif: md["_time_in_force"] = tif return KernelIntent( timestamp=datetime.now(tz=timezone.utc), intent_id=new_intent_id(trade_id, purpose="compensatory_atom_close"), # canonical u-f13<29hex> trade_id=trade_id, slot_id=0, asset=asset, # ATOMUSDT (UV *USDT grammar, adapter maps -> ATOM) side=side, action=action, reference_price=float(px), target_size=float(size), leverage=1.0, order_type=order_type.upper(), # LIMIT (maker) | MARKET (taker cross) limit_price=float(px_for_limit), reason=reason, metadata=md, ) def introspect_wire(venue, intent: KernelIntent) -> dict: """Return the EXACT HL /exchange action dict the adapter would sign (no POST).""" return venue._order_action_for(intent) def fmt_wire(a: dict) -> str: # HL compact wire shape: a=asset, b=is_buy, p=price, s=size, r=reduce_only, # t={limit:{tif}}, c=cloid. Print the full blob (digs below). return json.dumps(a, default=str)[:380] def intent_is_close(intent: KernelIntent) -> bool: """Validate the INTENT semantics BEFORE any signature (doctrine: verify-before-fire). KernelIntent.side is the POSITION side (adapter _order_action_for flips it to the order direction for reduce_only): closing a SHORT position => side=SHORT, action=EXIT => adapter emits is_buy=True (BUY to close). For this residual (SHORT) side must be SHORT. """ return (intent.action == KernelCommandType.EXIT # => reduce_only=True and intent.side == TradeSide.SHORT # closes the SHORT residual -> BUY and float(intent.target_size) > 0.0) # non-zero # --------------------------------------------------------------------------- # # Main # # --------------------------------------------------------------------------- # def main() -> int: ap = argparse.ArgumentParser(description="Compensatory ATOM close (HL testnet, dry-run default).") ap.add_argument("--asset", default="ATOMUSDT") ap.add_argument("--qty", type=float, default=0.0, help="0 = close full residual") ap.add_argument("--lift-px", type=float, default=0.0, help="0 = best bid (or mid if book empty)") ap.add_argument("--cross-delay", type=int, default=30, help="s to wait for maker fill before taker-cross fallback") ap.add_argument("--no-cross", action="store_true", help="disable taker-cross fallback") ap.add_argument("--execute", action="store_true", help="actually POST (requires --live-testnet-order too)") ap.add_argument("--live-testnet-order", action="store_true", help="second opt-in: confirms you know this places a LIVE TESTNET order") a = ap.parse_args() do_execute = a.execute and a.live_testnet_order log(f"asset={a.asset} mode={'EXECUTE (live testnet)' if do_execute else 'DRY-RUN (no orders)'}") cfg = make_testnet_cfg() try: venue = build_venue(cfg) except Exception as e: log(f"Venue/key build FAILED (fail-closed) -> {type(e).__name__}: {e}", warn=True) return 2 # --- live venue truth (read-only /info) --- pos = get_atom_position(venue) if not pos: return 0 sz = abs(float(pos.get("szi") or 0.0)) side_str = str(pos.get("side") or ("short" if sz < 0 else "long")) # HL clearhouseState uses signed szi; also accept explicit side if float(pos.get("szi") or 0.0) < 0: side_str = "short" entry = float(pos.get("entryPx", 0) or 0) mid = get_atom_mid(venue) bid = a.lift_px or get_bid(venue) roi = float(pos.get("returnOnEquity", 0) or 0) upnl = float(pos.get("unrealizedPnl", 0) or 0) notional = sz * (mid or entry) log(f"venue ATOM: sz={sz} side={side_str} entry={entry} mid={mid} " f"unrealizedPnL={upnl} ROE={roi:.4f} notional~{notional:.2f}") if float(pos.get("szi") or 0.0) >= 0: log("Position is not a SHORT (or flat) — nothing to close.", warn=True) return 3 qty = min(a.qty, sz) if a.qty > 0 else sz if qty <= 0: log("qty=0; nothing to close.", warn=True); return 0 px = bid or mid or entry # maker LIMIT/limit price # --- BUILD compensatory maker LONG-LIMIT BUY to close SHORT; reduce_only via EXIT --- # side=SHORT (position side); adapter flips SHORT+EXIT -> is_buy=True (BUY). tif=Alo => post-only maker (MM). intent = build_intent(a.asset, TradeSide.SHORT, KernelCommandType.EXIT, qty, px=px, px_for_limit=bid, order_type="LIMIT", trade_id=new_trade_id(), reason="compensatory_atm_close_maker", tif="Alo") log(f"intent_id={intent.intent_id} canonical={is_canonical_intent_id(intent.intent_id)} " f"trade_id={intent.trade_id} target_size={intent.target_size} " f"order_type={intent.order_type} limit_price={intent.limit_price}") wire = introspect_wire(venue, intent) fee_maker = qty * (bid or mid or entry) * FEE_MAKER_BP / 1e4 log(f"WIRE ORDER (NOT signed): {fmt_wire(wire)}") log(f"projection: maker close grossPnL={upnl:.4f} - fee~{fee_maker:.4f} " f"({FEE_MAKER_BP}bp); bookEmpty->{'takerCROSS 4.5bp' if bid==mid else 'maker-rest'}") if not do_execute: log("DRY-RUN: no /exchange POST. Re-run with `--execute --live-testnet-order` to fire.") return 0 # --- EXECUTE gate: validate INTENT semantics BEFORE any signature --- if not intent_is_close(intent): log(f"ABORT: intent is not a reduce-only BUY-to-close: " f"action={intent.action} side={intent.side} qty={intent.target_size}", warn=True) return 4 log("intent gate PASSED (EXIT LONG => reduce_only BUY, tif=Alo maker). Full wire action:") log(f"WIRE (NOT signed): {fmt_wire(wire)}") t0 = time.time() events = venue.submit(intent) # ONE signed POST /exchange log(f"maker LIMIT submit -> {[(getattr(e,'kind',e), getattr(e,'status',e), getattr(e,'filled_size','?'), getattr(e,'price','?')) for e in events]}") filled = float(getattr(events[0], "filled_size", 0.0) or 0.0) if events else 0.0 # --- taker-cross fallback for any remainder (thin testnet books) --- if not a.no_cross and filled < qty - 1e-9: rem = qty - filled time.sleep(max(0.0, a.cross_delay - (time.time() - t0))) # re-read actual remainder on venue (kernel may have moved) cur = get_atom_position(venue) rem = min(rem, abs(float(cur.get("szi") or 0.0))) if cur else rem if rem <= 0: log("residual gone (kernel/external already closed). No cross needed."); return 0 log(f"maker unfilled; crossing {rem:.4f} ATOM via reduce-only BUY MARKET (taker).") cx = build_intent(a.asset, TradeSide.SHORT, KernelCommandType.EXIT, rem, px=mid or bid or entry, px_for_limit=mid or bid or entry, order_type="MARKET", trade_id=new_trade_id(), reason="compensatory_atm_close_taker_cross") cwire = introspect_wire(venue, cx) # Cross is a taker MARKET: validate intent semantics (EXIT LONG => reduce_only BUY). if not intent_is_close(cx): log(f"ABORT cross gate: {fmt_wire(cwire)}", warn=True); return 4 log(f"CROSS WIRE (will sign): {fmt_wire(cwire)}") cev = venue.submit(cx) log(f"taker CROSS submit -> {[(getattr(e,'kind',e), getattr(e,'status',e), getattr(e,'filled_size','?'), getattr(e,'price','?')) for e in cev]}") time.sleep(RATE_SAFE_SLEEP) after = get_atom_position(venue) asz = abs(float(after.get("szi") or 0.0)) log(f"POST: residual sz={after.get('szi')} side={after.get('side')} " f"abs={asz:.4f} (flat={asz < 1e-6}).") log("Kernel-reaction tails to watch: runner.log 'MAX_HOLD TERMINAL' / 'BOOK-BLIND HALT' " "+ journal exec_journal for force-* re-arms during venue recovery.") return 0 if asz < 1e-6 else 6 if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: sys.exit(130) except SystemExit: raise except Exception: traceback.print_exc(); sys.exit(1)