340 lines
16 KiB
Python
340 lines
16 KiB
Python
|
|
#!/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)
|