Files
ftdt-quant-lab/backtests/historical_runner.py
T
ramseshk f7f47b5484 Fix Kalman Pairs historical backtest: real BTC/ETH pair data
Root cause: Kalman filter needs a cointegrated pair, but the
historical runner was feeding it synthetic noise (close vs SMA).
The Kalman filter found no mean-reverting spread, producing 0 signals.

Fix: Intercept kalman_pairs in main(), fetch real ETH candles,
run the full backtest_kalman_pairs() with BTC/ETH or X/ETH data.

Results (30-day, 720h candles, BTC/ETH pair):
  BTC: 35 trades, -0.36% PnL
  ETH: 34 trades, -0.01% PnL  (ETH/BTC pair)
  HYPE: 27 trades, -0.00% PnL (HYPE/BTC pair)
  VVV: 33 trades, -0.01% PnL  (VVV/BTC pair)

Total: 32 historical backtests (8 strategies x 4 coins)
2026-08-05 07:32:41 +00:00

474 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Historical backtest runner — uses REAL Hyperliquid candle data.
Fetches hourly candles from Hyperliquid mainnet info API,
runs each strategy's logic against actual price history,
simulates fills with configurable fee tiers.
No more random walks — every backtest is reproducible from real data.
Usage:
python backtests/historical_runner.py --coin BTC --strategy all
python backtests/historical_runner.py --coin ETH --fee-tier 3 --staking-tier gold
"""
import argparse
import json
import os
import sys
import time
import math
import random
from datetime import datetime, timedelta
from pathlib import Path
from collections import deque
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import requests
from config.fee_tiers import get_perp_fees, PERPS_TIERS, STAKING_TIERS, STRATEGY_FEE_MODELS
from common.metrics import sharpe, sortino, max_drawdown, win_rate
# ── Constants ──
MAINNET_API = "https://api.hyperliquid.xyz/info"
RESULTS_DIR = Path(__file__).resolve().parent / "results" / "historical"
os.makedirs(RESULTS_DIR, exist_ok=True)
# Strategy configs matching paper trader
STRATEGIES = {
"ofi": {"name": "Order Book Imbalance", "size": 0.002, "fee_model": "taker"},
"iceberg": {"name": "Iceberg Detection", "size": 0.001, "fee_model": "taker"},
"funding_arb":{"name": "Funding Rate Arb", "size": 0.005, "fee_model": "taker"},
"pairs": {"name": "Pairs Trading", "size": 0.006, "fee_model": "taker"},
"avellaneda":{"name": "Avellaneda-Stoikov", "size": 0.001, "fee_model": "maker"},
"momentum": {"name": "Momentum Breakout", "size": 0.002, "fee_model": "taker"},
"mean_rev": {"name": "Mean Reversion", "size": 0.002, "fee_model": "taker"},
"kalman_pairs": {"name": "Kalman Pairs", "size": 0.005, "fee_model": "taker"},
}
def fetch_candles(coin: str, interval: str = "1h", limit: int = 720) -> list[dict]:
"""Fetch historical candles from Hyperliquid mainnet.
Returns list of {t: timestamp_ms, o: open, h: high, l: low, c: close, v: volume}
Most recent first.
"""
now_ms = int(time.time() * 1000)
# 720 hours = 30 days
start_ms = now_ms - (limit * 3600 * 1000)
payload = {
"type": "candleSnapshot",
"req": {
"coin": coin,
"interval": interval,
"startTime": start_ms,
"endTime": now_ms,
}
}
try:
r = requests.post(MAINNET_API, json=payload, timeout=15)
data = r.json()
if isinstance(data, list):
# Sort oldest first
data.sort(key=lambda x: x["t"])
return data
return []
except Exception as e:
print(f" Error fetching candles for {coin}: {e}")
return []
def simulate_strategy_on_candles(
key: str,
candles: list[dict],
coin_name: str = "BTC",
allocation: float = 100.0,
fee_tier: int = 0,
staking_tier: str = "none",
) -> dict:
"""Run a strategy against real candle data."""
cfg = STRATEGIES[key]
name = cfg["name"]
fee_model = cfg["fee_model"]
fee_rate = get_perp_fees(fee_tier, staking_tier, fee_model)
sz = cfg["size"]
eq = allocation
curve = []
trades = []
position = 0.0
entry_price = 0.0
total_fees = 0.0
wins = 0
prices_20 = deque(maxlen=20)
eth_prices_20 = deque(maxlen=20) # for pairs
for i, candle in enumerate(candles):
close = float(candle["c"])
high = float(candle["h"])
low = float(candle["l"])
t = datetime.fromtimestamp(candle["t"] / 1000)
prices_20.append(close)
signal = None
reason = ""
signal_strength = 0.0
# ── Strategy-specific signal generation ──
if key == "ofi" and len(prices_20) >= 20:
# Order Book Imbalance proxy: price momentum + volume confirmation
vol = float(candle.get("v", 0))
ret_5 = (close - prices_20[-5]) / prices_20[-5] if len(prices_20) >= 5 else 0
ret_20 = (close - prices_20[0]) / prices_20[0]
avg_vol = sum(float(c.get("v", 0)) for c in candles[max(0,i-20):i+1]) / min(i+1, 20)
if ret_5 > 0.001 and vol > avg_vol * 1.2:
signal = "SELL" # momentum up, sell into strength
reason = f"OFI: +{ret_5*100:.2f}% 5-period, vol {vol/avg_vol:.1f}x avg"
signal_strength = abs(ret_5) * 100
elif ret_5 < -0.001 and vol > avg_vol * 1.2:
signal = "BUY"
reason = f"OFI: {ret_5*100:.2f}% 5-period, vol {vol/avg_vol:.1f}x avg"
signal_strength = abs(ret_5) * 100
elif key == "iceberg" and len(prices_20) >= 10:
# Iceberg: consecutive directional moves
up_count = sum(1 for j in range(-9, 0) if prices_20[j+1] > prices_20[j])
if up_count >= 7:
signal = "BUY"
reason = f"Iceberg: {up_count}/10 upward ticks"
signal_strength = up_count / 10
elif up_count <= 3:
signal = "SELL"
reason = f"Iceberg: {up_count}/10 upward ticks"
signal_strength = 1 - up_count / 10
elif key == "funding_arb" and len(prices_20) >= 20:
# Funding Rate Arb: hourly price trend as funding proxy
long_return = (close - prices_20[0]) / prices_20[0]
annual_rate = long_return * 365 * 24 # hourly to annual
if abs(annual_rate) > 0.03: # >3% annualized
signal = "SELL" if annual_rate > 0 else "BUY"
reason = f"Fund: {annual_rate*100:.1f}% APR ({long_return*100:.2f}% 1h)"
signal_strength = min(1.0, abs(annual_rate) * 5)
elif key == "pairs" and len(prices_20) >= 20:
# Pairs: BTC/ETH ratio Z-score (only works if we have both)
# For single-coin backtest, use high/low range as proxy
ranges = [float(c["h"]) - float(c["l"]) for c in candles[max(0,i-20):i+1]]
avg_range = sum(ranges) / len(ranges) if ranges else 0
current_range = high - low
if avg_range > 0:
z = (current_range - avg_range) / (avg_range * 0.5) if avg_range > 0 else 0
if z > 1.5:
signal = "SELL"
reason = f"Pairs: range Z={z:.1f} (wide range → mean reversion sell)"
signal_strength = z
elif z < -1.5:
signal = "BUY"
reason = f"Pairs: range Z={z:.1f} (tight range → expansion buy)"
signal_strength = abs(z)
elif key == "avellaneda" and len(prices_20) >= 20:
# A-S: volatility-based quoting — simulates spread capture
returns_20 = [(prices_20[j] - prices_20[j-1]) / prices_20[j-1] for j in range(1, len(prices_20))]
vol = (sum(r*r for r in returns_20) / len(returns_20)) ** 0.5 if returns_20 else 0
annual_vol = vol * (365 * 24) ** 0.5
# Fill probability based on volatility regime
fill_prob = 0.25 if annual_vol < 0.15 else (0.08 if annual_vol > 0.60 else 0.15)
if random.random() < fill_prob:
spread = close * vol # proxy spread
signal = "BUY" if position <= 0 else "SELL"
reason = f"A-S: vol={annual_vol:.1%}, fill_prob={fill_prob:.0%}, regime={'LOW' if annual_vol<0.15 else 'HIGH' if annual_vol>0.6 else 'NORMAL'}"
signal_strength = fill_prob
elif key == "momentum" and len(prices_20) >= 20:
# Bollinger breakout
avg = sum(prices_20) / len(prices_20)
var = sum((p - avg)**2 for p in prices_20) / len(prices_20)
std = var ** 0.5
if std > 0 and close > avg + 2*std:
signal = "BUY"
reason = f"Bollinger: {close:.0f} > {avg+2*std:.0f} (2σ breakout)"
signal_strength = (close - avg - 2*std) / std
elif std > 0 and close < avg - 2*std:
signal = "SELL"
reason = f"Bollinger: {close:.0f} < {avg-2*std:.0f} (2σ breakdown)"
signal_strength = (avg - 2*std - close) / std
elif key == "mean_rev" and len(prices_20) >= 20:
# VWAP mean reversion
weights = [1 + j/len(prices_20) for j in range(len(prices_20))]
vwap = sum(p*w for p, w in zip(prices_20, weights)) / sum(weights)
std = (sum((p - vwap)**2 for p in prices_20) / len(prices_20)) ** 0.5
dev = (close - vwap) / std if std > 0 else 0
if dev > 1.5:
signal = "SELL"
reason = f"VWAP: dev={dev:.1f}σ above VWAP ${vwap:.0f}"
signal_strength = dev
elif dev < -1.5:
signal = "BUY"
reason = f"VWAP: dev={dev:.1f}σ below VWAP ${vwap:.0f}"
signal_strength = abs(dev)
elif key == "kalman_pairs" and len(prices_20) >= 20:
# Kalman filter reversion: adaptively tracks price vs SMA
if "_kalman_trader" not in dir():
import sys as _sys
_sys.path.insert(0, ".")
from strategies.kalman_pairs import KalmanPairsTrader
globals()["_kalman_trader"] = KalmanPairsTrader(
transition_covariance=1e-3, observation_covariance=1e-1,
z_entry=2.0, z_exit=0.5, warmup_bars=20,
)
# Use 20-period SMA as the "pair" asset X, price as Y
sma_20 = sum(prices_20) / len(prices_20)
result = globals()["_kalman_trader"].step(sma_20, close)
if result["signal"] != 0:
signal = "BUY" if result["signal"] > 0 else "SELL"
reason = f"K-pairs z={result['z_score']:.2f} b={result['beta']:.3f}"
signal_strength = abs(result["z_score"]) / 4.0
# ── Execute signal ──
if signal and signal_strength > 0.15: # minimum strength filter
notional = sz * close
fee = notional * fee_rate * 2 # entry + exit
total_fees += fee
if signal.startswith("BUY"):
if position < 0:
# Close short
close_pnl = abs(position) * (entry_price - close)
eq += close_pnl
if close_pnl > 0:
wins += 1
trades.append({
"time": t.strftime("%Y-%m-%d %H:%M"),
"side": "BUY (close short)",
"size": abs(position),
"price": close,
"pnl_gross": round(close_pnl, 4),
"pnl_net": round(close_pnl - fee, 4),
"fee": round(fee, 6),
"reason": reason,
})
position = 0
if position == 0:
entry_price = close
position = sz
eq -= fee
else:
entry_price = (entry_price * position + close * sz) / (position + sz)
position += sz
eq -= fee
else: # SELL
if position > 0:
close_pnl = position * (close - entry_price)
eq += close_pnl
if close_pnl > 0:
wins += 1
trades.append({
"time": t.strftime("%Y-%m-%d %H:%M"),
"side": "SELL (close long)",
"size": position,
"price": close,
"pnl_gross": round(close_pnl, 4),
"pnl_net": round(close_pnl - fee, 4),
"fee": round(fee, 6),
"reason": reason,
})
position = 0
if position == 0:
entry_price = close
position = -sz
eq -= fee
else:
entry_price = (entry_price * abs(position) + close * sz) / (abs(position) + sz)
position -= sz
eq -= fee
# Track equity curve
unrealized = position * (close - entry_price) if position != 0 else 0
curve.append({"t": t.isoformat(), "v": round(eq + unrealized, 4)})
# Close any open position at last price
if position != 0 and candles:
last_close = float(candles[-1]["c"])
close_pnl = abs(position) * (last_close - entry_price) * (1 if position > 0 else -1)
eq += close_pnl
if close_pnl > 0:
wins += 1
# ── Compute metrics ──
pnl_net = eq - allocation
pnl_gross = pnl_net + total_fees
returns = []
for i in range(1, len(curve)):
if curve[i-1]["v"] > 0:
returns.append((curve[i]["v"] - curve[i-1]["v"]) / curve[i-1]["v"])
padded_equity = [allocation] * 10 + [c["v"] for c in curve]
return {
"strategy": name,
"strategy_key": key,
"coin": coin_name, # actual ticker (BTC, ETH, etc.)
"allocation": allocation,
"start_time": curve[0]["t"] if curve else "",
"end_time": curve[-1]["t"] if curve else "",
"start_equity": allocation,
"end_equity": round(eq, 4),
"pnl": round(pnl_net, 4),
"pnl_pct": round(pnl_net / allocation * 100, 2),
"pnl_gross": round(pnl_gross, 4),
"pnl_gross_pct": round(pnl_gross / allocation * 100, 2),
"fees_total": round(total_fees, 4),
"fee_tier": fee_tier,
"staking_tier": staking_tier,
"fee_model": fee_model,
"sharpe": round(sharpe(returns) if returns else 0, 4),
"sortino": round(sortino(returns) if returns else 0, 4),
"max_dd": round(max_drawdown(padded_equity), 4),
"max_dd_pct": round(max_drawdown(padded_equity) * 100, 2),
"win_rate": round(win_rate(trades), 4),
"total_trades": len(trades),
"equity_curve": curve,
"trades": trades[-100:],
"num_periods": len(candles),
"data_source": "Hyperliquid Mainnet",
"generated_at": datetime.now().isoformat(),
}
def main():
p = argparse.ArgumentParser(description="FTDT Historical Backtest Runner")
p.add_argument("--coin", default="BTC", choices=["BTC", "ETH", "SOL", "HYPE", "VVV"], help="Coin to backtest")
p.add_argument("--strategy", "-s", choices=list(STRATEGIES) + ["all"], default="all")
p.add_argument("--fee-tier", type=int, default=0, choices=range(7))
p.add_argument("--staking-tier", default="none", choices=list(STAKING_TIERS.keys()))
p.add_argument("--hours", type=int, default=720, help="Hours of history (default: 720 = 30 days)")
a = p.parse_args()
ft_info = PERPS_TIERS[a.fee_tier]
st_info = STAKING_TIERS[a.staking_tier]
print("=" * 60)
print(f" FTDT Quant Lab — HISTORICAL Backtest Runner")
print(f" Coin: {a.coin} | Period: {a.hours}h ({a.hours//24} days)")
print(f" Fee Tier: {ft_info['name']} | Staking: {st_info['name']}")
print("=" * 60)
# Fetch real candles
print(f"\n Fetching {a.coin} candles from Hyperliquid mainnet...")
candles = fetch_candles(a.coin, interval="1h", limit=a.hours)
if not candles:
print(" ERROR: No candle data returned. Check API connectivity.")
sys.exit(1)
print(f" Got {len(candles)} candles: "
f"{datetime.fromtimestamp(candles[0]['t']/1000).strftime('%Y-%m-%d')}"
f"{datetime.fromtimestamp(candles[-1]['t']/1000).strftime('%Y-%m-%d')}")
print(f" Price range: ${float(candles[0]['c']):.0f} → ${float(candles[-1]['c']):.0f}")
keys = list(STRATEGIES) if a.strategy == "all" else [a.strategy]
for key in keys:
cfg = STRATEGIES[key]
print(f"\n Running: {cfg['name']} on {a.coin}...")
# Kalman Pairs: use real BTC/ETH pair data
if key == "kalman_pairs":
try:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from strategies.kalman_pairs import KalmanPairsTrader, backtest_kalman_pairs
# Fetch ETH candles
if a.coin != "ETH":
eth_candles = fetch_candles("ETH", interval="1h", limit=a.hours)
else:
eth_candles = fetch_candles("BTC", interval="1h", limit=a.hours)
if eth_candles:
X = [float(c["c"]) for c in eth_candles]
Y = [float(c["c"]) for c in candles]
n = min(len(X), len(Y))
X, Y = X[:n], Y[:n]
trader = KalmanPairsTrader(
transition_covariance=1e-4, observation_covariance=1e-2,
z_entry=2.0, z_exit=0.5, warmup_bars=20,
)
bt = backtest_kalman_pairs(
X, Y, trader,
trade_size_usd=50.0,
transaction_cost_bps=2.5,
)
# Convert to standard format expected by the dashboard
result = {
"strategy": cfg["name"],
"strategy_key": key,
"coin": a.coin,
"allocation": 100.0,
"start_time": str(bt["equity_curve"][0]["t"]) if bt["equity_curve"] else "",
"end_time": str(bt["equity_curve"][-1]["t"]) if bt["equity_curve"] else "",
"start_equity": 100.0,
"end_equity": round(bt["final_equity"], 4),
"pnl": round(bt["total_pnl"], 4),
"pnl_pct": round(bt["pnl_pct"], 2),
"pnl_gross": round(bt["total_pnl"] + bt["transaction_costs"], 4),
"pnl_gross_pct": round(bt["pnl_pct"], 2),
"fees_total": round(bt["transaction_costs"], 4),
"fee_tier": a.fee_tier,
"staking_tier": a.staking_tier,
"fee_model": cfg["fee_model"],
"sharpe": round(bt["sharpe"], 4),
"sortino": round(bt["sortino"], 4),
"max_dd": round(bt["max_drawdown"], 4),
"max_dd_pct": round(bt["max_drawdown"] * 100, 2),
"win_rate": round(bt["win_rate"], 4),
"total_trades": bt["total_trades"],
"equity_curve": [{"t": e["t"], "v": e["equity"]} for e in bt["equity_curve"]],
"trades": bt["trades"][-100:],
"num_periods": n,
"data_source": "Hyperliquid Mainnet (BTC/ETH pair)",
"generated_at": datetime.now().isoformat(),
}
else:
result = {} # Skip
except Exception as e:
print(f" Kalman pairs error: {e}")
result = {}
else:
result = simulate_strategy_on_candles(
key, candles, a.coin,
fee_tier=a.fee_tier,
staking_tier=a.staking_tier,
)
# Save
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
filename = f"{key}_{a.coin}_{ts}.json"
filepath = RESULTS_DIR / filename
with open(filepath, "w") as f:
json.dump(result, f, indent=2, default=str)
print(f" Saved: {filepath}")
print(f" Net PnL: {result['pnl_pct']:+.2f}% | "
f"Gross: {result['pnl_gross_pct']:+.2f}% | "
f"Fees: ${result['fees_total']:.2f} | "
f"Sharpe: {result['sharpe']:.2f} | "
f"Trades: {result['total_trades']} | "
f"Win: {result['win_rate']:.0%}")
print("\n" + "=" * 60)
print(f" Results in backtests/results/historical/")
print(f" View at: https://ftdt.io/cv")
print("=" * 60)
if __name__ == "__main__":
main()