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.
607 lines
32 KiB
Python
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
|