Files
fxhnt/tests/integration/test_bybit_drawdown_kill_test.py

240 lines
11 KiB
Python

"""OOS test of a PURE drawdown-kill on the combined Bybit 4-edge book — READ-ONLY.
The combined Bybit book (naive eq-wt of tstrend+unlock+carry+positioning) is the deployable book (OOS Sharpe
~2.43, maxDD ~-13%). The full risk OVERLAY was rejected — it over-levered and lost OOS. A PURE drawdown-kill
is a DIFFERENT, simpler thing: capital preservation only. It flattens to cash on a tail drawdown (exposure 0)
and re-enters per the existing `DrawdownKillSwitch` hysteresis (exposure 1) — NO leverage change EVER. The
overlay A/B's kill rode on TOP of vol-target leverage; this is the kill ALONE.
These tests prove:
* the naive eq-wt combined book is built from `bybit_sleeve_ret` (repo or prebuilt dict);
* the pure kill flattens (return 0) while in the drawdown-kill state, re-enters per the logic; exposure is
0/1 only — NEVER levered;
* a sharp-tail-drawdown fixture → the kill REDUCES maxDD (DD benefit shown);
* a choppy fixture → the kill WHIPSAWS (Sharpe loss without DD benefit, flagged);
* train/test verdict: a config that helps on TRAIN but not TEST is NOT adopted (OOS discipline); naive is
chosen when no kill clears the DD+Sharpe bar;
* READ-ONLY (WriteTripwire).
NO network — the repo is in-memory; external inputs are injected.
"""
from __future__ import annotations
import math
from fxhnt.application.bybit_drawdown_kill_test import (
KILL_CONFIG_NAMES,
_pure_kill_curve,
drawdown_kill_test,
)
# --- fixtures ----------------------------------------------------------------------------------
def _flat_up_then_crash(start: int = 0, up_days: int = 120, crash_days: int = 30,
up: float = 0.004, crash: float = -0.02) -> dict[int, float]:
"""A book that rises steadily then suffers a sharp sustained tail drawdown — the case a kill is FOR."""
series: dict[int, float] = {}
d = start
for _ in range(up_days):
series[d] = up
d += 1
for _ in range(crash_days):
series[d] = crash
d += 1
return series
def _choppy(days: int = 200, amp: float = 0.03) -> dict[int, float]:
"""A choppy oscillating book with no sustained drawdown — the case a kill WHIPSAWS on."""
return {d: amp * math.sin(0.7 * d) for d in range(days)}
def _two_sleeves_from(book: dict[int, float]) -> dict[str, dict[int, float]]:
"""Make an eq-wt of two identical sleeves reproduce `book` exactly (eq-wt of x,x = x)."""
return {"crypto_tstrend": dict(book), "xsfunding": dict(book)}
class _FakeRepo:
"""A stand-in for PaperRepo exposing only `bybit_sleeve_returns()` (the precomputed table reader)."""
def __init__(self, returns_by_sleeve: dict[str, dict[int, float]]) -> None:
self._r = {s: dict(v) for s, v in returns_by_sleeve.items()}
def bybit_sleeve_returns(self) -> dict[str, dict[int, float]]:
return {s: dict(v) for s, v in self._r.items()}
class _WriteTripwireRepo:
_FORBIDDEN = frozenset({
"upsert_bybit_sleeve_ret", "replace_positions", "upsert_nav", "upsert_sleeve_ret",
"replace_shadow_positions", "replace_trades", "_create_schema",
})
def __init__(self, inner: _FakeRepo) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireRepo._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: drawdown-kill test called write method {name!r}")
return getattr(self._inner, name)
# --- the pure kill: flatten / re-enter / exposure is 0|1 only ----------------------------------
def test_pure_kill_curve_exposure_is_zero_or_one_never_levered() -> None:
"""The pure-kill curve return on each day equals either the book return (exposure 1) or 0 (exposure 0,
flattened) — it is NEVER a scaled / levered multiple of the book return."""
book = _flat_up_then_crash()
killed, exposure = _pure_kill_curve(book, kill_dd=0.10)
for d, r in killed.items():
assert exposure[d] in (0.0, 1.0), (d, exposure[d])
expected = exposure[d] * book[d]
assert abs(r - expected) < 1e-12, (d, r, expected)
def test_pure_kill_flattens_during_drawdown_state() -> None:
"""Once the running drawdown breaches the kill threshold, the pure kill flattens (return 0) on subsequent
days until re-entry — so at least one crash day is zeroed out."""
book = _flat_up_then_crash(up=0.004, crash=-0.03, crash_days=40)
killed, exposure = _pure_kill_curve(book, kill_dd=0.10)
flattened = [d for d, e in exposure.items() if e == 0.0]
assert flattened, "expected the kill to flatten at least one day during the crash"
# every flattened day contributes exactly 0 to the curve.
for d in flattened:
assert killed[d] == 0.0
def test_pure_kill_reenters_after_recovery() -> None:
"""After a kill, when the shadow drawdown recovers below the re-enter threshold the exposure returns to 1
(the book is re-entered) — so a kill is NOT permanent."""
# crash then a long recovery rally.
book = _flat_up_then_crash(up_days=80, crash_days=20, up=0.004, crash=-0.03)
d = max(book) + 1
for _ in range(200):
book[d] = 0.01 # strong recovery
d += 1
_killed, exposure = _pure_kill_curve(book, kill_dd=0.10)
got_killed = any(e == 0.0 for e in exposure.values())
reentered = exposure[max(exposure)] == 1.0
assert got_killed and reentered, (got_killed, reentered)
# --- DD benefit on a tail drawdown; whipsaw on a choppy book -----------------------------------
def test_kill_reduces_maxdd_on_sharp_tail_drawdown() -> None:
"""On a book with a sharp sustained tail drawdown, a pure kill REDUCES maxDD vs naive (the DD benefit)."""
book = _flat_up_then_crash(up=0.004, crash=-0.025, crash_days=40)
rep = drawdown_kill_test(_two_sleeves_from(book), train_frac=0.6)
assert rep["available"] is True
naive_dd = rep["table"]["naive"]["full"]["max_dd"]
# the deepest-threshold kill that actually triggers should cut maxDD.
best_dd = min(rep["table"][c]["full"]["max_dd"] for c in KILL_CONFIG_NAMES)
# max_dd is <= 0; "less deep" = closer to 0 = larger value.
assert best_dd > naive_dd + 1e-9, (best_dd, naive_dd)
# and at least one kill config actually triggered.
assert any(rep["table"][c]["full"]["triggers"] > 0 for c in KILL_CONFIG_NAMES)
def test_kill_whipsaws_on_choppy_book() -> None:
"""On a choppy book with no sustained drawdown, a tight kill that triggers should COST Sharpe without a
meaningful DD benefit — the whipsaw case. The verdict must NOT adopt a whipsawing kill."""
book = _choppy(days=240, amp=0.03)
rep = drawdown_kill_test(_two_sleeves_from(book), train_frac=0.6)
assert rep["available"] is True
# naive is the recommendation when no kill clears the DD+Sharpe bar on choppy data.
assert rep["verdict"]["recommendation"] == "naive", rep["verdict"]
# --- train/test OOS discipline -----------------------------------------------------------------
def test_kill_helps_train_not_test_is_not_adopted() -> None:
"""A kill that cuts DD on TRAIN but whipsaws on TEST (no OOS DD benefit) must NOT be adopted — the
recommendation falls back to naive. Fixture: a tail crash early (TRAIN) then choppy (TEST)."""
book = _flat_up_then_crash(up_days=80, crash_days=40, up=0.004, crash=-0.025)
d = max(book) + 1
# TEST region: choppy, no sustained drawdown — a kill only whipsaws here.
for k in range(160):
book[d] = 0.025 * math.sin(0.7 * k)
d += 1
rep = drawdown_kill_test(_two_sleeves_from(book), train_frac=0.5)
assert rep["available"] is True
rec = rep["verdict"]["recommendation"]
# whatever is recommended must satisfy the OOS bar (or be naive); a TRAIN-only winner is not adopted.
if rec != "naive":
te = rep["table"][rec]["test"]
naive_te = rep["table"]["naive"]["test"]
rel_dd_red = (naive_te["max_dd"] - te["max_dd"]) / abs(naive_te["max_dd"]) if naive_te["max_dd"] < 0 else 0.0
assert rel_dd_red >= rep["verdict"]["dd_bar"] - 1e-9
assert (naive_te["sharpe"] - te["sharpe"]) <= rep["verdict"]["sharpe_bar"] + 1e-9
def test_naive_chosen_when_no_kill_clears_the_bar() -> None:
"""When the book's drawdown is already contained (no tail event), every kill barely triggers → no DD
benefit → STAY NAIVE."""
book = {d: 0.003 + 0.005 * math.sin(0.3 * d) for d in range(300)} # mild, well-contained
rep = drawdown_kill_test(_two_sleeves_from(book), train_frac=0.6)
assert rep["available"] is True
assert rep["verdict"]["recommendation"] == "naive"
# --- repo path + read-only ---------------------------------------------------------------------
def test_reads_combined_book_from_repo() -> None:
"""drawdown_kill_test accepts a repo and builds the naive eq-wt combined book from bybit_sleeve_returns()."""
book = _flat_up_then_crash(up=0.004, crash=-0.025, crash_days=30)
repo = _FakeRepo(_two_sleeves_from(book))
rep = drawdown_kill_test(repo, train_frac=0.6)
assert rep["available"] is True
assert set(rep["table"]) == {"naive", *KILL_CONFIG_NAMES}
def test_drawdown_kill_test_is_read_only() -> None:
book = _flat_up_then_crash()
guarded = _WriteTripwireRepo(_FakeRepo(_two_sleeves_from(book)))
rep = drawdown_kill_test(guarded, train_frac=0.6)
assert rep["available"] is True
def test_empty_repo_reports_unavailable() -> None:
rep = drawdown_kill_test(_FakeRepo({}), train_frac=0.6)
assert rep["available"] is False
assert "reason" in rep
# --- table shape: train + test + full per config, with triggers --------------------------------
def test_table_reports_train_test_full_with_triggers() -> None:
book = _flat_up_then_crash(up=0.004, crash=-0.02, crash_days=40)
rep = drawdown_kill_test(_two_sleeves_from(book), train_frac=0.6)
assert rep["available"] is True
for name, row in rep["table"].items():
for split in ("train", "test", "full"):
m = row[split]
assert math.isfinite(m["sharpe"]), (name, split)
assert math.isfinite(m["cagr"]), (name, split)
assert m["max_dd"] <= 1e-9, (name, split)
assert m["days"] >= 0
assert m["triggers"] >= 0
# --- CLI smoke (mocked repo) -------------------------------------------------------------------
def test_cli_bybit_drawdown_kill_test_prints_table(monkeypatch) -> None:
from typer.testing import CliRunner
import fxhnt.cli as cli
book = _flat_up_then_crash(up=0.004, crash=-0.025, crash_days=40)
repo = _FakeRepo(_two_sleeves_from(book))
monkeypatch.setattr(
"fxhnt.adapters.persistence.paper_repo.PaperRepo", lambda *a, **k: repo, raising=False)
result = CliRunner().invoke(cli.app, ["bybit-drawdown-kill-test", "--train-frac", "0.6"])
assert result.exit_code == 0, result.output
out_lower = result.output.lower()
assert "drawdown" in out_lower and "kill" in out_lower
assert "naive" in out_lower
assert "train" in out_lower and "test" in out_lower
assert "recommend" in out_lower or "verdict" in out_lower