# hl_slippage.py — walk Hyperliquid's historical 25-level order book for a
# market order and compare the price you would actually have got with the mid
# a naive backtest fills at. Time-weighted: every book state counts for as
# long as it was live, so the numbers answer "at a random moment of the day".
#
# Runs on a Hyperliquid pull as delivered (hl_l2/date=D/<COIN>/l2.parquet …),
# including the free sample day, 2026-08-12, that any free account can pull:
# https://tickfoundry.com/catalog/hyperliquid
#
#   python hl_slippage.py [bundle_dir] [COIN ...]      # default: . BTC HYPE
import glob
import sys

import numpy as np
import pandas as pd

root = sys.argv[1] if len(sys.argv) > 1 else "."
coins = sys.argv[2:] or ["BTC", "HYPE"]
SIZES = (10_000, 100_000, 1_000_000)  # order notional, USD
LEVELS = range(1, 26)
COLS = ["ts_src_ms", "msg_seq", "best_bid", "best_ask"] + [
    f"{side}_{k}_{i}" for side in ("bid", "ask") for k in ("px", "sz") for i in LEVELS
]


def load(table, coin, columns=None):
    files = sorted(glob.glob(f"{root}/hl_{table}/date=*/{coin}/{table}.parquet"))
    if not files:
        sys.exit(f"no hl_{table} files for {coin} under {root}")
    return pd.concat([pd.read_parquet(f, columns=columns) for f in files], ignore_index=True)


def walk(px, sz, usd):
    """Average fill price of a market order for `usd` notional, taking levels
    best-first (px/sz: rows x 25). NaN where the 25 levels hold less."""
    cost = px * sz  # $ resting at each level
    cum = np.nancumsum(cost, axis=1)
    take = np.minimum(cost, np.maximum(usd - (cum - cost), 0))  # $ taken per level
    qty = np.nansum(take / px, axis=1)
    with np.errstate(divide="ignore", invalid="ignore"):
        return np.where(cum[:, -1] >= usd, usd / qty, np.nan)


def wq(x, w, q):
    """Weighted quantile of x, ignoring NaN."""
    ok = ~np.isnan(x)
    x, w = x[ok], w[ok]
    order = np.argsort(x)
    cw = np.cumsum(w[order])
    return x[order][np.searchsorted(cw, q * cw[-1])]


for coin in coins:
    book = load("l2", coin, COLS).sort_values("msg_seq").reset_index(drop=True)
    book = book[book.best_bid.notna() & book.best_ask.notna()].reset_index(drop=True)
    # each row is the book from its block until the next change
    w = book.ts_src_ms.shift(-1).sub(book.ts_src_ms).fillna(0).to_numpy(float)
    mid = ((book.best_bid + book.best_ask) / 2).to_numpy()
    spread_bps = ((book.best_ask - book.best_bid) / mid * 1e4).to_numpy()
    px = {s: book[[f"{s}_px_{i}" for i in LEVELS]].to_numpy() for s in ("bid", "ask")}
    sz = {s: book[[f"{s}_sz_{i}" for i in LEVELS]].to_numpy() for s in ("bid", "ask")}
    depth = {s: np.nansum(px[s] * sz[s], axis=1) for s in ("bid", "ask")}  # $ in 25 levels

    fills = load("trades", coin, ["price", "size"])
    hours = w.sum() / 3.6e6
    print(f"\n{coin} · {len(book):,} book changes · {len(fills):,} fills · "
          f"${(fills.price * fills['size']).sum() / 1e6:,.0f}M traded · {hours:.1f} h")
    print(f"spread: time-weighted mean {np.average(spread_bps, weights=w):.2f} bps, "
          f"median {wq(spread_bps, w, 0.5):.2f} bps")
    print(f"$ in the 25 visible levels (median): bids ${wq(depth['bid'], w, 0.5) / 1e6:.2f}M, "
          f"asks ${wq(depth['ask'], w, 0.5) / 1e6:.2f}M")

    rows = []
    for usd in SIZES:
        for side, book_side, sign in (("buy", "ask", 1), ("sell", "bid", -1)):
            vwap = walk(px[book_side], sz[book_side], usd)
            slip = sign * (vwap / mid - 1) * 1e4  # bps worse than mid
            touch = sign * (vwap / book[f"best_{book_side}"].to_numpy() - 1) * 1e4
            rows.append({
                "order": f"{side} ${usd:,}",
                "fillable": f"{w[~np.isnan(vwap)].sum() / w.sum():.1%}",  # share of the day
                "vs mid, median (bps)": wq(slip, w, 0.5),
                "vs mid, p90 (bps)": wq(slip, w, 0.9),
                "vs touch, median (bps)": wq(touch, w, 0.5),
            })
    print(pd.DataFrame(rows).round(2).to_string(index=False))
