Files
sentiment-engine/prod/ops/f13_atom_compensatory_close.py

340 lines
16 KiB
Python
Raw Normal View History

#!/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)