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:
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Funding rate whipsaw trader — premium index decay in final seconds of funding epoch.
|
||||
|
||||
Hyperliquid funding settles every 8 hours (UTC 00:00, 08:00, 16:00).
|
||||
In the final 60 seconds before settlement, the premium index (Mark - Oracle)
|
||||
must converge to prevent arbitrage. HFTs trade this convergence deterministically.
|
||||
|
||||
The strategy:
|
||||
- If premium is positive with <60s until funding → SHORT perp (price will drop)
|
||||
- If premium is negative with <60s until funding → LONG perp (price will rise)
|
||||
- Scale position based on premium magnitude and time remaining
|
||||
- Close position at funding settlement (T+0)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class FundingWhipsawTrader:
|
||||
"""Trade the deterministic decay of the premium index in the final seconds
|
||||
before Hyperliquid's funding settlement.
|
||||
|
||||
Funding epochs: 00:00, 08:00, 16:00 UTC every day.
|
||||
Premium index = (mark_price - oracle_price) / oracle_price
|
||||
|
||||
Usage:
|
||||
trader = FundingWhipsawTrader()
|
||||
trader.update(mark_px=64500, oracle_px=64480)
|
||||
signal = trader.signal()
|
||||
if signal['action'] != 'none':
|
||||
# place order: signal['side'], signal['size'], signal['confidence']
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_premium_bps: float = 0.5, # minimum premium in bps to trigger
|
||||
max_size: float = 0.001, # max position size
|
||||
enter_seconds_before: float = 60.0, # seconds before funding to enter
|
||||
close_seconds_after: float = 5.0, # seconds after funding to close
|
||||
):
|
||||
self._min_premium_bps = min_premium_bps
|
||||
self._max_size = max_size
|
||||
self._enter_seconds = enter_seconds_before
|
||||
self._close_seconds = close_seconds_after
|
||||
|
||||
self._mark_px: float = 0.0
|
||||
self._oracle_px: float = 0.0
|
||||
self._premium_bps: float = 0.0
|
||||
self._last_update: float = 0.0
|
||||
self._in_position: bool = False
|
||||
self._position_side: str = ""
|
||||
self._entry_time: float = 0.0
|
||||
|
||||
def update(self, mark_px: float, oracle_px: float):
|
||||
"""Feed current mark and oracle prices."""
|
||||
self._mark_px = mark_px
|
||||
self._oracle_px = oracle_px
|
||||
self._last_update = time.time()
|
||||
|
||||
if oracle_px > 0:
|
||||
self._premium_bps = (mark_px - oracle_px) / oracle_px * 10000
|
||||
|
||||
def seconds_to_funding(self) -> float:
|
||||
"""Seconds until the next funding settlement (every 8 hours, UTC)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
epoch_hours = [0, 8, 16] # Funding at 00:00, 08:00, 16:00 UTC
|
||||
current_hour = now.hour
|
||||
|
||||
# Find next funding epoch
|
||||
next_epoch_hour = None
|
||||
for h in epoch_hours:
|
||||
if h > current_hour or (h == current_hour and now.minute == 0 and now.second < 5):
|
||||
next_epoch_hour = h
|
||||
break
|
||||
|
||||
if next_epoch_hour is None:
|
||||
# After 16:00, next is 00:00 tomorrow
|
||||
next_epoch = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
from datetime import timedelta
|
||||
next_epoch += timedelta(days=1)
|
||||
else:
|
||||
next_epoch = now.replace(hour=next_epoch_hour, minute=0, second=0, microsecond=0)
|
||||
|
||||
delta = (next_epoch - now).total_seconds()
|
||||
return max(0.0, delta)
|
||||
|
||||
def seconds_since_funding(self) -> float:
|
||||
"""Seconds since the most recent funding settlement."""
|
||||
seconds_to = self.seconds_to_funding()
|
||||
if seconds_to < 3600: # <1 hour to next
|
||||
return 8 * 3600 - seconds_to
|
||||
return 8 * 3600 + (3600 - seconds_to % 3600) # Approximate
|
||||
|
||||
def signal(self) -> dict:
|
||||
"""Generate trading signal based on premium and time to funding.
|
||||
|
||||
Returns:
|
||||
action: "enter_long", "enter_short", "close", "none"
|
||||
side: "buy" or "sell" (for orders)
|
||||
size: position size (scaled by time remaining)
|
||||
confidence: 0-1 confidence in the signal
|
||||
premium_bps: current premium in bps
|
||||
seconds_to_funding: time until settlement
|
||||
"""
|
||||
secs = self.seconds_to_funding()
|
||||
premium = self._premium_bps
|
||||
|
||||
# After funding + small delay: close any position
|
||||
secs_since = self.seconds_since_funding()
|
||||
if self._in_position and secs_since < self._close_seconds:
|
||||
self._in_position = False
|
||||
return {
|
||||
"action": "close",
|
||||
"side": "sell" if self._position_side == "buy" else "buy",
|
||||
"size": self._max_size,
|
||||
"confidence": 1.0,
|
||||
"premium_bps": round(premium, 2),
|
||||
"seconds_to_funding": round(secs, 1),
|
||||
"reason": "funding_settled",
|
||||
}
|
||||
|
||||
# Outside entry window: no action
|
||||
if secs > self._enter_seconds or secs < 0:
|
||||
return {
|
||||
"action": "none",
|
||||
"side": "",
|
||||
"size": 0.0,
|
||||
"confidence": 0.0,
|
||||
"premium_bps": round(premium, 2),
|
||||
"seconds_to_funding": round(secs, 1),
|
||||
"reason": "outside_entry_window",
|
||||
}
|
||||
|
||||
# Already in position
|
||||
if self._in_position:
|
||||
return {
|
||||
"action": "hold",
|
||||
"side": self._position_side,
|
||||
"size": self._max_size,
|
||||
"confidence": 0.8,
|
||||
"premium_bps": round(premium, 2),
|
||||
"seconds_to_funding": round(secs, 1),
|
||||
"reason": "holding",
|
||||
}
|
||||
|
||||
# Check premium threshold
|
||||
if abs(premium) < self._min_premium_bps:
|
||||
return {
|
||||
"action": "none",
|
||||
"side": "",
|
||||
"size": 0.0,
|
||||
"confidence": 0.0,
|
||||
"premium_bps": round(premium, 2),
|
||||
"seconds_to_funding": round(secs, 1),
|
||||
"reason": "premium_too_small",
|
||||
}
|
||||
|
||||
# Scale size by time remaining (more remaining = more uncertainty = smaller size)
|
||||
time_factor = max(0.3, secs / self._enter_seconds)
|
||||
scaled_size = self._max_size * (1.0 - time_factor * 0.5)
|
||||
|
||||
if premium > 0:
|
||||
# Premium positive → perp is expensive → short it
|
||||
side = "sell"
|
||||
action = "enter_short"
|
||||
confidence = min(1.0, abs(premium) / self._min_premium_bps * 0.3)
|
||||
else:
|
||||
# Premium negative → perp is cheap → long it
|
||||
side = "buy"
|
||||
action = "enter_long"
|
||||
confidence = min(1.0, abs(premium) / self._min_premium_bps * 0.3)
|
||||
|
||||
self._in_position = True
|
||||
self._position_side = side
|
||||
self._entry_time = time.time()
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"side": side,
|
||||
"size": round(scaled_size, 8),
|
||||
"confidence": round(confidence, 3),
|
||||
"premium_bps": round(premium, 2),
|
||||
"seconds_to_funding": round(secs, 1),
|
||||
"reason": f"premium_{premium:.1f}bps_{secs:.0f}s",
|
||||
}
|
||||
|
||||
@property
|
||||
def premium_bps(self) -> float:
|
||||
return self._premium_bps
|
||||
|
||||
@property
|
||||
def in_position(self) -> bool:
|
||||
return self._in_position
|
||||
Reference in New Issue
Block a user