453 lines
21 KiB
Python
453 lines
21 KiB
Python
"""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
|