Files
fxhnt/tests/integration/test_vrp_eval.py

494 lines
22 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.
"""READ-ONLY, TAIL-HONEST evaluator for the NEW VRP (volatility-risk-premium) edge.
Thesis: Deribit DVOL (implied vol) systematically exceeds realized vol, so SELLING variance (short-vol)
earns the spread — but with fat LEFT tails on vol spikes (RV >> IV). The signal/backtest uses DVOL (IV) +
close-to-close RV; live execution would be short Bybit option straddles (a cross-venue caveat).
VRP return (per asset, per day, CAUSAL):
short_var_ret_t = k * [ (DVOL_{t-1}/sqrt(365)/100)^2 - ret_t^2 ]
DVOL_{t-1} is the IV known at the START of day t (no look-ahead); ret_t = close-to-close (t-1 -> t). The book
is BTC+ETH equal-weight, vol-targeted to a sane annual vol (k). direction="short_vol" harvests; "long_vol" is
the negation.
Tests assert:
* the VRP return is POSITIVE on a calm-IV-high fixture and BIG-NEGATIVE on a spike fixture (the tail shows);
* sizing (vol-target) scales the series; direction flips the sign;
* the evaluator is READ-ONLY (a write-tripwire never trips);
* verify is cost-monotone (incl 50/100bp option costs), per-year, reports worst-day + maxDD, and the
correlation vs ALL FOUR existing edges (tstrend/unlock/xsfunding/positioning);
* walk-forward train/test OOS + look-ahead-clean; a tail-survivable fixture PASSES, a tail-dominated one
FAILS;
* the CLI smoke prints metrics + verify + walk-forward (mocked in-memory store, NO network).
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.vrp_eval import (
look_ahead_audit_vrp,
short_var_return,
variance_swap_payoff,
verify_vrp_edge,
vrp_metrics_from_store,
vrp_returns_from_store,
walk_forward_vrp,
)
_DAY = 86_400
_START = 19_358 # 2023-01-01 epoch day
class _WriteTripwireStore:
"""Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""
_FORBIDDEN = frozenset({
"write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
"replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
"replace_trades", "_create_schema", "_bulk_upsert",
})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireStore._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: evaluator called write method {name!r}")
return getattr(self._inner, name)
# --- 1. short_var_return (the formula) ---------------------------------------------------------
def test_short_var_positive_when_calm_iv_high() -> None:
# IV (DVOL) = 80 vol pts; realized daily move tiny -> RV << IV -> short-vol HARVESTS (positive).
r = short_var_return(dvol_prev=80.0, ret=0.001)
assert r > 0.0
def test_short_var_big_negative_on_spike() -> None:
# IV = 40 vol pts (calm implied), but realized move is a -20% spike -> RV >> IV -> big NEGATIVE (the tail).
calm = short_var_return(dvol_prev=40.0, ret=0.005)
spike = short_var_return(dvol_prev=40.0, ret=-0.20)
assert spike < 0.0
assert spike < calm
# the spike loss dwarfs a calm-day gain in magnitude (fat left tail).
assert abs(spike) > 5.0 * abs(calm)
def test_short_var_implied_daily_variance_is_iv_squared() -> None:
# On a zero-realized-move day the harvest equals the implied daily variance (DVOL/sqrt(365)/100)^2.
iv_daily = 60.0 / math.sqrt(365) / 100.0
r = short_var_return(dvol_prev=60.0, ret=0.0)
assert math.isclose(r, iv_daily ** 2, rel_tol=1e-12)
def test_long_vol_is_negation_of_short_vol() -> None:
assert math.isclose(short_var_return(dvol_prev=50.0, ret=0.03, direction="long_vol"),
-short_var_return(dvol_prev=50.0, ret=0.03, direction="short_vol"),
rel_tol=1e-12)
# --- 1b. variance_swap_payoff (the PROPER variance swap: implied strike vs realized-WINDOW variance) ----
def test_variance_swap_payoff_positive_when_calm_window() -> None:
# IV (DVOL) = 60 vol pts (strike K=0.36 annual var); realized window is tiny ±0.2% daily moves -> RV << K
# -> short-var swap HARVESTS the premium (positive).
fwd = [0.002 if i % 2 == 0 else -0.002 for i in range(30)]
assert variance_swap_payoff(dvol_entry=60.0, fwd_rets=fwd, swap_days=30) > 0.0
def test_variance_swap_payoff_negative_on_spike_window() -> None:
# IV = 40 vol pts (K=0.16); the realized window contains a -25% crash -> RV >> K -> NEGATIVE (the tail).
fwd = [0.001] * 29 + [-0.25]
assert variance_swap_payoff(dvol_entry=40.0, fwd_rets=fwd, swap_days=30) < 0.0
def test_variance_swap_payoff_annualization_zero_when_rv_equals_iv() -> None:
# Constant-vol synthetic: pick a daily move whose annualised realized variance EXACTLY equals the strike
# K=(DVOL/100)^2 -> the short-var swap payoff is ~0 (the annualisation 365/swap_days is correct).
dvol = 50.0
k = (dvol / 100.0) ** 2 # annual implied variance = 0.25
daily_var = k / 365.0 # per-day variance so (365/n)*n*daily_var == k
move = math.sqrt(daily_var)
fwd = [move if i % 2 == 0 else -move for i in range(30)]
payoff = variance_swap_payoff(dvol_entry=dvol, fwd_rets=fwd, swap_days=30)
assert abs(payoff) < 1e-12
def test_variance_swap_payoff_long_is_negation() -> None:
fwd = [0.01, -0.02, 0.015] * 10
assert math.isclose(variance_swap_payoff(dvol_entry=55.0, fwd_rets=fwd, direction="long_vol"),
-variance_swap_payoff(dvol_entry=55.0, fwd_rets=fwd, direction="short_vol"),
rel_tol=1e-12)
def test_variance_swap_payoff_empty_window_is_zero() -> None:
assert variance_swap_payoff(dvol_entry=60.0, fwd_rets=[]) == 0.0
# --- 2. vrp_returns_from_store / sizing --------------------------------------------------------
def _seed_calm_premium(store: TimescaleFeatureStore, *, days: int = 260, dvol: float = 60.0,
daily_move: float = 0.004) -> None:
"""Seed BTC+ETH so IV (dvol=60) comfortably exceeds RV (a tiny ±daily_move oscillation): short-vol earns
a steady premium. close oscillates so RV is small + bounded; dvol flat-high. Spans ~9 months."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * daily_move)
def test_vrp_returns_positive_on_calm_premium() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
series = vrp_returns_from_store(store, cost_bps=0.0, direction="short_vol")
store.close()
assert len(series) > 2
assert sum(series.values()) > 0.0 # the premium is harvested gross
def test_vrp_sizing_targets_annual_vol() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
s15 = vrp_returns_from_store(store, cost_bps=0.0, target_ann_vol=0.15)
s30 = vrp_returns_from_store(store, cost_bps=0.0, target_ann_vol=0.30)
store.close()
import statistics as st
v15 = st.pstdev(s15.values()) * math.sqrt(365)
v30 = st.pstdev(s30.values()) * math.sqrt(365)
# vol-target hits (roughly) the requested annual vol, and doubling the target ~doubles realized vol.
assert abs(v15 - 0.15) < 0.03
assert v30 > 1.8 * v15
def test_vrp_constant_premium_series_is_low_noise_and_sane() -> None:
"""THE KEY FIX. A constant-premium synthetic (IV steadily > RV every day) must give a SMOOTH, LOW-noise,
SANE-Sharpe daily series — NOT the 40 Sharpe / blow-up the old single-day-ret^2 proxy produced. The
rolling variance-swap ladder averages RV over a 30-day window, so the day-to-day series is far less noisy
than a raw ret^2. We assert the GROSS (pre-vol-target) ladder series has a high, positive Sharpe and tiny
relative dispersion — i.e. it is well-behaved enough that vol-targeting it can't lose ~everything."""
import statistics as st
from fxhnt.application.vrp_eval import _book_short_var
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store, days=400, dvol=60.0, daily_move=0.004)
raw = _book_short_var(store, direction="short_vol")
store.close()
vals = [raw[d] for d in sorted(raw)]
assert len(vals) > 30
mean, sd = st.mean(vals), st.pstdev(vals)
assert mean > 0.0 # a steady harvested premium
gross_sharpe = mean / sd * math.sqrt(365)
# the ladder series is sane: a strongly POSITIVE Sharpe (the old single-day proxy gave -40 / blew up).
assert gross_sharpe > 3.0
# and genuinely low-noise: the daily dispersion is small relative to the mean (a smooth window-averaged
# quantity, not a spiky raw ret^2).
assert sd < mean # coefficient of variation < 1 (the smoothing fix)
def test_vrp_vol_targeted_constant_premium_does_not_blow_up_drawdown() -> None:
"""The corrected + vol-targeted constant-premium curve must be SANE: no catastrophic drawdown. The old
proxy's single-day spikes blew through the vol-target to 96%/180d; the window-averaged ladder must not."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store, days=400, dvol=60.0, daily_move=0.004)
m = vrp_metrics_from_store(store, cost_bps=5.5, target_ann_vol=0.15)
store.close()
assert m["available"] is True
assert m["sharpe"] > 1.0 # a real, sane edge on the calm fixture
assert m["max_dd"] > -0.40 # NOT a 96% blow-up
def test_vrp_direction_flips_sign() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
short = vrp_returns_from_store(store, cost_bps=0.0, direction="short_vol")
long_ = vrp_returns_from_store(store, cost_bps=0.0, direction="long_vol")
store.close()
assert sum(short.values()) > 0.0 > sum(long_.values())
def test_vrp_metrics_na_when_no_dvol() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
store.write_features("BTCUSDT", [(0, {"close": 30_000.0})]) # close but no dvol
m = vrp_metrics_from_store(store)
store.close()
assert m["available"] is False
assert "reason" in m and m["reason"]
def test_vrp_metrics_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
m = vrp_metrics_from_store(store, cost_bps=5.5)
inner.close()
assert m["available"] is True
def test_vrp_metrics_reports_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
m = vrp_metrics_from_store(store, cost_bps=0.0)
store.close()
assert m["available"] is True
assert m["max_dd"] <= 0.0 # maxDD is a (non-positive) drawdown
assert "worst_day" in m # the single worst vol-spike day loss
assert "avg_leverage" in m # exposure from the vol-target
# --- 3. tail handling: a spike fixture must show the loss ---------------------------------------
def _seed_with_spike(store: TimescaleFeatureStore, *, days: int = 260, spike_at: int = 130) -> None:
"""Calm short-vol premium most days, but ONE catastrophic vol-spike day (a -35% move with calm prior IV)
that drives RV >> IV -> a huge negative VRP return on that day (the tail the backtest must capture)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": 55.0})])
if i == spike_at:
px[s] *= (1.0 - 0.35) # crash: realized vol explodes past implied
else:
px[s] *= (1.0 + (0.003 if i % 2 == 0 else -0.003))
def test_spike_day_is_the_worst_day_and_dominates_maxdd() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store)
m = vrp_metrics_from_store(store, cost_bps=0.0)
series = vrp_returns_from_store(store, cost_bps=0.0)
store.close()
worst = min(series.values())
assert worst < 0.0
assert math.isclose(m["worst_day"], worst, rel_tol=1e-9)
# the spike produces a material drawdown — the tail is captured, not smoothed away.
assert m["max_dd"] < -0.05
# --- 4. adversarial verify ---------------------------------------------------------------------
def test_verify_cost_monotone_incl_option_costs() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_vrp_edge(store, cost_bps=5.5, cost_grid=(5.5, 11.0, 22.0, 50.0, 100.0))
store.close()
sv = rep["net_cost"]["short_vol"]
grid = sorted(sv)
for a, b in zip(grid, grid[1:], strict=False):
assert sv[a]["sharpe"] >= sv[b]["sharpe"] # higher cost -> lower Sharpe
assert 50.0 in sv and 100.0 in sv # realistic option-cost sensitivity shown
def test_verify_reports_worst_day_and_per_year() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store, days=400) # spans 2023 and 2024
rep = verify_vrp_edge(store, cost_bps=5.5)
store.close()
assert rep["available"] is True
assert rep["worst_day"]["short_vol"] < 0.0
py = rep["per_year"]["short_vol"]
assert len(py) >= 2
def test_verify_reports_correlation_with_all_four_edges() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_vrp_edge(store, cost_bps=5.5)
store.close()
for d in ("short_vol", "long_vol"):
corrs = rep["corr_edges"][d]
for edge in ("tstrend", "unlock", "xsfunding", "positioning"):
assert edge in corrs
v = corrs[edge]
assert v is None or (-1.0 - 1e-9 <= v <= 1.0 + 1e-9)
def test_verify_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
rep = verify_vrp_edge(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 5. look-ahead audit -----------------------------------------------------------------------
def _seed_varying_dvol(store: TimescaleFeatureStore, *, days: int = 120) -> None:
"""A premium fixture with NON-PERIODIC TIME-VARYING DVOL so the causal (strike=DVOL at swap entry) and
leaked (strike=DVOL at the window END) mappings are genuinely distinguishable on EVERY day — the
look-ahead audit can only bite when the entry-day and window-end IV differ. A slow irrational-step ramp
guarantees no two days share a DVOL value (no coincidental entry/window-end collision)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 50.0 + 0.137 * i # strictly monotone, distinct every day (no collisions)
sign = 1.0 if i % 2 == 0 else -1.0
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol})])
px[s] *= (1.0 + sign * 0.004)
def test_look_ahead_audit_passes_on_causal_signal() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
rep = look_ahead_audit_vrp(store)
store.close()
assert rep["causal"] is True
assert rep["leak_days"] == 0
def test_look_ahead_audit_catches_a_deliberately_leaked_mapping() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
rep = look_ahead_audit_vrp(store, _leak=True) # pair the SAME-day DVOL with the day's return
store.close()
assert rep["causal"] is False
assert rep["leak_days"] > 0
# --- 6. walk-forward / OOS deploy gate ---------------------------------------------------------
def _seed_tail_survivable(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A short-vol premium that SURVIVES its occasional tails: calm harvest most days (RV well below the
varying IV), a moderate spike every ~90 days whose loss is recouped within the next window. Positive in
most windows + OOS, drawdown bounded above the 40% floor. DVOL varies day to day (so the causality audit
can distinguish the prior-day from the same-day IV)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 60.0 + 8.0 * math.sin(i * 0.11) # non-periodic-on-30d drift in 52..68 (no collisions)
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol, "turnover": 5e8})])
if i % 90 == 45:
px[s] *= (1.0 - 0.06) # moderate, recoverable spike
else:
px[s] *= (1.0 + (0.004 if i % 2 == 0 else -0.004))
def _seed_tail_dominated(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A short-vol book the gate must FAIL: thin implied premium (low DVOL), but FREQUENT, LARGE spikes whose
realized variance dwarfs the premium. After vol-targeting the rare-but-huge spike days produce a
CATASTROPHIC drawdown (worse than the 40% floor) — VRP looks fine on the calm days and then a cluster of
spikes wipes it out. DVOL varies day to day (so the causality audit can distinguish prior vs same day)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 25.0 + 4.0 * math.sin(i * 0.11) # thin implied vol ~21..29, non-periodic-on-30d
for s in ("BTCUSDT", "ETHUSDT"):
store.write_features(s, [(d * _DAY, {"close": px[s], "dvol": dvol, "turnover": 5e8})])
# spikes cluster late (after the train window) so the short-vol harvest that "worked" on TRAIN
# is wiped in TEST -> OOS negative AND catastrophic drawdown.
late_cluster = i > int(days * 0.6) and (i % 9 == 0)
if late_cluster:
px[s] *= (1.0 - 0.22) # frequent, large, late, unrecouped spikes
else:
px[s] *= (1.0 + (0.0008 if i % 2 == 0 else -0.0008))
def test_walk_forward_pass_on_tail_survivable() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_vrp(store, cost_bps=5.5, window_days=180)
store.close()
v = rep["verdict"]
assert v["deployable"] is True
assert v["checks"]["oos_direction_positive"] is True
assert v["checks"]["tail_survivable"] is True
assert v["checks"]["look_ahead_clean"] is True
def test_walk_forward_fail_on_tail_dominated() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_dominated(store)
rep = walk_forward_vrp(store, cost_bps=5.5, window_days=180)
store.close()
v = rep["verdict"]
assert v["deployable"] is False
# it fails because the tail is NOT survivable (and/or OOS is negative).
assert v["checks"]["tail_survivable"] is False or v["checks"]["oos_direction_positive"] is False
def test_walk_forward_at_realistic_option_costs_judges_net_of_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_dominated(store)
# at a realistic OPTION cost (selling options isn't 5.5bp) the dominated book is plainly undeployable.
rep = walk_forward_vrp(store, cost_bps=50.0, window_days=180)
store.close()
assert rep["verdict"]["deployable"] is False
def test_walk_forward_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(inner)
store = _WriteTripwireStore(inner)
rep = walk_forward_vrp(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 7. CLI ------------------------------------------------------------------------------------
def test_cli_vrp_eval_prints_metrics(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "vrp" in out or "volatility risk premium" in out
assert "maxdd" in out or "max dd" in out
assert "worst" in out
def test_cli_vrp_eval_verify_prints_diagnostics(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(store, days=400)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval", "--verify"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "short" in out and "long" in out
assert "worst" in out
assert "100" in out # the 100bp option-cost column
assert "tstrend" in out and "positioning" in out
def test_cli_vrp_eval_walk_forward_prints_verdict(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
monkeypatch.setattr(
"fxhnt.adapters.warehouse.timescale_feature_store.TimescaleFeatureStore",
lambda *_a, **_k: store, raising=False)
result = CliRunner().invoke(cli.app, ["vrp-eval", "--walk-forward"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "walk-forward" in out or "walk forward" in out
assert "verdict" in out
assert "pass" in out or "fail" in out