494 lines
22 KiB
Python
494 lines
22 KiB
Python
"""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
|