Files
ftdt-quant-lab/strategies/hurst_vpin.py
T
ramseshk c98681c130 Fix historical cards + Hurst/VPIN strategy
Historical tab fix:
  - StrategyCard: handle BacktestSummary type (not Strategy)
  - Pass coin/badge/stats/pnlPct/status props for historical
  - Historical cards now show proper data

Hurst/VPIN directional strategy (Hyperliquid BTC-USD):
  - Dollar bars (constant-notional 0K)
  - Hurst exponent R/S analysis on 128-bar window
  - VPIN on 50-bucket volume imbalance
  - Quote-driven entry: both signals agree → BUY/SELL
  - Exit: Hurst decays below exit threshold
2026-08-06 04:49:00 +00:00

333 lines
13 KiB
Python

"""
Hurst Exponent + VPIN Directional Strategy for Hyperliquid BTC-USD-PERP.
Based on nautilustrader tutorial:
https://nautilustrader.io/docs/latest/tutorials/hurst_vpin_kraken/
Components:
1. HURST EXPONENT (dollar bars) — R/S analysis, >0.55 = trending
2. VPIN (Volume-synchronized Probability of Informed Trading) —
buy/sell aggressor volume imbalance over dollar-bar buckets
3. QUOTE-DRIVEN ENTRY — both signals agree → place order on next tick
Data: Real Hyperliquid API trade fills (aggressor side + size + price).
Dollar bars: constant-notional $10,000 bars.
Hurst window: 128 bars (~R/S needs ≥ 64).
VPIN window: 50 buckets.
"""
import numpy as np
from collections import deque
import time, json, requests, os
# ═══════════════════════════════════════════════════════════
# 1. Dollar Bar Construction
# ═══════════════════════════════════════════════════════════
class DollarBarBuilder:
"""Accumulate trades until notional threshold reached → emit bar."""
def __init__(self, threshold: float = 10_000.0):
self.threshold = threshold
self.reset()
def reset(self):
self.accum_vol = 0.0
self.open = self.high = self.low = self.close = None
self.buy_vol = 0.0
self.sell_vol = 0.0
def add(self, price: float, size: float, side: str):
notional = price * size
self.accum_vol += notional
if side.upper() == "B":
self.buy_vol += notional
else:
self.sell_vol += notional
if self.open is None:
self.open = self.high = self.low = price
else:
self.high = max(self.high, price)
self.low = min(self.low, price)
self.close = price
def is_ready(self) -> bool:
return self.accum_vol >= self.threshold
def emit(self) -> dict:
bar = {
"open": self.open,
"high": self.high,
"low": self.low,
"close": self.close,
"buy_vol": self.buy_vol,
"sell_vol": self.sell_vol,
"total_vol": self.accum_vol,
}
self.reset()
return bar
# ═══════════════════════════════════════════════════════════
# 2. Hurst Exponent (R/S Rescaled Range)
# ═══════════════════════════════════════════════════════════
def hurst_rs(log_returns: list, max_lag: int = None) -> float:
"""R/S Hurst exponent on log returns.
H > 0.55 → persistent (trending)
H < 0.50 → anti-persistent (mean-reverting)
H ≈ 0.50 → random walk
"""
n = len(log_returns)
if n < 32:
return 0.50 # not enough data
if max_lag is None:
max_lag = min(n // 2, 64)
lags = range(2, min(max_lag + 1, n // 2 + 1))
rs_vals = []
for lag in lags:
if lag < 2: continue
segments = n // lag
if segments < 2: continue
r_div_s = []
for s in range(segments):
seg = log_returns[s * lag:(s + 1) * lag]
mean = np.mean(seg)
deviations = np.cumsum(seg - mean)
r = np.max(deviations) - np.min(deviations)
sd = np.std(seg, ddof=1)
if sd > 1e-12:
r_div_s.append(r / sd)
if r_div_s:
rs_vals.append(np.mean(r_div_s))
if len(rs_vals) < 4:
return 0.50
# H = slope of log(R/S) vs log(lag)
log_lags = np.log([l for l in lags if l >= 2][:len(rs_vals)])
log_rs = np.log(rs_vals)
slope, _ = np.polyfit(log_lags, log_rs, 1)
return min(max(slope, 0.20), 0.90)
# ═══════════════════════════════════════════════════════════
# 3. VPIN (Volume-synchronized Probability of Informed Trading)
# ═══════════════════════════════════════════════════════════
class VPINComputer:
"""VPIN on dollar-bar buckets.
Each bucket = one dollar bar.
VPIN = abs(buy_vol - sell_vol) / total_vol of bucket.
Running average over `window` buckets.
"""
def __init__(self, window: int = 50):
self.window = window
self.buckets = deque(maxlen=window)
def add_bucket(self, buy_vol: float, sell_vol: float):
total = buy_vol + sell_vol
if total < 1.0:
self.buckets.append((0.0, 0.0))
else:
vpin = abs(buy_vol - sell_vol) / total
signed = (buy_vol - sell_vol) / total # + = net buying
self.buckets.append((vpin, signed))
@property
def vpin(self) -> float:
if not self.buckets:
return 0.0
return np.mean([b[0] for b in self.buckets])
@property
def direction(self) -> float:
"""Signed net direction: +1 = strong buying, -1 = strong selling."""
if not self.buckets:
return 0.0
return np.mean([b[1] for b in self.buckets])
@property
def ready(self) -> bool:
return len(self.buckets) >= self.window
# ═══════════════════════════════════════════════════════════
# 4. Strategy Signal Generator
# ═══════════════════════════════════════════════════════════
class HurstVPINSignal:
def __init__(self, notional_threshold: float = 10_000.0,
hurst_window: int = 128, vpin_window: int = 50,
hurst_entry: float = 0.55, hurst_exit: float = 0.52,
vpin_threshold: float = 0.25):
self.builder = DollarBarBuilder(notional_threshold)
self.vpin = VPINComputer(vpin_window)
self.hurst_window = hurst_window
self.hurst_entry = hurst_entry
self.hurst_exit = hurst_exit
self.vpin_threshold = vpin_threshold
self.returns = deque(maxlen=hurst_window)
# Current state
self.hurst_val = 0.50
self.vpin_val = 0.0
self.vpin_dir = 0.0
self.position = 0 # -1 short, 0 flat, +1 long
self.last_bar_close = 0.0
self.bar_count = 0
def add_trade(self, price: float, size: float, side: str):
"""Process a single trade tick."""
self.builder.add(price, size, side)
if self.builder.is_ready():
bar = self.builder.emit()
return self._process_bar(bar)
return None
def _process_bar(self, bar: dict) -> dict | None:
self.bar_count += 1
# Update VPIN
self.vpin.add_bucket(bar["buy_vol"], bar["sell_vol"])
self.vpin_val = self.vpin.vpin if self.vpin.ready else 0.0
self.vpin_dir = self.vpin.direction if self.vpin.ready else 0.0
# Update Hurst returns
if self.last_bar_close > 0:
log_ret = np.log(bar["close"] / self.last_bar_close)
self.returns.append(log_ret)
self.last_bar_close = bar["close"]
# Compute Hurst
if len(self.returns) >= self.hurst_window:
self.hurst_val = hurst_rs(list(self.returns))
else:
self.hurst_val = 0.50
# Signal logic
signal = self._compute_signal()
return {
"bar": bar,
"hurst": round(self.hurst_val, 4),
"vpin": round(self.vpin_val, 4),
"vpin_dir": round(self.vpin_dir, 4),
"signal": signal,
"position": self.position,
"bar_count": self.bar_count,
}
def _compute_signal(self) -> str:
trending = self.hurst_val >= self.hurst_entry
high_vpin = self.vpin_val >= self.vpin_threshold
exiting = self.hurst_val <= self.hurst_exit
# Exit: Hurst decays below exit threshold
if self.position != 0 and exiting:
self.position = 0
return "EXIT"
# Entry: both agree
if self.position == 0 and trending and high_vpin:
if self.vpin_dir > 0.02:
self.position = 1
return "BUY"
elif self.vpin_dir < -0.02:
self.position = -1
return "SELL"
return "HOLD"
# ═══════════════════════════════════════════════════════════
# 5. Hyperliquid Data Fetcher
# ═══════════════════════════════════════════════════════════
def fetch_recent_trades(user: str = None, limit: int = 500) -> list:
"""Fetch recent BTC-USD-PERP fills from Hyperliquid mainnet."""
url = "https://api.hyperliquid.xyz/info"
payload = {"type": "userFills", "user": user} if user else {
"type": "allMids"}
if user:
resp = requests.post(url, json=payload, timeout=10)
fills = resp.json()
return fills[:limit] if isinstance(fills, list) else []
return []
# ═══════════════════════════════════════════════════════════
# 6. Backtest Runner
# ═══════════════════════════════════════════════════════════
def run_hurst_vpin(trades: list, starting_capital: float = 100.0,
size: float = 0.0002) -> dict:
signal_gen = HurstVPINSignal()
equity = [{"t": 0, "v": starting_capital}]
capital = starting_capital
position = 0
entry_price = 0.0
all_trades = []
signals = []
for i, trade in enumerate(trades):
price = float(trade.get("px", 0))
sz = float(trade.get("sz", 0))
side = trade.get("side", "B")
result = signal_gen.add_trade(price, sz, side)
if result:
signals.append(result)
# Execute signal
sig = result["signal"]
if sig in ("BUY", "SELL") and position == 0:
entry_price = price
direction = 1 if sig == "BUY" else -1
notional = price * size
if capital >= notional:
all_trades.append({
"i": i, "side": sig, "price": price, "size": size,
"hurst": result["hurst"], "vpin": result["vpin"],
"bar_count": result["bar_count"],
})
position = direction
elif sig == "EXIT" and position != 0:
pnl_pct = (price / entry_price - 1) * position
pnl = capital * pnl_pct * 0.01 # 1% of capital at risk
capital += pnl
all_trades[-1]["exit_price"] = price
all_trades[-1]["pnl"] = round(pnl, 4)
equity.append({"t": i, "v": round(capital, 4)})
position = 0
entry_price = 0.0
return {
"total_trades": len(all_trades),
"signals": len(signals),
"final_equity": round(capital, 4),
"pnl_pct": round((capital / starting_capital - 1) * 100, 2),
"trades": all_trades,
"signals_history": signals[-20:],
}
# ═══════════════════════════════════════════════════════════
# 7. Test
# ═══════════════════════════════════════════════════════════
if __name__ == "__main__":
# Simulated backtest with synthetic trades
print("Hurst/VPIN Strategy — Hyperliquid BTC-USD")
np.random.seed(42)
n = 50000
prices = 64000 + np.cumsum(np.random.randn(n) * 50)
sizes = np.abs(np.random.randn(n) * 0.01) + 0.001
sides = ["B" if np.random.random() > 0.5 else "A" for _ in range(n)]
sim_trades = [{"px": p, "sz": s, "side": sd} for p, s, sd in zip(prices, sizes, sides)]
result = run_hurst_vpin(sim_trades)
print(f" Total trades: {result['total_trades']}")
print(f" Signals generated: {result['signals']}")
print(f" Final equity: ${result['final_equity']:.2f} ({result['pnl_pct']:+.2f}%)")
print(f" Last signals:")
for s in result["signals_history"][-5:]:
print(f" H={s['hurst']:.3f} VPIN={s['vpin']:.3f} dir={s['vpin_dir']:+.3f}{s['signal']}")