The broad-Binance-universe momentum book (venue binance+glbx) was a survivorship/ microcap mirage superseded by the isolated crypto_tstrend edge + bybit_4edge. Removed the combined_forward_nav Dagster asset (+ its cockpit_forward/paper_book_snapshot deps), the registry entry (DB registry row auto-prunes via _seed_registry), the dashboard headline preference, and the dead migration builder. CombinedBook modules retained (still used by the forward-track CLI). Test fixtures repointed combined -> crypto_tstrend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""END-TO-END forward-gate foundation test — the invariant net that the prior three prod resets/freezes lacked.
|
|
|
|
Real capital gates on the Bybit forward track. It reset/froze THREE times because every prior fix "passed
|
|
tests" yet the tests never simulated the actual failure mode: many nightly runs over ADVANCING dates, with a
|
|
crash-induced state loss in the middle. This drives the REAL `ForwardTracker` (both a deterministic nightly
|
|
strategy that mirrors the live cadence AND, network-free, the real `BybitFourEdgeStrategy` over a seeded
|
|
in-memory store) across ~20 simulated nights and asserts the three invariants that were violated in prod:
|
|
|
|
1. MONOTONIC ACCUMULATION — one `step()` per advancing day: forward_days goes 0→1→2→…, inception NEVER
|
|
changes after the first run, and the reconciliation gate goes WAIT("building N/14") → PASS at day ≥ 14.
|
|
2. SURVIVES CORRUPTION / STATE-LOSS / RESTART — mid-sequence the state JSON is lost / truncated / partially
|
|
written (the crash signature). The tracker must NEVER silently re-inception (forward_days→0, new T0): it
|
|
EITHER recovers the prior days from the `.bak` OR fails loudly (raises). Accumulation then continues from
|
|
where it was, not from 0.
|
|
3. ATOMIC WRITE — an interrupted save never destroys the last good state: the previous state stays loadable.
|
|
|
|
NO network — the real-strategy leg uses an in-memory SQLite feature store; external inputs are seeded.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fxhnt.adapters.orchestration.assets import build_bybit_4edge_forward_nav
|
|
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
|
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
|
|
from fxhnt.application.bybit_forward_track import BybitFourEdgeStrategy
|
|
from fxhnt.application.forward_models import ForwardNavRow, ForwardSummary
|
|
from fxhnt.application.forward_tracker import ForwardTracker
|
|
from fxhnt.application.gate_policy import POLICY
|
|
from fxhnt.application.gate_policy_resolve import resolve_policy
|
|
from fxhnt.application.reconciliation_gate import BacktestReference, evaluate_reconciliation_gate
|
|
from fxhnt.registry import STRATEGY_REGISTRY
|
|
|
|
_DAY = 86_400
|
|
|
|
|
|
def _read_days_list_state(path: str, sid: str) -> tuple[ForwardSummary, list[ForwardNavRow]]:
|
|
"""Read the days-list state file `ForwardTracker` writes directly — the retired `ForwardStateReader`'s
|
|
days-list branch, inlined here since this is the ONLY shape `ForwardTracker` ever produces (the gate
|
|
ETL now reads the DB record instead; this test drives the standalone JSON-based tracker + gate math
|
|
directly, network-free)."""
|
|
state = json.loads(Path(path).read_text())
|
|
days = state["days"]
|
|
rows = [ForwardNavRow(strategy_id=sid, date=d["date"], ret=float(d["ret"]), nav=float(d["nav"]))
|
|
for d in days]
|
|
nav = float(state.get("nav", rows[-1].nav if rows else 1.0))
|
|
as_of = state.get("last_date") or (rows[-1].date if rows else "")
|
|
summary = ForwardSummary(strategy_id=sid, as_of=as_of, days=len(rows), nav=nav,
|
|
total_return=nav - 1.0, sharpe=0.0, maxdd=0.0)
|
|
return summary, rows
|
|
|
|
|
|
class _NightlyRecomputable:
|
|
"""Mirrors the live nightly cadence: a recomputable strategy whose known `(iso_date, ret)` series grows by
|
|
exactly ONE contiguous calendar day per simulated night (exactly like `BybitFourEdgeStrategy` returning the
|
|
full series each run — the tracker books only the days strictly after `last_date`). Deterministic return so
|
|
the gate transition and accumulation are exact."""
|
|
|
|
def __init__(self, start: dt.date, daily_ret: float) -> None:
|
|
self._start = start
|
|
self._daily = daily_ret
|
|
self._n = 0
|
|
self._series: list[tuple[str, float]] = []
|
|
|
|
def add_night(self) -> None:
|
|
d = self._start + dt.timedelta(days=self._n)
|
|
self._series.append((d.isoformat(), self._daily))
|
|
self._n += 1
|
|
|
|
def advance(self, last_date, extra): # noqa: ANN001, ANN201 — ForwardStrategy port
|
|
return list(self._series), dict(extra)
|
|
|
|
|
|
def _build_to_day(strat: _NightlyRecomputable, path: str, forward_days: int):
|
|
"""Freeze inception, then run one `step()` per night until `forward_days` days are booked."""
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, path).step() # first run freezes T0, books nothing
|
|
assert st.forward_days == 0
|
|
for _ in range(forward_days):
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, path).step()
|
|
assert st.forward_days == forward_days
|
|
return st
|
|
|
|
|
|
# ---- invariant 1: monotonic accumulation + gate WAIT→PASS ---------------------------------------------
|
|
|
|
def test_monotonic_accumulation_and_gate_waits_then_passes(tmp_path) -> None:
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
spec = STRATEGY_REGISTRY["bybit_4edge"]["gate_spec"]
|
|
policy = resolve_policy(POLICY, spec)
|
|
# bybit_4edge uses a longer 21-day window (lumpy edge; median ~17d between spikes), not the 14-day default.
|
|
min_days = policy.min_forward_days
|
|
assert min_days == 21
|
|
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, p).step() # inception
|
|
assert st.forward_days == 0
|
|
inception = st.inception
|
|
|
|
seen = [0]
|
|
for night in range(1, min_days + 2): # run past the 21-day threshold to observe WAIT→PASS
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, p).step()
|
|
assert st.inception == inception, "inception must NEVER move after the first run"
|
|
assert st.forward_days == night, "forward_days must accumulate 1 per advancing day"
|
|
seen.append(st.forward_days)
|
|
|
|
summary, rows = _read_days_list_state(p, "bybit_4edge")
|
|
# a backtest reference the forward reconciles with (positive edge, modest expectation).
|
|
bt = BacktestReference(expected_window_return=0.001,
|
|
daily_returns=[0.002] * st.forward_days)
|
|
verdict = evaluate_reconciliation_gate(summary, rows, bt, policy)
|
|
if st.forward_days < min_days:
|
|
assert verdict.status == "WAIT" and "building" in verdict.reason.lower(), verdict
|
|
else:
|
|
assert verdict.status == "PASS", verdict.reason
|
|
|
|
assert seen == list(range(0, min_days + 2)), "accumulation must be strictly monotonic"
|
|
|
|
|
|
# ---- invariant 2: survives corruption / state-loss / restart -----------------------------------------
|
|
|
|
def test_lost_state_file_does_not_silently_reinception(tmp_path) -> None:
|
|
"""THE PROD RESET: the state file is LOST (PVC loss / redeploy). On the broken foundation `_load` returns
|
|
None → the tracker silently re-inceptions (new T0, forward_days=0). It MUST instead recover from the `.bak`
|
|
and continue accumulating — never silently reset to day 0."""
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
st = _build_to_day(strat, p, 8)
|
|
inception = st.inception
|
|
|
|
os.remove(p) # crash: the live state is gone
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, p).step()
|
|
assert st.inception == inception, "must NOT re-inception on a lost state file"
|
|
assert st.forward_days == 9, "must recover the prior 8 days from .bak and book the new one"
|
|
|
|
|
|
def test_empty_truncated_main_recovers_from_bak(tmp_path) -> None:
|
|
"""The non-atomic-writer crash signature: `open(w)` truncated the file, then SIGKILL before the write
|
|
finished → an EMPTY file. The tracker must recover from the `.bak`, not reset and not stay dead."""
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
st = _build_to_day(strat, p, 8)
|
|
inception = st.inception
|
|
|
|
Path(p).write_text("") # truncated-mid-save
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, p).step()
|
|
assert st.inception == inception
|
|
assert st.forward_days == 9
|
|
|
|
|
|
def test_partial_json_main_recovers_from_bak(tmp_path) -> None:
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
st = _build_to_day(strat, p, 8)
|
|
inception = st.inception
|
|
|
|
Path(p).write_text('{"inception": "2026-07-01", "days": [') # partial JSON
|
|
strat.add_night()
|
|
st = ForwardTracker(strat, p).step()
|
|
assert st.inception == inception
|
|
assert st.forward_days == 9
|
|
|
|
|
|
def test_both_main_and_bak_unreadable_fails_loudly_never_resets(tmp_path) -> None:
|
|
"""If BOTH the state and its backup are gone/corrupt (but the track HAS existed), the tracker must FAIL
|
|
LOUDLY — never silently re-inception to day 0. A loud failure surfaces in the asset; a silent reset does
|
|
not (this is the mode that burned real capital)."""
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
_build_to_day(strat, p, 8)
|
|
|
|
Path(p).write_text("")
|
|
Path(p + ".bak").write_text("") # both destroyed
|
|
strat.add_night()
|
|
with pytest.raises((RuntimeError, ValueError)):
|
|
ForwardTracker(strat, p).step()
|
|
# and it must NOT have written a fresh day-0 inception in the process.
|
|
if Path(p).exists() and Path(p).read_text().strip():
|
|
st = json.loads(Path(p).read_text())
|
|
assert st.get("days"), "must not have reset to an empty days-list"
|
|
|
|
|
|
# ---- invariant 3: atomic write preserves the previous good state -------------------------------------
|
|
|
|
def test_interrupted_save_preserves_previous_good_state(tmp_path, monkeypatch) -> None:
|
|
"""A crash DURING the save (json.dump raises) must NEVER leave a readable-but-partial main file. With an
|
|
atomic tmp+replace writer the previous good state stays intact and loadable; the broken `open(w)` writer
|
|
truncates it and loses everything."""
|
|
import fxhnt.application.forward_tracker as ft
|
|
|
|
p = str(tmp_path / "bybit_4edge_state.json")
|
|
strat = _NightlyRecomputable(dt.date(2026, 7, 1), 0.002)
|
|
_build_to_day(strat, p, 5)
|
|
good = Path(p).read_text()
|
|
assert json.loads(good)["days"], "sanity: a good state exists before the interrupted save"
|
|
|
|
def _boom(*_a, **_k):
|
|
raise RuntimeError("SIGKILL during json.dump")
|
|
|
|
monkeypatch.setattr(ft.json, "dump", _boom)
|
|
strat.add_night()
|
|
with pytest.raises(RuntimeError):
|
|
ForwardTracker(strat, p).step()
|
|
monkeypatch.undo()
|
|
|
|
# the previous good state must be intact and loadable — the interrupted save did not destroy it.
|
|
st = ForwardTracker(strat, p).step()
|
|
assert st.forward_days == 6, "must continue from day 5, not reset after the interrupted save"
|
|
assert json.loads(Path(p).read_text())["days"]
|
|
|
|
|
|
# ---- integration: the REAL BybitFourEdgeStrategy over a seeded store, network-free -------------------
|
|
|
|
def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int, drift: float = 0.01) -> None:
|
|
for idx in range(n_symbols):
|
|
sign = 1.0 if idx % 2 == 0 else -1.0
|
|
px = 100.0
|
|
rows = []
|
|
for d in range(days):
|
|
wobble = 0.003 * math.cos(0.4 * d + idx)
|
|
px *= (1.0 + sign * drift + wobble)
|
|
rows.append((d * _DAY, {"close": px}))
|
|
store.write_features(f"SYM{idx:02d}USDT", rows)
|
|
|
|
|
|
def _seed_carry(store: TimescaleFeatureStore, *, days: int) -> None:
|
|
funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
|
|
"XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
|
|
for idx, (sym, fund) in enumerate(funding.items()):
|
|
rows = []
|
|
for d in range(days):
|
|
f = fund + 0.0002 * math.cos(0.5 * d + idx)
|
|
rows.append((d * _DAY, {"funding": f, "close": 100.0, "spot_close": 100.0}))
|
|
store.write_features(sym, rows)
|
|
|
|
|
|
def test_real_bybit_strategy_accumulates_through_the_deterministic_engine(tmp_path) -> None:
|
|
"""The REAL `BybitFourEdgeStrategy`, run through `forward_engine.run_track` (DB anchor + recompute)
|
|
instead of the retired JSON `ForwardTracker`: T0 freezes on the first run and NEVER moves as more data
|
|
lands (same definition_hash -> no re-inception), and the recomputed day count is monotonically
|
|
non-decreasing as the window grows — the same MONOTONIC ACCUMULATION invariant this file's synthetic-
|
|
strategy tests establish above, now proven with the real production strategy.
|
|
|
|
The old JSON STATE-LOSS/RESTART scenario (`os.remove(p)` mid-sequence, invariant 2 above) does not apply
|
|
to this track anymore: recompute mode has no accumulation state to lose in the first place — the DB
|
|
anchor (just T0 + a definition hash) is the ONLY persistent state, and the daily record is always
|
|
freshly recomputed from the warehouse on every run, never appended incrementally to a mutable file. The
|
|
crash-recovery contract itself (`ForwardTracker` + its `.bak`) is unchanged and still covered above by
|
|
the synthetic-strategy invariant-2 tests, since the class remains in use by the standalone `fxhnt
|
|
forward-track` CLI command (`cli.py`), the last caller left after the Dagster forward tracks moved onto
|
|
the engine (the crypto-momentum `combined` book that used this path was retired 2026-07-11)."""
|
|
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/x.db")
|
|
repo.migrate()
|
|
at = dt.datetime(2026, 1, 1)
|
|
|
|
seed0 = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
|
_seed_momentum(seed0, n_symbols=8, days=60)
|
|
_seed_carry(seed0, days=60)
|
|
t0 = BybitFourEdgeStrategy(seed0, cost_bps=0.0).advance(None, {})[0][-1][0]
|
|
seed0.close()
|
|
|
|
def run(days: int):
|
|
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
|
_seed_momentum(store, n_symbols=8, days=days)
|
|
_seed_carry(store, days=days)
|
|
try:
|
|
return build_bybit_4edge_forward_nav(repo, store, today=t0, at=at, cost_bps=0.0)
|
|
finally:
|
|
store.close()
|
|
|
|
out0 = run(60) # freeze T0 (recompute mode books T0 itself → day 1)
|
|
assert out0["days"] == 1
|
|
inception = out0["t0"]
|
|
|
|
last = out0["days"]
|
|
for extra in range(1, 12): # 11 advancing nights
|
|
out = run(60 + extra)
|
|
assert out["t0"] == inception, "real-strategy T0 must be frozen across nights"
|
|
assert out["reinception"] is False
|
|
assert out["days"] >= last, "real-strategy day count must be non-decreasing"
|
|
last = out["days"]
|
|
assert last > 1, "the real strategy must actually recompute additional forward days"
|
|
|
|
# idempotent re-run on UNCHANGED data — the recompute-engine analogue of "no state loss": the record is
|
|
# deterministically derived from [T0, today]'s warehouse data, so repeating a run never drifts.
|
|
out_again = run(60 + 11)
|
|
assert out_again["t0"] == inception
|
|
assert out_again["days"] == last
|