Files
fxhnt/tests/integration/test_vrp_defined_risk_eval.py

453 lines
21 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 DEFINED-RISK VRP — the deployable form of the vol-risk premium.
The naked short-variance VRP has a real gross edge but FAILS the deploy gate on an UNBOUNDED left tail
(modelled maxDD ~74%). The fix: sell the ATM straddle but BUY OTM wings so the max loss is BOUNDED (an iron
condor). The legs are priced from DVOL via Black-Scholes (we have no option chains — documented caveat).
Tests assert:
* the BOUNDED MAX LOSS — a huge move loses ≤ the defined max (wing_width credit), NOT unbounded;
* a calm expiry keeps (most of) the credit; the wing cost reduces the credit vs a naked straddle;
* the capped curve has a DRAMATICALLY shallower maxDD than the naked variance swap on the SAME spike fixture
(the wings work — this is the whole point);
* verify is cost-monotone (incl 50/100bp option costs), per-year, worst-day, maxDD, corr-vs-4-edges;
* walk-forward OOS + look-ahead-clean; tail-survivable PASSES;
* READ-ONLY (a write-tripwire never trips);
* the CLI smoke prints metrics + verify + walk-forward (in-memory store, NO network).
"""
from __future__ import annotations
import math
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application import vrp_eval
from fxhnt.application.vrp_defined_risk_eval import (
_N_LEGS,
_defined_risk_daily_cost,
_defined_risk_sized_series,
defined_risk_credit,
defined_risk_max_loss,
defined_risk_metrics_from_store,
defined_risk_payoff,
defined_risk_returns_from_store,
look_ahead_audit_defined_risk_ok,
verify_defined_risk_edge,
walk_forward_defined_risk,
)
_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. the defined-risk spread: credit + BOUNDED payoff ---------------------------------------
def test_credit_is_positive_and_a_fraction_of_spot() -> None:
# selling the ATM straddle collects more than the OTM wings cost -> a positive credit, sane in magnitude.
c = defined_risk_credit(s=30_000.0, iv=0.60, t=30.0 / 365.0, wing_width=0.15)
assert 0.0 < c < 0.20 # a fraction of spot, smaller than the wing distance
def test_wing_cost_reduces_credit_vs_naked_straddle() -> None:
# the naked straddle credit (no wings) = w -> infinity limit; buying wings (finite w) costs premium, so the
# defined-risk credit is STRICTLY LESS than the naked straddle premium. A WIDER wing is cheaper -> larger
# credit (approaching the naked premium).
s, iv, t = 30_000.0, 0.60, 30.0 / 365.0
narrow = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.10)
wide = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.30)
naked = defined_risk_credit(s=s, iv=iv, t=t, wing_width=0.95) # ~no wing cost -> ~naked straddle premium
assert narrow < wide < naked
def test_max_loss_is_bounded_the_core_property() -> None:
# THE CORE PROPERTY: a catastrophic move loses AT MOST the defined max (wing_width credit), NOT unbounded.
credit = defined_risk_credit(s=30_000.0, iv=0.60, t=30.0 / 365.0, wing_width=0.15)
defined_max = defined_risk_max_loss(credit=credit, wing_width=0.15)
# a 10%, 50%, 90%, 99% crash all lose the SAME bounded amount once past the wing.
for move in (-0.50, -0.90, -0.99, +0.95):
pnl = defined_risk_payoff(credit=credit, realized_move=move, wing_width=0.15)
assert pnl >= -defined_max - 1e-12 # never worse than the defined max loss
assert math.isclose(pnl, -defined_max, abs_tol=1e-9) # past the wing -> exactly the cap
def test_naked_straddle_loss_is_unbounded_but_capped_is_not() -> None:
# contrast: with NO wings the loss grows with the move (unbounded); with wings it plateaus at the cap.
credit = 0.05
capped_small = defined_risk_payoff(credit=credit, realized_move=-0.20, wing_width=0.15)
capped_huge = defined_risk_payoff(credit=credit, realized_move=-0.80, wing_width=0.15)
assert math.isclose(capped_small, capped_huge, abs_tol=1e-12) # capped: same loss past the wing
# the implicit naked short straddle (credit |m|) keeps getting worse.
naked_small = credit - 0.20
naked_huge = credit - 0.80
assert naked_huge < naked_small # naked: unbounded
def test_calm_expiry_keeps_the_credit() -> None:
# a small realized move (well inside the wings) -> pnl ≈ credit |m|, still positive when |m| < credit.
credit = 0.05
pnl = defined_risk_payoff(credit=credit, realized_move=0.01, wing_width=0.15)
assert math.isclose(pnl, credit - 0.01, abs_tol=1e-12)
assert pnl > 0.0
def test_long_vol_direction_negates() -> None:
credit = 0.04
short = defined_risk_payoff(credit=credit, realized_move=-0.30, wing_width=0.12, direction="short_vol")
long_ = defined_risk_payoff(credit=credit, realized_move=-0.30, wing_width=0.12, direction="long_vol")
assert math.isclose(long_, -short, rel_tol=1e-12)
# --- 2. fixtures (mirror the naked evaluator's, with a tradeable defined-risk premium) ----------
def _seed_calm_premium(store: TimescaleFeatureStore, *, days: int = 400, dvol: float = 60.0,
daily_move: float = 0.004) -> None:
"""Calm: IV high (dvol=60), tiny realized oscillation -> the defined-risk condor harvests its credit."""
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 _seed_with_spike(store: TimescaleFeatureStore, *, days: int = 400, spike_at: int = 200,
crash: float = 0.45) -> None:
"""Calm condor premium most days, but ONE catastrophic crash that would blow up a NAKED short straddle;
the wings cap the defined-risk loss at the spread width. Used for the naked-vs-defined tail comparison."""
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": 50.0})])
if i == spike_at:
px[s] *= (1.0 - crash)
else:
px[s] *= (1.0 + (0.003 if i % 2 == 0 else -0.003))
def _seed_tail_survivable(store: TimescaleFeatureStore, *, days: int = 700) -> None:
"""A defined-risk premium that survives its tails: calm harvest, a moderate spike every ~90 days. DVOL
varies day to day (so the causality audit can distinguish prior-day from 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)
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)
else:
px[s] *= (1.0 + (0.004 if i % 2 == 0 else -0.004))
def _seed_varying_dvol(store: TimescaleFeatureStore, *, days: int = 120) -> None:
"""Premium fixture with monotone time-varying DVOL so the causal and leaked credit-pricing mappings are
distinguishable on every day (the look-ahead audit can only bite when entry-day and expiry IV differ)."""
px = {"BTCUSDT": 30_000.0, "ETHUSDT": 2_000.0}
for i in range(days):
d = _START + i
dvol = 50.0 + 0.137 * 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 * 0.004)
# --- 3. the book series: harvest, sizing, read-only --------------------------------------------
def test_defined_risk_returns_positive_on_calm_premium() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
series = defined_risk_returns_from_store(store, cost_bps=0.0, direction="short_vol")
store.close()
assert len(series) > 2
assert sum(series.values()) > 0.0 # the condor credit is harvested gross
def test_defined_risk_metrics_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(inner)
store = _WriteTripwireStore(inner)
m = defined_risk_metrics_from_store(store, cost_bps=5.5)
inner.close()
assert m["available"] is True
assert m["wing_width"] > 0.0
def test_defined_risk_metrics_reports_tail() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
m = defined_risk_metrics_from_store(store, cost_bps=0.0)
store.close()
assert m["available"] is True
assert m["max_dd"] <= 0.0
assert "worst_day" in m and "avg_leverage" in m
# --- 4. THE TAIL COMPARISON: defined-risk maxDD << naked maxDD on the SAME spike ---------------
def test_defined_risk_maxdd_dramatically_shallower_than_naked_on_spike() -> None:
"""THE WHOLE POINT. On the SAME catastrophic-spike fixture, the defined-risk (wings) curve must have a
DRAMATICALLY shallower maxDD than the naked variance-swap curve — the wings cap the tail the naked book
cannot. Both are vol-targeted+costed by the identical harness; only the construction differs."""
from fxhnt.application.vrp_eval import vrp_metrics_from_store
naked_store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(naked_store)
naked = vrp_metrics_from_store(naked_store, cost_bps=5.5, direction="short_vol")
naked_store.close()
capped_store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_with_spike(capped_store)
capped = defined_risk_metrics_from_store(capped_store, cost_bps=5.5, direction="short_vol",
wing_width=0.15)
capped_store.close()
assert naked["available"] and capped["available"]
# the defined-risk drawdown is materially shallower than the naked one (the wings work).
assert capped["max_dd"] > naked["max_dd"]
assert capped["max_dd"] > 1.3 * naked["max_dd"] # at least ~30% shallower (maxDD is negative)
# --- 4b. THE TURNOVER / COST-ACCOUNTING MODEL (the amortized daily roll, not full-ladder-daily) -
def test_daily_cost_is_one_spreads_legs_not_the_whole_ladder() -> None:
"""THE CORE TURNOVER FIX. A 30-day-roll condor ladder opens exactly ONE new spread per day (n_legs legs)
and lets one expire (settles — NO closing trade). So the daily traded notional is (n_legs / swap_days) of
the gross book, NOT the whole k-leverage ladder re-traded every day. The defined-risk daily haircut must be
k · (n_legs / swap_days) · cost_bps/1e4 — far smaller than the naked daily-rehedge haircut k · cost_bps/1e4."""
k, cost_bps, swap_days = 3.0, 50.0, 30
dr = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
expected = k * (_N_LEGS / swap_days) * (cost_bps / 1e4)
assert math.isclose(dr, expected, rel_tol=1e-12)
# it is exactly (n_legs / swap_days) of the naked full-ladder-daily haircut.
naked_full_ladder_daily = k * (cost_bps / 1e4)
assert math.isclose(dr, naked_full_ladder_daily * (_N_LEGS / swap_days), rel_tol=1e-12)
# with 4 legs over a 30-day roll that is ~7.5x cheaper than charging the whole ladder daily.
assert dr < naked_full_ladder_daily / 7.0
def test_old_full_ladder_daily_overcharging_fails_the_turnover_assertion() -> None:
"""REGRESSION GUARD. The OLD behavior charged the FULL ladder every day (k · cost_bps/1e4 — the naked
daily-rehedge model). On a 30-day-roll 4-leg condor that over-charges turnover by swap_days/n_legs = 7.5x;
the corrected daily roll must be strictly, materially smaller — proving the bug is fixed, not re-introduced."""
k, cost_bps, swap_days = 3.0, 50.0, 30
old_full_ladder_daily = k * (cost_bps / 1e4) # the buggy model
corrected = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
over_charge_factor = old_full_ladder_daily / corrected
assert math.isclose(over_charge_factor, swap_days / _N_LEGS, rel_tol=1e-9)
assert over_charge_factor > 7.0 # ~30x/4legs ≈ 7.5x over-charge
def test_defined_risk_sized_series_uses_amortized_roll_cost() -> None:
"""The defined-risk sized series subtracts the AMORTIZED daily-roll haircut from each k·raw day — NOT the
naked full-ladder haircut the reused vrp_eval._sized_series applies."""
raw = {1: 0.001, 2: -0.002, 3: 0.0015}
k, cost_bps, swap_days = 2.0, 50.0, 30
dr = _defined_risk_sized_series(raw, k=k, cost_bps=cost_bps, swap_days=swap_days)
haircut = _defined_risk_daily_cost(k=k, cost_bps=cost_bps, swap_days=swap_days)
for d in raw:
assert math.isclose(dr[d], k * raw[d] - haircut, rel_tol=1e-12, abs_tol=1e-15)
# and it is strictly less punitive than the naked full-ladder series on every day (cost-only difference).
naked = vrp_eval._sized_series(raw, k=k, cost_bps=cost_bps)
for d in raw:
assert dr[d] > naked[d]
def test_per_year_cost_drag_is_proportionate_not_strategy_destroying() -> None:
"""SANITY on the realized cost drag. The per-YEAR cost of the amortized roll is
n_legs · cost_bps · (365/swap_days) · 1 (per unit notional, leverage 1). At 50bp on a 4-leg 30-day roll
that is 4·0.005·(365/30) ≈ 24%/yr at leverage 1 — a meaningful but NON-catastrophic drag, NOT the ~7.5x
inflated number the old full-ladder-daily model implied (which would be ~180%/yr)."""
cost_bps, swap_days = 50.0, 30
per_year_drag = _N_LEGS * (cost_bps / 1e4) * 365 / swap_days
assert 0.20 < per_year_drag < 0.30 # ~24%/yr at leverage 1 — bounded
# at the cheap 5.5bp the per-year drag is tiny (~2-3%/yr at leverage 1).
cheap = _N_LEGS * (5.5 / 1e4) * 365 / swap_days
assert cheap < 0.04
def test_cost_sensitivity_is_proportionate_5p5_close_to_gross_50_bounded() -> None:
"""END-TO-END SANITY: the corrected cost makes the strategy COST-PROPORTIONATE, not cost-destroyed. On the
tail-survivable fixture: at 5.5bp the net Sharpe is CLOSE to gross (small drag, NO sign flip); at 50bp the
drag is meaningful but BOUNDED — Sharpe stays in the same ballpark and maxDD nowhere near 100%."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
gross = defined_risk_metrics_from_store(store, cost_bps=0.0, direction="short_vol")
cheap = defined_risk_metrics_from_store(store, cost_bps=5.5, direction="short_vol")
pricey = defined_risk_metrics_from_store(store, cost_bps=50.0, direction="short_vol")
store.close()
assert gross["available"] and cheap["available"] and pricey["available"]
assert gross["sharpe"] > 0.0
# 5.5bp: a SMALL drag, no sign flip, close to gross.
assert cheap["sharpe"] > 0.0
assert cheap["sharpe"] > 0.80 * gross["sharpe"]
# 50bp: meaningful but proportionate — still a sizeable fraction of gross, NOT a catastrophic collapse.
assert pricey["sharpe"] > 0.40 * gross["sharpe"]
# the tail stays survivable (the wings cap it) — nowhere near 100%.
assert pricey["max_dd"] > -0.40
# --- 5. adversarial verify ---------------------------------------------------------------------
def test_verify_cost_monotone_incl_option_costs() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_defined_risk_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"]
assert 50.0 in sv and 100.0 in sv
def test_verify_reports_correlation_with_all_four_edges() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_calm_premium(store)
rep = verify_defined_risk_edge(store, cost_bps=5.5)
store.close()
for d in ("short_vol", "long_vol"):
for edge in ("tstrend", "unlock", "xsfunding", "positioning"):
assert edge in rep["corr_edges"][d]
v = rep["corr_edges"][d][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_defined_risk_edge(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
assert rep["wing_width"] > 0.0
# --- 6. look-ahead audit -----------------------------------------------------------------------
def test_look_ahead_clean_on_causal_signal() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
ok = look_ahead_audit_defined_risk_ok(store)
store.close()
assert ok is True
def test_look_ahead_audit_catches_leaked_mapping() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_varying_dvol(store, days=120)
ok = look_ahead_audit_defined_risk_ok(store, _leak=True)
store.close()
assert ok is False
# --- 7. walk-forward / OOS deploy gate ---------------------------------------------------------
def test_walk_forward_runs_and_reports_verdict() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_defined_risk(store, cost_bps=5.5, window_days=180)
store.close()
assert rep["available"] is True
assert "deployable" in rep["verdict"]
assert rep["look_ahead"]["causal"] is True
assert rep["wing_width"] > 0.0
def test_walk_forward_tail_survivable_passes_tail_check() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(store)
rep = walk_forward_defined_risk(store, cost_bps=5.5, window_days=180)
store.close()
# the wings make the tail survivable (the defining property of the defined-risk form).
assert rep["verdict"]["checks"]["tail_survivable"] is True
def test_walk_forward_is_read_only() -> None:
inner = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_tail_survivable(inner)
store = _WriteTripwireStore(inner)
rep = walk_forward_defined_risk(store, cost_bps=5.5)
inner.close()
assert rep["available"] is True
# --- 8. CLI ------------------------------------------------------------------------------------
def test_cli_defined_risk_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-defined-risk-eval"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "defined" in out or "wing" in out
assert "maxdd" in out or "max dd" in out
assert "worst" in out
def test_cli_defined_risk_verify(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-defined-risk-eval", "--verify"])
store.close()
assert result.exit_code == 0, result.output
out = result.output.lower()
assert "short" in out and "long" in out
assert "100" in out
assert "tstrend" in out and "positioning" in out
def test_cli_defined_risk_walk_forward(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-defined-risk-eval", "--walk-forward", "--cost-bps", "50"])
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