Add real historical backtesting with Hyperliquid mainnet candle data
backtests/historical_runner.py: Fetches real 1h candles from Hyperliquid
mainnet API (candleSnapshot endpoint). Runs all 7 strategies against
actual BTC price history (721 candles, 30 days, $63,024→$63,605).
Each strategy's signal logic operates on real OHLCV data with
configurable fee tiers. Saves to backtests/results/historical/.
Results on 30d BTC data at VIP0:
Mean Reversion: +93.87% net (Sharpe 0.94)
Order Book Imbalance: +54.31% net (Sharpe 1.03)
Avellaneda-Stoikov: -1.02% net (Sharpe -0.13)
Iceberg Detection: -33.20% net
Momentum Breakout: -54.72% net
Server: Added /api/backtests/historical (list) and
/api/backtest/historical/{name} (full data) endpoints.
Dashboard: Added "Historical" tab with "Real Data" badge. Cards show
coin + mainnet source. Click opens the same detail panel with fee
tier dropdown and equity chart.
This commit is contained in:
+267
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Risk analytics module.
|
||||
|
||||
Value at Risk, Conditional VaR, drawdown analysis, correlation,
|
||||
and composite risk ratios. Builds on common/metrics.py for
|
||||
Sharpe/Sortino/max_drawdown which are re-exported here.
|
||||
|
||||
All functions accept equity curves as either plain lists of floats
|
||||
or lists of {t: timestamp, v: equity_value} dicts.
|
||||
"""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _to_values(curve):
|
||||
"""Normalise an equity curve to a list of floats."""
|
||||
if not curve:
|
||||
return []
|
||||
if isinstance(curve[0], dict):
|
||||
return [p["v"] for p in curve]
|
||||
return [float(v) for v in curve]
|
||||
|
||||
|
||||
def _daily_returns(curve):
|
||||
"""Compute daily log returns from an equity curve."""
|
||||
vals = _to_values(curve)
|
||||
if len(vals) < 2:
|
||||
return []
|
||||
return [math.log(vals[i] / vals[i - 1]) for i in range(1, len(vals))]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Value at Risk & Conditional VaR
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def var_95(daily_returns):
|
||||
"""
|
||||
95% historical Value at Risk.
|
||||
|
||||
Returns a *positive* number representing the loss threshold
|
||||
(e.g. 0.02 means "we are 95% confident daily loss won't exceed 2%").
|
||||
"""
|
||||
if len(daily_returns) < 5:
|
||||
return 0.0
|
||||
sorted_ret = sorted(daily_returns)
|
||||
idx = max(0, int(len(sorted_ret) * 0.05))
|
||||
var = sorted_ret[idx]
|
||||
return -min(var, 0.0) # return positive loss magnitude
|
||||
|
||||
|
||||
def cvar_95(daily_returns):
|
||||
"""
|
||||
95% Conditional Value at Risk (Expected Shortfall).
|
||||
|
||||
Average loss *beyond* the VaR threshold. Returns a positive number.
|
||||
"""
|
||||
if len(daily_returns) < 5:
|
||||
return 0.0
|
||||
sorted_ret = sorted(daily_returns)
|
||||
cutoff = max(0, int(len(sorted_ret) * 0.05))
|
||||
tail = [r for r in sorted_ret[:cutoff + 1] if r < 0]
|
||||
if not tail:
|
||||
return 0.0
|
||||
return -np.mean(tail)
|
||||
|
||||
|
||||
def var_95_from_equity(equity_curve):
|
||||
"""Convenience: VaR computed directly from an equity curve."""
|
||||
return var_95(_daily_returns(equity_curve))
|
||||
|
||||
|
||||
def cvar_95_from_equity(equity_curve):
|
||||
"""Convenience: CVaR computed directly from an equity curve."""
|
||||
return cvar_95(_daily_returns(equity_curve))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Drawdown
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def max_drawdown(equity_curve):
|
||||
"""
|
||||
Maximum drawdown from an equity curve (peak-to-trough).
|
||||
|
||||
Returns a positive fraction (0.25 = 25% max DD).
|
||||
Accepts list of floats or list of {t, v} dicts.
|
||||
"""
|
||||
vals = _to_values(equity_curve)
|
||||
if not vals:
|
||||
return 0.0
|
||||
peak = vals[0]
|
||||
worst = 0.0
|
||||
for v in vals:
|
||||
if v > peak:
|
||||
peak = v
|
||||
if peak > 0:
|
||||
dd = (peak - v) / peak
|
||||
worst = max(worst, dd)
|
||||
return worst
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Ratios
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def sharpe(returns, rf=0.0, periods=365):
|
||||
"""
|
||||
Annualised Sharpe ratio.
|
||||
|
||||
`returns` should be a list of daily log returns (floats).
|
||||
"""
|
||||
if len(returns) < 2:
|
||||
return 0.0
|
||||
excess = np.mean(returns) - rf
|
||||
std = np.std(returns, ddof=1)
|
||||
if std <= 0:
|
||||
return 0.0
|
||||
return (excess / std) * math.sqrt(periods)
|
||||
|
||||
|
||||
def sortino(returns, rf=0.0, periods=365):
|
||||
"""
|
||||
Annualised Sortino ratio (downside deviation only).
|
||||
"""
|
||||
if len(returns) < 2:
|
||||
return 0.0
|
||||
excess = np.mean(returns) - rf
|
||||
downside = [r for r in returns if r < 0]
|
||||
d_std = np.std(downside, ddof=1) if downside else 0.0
|
||||
if d_std <= 0:
|
||||
return 0.0
|
||||
return (excess / d_std) * math.sqrt(periods)
|
||||
|
||||
|
||||
def calmar_ratio(returns, max_dd):
|
||||
"""
|
||||
Calmar ratio = annualised return / maximum drawdown.
|
||||
|
||||
`returns` is a list of daily log returns.
|
||||
`max_dd` is a positive fraction (0.25 = 25% drawdown).
|
||||
"""
|
||||
if len(returns) < 2 or max_dd <= 0:
|
||||
return 0.0
|
||||
ann_return = np.mean(returns) * 365
|
||||
return ann_return / max_dd
|
||||
|
||||
|
||||
def sharpe_from_equity(equity_curve):
|
||||
"""Sharpe ratio computed from an equity curve."""
|
||||
return sharpe(_daily_returns(equity_curve))
|
||||
|
||||
|
||||
def sortino_from_equity(equity_curve):
|
||||
"""Sortino ratio computed from an equity curve."""
|
||||
return sortino(_daily_returns(equity_curve))
|
||||
|
||||
|
||||
def calmar_from_equity(equity_curve):
|
||||
"""Calmar ratio computed from an equity curve."""
|
||||
dr = _daily_returns(equity_curve)
|
||||
dd = max_drawdown(equity_curve)
|
||||
return calmar_ratio(dr, dd)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Correlation
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def correlation_matrix(strategy_returns_dict):
|
||||
"""
|
||||
Pearson correlation matrix between strategies.
|
||||
|
||||
Args:
|
||||
strategy_returns_dict: {name: [daily_log_returns], ...}
|
||||
|
||||
Returns:
|
||||
{name: {name: float, ...}, ...}
|
||||
or empty dict if fewer than 2 strategies.
|
||||
"""
|
||||
names = list(strategy_returns_dict.keys())
|
||||
if len(names) < 2:
|
||||
return {}
|
||||
|
||||
# Align lengths (truncate to shortest)
|
||||
min_len = min(len(strategy_returns_dict[n]) for n in names)
|
||||
if min_len < 2:
|
||||
return {}
|
||||
|
||||
matrix = {}
|
||||
for n1 in names:
|
||||
r1 = strategy_returns_dict[n1][-min_len:]
|
||||
row = {}
|
||||
for n2 in names:
|
||||
r2 = strategy_returns_dict[n2][-min_len:]
|
||||
if n1 == n2:
|
||||
row[n2] = 1.0
|
||||
else:
|
||||
corr = np.corrcoef(r1, r2)[0, 1]
|
||||
row[n2] = float(corr) if not np.isnan(corr) else 0.0
|
||||
matrix[n1] = row
|
||||
return matrix
|
||||
|
||||
|
||||
def correlation_from_equity(strategy_equity_dict):
|
||||
"""
|
||||
Convenience: correlation matrix from {name: [{t,v},...]} equity curves.
|
||||
"""
|
||||
returns_dict = {}
|
||||
for name, curve in strategy_equity_dict.items():
|
||||
dr = _daily_returns(curve)
|
||||
if len(dr) >= 2:
|
||||
returns_dict[name] = dr
|
||||
return correlation_matrix(returns_dict)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# Composite risk summary
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def risk_summary(equity_history, strategy_equity=None):
|
||||
"""
|
||||
One-shot: compute all risk metrics for a portfolio.
|
||||
|
||||
Args:
|
||||
equity_history: portfolio-level equity curve [{t, v}, ...]
|
||||
strategy_equity: optional {name: [{t, v}, ...]}
|
||||
|
||||
Returns dict with VaR, CVaR, MaxDD, Calmar, Sharpe, Sortino,
|
||||
and optionally correlation/cross-strategy metrics.
|
||||
"""
|
||||
dr = _daily_returns(equity_history)
|
||||
dd = max_drawdown(equity_history)
|
||||
|
||||
summary = {
|
||||
"var_95": round(var_95(dr), 6),
|
||||
"cvar_95": round(cvar_95(dr), 6),
|
||||
"max_drawdown": round(dd, 6),
|
||||
"calmar_ratio": round(calmar_ratio(dr, dd), 4),
|
||||
"sharpe": round(sharpe(dr), 4),
|
||||
"sortino": round(sortino(dr), 4),
|
||||
"num_observations": len(dr),
|
||||
}
|
||||
|
||||
if strategy_equity and len(strategy_equity) >= 2:
|
||||
summary["correlation"] = correlation_from_equity(strategy_equity)
|
||||
|
||||
# Per-strategy metrics
|
||||
per_strat = {}
|
||||
for name, curve in strategy_equity.items():
|
||||
sdr = _daily_returns(curve)
|
||||
sdd = max_drawdown(curve)
|
||||
per_strat[name] = {
|
||||
"sharpe": round(sharpe(sdr), 4),
|
||||
"sortino": round(sortino(sdr), 4),
|
||||
"max_drawdown": round(sdd, 6),
|
||||
"calmar_ratio": round(calmar_ratio(sdr, sdd), 4),
|
||||
"var_95": round(var_95(sdr), 6),
|
||||
"cvar_95": round(cvar_95(sdr), 6),
|
||||
}
|
||||
summary["per_strategy"] = per_strat
|
||||
|
||||
return summary
|
||||
Reference in New Issue
Block a user