diff --git a/prod/ops/f13_atom_compensatory_close.py b/prod/ops/f13_atom_compensatory_close.py new file mode 100644 index 0000000..26a6355 --- /dev/null +++ b/prod/ops/f13_atom_compensatory_close.py @@ -0,0 +1,339 @@ +#!/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)