Files
fxhnt/tests/integration/test_bybit_forward_track.py
jgrusewski 62357d6cc2 feat(0b): remove vestigial registry state_file field (DB anchor is the forward SSOT)
Step-1 finding: migration_builders.py and the migrate-forward-anchors CLI
(cli.py:427) already carry their own local strategy_id -> state_file basename
map (track_builders()) from an earlier Task-8 pass -- neither reads
entry["state_file"] off STRATEGY_REGISTRY, so removing the field doesn't
touch them. Only three direct consumers needed changes:
 - registry.py: dropped "state_file" from all 11 entries + the header comment.
 - assets.py: _run_paper_tracker's state_file param was unused at steady
   state (its own docstring said so) -- dropped it + updated its 3 call
   sites (sixtyforty_nav, multistrat_nav, vrp_nav).
 - a handful of tests asserted on the registry's state_file key; updated to
   drop those assertions, and added tests/unit/test_no_registry_state_file.py
   as a permanent guard.

The JSON ForwardTracker + its .bak crash-recovery, and migrate-forward-anchors
itself, are untouched -- still the one-time JSON->DB seed / deployment tool.
2026-07-14 10:38:15 +02:00

607 lines
32 KiB
Python

"""Live FORWARD PAPER TRACK for the 4-edge Bybit book — the out-of-sample record.
Asserts (mirroring the existing live paper-track + bybit_book_eval conventions):
* the snapshot computes the naive eq-wt 4-sleeve book NAV from a synthetic `bybit_features` store and
recomputes it through the deterministic engine (DB anchor + recompute); idempotent (re-run same data →
no dup, NAV updated in place);
* the gate: reconciliation gate (~2wk min window), status WAIT while the forward record is still building;
* the forward NAV read returns the `bybit_4edge` series from the cockpit forward_nav table;
* read paths don't clobber the live Binance paper tables (separate track label `bybit_4edge`);
* the incremental refresh calls the ingests in RESUME mode (only new days) — mocked adapters, no network.
NO network — the store is in-memory SQLite; external inputs are injected.
"""
from __future__ import annotations
import datetime as dt
import math
from fxhnt.adapters.orchestration.assets import (
build_bybit_4edge_forward_nav,
refresh_bybit_warehouse,
)
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,
naive_eqwt_daily_returns,
)
from fxhnt.registry import STRATEGY_REGISTRY
_DAY = 86_400
_EPOCH = dt.date(1970, 1, 1)
_AT = dt.datetime(2026, 1, 1)
def _repo(tmp_path) -> ForwardNavRepo: # noqa: ANN001
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/x.db")
repo.migrate()
return repo
def _inception_t0(store: TimescaleFeatureStore, **kwargs) -> str: # noqa: ANN003
"""The strategy's OWN latest known date at inception time — passed as `today` on the first `run_track`
call so the anchor's T0 matches what the old JSON `ForwardTracker` would have frozen as inception."""
rows, _ = BybitFourEdgeStrategy(store, **kwargs).advance(None, {})
return rows[-1][0]
def _seed_momentum(store: TimescaleFeatureStore, *, n_symbols: int = 8, days: int = 260,
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 = 260) -> 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)
# --- naive eq-wt curve --------------------------------------------------------------------------
def test_bybit_4edge_backtest_summary_reproduces_the_forward_book_cagr() -> None:
"""The backtest reference the recon gate reconciles against = the naive eq-wt funding-net 4-edge book's
full-history CAGR under strategy_id 'bybit_4edge'. It must be the SAME book series the forward track
records, so the gate compares like-for-like (else it degrades to a bare 21-day timer)."""
import numpy as np
from fxhnt.application.bybit_forward_track import bybit_4edge_backtest_summary
from fxhnt.domain.backtest import compute_stats
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
rows, _ = BybitFourEdgeStrategy(store, cost_bps=0.0).advance(None, {}) # the exact forward-book series
expected = compute_stats(np.array([r for _, r in rows]), periods_per_year=365).cagr
sm = bybit_4edge_backtest_summary(store, cost_bps=0.0)
store.close()
assert sm.strategy_id == "bybit_4edge"
assert math.isfinite(sm.cagr) and sm.cagr != 0.0
assert math.isclose(sm.cagr, expected, rel_tol=1e-9)
assert math.isfinite(sm.sharpe) and math.isfinite(sm.max_drawdown)
def test_backtest_summary_from_rows_is_basis_of_the_rows_given() -> None:
"""`backtest_summary_from_rows` builds the gate reference from WHATEVER inception series it is handed — the
per-track ref path uses this so a track's ref comes from its OWN strategy's series (correct basis), not the
raw sleeve series. Metrics must match compute_stats on those exact rows; as_of = last row's date; a <2-row
series returns the zeroed guard (so the gate withholds PASS rather than reconciling against noise)."""
import numpy as np
from fxhnt.application.bybit_forward_track import backtest_summary_from_rows
from fxhnt.domain.backtest import compute_stats
rows = [(f"2026-01-{i:02d}", r) for i, r in enumerate(
[0.012, -0.004, 0.020, -0.011, 0.015, 0.003, -0.008, 0.017, 0.006, -0.002], start=1)]
sm = backtest_summary_from_rows("crypto_tstrend", rows)
expected_cagr = compute_stats(np.array([r for _, r in rows]), periods_per_year=365).cagr
assert sm.strategy_id == "crypto_tstrend"
assert sm.as_of == "2026-01-10"
assert math.isclose(sm.cagr, expected_cagr, rel_tol=1e-9)
assert math.isfinite(sm.sharpe) and math.isfinite(sm.max_drawdown)
guard = backtest_summary_from_rows("crypto_tstrend", [("2026-01-01", 0.01)])
assert guard.cagr == 0.0 and guard.passed is False and guard.as_of == ""
def test_backtest_summary_from_rows_matches_bybit_4edge_path() -> None:
"""The refactor is behaviour-preserving: `bybit_4edge_backtest_summary` == `backtest_summary_from_rows`
fed the same book series (so the shared helper did not change the book ref the gate already relies on)."""
from fxhnt.application.bybit_forward_track import backtest_summary_from_rows, bybit_4edge_backtest_summary
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
rows, _ = BybitFourEdgeStrategy(store, cost_bps=0.0).advance(None, {})
via_helper = backtest_summary_from_rows("bybit_4edge", rows)
via_fn = bybit_4edge_backtest_summary(store, cost_bps=0.0)
store.close()
assert math.isclose(via_helper.cagr, via_fn.cagr, rel_tol=1e-12)
assert math.isclose(via_helper.sharpe, via_fn.sharpe, rel_tol=1e-12)
assert via_helper.as_of == via_fn.as_of
def test_levered_shape_is_tail_budgeted_carry_40pct_of_gross() -> None:
"""The levered shape is TAIL-BUDGETED: carry's effective weight is ~40% of the 1.5x book gross — so a
plausible -20% carry dislocation is bounded to ~-8% of the book — NOT the fat-tailed ~53% Sharpe-max
weight. Probe: run each sleeve as a lone +1.0 day through the real combine; the booked value on that day
IS that sleeve's effective weight."""
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, MAX_BOOK_GROSS, levered_eqwt_daily_returns
sleeves = list(_LEVERED_SLEEVE_SHAPE)
rbs = {s: {i: (1.0 if s == t else 0.0) for i, t in enumerate(sleeves)} for s in sleeves}
daily = dict(levered_eqwt_daily_returns(rbs, sleeves, _LEVERED_SLEEVE_SHAPE,
max_gross=MAX_BOOK_GROSS, cost_bps=0.0))
eff = {sleeves[i]: daily[i] for i in range(len(sleeves))}
gross = sum(abs(v) for v in eff.values())
assert abs(gross - MAX_BOOK_GROSS) < 1e-9 # the book gross hits the 1.5x cap
assert abs(eff["xsfunding"] / gross - 0.40) < 0.02 # carry ~40% of gross (tail-budgeted, not ~53%)
def test_backtest_summary_reuses_precomputed_sleeve_returns_equivalently() -> None:
"""The compute-once optimization is behavior-neutral: passing precomputed `sleeve_returns` yields the
IDENTICAL backtest summary as recomputing each sleeve from the store (same cagr/sharpe/maxDD). This lets
the persist Job compute the 4 sleeves ONCE and reuse them for both the naive and levered references
instead of recomputing the heavy backtest 3x."""
from fxhnt.application.bybit_book_eval import _DEFAULT_BYBIT_SLEEVES, sleeve_returns_from_store
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, bybit_4edge_backtest_summary
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
rbs = {s: sleeve_returns_from_store(store, s, cost_bps=0.0) for s in _DEFAULT_BYBIT_SLEEVES}
recomputed = bybit_4edge_backtest_summary(store, cost_bps=0.0)
reused = bybit_4edge_backtest_summary(store, cost_bps=0.0, sleeve_returns=rbs)
rec_lev = bybit_4edge_backtest_summary(store, cost_bps=0.0, leverage_shape=_LEVERED_SLEEVE_SHAPE,
strategy_id="bybit_4edge_levered")
reu_lev = bybit_4edge_backtest_summary(store, cost_bps=0.0, leverage_shape=_LEVERED_SLEEVE_SHAPE,
strategy_id="bybit_4edge_levered", sleeve_returns=rbs)
store.close()
assert reused.cagr == recomputed.cagr and reused.sharpe == recomputed.sharpe
assert reused.max_drawdown == recomputed.max_drawdown
assert reu_lev.cagr == rec_lev.cagr and reu_lev.strategy_id == "bybit_4edge_levered"
def test_backtest_summary_gives_recon_gate_a_bybit_4edge_reference() -> None:
"""After persisting the bybit_4edge backtest summary, the cockpit ref provider returns a REAL
expected_window_return for it — so the recon gate's divergence band is ACTIVE, not the degraded
no-reference path that PASSes on a bare 21-day timer."""
from fxhnt.application.bybit_forward_track import bybit_4edge_backtest_summary
from fxhnt.application.forward_ingest import CockpitBacktestRefProvider
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
sm = bybit_4edge_backtest_summary(store, cost_bps=0.0)
store.close()
repo = ForwardNavRepo("sqlite://")
repo.migrate()
repo.upsert_backtest_summary(sm, at=dt.datetime(2026, 7, 3))
ref = CockpitBacktestRefProvider(repo).reference("bybit_4edge", 21)
assert ref is not None, "no bybit_4edge reference -> recon gate silently degrades to a 21-day timer"
assert ref.expected_window_return != 0.0
def test_naive_eqwt_daily_returns_is_equal_weight_blend() -> None:
rbs = {"a": {0: 0.10, 1: 0.20}, "b": {0: 0.30, 1: -0.10}}
daily = naive_eqwt_daily_returns(rbs, ["a", "b"], cost_bps=0.0)
assert daily == [(0, 0.20), (1, 0.05)] # (0.1+0.3)/2, (0.2-0.1)/2
def test_naive_eqwt_matches_bybit_book_eval_baseline() -> None:
"""The forward curve's per-day series must compound to the SAME naive_equal_weight summary the
read-only bybit_book_eval reports (same engine, same eq-wt math)."""
from fxhnt.application.bybit_book_eval import risk_managed_book_from_store
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
book = risk_managed_book_from_store(store, capital=100_000.0, cost_bps=0.0)
rbs = {s: __import__("fxhnt.application.bybit_book_eval", fromlist=["sleeve_returns_from_store"])
.sleeve_returns_from_store(store, s, cost_bps=0.0) for s in book["sleeves"]}
store.close()
daily = naive_eqwt_daily_returns(rbs, book["sleeves"], cost_bps=0.0)
nav = 1.0
for _d, r in daily:
nav *= (1.0 + r)
assert math.isclose(nav - 1.0, book["naive_equal_weight"]["total_return"], rel_tol=1e-9, abs_tol=1e-9)
# --- snapshot (ForwardTracker) NAV + idempotency ------------------------------------------------
def test_snapshot_freezes_t0_and_books_the_inception_day(tmp_path) -> None:
"""First run freezes T0 at the strategy's latest known date. Recompute mode books `[T0, today]`
INCLUSIVE of T0 itself (the anchor's own day is part of the record) — unlike the retired JSON
`ForwardTracker`, which froze T0 but booked strictly-AFTER days only (day 0 on first run). The real
invariant preserved here is T0-freeze + a deterministic day count, not the old "0 days" number."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=120)
_seed_carry(store, days=120)
t0 = _inception_t0(store, cost_bps=0.0)
repo = _repo(tmp_path)
out = build_bybit_4edge_forward_nav(repo, store, today=t0, at=_AT, cost_bps=0.0)
store.close()
assert out["t0"] == t0 == out["last_date"]
assert out["days"] == 1
assert out["reinception"] is True
def test_snapshot_books_forward_days_and_is_idempotent(tmp_path) -> None:
"""Two stores: the 2nd has the SAME data plus extra forward days → recompute now covers more days;
re-running on the identical 2nd store changes nothing (idempotent — no dup, updated in place). T0 stays
frozen at the anchor set on the first run regardless of the `today` passed on later calls."""
repo = _repo(tmp_path)
early = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(early, n_symbols=8, days=120)
_seed_carry(early, days=120)
t0 = _inception_t0(early, cost_bps=0.0)
out0 = build_bybit_4edge_forward_nav(repo, early, today=t0, at=_AT, cost_bps=0.0) # freeze T0
early.close()
later = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(later, n_symbols=8, days=130)
_seed_carry(later, days=130)
out1 = build_bybit_4edge_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0)
assert out1["t0"] == t0 == out0["t0"] # T0 never moves
assert out1["reinception"] is False
assert out1["days"] > out0["days"] # the extra days got recomputed forward
out2 = build_bybit_4edge_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0) # re-run, SAME data
later.close()
assert out2["days"] == out1["days"] # idempotent: no extra rows
assert out2["last_date"] == out1["last_date"]
assert math.isclose(out2["total_return"], out1["total_return"], rel_tol=1e-12)
# --- gate: reconciliation gate (~2wk window), replacing the blind 60-day calendar wait ----------
def test_gate_is_reconciliation_and_waits_while_building(tmp_path) -> None:
repo = _repo(tmp_path)
early = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(early, n_symbols=8, days=120)
_seed_carry(early, days=120)
t0 = _inception_t0(early, cost_bps=0.0)
build_bybit_4edge_forward_nav(repo, early, today=t0, at=_AT, cost_bps=0.0)
early.close()
later = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(later, n_symbols=8, days=130)
_seed_carry(later, days=130)
out = build_bybit_4edge_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0)
later.close()
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.application.gate_policy import POLICY
from fxhnt.application.gate_policy_resolve import resolve_policy
from fxhnt.application.reconciliation_gate import evaluate_reconciliation_gate
summary = ForwardSummary(strategy_id="bybit_4edge", as_of=out["last_date"], days=out["days"],
nav=out["nav"], total_return=out["total_return"], sharpe=out["sharpe"],
maxdd=out["maxdd"])
rows = repo.nav_history("bybit_4edge")
spec = STRATEGY_REGISTRY["bybit_4edge"]["gate_spec"]
policy = resolve_policy(POLICY, spec)
# the blind 60-day calendar wait is gone — this is a reconciliation gate with a ~2wk min window.
assert spec["gate_type"] == "reconciliation"
assert "min_days" not in spec
# bybit_4edge uses a longer 21-day window (lumpy edge; median ~17d between spikes), not the 14-day default.
assert policy.min_forward_days == 21
# only ~10 forward days booked (120→130) → still building the OOS record → WAIT.
verdict = evaluate_reconciliation_gate(summary, rows, None, policy)
assert verdict.status == "WAIT" and "building" in verdict.reason.lower()
# the nav rows carry per-date NAV (one row per booked forward date).
assert rows and all(r.strategy_id == "bybit_4edge" for r in rows)
# --- cockpit forward_nav read returns the bybit_4edge series; no Binance clobber -----------------
def test_forward_nav_read_returns_bybit_4edge_series_without_clobbering_binance(tmp_path) -> None:
"""`run_track` now writes DIRECTLY into forward_nav (no JSON→DB ingest hop needed for this track); the
invariant that matters is that recording bybit_4edge into the shared cockpit repo never touches a
differently-labeled (Binance) track's rows."""
repo = _repo(tmp_path)
# Seed a Binance live track row FIRST, to prove the bybit_4edge run doesn't clobber it.
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
repo.upsert_rows([NavRowDTO(strategy_id="xsfunding", date="2025-01-01", ret=0.0, nav=1.0)],
at=dt.datetime(2025, 1, 1))
early = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(early, n_symbols=8, days=120)
_seed_carry(early, days=120)
t0 = _inception_t0(early, cost_bps=0.0)
build_bybit_4edge_forward_nav(repo, early, today=t0, at=dt.datetime(2025, 1, 2), cost_bps=0.0)
early.close()
later = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(later, n_symbols=8, days=130)
_seed_carry(later, days=130)
build_bybit_4edge_forward_nav(repo, later, today=t0, at=dt.datetime(2025, 1, 3), cost_bps=0.0)
later.close()
series = repo.nav_history("bybit_4edge")
assert series, "expected a bybit_4edge forward NAV series in the cockpit"
assert all(r.strategy_id == "bybit_4edge" for r in series)
# The Binance xsfunding live track is untouched (separate label).
binance = repo.nav_history("xsfunding")
assert any(r.date == "2025-01-01" and r.nav == 1.0 for r in binance)
# bybit_4edge is in the registry-driven fleet (cockpit visibility).
assert any(r.strategy_id == "bybit_4edge" for r in repo.registry())
# --- incremental refresh calls ingests in RESUME mode (only new days) ---------------------------
class _FakeFunding:
def __init__(self) -> None:
self.calls: list[str] = []
def funding_history(self, symbol, *, start_ms=None): # noqa: ANN001, ANN201
self.calls.append(symbol)
return {19000: 0.001, 19001: 0.001}
class _FakeKlines:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = [] # (symbol, category) — to distinguish perp vs spot fetches
def daily_ohlcv(self, symbol, *, category, start_ms=None): # noqa: ANN001, ANN201 — _BybitKlines shape
self.calls.append((symbol, category))
bar = {"close": 100.0, "high": 101.0, "low": 99.0, "turnover": 50_000_000.0}
return {19000: dict(bar), 19001: dict(bar)}
class _FakeAccountRatio:
def __init__(self) -> None:
self.calls: list[str] = []
def long_ratio_history(self, symbol, *, start_ms=None): # noqa: ANN001, ANN201
self.calls.append(symbol)
return {19000: 0.5, 19001: 0.5}
def test_refresh_all_legs_date_resume_including_perp() -> None:
"""Uniform DATE-RESUME contract after the perp-leg migration (FIX 1): EVERY per-symbol leg — the PERP
(linear) klines leg included — now resumes from its last persisted day instead of symbol-skipping a symbol
that already carries `turnover`. So a done symbol is still fetched for ALL legs (from its last day) and
advances every run; a brand-new symbol is also fetched (its floor is None → full back-fill)."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
# Pre-seed DONE (perp turnover + funding + long_ratio) but NO spot_close.
store.write_features("BTCUSDT", [(19000 * _DAY, {"close": 100.0, "turnover": 9e9, "funding": 0.001,
"long_ratio": 0.5})])
funding, klines, ratio = _FakeFunding(), _FakeKlines(), _FakeAccountRatio()
universe = ["BTCUSDT", "ETHUSDT"]
summary = refresh_bybit_warehouse(
store, funding=funding, klines=klines, account_ratio=ratio,
universe=universe, min_dollar_vol=1_000_000.0)
store.close()
# PERP leg now DATE-RESUMES → the already-done BTCUSDT IS re-fetched (advance, not skip); ETHUSDT too.
assert ("BTCUSDT", "linear") in klines.calls
assert ("ETHUSDT", "linear") in klines.calls
# spot + funding DATE-RESUME → the done symbol IS fetched for those legs (advance, not skip).
assert ("BTCUSDT", "spot") in klines.calls
assert "BTCUSDT" in funding.calls
assert "ETHUSDT" in funding.calls
assert summary["klines"]["symbols"] >= 1
def test_refresh_advances_spot_leg_for_already_perp_done_symbol() -> None:
"""NIGHTLY freeze fix: the spot leg (spot_close/high/low) advances from its last persisted date EVEN for a
symbol whose perp leg is already done (carries turnover). Before the dedicated spot refresh, the symbol-skip
resume on 'turnover' left the spot leg frozen at the last manual ingest → the carry sleeve + Bybit forward
gate stalled."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
# Perp leg done THROUGH 19001; spot leg only THROUGH 19000 (the freeze: spot lags perp).
store.write_features("BTCUSDT", [
(19000 * _DAY, {"close": 100.0, "turnover": 9e9, "funding": 0.001, "long_ratio": 0.5,
"spot_close": 99.0, "spot_high": 99.5, "spot_low": 98.5}),
(19001 * _DAY, {"close": 101.0, "turnover": 9e9, "funding": 0.001, "long_ratio": 0.5}),
])
funding, klines, ratio = _FakeFunding(), _FakeKlines(), _FakeAccountRatio()
summary = refresh_bybit_warehouse(
store, funding=funding, klines=klines, account_ratio=ratio,
universe=["BTCUSDT"], min_dollar_vol=1_000_000.0)
spot = store.crypto_spot_panel()
store.close()
# _FakeKlines returns spot bars for 19000 AND 19001 → the spot leg now reaches 19001 (advanced past 19000).
assert 19000 in spot["BTCUSDT"] and 19001 in spot["BTCUSDT"], "spot leg must advance in the nightly refresh"
assert ("BTCUSDT", "spot") in klines.calls
assert summary["spot"]["symbols"] >= 1
def test_refresh_advances_perp_leg_for_already_perp_done_symbol() -> None:
"""NIGHTLY freeze ROOT-CAUSE fix (FIX 1): the PERP leg (close/turnover/high/low) advances from its last
persisted day EVEN for a symbol that already carries `turnover`. The old symbol-skip resume on `turnover`
froze close/turnover at the last manual ingest for EVERY existing symbol (only brand-new symbols advanced)
→ the whole 4-edge book's cross-section (all sleeves mark on `close`) + the Bybit forward gate stalled."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
# Perp leg present only THROUGH 19000 (the freeze) — new days 19001+ must be re-fetched despite `turnover`.
store.write_features("BTCUSDT", [
(19000 * _DAY, {"close": 100.0, "turnover": 9e9, "high": 101.0, "low": 99.0}),
])
funding, klines, ratio = _FakeFunding(), _FakeKlines(), _FakeAccountRatio()
summary = refresh_bybit_warehouse(
store, funding=funding, klines=klines, account_ratio=ratio,
universe=["BTCUSDT"], min_dollar_vol=1_000_000.0)
close_panel = store.crypto_close_panel()
store.close()
# _FakeKlines returns perp bars for 19000 AND 19001 → the perp close reaches 19001 (advanced past 19000).
assert 19000 in close_panel["BTCUSDT"] and 19001 in close_panel["BTCUSDT"], \
"perp close leg must advance in the nightly refresh"
assert ("BTCUSDT", "linear") in klines.calls, "the already-done symbol must be RE-FETCHED (date-resume)"
assert summary["klines"]["symbols"] >= 1
def test_refresh_logs_loud_error_when_close_cross_section_is_stale() -> None:
"""Data-freshness guard (FIX 2): when the `close` cross-section's freshest healthy day lags `today` by more
than the tolerance, the refresh emits a LOUD error (the data-layer analog of the tracker's re-inception
guard) so a future ingest stall is VISIBLE, not a silent book freeze. It does NOT hard-crash the run."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
funding, klines, ratio = _FakeFunding(), _FakeKlines(), _FakeAccountRatio()
errors: list[str] = []
# _FakeKlines lands close through day 19001; today=19010 → 9 days behind (> 2-day tolerance) → loud error.
summary = refresh_bybit_warehouse(
store, funding=funding, klines=klines, account_ratio=ratio,
universe=["BTCUSDT"], min_dollar_vol=1_000_000.0,
today_day=19_010, log_error=errors.append)
store.close()
assert errors, "a stale close cross-section must emit a loud error"
assert "stale" in errors[0].lower() and "19001" in errors[0]
assert summary["klines"]["symbols"] >= 1 # run still completes (no hard crash)
def test_refresh_no_error_when_close_cross_section_is_fresh() -> None:
"""The freshness guard is SILENT when the close cross-section is within tolerance (no false alarms)."""
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
funding, klines, ratio = _FakeFunding(), _FakeKlines(), _FakeAccountRatio()
errors: list[str] = []
# close lands through day 19001; today=19002 → 1 day behind (<= 2-day tolerance) → no error.
refresh_bybit_warehouse(
store, funding=funding, klines=klines, account_ratio=ratio,
universe=["BTCUSDT"], min_dollar_vol=1_000_000.0,
today_day=19_002, log_error=errors.append)
store.close()
assert errors == []
# --- strategy advance is pure + extends forward only --------------------------------------------
def test_strategy_advance_books_only_after_last_date() -> None:
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=130)
_seed_carry(store, days=130)
strat = BybitFourEdgeStrategy(store, cost_bps=0.0)
rows, _extra = strat.advance(last_date=None, extra={})
store.close()
assert rows
dates = [d for d, _ in rows]
assert dates == sorted(dates) # chronological
assert len(set(dates)) == len(dates) # one row per date
# --- strategy with leverage_shape parameter: levered vs naive book selection ----------------------
def test_bybit_four_edge_strategy_levered_differs_from_naive() -> None:
"""With a leverage_shape the strategy books the levered book (carry-weighted, gross-capped); without it,
the naive book — same days, different returns."""
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, BybitFourEdgeStrategy
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
naive, _ = BybitFourEdgeStrategy(store, cost_bps=0.0).advance(None, {})
lev, _ = BybitFourEdgeStrategy(store, cost_bps=0.0, leverage_shape=_LEVERED_SLEEVE_SHAPE).advance(None, {})
store.close()
assert [d for d, _ in lev] == [d for d, _ in naive] # same day spine
assert [r for _, r in lev] != [r for _, r in naive] # different (levered) returns
# --- levered eq-wt combine (gross-capped levered sleeve weighting) --------------------------------
def test_levered_eqwt_uniform_shape_equals_naive() -> None:
"""With a uniform shape (all 1.0) and max_gross >= 1, the levered combine reduces EXACTLY to the naive
equal-weight book — same weights, same gross 1.0."""
from fxhnt.application.bybit_forward_track import levered_eqwt_daily_returns, naive_eqwt_daily_returns
rbs = {"a": {1: 0.01, 2: -0.02, 3: 0.03}, "b": {1: 0.00, 2: 0.04, 3: -0.01}}
naive = naive_eqwt_daily_returns(rbs, ["a", "b"], cost_bps=0.0)
lev = levered_eqwt_daily_returns(rbs, ["a", "b"], {"a": 1.0, "b": 1.0}, max_gross=1.5, cost_bps=0.0)
assert lev == naive
def test_levered_eqwt_carry_weighted_and_gross_capped() -> None:
"""A carry-heavy shape puts more weight on the high-shape sleeve, and the book gross is capped at
max_gross (the summed absolute weight never exceeds it)."""
from fxhnt.application.bybit_forward_track import levered_eqwt_daily_returns
rbs = {"trend": {1: 0.02, 2: 0.02}, "carry": {1: 0.02, 2: 0.02}}
lev = levered_eqwt_daily_returns(rbs, ["trend", "carry"], {"trend": 1.0, "carry": 5.0},
max_gross=1.5, cost_bps=0.0)
# both sleeves move +2%; shape 1:5 -> carry weight 5x trend; scaled so gross = 1.5.
# weights: trend=1/2, carry=5/2 -> gross=3.0 -> scale 0.5 -> trend=0.25, carry=1.25 (gross 1.5)
# day return = 0.25*0.02 + 1.25*0.02 = 0.03
assert len(lev) == 2
assert abs(lev[0][1] - 0.03) < 1e-12
assert abs(lev[1][1] - 0.03) < 1e-12
def test_levered_backtest_summary_uses_levered_strategy_id_and_differs() -> None:
"""The levered backtest reference carries strategy_id 'bybit_4edge_levered' and a DIFFERENT cagr from the
naive one (leverage scales the book), so the levered recon gate reconciles against the levered backtest."""
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, bybit_4edge_backtest_summary
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
naive = bybit_4edge_backtest_summary(store, cost_bps=0.0)
lev = bybit_4edge_backtest_summary(store, cost_bps=0.0, leverage_shape=_LEVERED_SLEEVE_SHAPE,
strategy_id="bybit_4edge_levered")
store.close()
assert lev.strategy_id == "bybit_4edge_levered"
assert lev.cagr != naive.cagr
# --- nightly builder: build_bybit_4edge_levered_forward_nav (levered shadow, own strategy_id) ----
def test_build_bybit_4edge_levered_forward_nav_books_the_levered_book(tmp_path) -> None:
"""The levered forward builder freezes T0 and records the levered (not naive) book under its OWN
strategy_id `bybit_4edge_levered` in the DB (separate anchor + nav series from `bybit_4edge`)."""
from fxhnt.adapters.orchestration.assets import build_bybit_4edge_levered_forward_nav
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=130)
_seed_carry(store, days=130)
t0 = _inception_t0(store, cost_bps=0.0)
repo = _repo(tmp_path)
out = build_bybit_4edge_levered_forward_nav(repo, store, today=t0, at=_AT, cost_bps=0.0)
store.close()
assert out["t0"] is not None
assert repo.active_anchor("bybit_4edge_levered") is not None
assert repo.nav_history("bybit_4edge_levered")
# --- registry: bybit_4edge_levered shadow track registration -------------------------------------------------------
def test_registry_has_bybit_4edge_levered_shadow() -> None:
"""The levered shadow track is registered in STRATEGY_REGISTRY so the nightly forward track + reconciliation
gate pick it up automatically (OBSERVE-ONLY: records what the levered book WOULD do, validated OOS before
capital rides it)."""
from fxhnt.registry import STRATEGY_REGISTRY
e = STRATEGY_REGISTRY["bybit_4edge_levered"]
assert e["venue"] == "bybit-perp"
assert e["gate_spec"] == {"gate_type": "reconciliation", "min_forward_days": 21}
# --- levered backtest reference (the persist Job wires it in) ----------------------------------------
def test_levered_backtest_ref_activates_the_levered_gate() -> None:
"""Persisting the levered summary gives the cockpit ref provider a real reference for
'bybit_4edge_levered' — the levered gate's band is active, not the degraded no-reference timer."""
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, bybit_4edge_backtest_summary
from fxhnt.application.forward_ingest import CockpitBacktestRefProvider
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_momentum(store, n_symbols=8, days=260)
_seed_carry(store, days=260)
sm = bybit_4edge_backtest_summary(store, cost_bps=0.0, leverage_shape=_LEVERED_SLEEVE_SHAPE,
strategy_id="bybit_4edge_levered")
store.close()
repo = ForwardNavRepo("sqlite://")
repo.migrate()
repo.upsert_backtest_summary(sm, at=dt.datetime(2026, 7, 3))
ref = CockpitBacktestRefProvider(repo).reference("bybit_4edge_levered", 21)
assert ref is not None and ref.expected_window_return != 0.0