feat: advanced microstructure modules — HLP, Hawkes, Whipsaw, Term Structure, Liq Waterfall, Spoof Detector

6 new modules with 46 new tests (230 total):

#21 HLP Vault Monitor (live/monitors/hlp_vault.py):
  Tracks Hyperliquid's native protocol market maker at address
  0xfefefe... Queries clearinghouseState + metaAndAssetCtxs.
  - Delta exposure per asset (notional + PnL)
  - Overextension detection (notional exceeds  M threshold)
  - Rebalancing signals: fade_short when HLP too short,
    fade_long when HLP too long (front-run forced rebalancing)
  - Toxicity score: HLP losing money = absorbing informed flow
  - Historical delta tracking

#29 Hawkes Processes (microstructure/hawkes.py):
  Multivariate Hawkes calibrator for limit order book dynamics.
  - MLE calibration via SGD gradient descent on log-likelihood
  - Branching ratio enforcement (alpha/beta < 0.99 for stationarity)
  - Intensity computation λ_i(t) with cross-excitation
  - Activity forecasting (expected event count in horizon)
  - Synthetic event generator (Ogata thinning)
  - Pure functions: hawkes_intensity, hawkes_log_likelihood,
    generate_hawkes_events

#23 Funding Whipsaw Trader (live/strategies/funding_whipsaw.py):
  Premium index decay trading in final 60s of funding epoch.
  - Detects deterministic convergence of premium→0 at settlement
  - Time-scaled position sizing (larger closer to settlement)
  - Auto-close after funding epoch completes
  - Confidence scoring based on premium magnitude

#32 Term Structure Monitor (live/monitors/term_structure.py):
  Perp/quarterly/bi-quarterly futures basis curve trading.
  - Quarterly-perp basis with z-score anomaly detection
  - BiQ-quarterly curve steepness monitoring
  - Fair quarterly price via interest rate parity + funding carry
  - Calendar spread signals: buy_basis, sell_basis,
    curve_steepener, curve_flattener

#24 Liquidation Waterfall (live/monitors/liq_waterfall.py):
  Cross-margin liquidation order prediction.
  - Margin ratio tracking (equity / maintenance margin)
  - Danger/critical level classification
  - Asset liquidation priority: maintenance / book_liquidity ratio
    (least liquid asset relative to margin = dumped first)
  - Strategy output: widen_spreads on target, tighten on rest

#31 Spoof Detector (microstructure/spoof_detector.py):
  Adversarial ML-style spoofing pattern recognition.
  - Rule 1: Large order far from mid, cancelled immediately
  - Rule 2: Cancel right before trade approaches price level
  - Rule 3: Oversized order with no fill within short lifetime
  - Spoof probability (rolling window ratio)
  - Cancel-to-fill ratio monitoring
This commit is contained in:
ramseshk
2026-08-07 17:52:20 +08:00
parent 31c1fe7fbe
commit 5304534e38
11 changed files with 1892 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
"""
HLP (Hyperliquidity Provider) Vault monitoring.
Tracks Hyperliquid's native protocol-level market-making vault at
address 0xfefefefefefefefefefefefefefefefefefefefe.
HLP acts as counterparty to all user trades. When it absorbs toxic flow
or becomes directionally overextended, it must rebalance — creating
predictable market impact that can be traded.
Signals:
- fade_short: HLP is too short → expect buying rebalance → go long
- fade_long: HLP is too long → expect selling rebalance → go short
- neutral: HLP delta is balanced, safe to provide liquidity alongside
"""
from __future__ import annotations
import logging
import time
from collections import deque
from typing import Optional
import requests
logger = logging.getLogger(__name__)
HLP_ADDRESS = "0xfefefefefefefefefefefefefefefefefefefefe"
TESTNET_API = "https://api.hyperliquid-testnet.xyz/info"
MAINNET_API = "https://api.hyperliquid.xyz/info"
class HlpVaultMonitor:
"""Monitor HLP vault state — delta, PnL, rebalancing pressure, toxicity."""
def __init__(
self,
testnet: bool = True,
overextended_threshold: float = 5.0, # notional in $M before overextended
history_window: int = 1000,
):
self._api_url = TESTNET_API if testnet else MAINNET_API
self._overextended_threshold = overextended_threshold * 1_000_000 # convert to USD
self._testnet = testnet
# Per-coin state
self._positions: dict[str, dict] = {} # coin → {side, szi, entry_px, upnl}
self._mark_prices: dict[str, float] = {} # coin → mark price
self._oracle_prices: dict[str, float] = {} # coin → oracle price
self._funding_rates: dict[str, float] = {} # coin → funding rate
self._open_interest: dict[str, float] = {} # coin → OI
# History
self._delta_history: dict[str, deque] = {} # coin → deque of (time, notional)
self._pnl_history: list[dict] = []
self._history_window = history_window
self._last_update: float = 0
self._update_count: int = 0
# ── Core update ──────────────────────────────────────────
def _api_post(self, payload: dict) -> dict:
"""Make a POST request to HL info API (testable via mock)."""
resp = requests.post(self._api_url, json=payload, timeout=10)
resp.raise_for_status()
return resp.json()
def update(self):
"""Fetch latest HLP state from Hyperliquid API.
Makes two calls:
1. metaAndAssetCtxs → mark prices, funding, OI
2. clearinghouseState(HLP_ADDRESS) → positions, PnL
"""
now = time.time()
# Fetch market data
try:
meta_and_ctx = self._api_post({"type": "metaAndAssetCtxs"})
if isinstance(meta_and_ctx, list) and len(meta_and_ctx) >= 2:
universe = meta_and_ctx[0].get("universe", [])
ctxs = meta_and_ctx[1]
for i, asset in enumerate(universe):
name = asset.get("name", "")
if name and i < len(ctxs):
self._mark_prices[name] = float(ctxs[i].get("markPx", 0))
self._oracle_prices[name] = float(ctxs[i].get("oraclePx", 0))
self._funding_rates[name] = float(ctxs[i].get("funding", 0))
self._open_interest[name] = float(ctxs[i].get("openInterest", 0))
except Exception as e:
logger.warning("HLP meta fetch error: %s", e)
# Fetch HLP positions
try:
ch_state = self._api_post({
"type": "clearinghouseState",
"user": HLP_ADDRESS,
})
self._parse_positions(ch_state, now)
except Exception as e:
logger.warning("HLP clearinghouse fetch error: %s", e)
self._last_update = now
self._update_count += 1
def _parse_positions(self, data: dict, now: float):
"""Parse clearinghouseState response into per-coin positions."""
asset_positions = data.get("assetPositions", [])
new_positions: dict[str, dict] = {}
for ap in asset_positions:
pos = ap.get("position", {})
if not pos:
continue
coin = pos.get("coin", "")
if not coin:
continue
szi = float(pos.get("szi", 0))
entry_px = float(pos.get("entryPx", 0))
upnl = float(pos.get("unrealizedPnl", 0))
side = pos.get("side", "") # "A" = short, "B" = long
# HLP short is side="A", long is side="B"
signed_szi = -szi if side == "A" else szi
new_positions[coin] = {
"side": side,
"szi": szi,
"signed_szi": signed_szi,
"entry_px": entry_px,
"unrealized_pnl": upnl,
"mark_px": self._mark_prices.get(coin, entry_px),
"notional_usd": abs(szi) * self._mark_prices.get(coin, entry_px),
}
# Update delta history
if coin not in self._delta_history:
self._delta_history[coin] = deque(maxlen=self._history_window)
self._delta_history[coin].append({
"t": now,
"signed_szi": signed_szi,
"notional_usd": new_positions[coin]["notional_usd"],
"upnl": upnl,
})
self._positions = new_positions
self._pnl_history.append({
"t": now,
"total_upnl": sum(p["unrealized_pnl"] for p in new_positions.values()),
"asset_count": len(new_positions),
})
if len(self._pnl_history) > self._history_window:
self._pnl_history = self._pnl_history[-self._history_window:]
# ── Queries ──────────────────────────────────────────────
def position(self, coin: str) -> float:
"""Signed position for a coin (positive = long)."""
pos = self._positions.get(coin.upper(), {})
return pos.get("signed_szi", 0.0)
def delta_exposure(self) -> dict:
"""Delta exposure per coin with notional and PnL."""
result = {}
for coin, pos in self._positions.items():
result[coin] = {
"signed_size": pos["signed_szi"],
"notional_usd": round(pos["notional_usd"], 2),
"unrealized_pnl": round(pos["unrealized_pnl"], 2),
"side": "long" if pos["side"] == "B" else "short",
}
return result
def is_overextended(self, coin: str) -> bool:
"""Check if HLP delta on this coin exceeds the threshold."""
pos = self._positions.get(coin.upper(), {})
notional = pos.get("notional_usd", 0)
return notional > self._overextended_threshold
def overextended_assets(self) -> list[str]:
"""List of assets where HLP is overextended."""
return [c for c in self._positions if self.is_overextended(c)]
def rebalancing_signal(self, coin: str) -> dict:
"""Generate a trading signal based on HLP rebalancing pressure.
Returns:
signal: "fade_short" | "fade_long" | "neutral"
direction: -1 (short) | 0 | 1 (long) for the TRADE direction
overextended: whether HLP is over capacity
"""
pos = self._positions.get(coin.upper(), {})
if not pos:
return {"signal": "neutral", "direction": 0, "overextended": False, "reason": "no_position"}
signed = pos["signed_szi"]
is_over = self.is_overextended(coin)
if not is_over:
return {"signal": "neutral", "direction": 0, "overextended": False, "reason": "balanced"}
# HLP is short (side=A, signed_szi negative) → it will need to buy to rebalance
# We should fade the short (go long)
if signed < -0.001:
return {
"signal": "fade_short",
"direction": 1,
"overextended": True,
"reason": f"HLP short {abs(signed):.2f} units, expect buying rebalance",
"notional_usd": round(pos["notional_usd"], 2),
}
# HLP is long (side=B, signed_szi positive) → it will need to sell to rebalance
# We should fade the long (go short)
elif signed > 0.001:
return {
"signal": "fade_long",
"direction": -1,
"overextended": True,
"reason": f"HLP long {signed:.2f} units, expect selling rebalance",
"notional_usd": round(pos["notional_usd"], 2),
}
else:
return {"signal": "neutral", "direction": 0, "overextended": False, "reason": "flat"}
def toxicity_score(self) -> float:
"""Estimate how much toxic flow HLP is absorbing.
Higher score = HLP is losing money = informed traders are beating it.
Range: 0 (healthy) to 1 (toxic).
Uses: unrealized PnL / total notional as a proxy.
"""
total_notional = sum(p["notional_usd"] for p in self._positions.values())
total_upnl = sum(p["unrealized_pnl"] for p in self._positions.values())
if total_notional <= 0:
return 0.0
# Negative PnL → toxic score > 0
# Positive PnL → toxic score 0 (healthy)
loss_ratio = max(0.0, -total_upnl / total_notional)
toxicity = min(1.0, loss_ratio * 10) # scale: 10% loss = 1.0 toxicity
return round(toxicity, 4)
def delta_history(self, coin: str) -> list[dict]:
"""Historical delta trace for a coin."""
return list(self._delta_history.get(coin.upper(), []))
# ── Summary ──────────────────────────────────────────────
def summary(self) -> dict:
"""One-shot summary of HLP state for dashboard/monitoring."""
assets = list(self._positions.keys())
total_delta = sum(p["notional_usd"] for p in self._positions.values())
overextended = self.overextended_assets()
signals = {coin: self.rebalancing_signal(coin) for coin in assets}
return {
"assets_tracked": len(assets),
"total_delta_usd": round(total_delta, 2),
"total_delta_m": round(total_delta / 1_000_000, 2),
"toxicity_score": self.toxicity_score(),
"overextended_assets": overextended,
"signals": signals,
"positions": self.delta_exposure(),
"last_update": self._last_update,
"update_count": self._update_count,
}
+168
View File
@@ -0,0 +1,168 @@
"""
Cross-margin liquidation waterfall prediction.
When a whale's cross-margin portfolio approaches liquidation, the
Hyperliquid liquidation engine selects which asset to dump first based
on maintenance margin requirements and order book liquidity.
By monitoring large cross-margin accounts via clearinghouseState,
we can predict which asset gets liquidated first and position accordingly:
- Widen spreads on the predicted liquidation asset
- Tighten spreads on non-liquidation assets
- Pre-position for the post-liquidation bounce
"""
from __future__ import annotations
import logging
from collections import deque
from typing import Optional
logger = logging.getLogger(__name__)
class LiquidationWaterfall:
"""Predict the order of cross-margin liquidations for large accounts."""
def __init__(
self,
danger_margin_ratio: float = 1.2, # margin_ratio < this = danger
critical_margin_ratio: float = 1.05, # margin_ratio < this = imminent
maintenance_margin_pct: float = 0.03, # 3% maintenance
book_depth_window: int = 10, # levels to estimate liquidity
):
self._danger = danger_margin_ratio
self._critical = critical_margin_ratio
self._mm_pct = maintenance_margin_pct
self._depth_window = book_depth_window
self._accounts: dict[str, dict] = {} # address → positions, equity
self._book_depths: dict[str, dict] = {} # coin → {bid_depth, ask_depth}
self._history: deque = deque(maxlen=1000)
# ── Data feed ────────────────────────────────────────────
def update_account(self, address: str, positions: list[dict], margin_balance: float):
"""Update a tracked account's positions and margin."""
self._accounts[address] = {
"positions": positions,
"margin_balance": margin_balance,
"margin_ratio": self._compute_margin_ratio(positions, margin_balance),
}
def update_book_depth(self, coin: str, bid_depth: float, ask_depth: float):
"""Update estimated order book depth for a coin."""
self._book_depths[coin.upper()] = {
"bid_depth": bid_depth,
"ask_depth": ask_depth,
}
# ── Risk assessment ─────────────────────────────────────
def _compute_margin_ratio(
self, positions: list[dict], margin_balance: float
) -> float:
"""Compute margin ratio = equity / maintenance_margin."""
if not positions or margin_balance <= 0:
return float("inf")
total_mm = 0.0
for pos in positions:
size = abs(float(pos.get("szi", 0)))
px = float(pos.get("entryPx", pos.get("markPx", 0)))
total_mm += size * px * self._mm_pct
return margin_balance / total_mm if total_mm > 0 else float("inf")
def at_risk_accounts(self) -> list[dict]:
"""List accounts approaching liquidation."""
risky = []
for addr, acct in self._accounts.items():
ratio = acct["margin_ratio"]
if ratio < self._danger:
level = "critical" if ratio < self._critical else "danger"
risky.append({
"address": addr[:10] + "...",
"margin_ratio": round(ratio, 3),
"level": level,
"positions": len(acct["positions"]),
})
return sorted(risky, key=lambda r: r["margin_ratio"])
def predict_liquidation_order(self, address: str) -> list[dict]:
"""Predict which assets get liquidated first for a given account.
Returns assets ranked by liquidation priority (first to go = top).
Uses: maintenance_margin_requirement / book_liquidity ratio.
Higher ratio = less liquid relative to margin cost = dumped first.
"""
acct = self._accounts.get(address)
if not acct:
return []
positions = acct["positions"]
ranked = []
for pos in positions:
coin = pos.get("coin", "").upper()
size = abs(float(pos.get("szi", 0)))
px = float(pos.get("entryPx", pos.get("markPx", 0)))
maintenance = size * px * self._mm_pct
# Liquidity: how much the book can absorb before significant slippage
book = self._book_depths.get(coin, {})
side = "buy" if float(pos.get("szi", 0)) < 0 else "sell"
depth = book.get("bid_depth" if side == "buy" else "ask_depth", size * px * 0.1)
# Liquidation priority score: higher = dumped first
liquidity_ratio = maintenance / max(depth, 1e-8)
ranked.append({
"coin": coin,
"size": size,
"notional_usd": round(size * px, 2),
"maintenance_usd": round(maintenance, 2),
"liquidity_ratio": round(liquidity_ratio, 4),
"predicted_first": False, # set below
})
# Sort by liquidity ratio (highest = least liquid = dumped first)
ranked.sort(key=lambda r: r["liquidity_ratio"], reverse=True)
if ranked:
ranked[0]["predicted_first"] = True
return ranked
def signal(self, address: str) -> dict:
"""Generate trading signal based on predicted liquidation waterfall.
If an account is in danger and we can predict the liquidation order:
- Asset predicted to be dumped first → widen spreads, go short
- Other assets in the portfolio → tighten spreads (safer to quote)
"""
acct = self._accounts.get(address)
if not acct or acct["margin_ratio"] > self._danger:
return {"action": "none", "reason": "account_safe"}
order = self.predict_liquidation_order(address)
if not order:
return {"action": "none", "reason": "no_positions"}
first_asset = order[0]
return {
"action": "position",
"reason": f"liquidation_imminent_{acct['margin_ratio']:.2f}",
"margin_ratio": round(acct["margin_ratio"], 3),
"liquidation_target": first_asset["coin"],
"strategy": {
first_asset["coin"]: "widen_spreads_2x",
**{r["coin"]: "tighten_spreads" for r in order[1:]},
},
"predicted_order": [r["coin"] for r in order],
}
def summary(self) -> dict:
return {
"at_risk_accounts": self.at_risk_accounts(),
"tracked_accounts": len(self._accounts),
}
+197
View File
@@ -0,0 +1,197 @@
"""
Term structure monitor — perp vs quarterly vs bi-quarterly futures basis.
Hyperliquid offers perpetual (funding-based), quarterly, and bi-quarterly
futures contracts. The basis curve (perp→quarterly→bi-quarterly) contains
information about market expectations and can be traded.
Anomalies:
- Perp funding deeply negative but quarterly basis remains steep →
go long perp (collect funding), short quarterly (lock basis)
- Quarterly futures converging to perp at expiration →
calendar spread mean-reversion
- Bi-quarterly premium over quarterly deviating from fair value →
curve steepener/flattener trades
"""
from __future__ import annotations
from collections import deque
from typing import Optional
class TermStructureMonitor:
"""Monitor perp/futures term structure for arbitrage opportunities.
Tracks:
- Perp funding rate and mark price
- Quarterly futures price
- Bi-quarterly futures price (if available)
- Basis spreads: quarterly-perp, biq-quarterly, biq-perp
- Calendar spread mean-reversion
"""
def __init__(
self,
funding_window: int = 1440, # 24h of 1-min samples
basis_window: int = 100,
):
self._funding_window = funding_window
self._basis_window = basis_window
self._perp_prices: dict[str, deque[float]] = {}
self._quarterly_prices: dict[str, deque[float]] = {}
self._biq_prices: dict[str, deque[float]] = {}
self._funding_rates: dict[str, deque[float]] = {}
self._funding_epoch_seconds: int = 8 * 3600
self._quarterly_expiry_days: int = 90
self._biq_expiry_days: int = 180
# ── Data feed ────────────────────────────────────────────
def update_perp(self, coin: str, price: float, funding_rate: float):
c = coin.upper()
self._perp_prices.setdefault(c, deque(maxlen=self._basis_window)).append(price)
self._funding_rates.setdefault(c, deque(maxlen=self._funding_window)).append(funding_rate)
def update_quarterly(self, coin: str, price: float):
self._quarterly_prices.setdefault(coin.upper(), deque(maxlen=self._basis_window)).append(price)
def update_biq(self, coin: str, price: float):
self._biq_prices.setdefault(coin.upper(), deque(maxlen=self._basis_window)).append(price)
# ── Basis computation ────────────────────────────────────
def quarterly_perp_basis(self, coin: str) -> dict | None:
"""Basis between quarterly future and perpetual."""
q = list(self._quarterly_prices.get(coin.upper(), []))
p = list(self._perp_prices.get(coin.upper(), []))
min_len = min(len(q), len(p))
if min_len < 2:
return None
qq = q[-min_len:]
pp = p[-min_len:]
basis_bps = [(qq[i] - pp[i]) / pp[i] * 10000 for i in range(min_len) if pp[i] > 0]
if not basis_bps:
return None
return {
"current_bps": round(basis_bps[-1], 2),
"mean_bps": round(sum(basis_bps) / len(basis_bps), 2),
"std_bps": round(_std(basis_bps), 2),
"z_score": round((basis_bps[-1] - sum(basis_bps) / len(basis_bps)) / max(_std(basis_bps), 0.01), 2),
"n_samples": len(basis_bps),
}
def biq_quarterly_basis(self, coin: str) -> dict | None:
"""Basis between bi-quarterly and quarterly futures (curve steepness)."""
bq = list(self._biq_prices.get(coin.upper(), []))
q = list(self._quarterly_prices.get(coin.upper(), []))
min_len = min(len(bq), len(q))
if min_len < 2:
return None
bb = bq[-min_len:]
qq = q[-min_len:]
basis_bps = [(bb[i] - qq[i]) / qq[i] * 10000 for i in range(min_len) if qq[i] > 0]
if not basis_bps:
return None
return {
"current_bps": round(basis_bps[-1], 2),
"mean_bps": round(sum(basis_bps) / len(basis_bps), 2),
"std_bps": round(_std(basis_bps), 2),
"z_score": round((basis_bps[-1] - sum(basis_bps) / len(basis_bps)) / max(_std(basis_bps), 0.01), 2),
"n_samples": len(basis_bps),
}
def fair_quarterly_price(self, coin: str, risk_free_annual: float = 0.05) -> dict | None:
"""Compute fair quarterly price from perp via interest rate parity."""
p = list(self._perp_prices.get(coin.upper(), []))
if not p:
return None
perp_px = p[-1]
days = self._quarterly_expiry_days
funding = list(self._funding_rates.get(coin.upper(), []))
avg_funding = sum(funding[-100:]) / max(len(funding[-100:]), 1) if funding else 0
# Fair quarterly = perp * (1 + (r + avg_funding) * days/365)
carry_rate = risk_free_annual + avg_funding * 3 * 365 # annualize 8h funding
fair_px = perp_px * (1 + carry_rate * days / 365)
return {
"perp_price": perp_px,
"fair_quarterly": round(fair_px, 2),
"carry_rate_annual_pct": round(carry_rate * 100, 2),
"days_to_expiry": days,
}
def signal(self, coin: str) -> dict:
"""Generate term-structure trading signal.
Returns:
signal: "buy_basis", "sell_basis", "curve_steepener", "curve_flattener", "none"
"""
c = coin.upper()
qp = self.quarterly_perp_basis(c)
bq = self.biq_quarterly_basis(c)
fair = self.fair_quarterly_price(c)
signals = []
# Check quarterly-perp basis anomalies
if qp and abs(qp["z_score"]) > 2.0:
if qp["z_score"] > 0:
signals.append({
"signal": "sell_basis",
"reason": f"Quarterly {qp['z_score']:.1f}σ rich vs perp",
"confidence": min(1.0, abs(qp["z_score"]) / 4.0),
})
else:
signals.append({
"signal": "buy_basis",
"reason": f"Quarterly {qp['z_score']:.1f}σ cheap vs perp",
"confidence": min(1.0, abs(qp["z_score"]) / 4.0),
})
# Check curve steepness
if bq and abs(bq["z_score"]) > 2.0:
if bq["z_score"] > 0:
signals.append({
"signal": "curve_flattener",
"reason": f"BiQ {bq['z_score']:.1f}σ rich vs quarterly",
"confidence": min(1.0, abs(bq["z_score"]) / 4.0),
})
else:
signals.append({
"signal": "curve_steepener",
"reason": f"BiQ {bq['z_score']:.1f}σ cheap vs quarterly",
"confidence": min(1.0, abs(bq["z_score"]) / 4.0),
})
result = {
"coin": c,
"signals": signals,
"primary_signal": signals[0]["signal"] if signals else "none",
"quarterly_perp_basis": qp,
"biq_quarterly_basis": bq,
"fair_quarterly": fair,
}
return result
def summary(self) -> dict:
return {coin: self.signal(coin) for coin in self._perp_prices}
def _std(vals: list[float]) -> float:
"""Population standard deviation."""
if len(vals) < 2:
return 0.0
mean = sum(vals) / len(vals)
return (sum((v - mean) ** 2 for v in vals) / len(vals)) ** 0.5