Files
fxhnt/tests/integration/test_forward_ingest.py
jgrusewski d7a9c32690 feat(0b): retire crypto_tstrend standalone track + stablecoin_rotation (not tradeable on Bybit); book sleeve unchanged
Neither is tradeable on Bybit: crypto_tstrend as a standalone Binance-perp forward
track was a -0.21 marginal-Sharpe drag/crash-amplifier, and stablecoin_rotation's
FDUSD/USDP pairs aren't listed on Bybit. Removes the two *_nav assets, their
registry entries, the orphaned migration_builders builders, and the
Binance-only StableRotationForward wrapper (stablecoin_runner.py's
StableReversionRunner stays -- it's still live via the Bybit stablecoin eval
paths). The crypto_tstrend SLEEVE inside the bybit_4edge deploy book
(_DEFAULT_BYBIT_SLEEVES, Bybit data) is untouched.

Updates dependent tests: deletes 5 whose subject (the retired asset/registry
entry/module) no longer exists, and swaps the retired sid for a still-registered
one (unlock/xsfunding/sixtyforty) in tests that only used crypto_tstrend as a
generic example sid.
2026-07-13 21:56:23 +02:00

169 lines
8.4 KiB
Python

"""Ingest reads each track's DB record (the engine's `forward_summary` + `forward_nav` rows), computes the
gate, and writes the verdict back. A track with no DB summary yet is skipped, never aborting the batch."""
from __future__ import annotations
import datetime as dt
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.forward_ingest import ForwardIngestService
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.application.reconciliation_gate import BacktestReference
def _setup() -> ForwardNavRepo:
repo = ForwardNavRepo("sqlite://")
repo.migrate()
return repo
def _seed_days_list(repo: ForwardNavRepo, sid: str, rets: list[float], at: dt.datetime, *,
start: dt.date = dt.date(2026, 6, 1), gap_after: int | None = None) -> None:
"""Seed the DB as the engine (`forward_engine.run_track`) would for a days-list track: `forward_nav`
rows + a `forward_summary` computed from them (nav compounds the daily returns; total_return is the
cumulative fractional return). `gate_status`/`gate_reason` start at the engine's WAIT placeholder —
`ForwardIngestService.ingest` is what computes + overwrites the real verdict."""
rows: list[NavRowDTO] = []
nav = 1.0
d = start
for i, r in enumerate(rets):
nav *= 1.0 + r
rows.append(NavRowDTO(strategy_id=sid, date=d.isoformat(), ret=r, nav=nav))
d = d + dt.timedelta(days=1)
if gap_after is not None and i == gap_after:
d = d + dt.timedelta(days=1)
repo.upsert_rows(rows, at)
repo.upsert_summary(
ForwardSummary(strategy_id=sid, as_of=rows[-1].date, days=len(rows), nav=nav,
total_return=nav - 1.0, sharpe=0.0, maxdd=0.0),
gate_status="WAIT", gate_reason="building", at=at)
def test_ingest_populates_summaries_and_rows() -> None:
repo = _setup()
at = dt.datetime(2026, 6, 14, 23, 30)
_seed_days_list(repo, "xsfunding", [0.01, 0.0099], at) # 2 forward days
_seed_days_list(repo, "sixtyforty", [-0.0007, 0.0001, -0.0002, 0.0003, -0.0001], at) # 5 forward days
n = ForwardIngestService(repo).ingest(at=at)
assert n == 2 # two DB-recorded trackers evaluated
summaries = {r.strategy_id: r for r in repo.all_summaries()}
assert "xsfunding" in summaries and "sixtyforty" in summaries
assert summaries["xsfunding"].gate_status == "WAIT" # 2 < 20 days
assert len(repo.nav_history("xsfunding")) == 2
def test_ingest_skips_tracks_with_no_db_summary() -> None:
repo = _setup()
at = dt.datetime(2026, 6, 14, 23, 30)
_seed_days_list(repo, "xsfunding", [0.01, 0.0099], at) # only xsfunding has a DB record
n = ForwardIngestService(repo).ingest(at=at)
assert n == 1
class _StubBacktestRef:
"""Injected backtest reference for the reconciliation gate — keeps ingest pure + network-free in tests."""
def __init__(self, refs: dict[str, BacktestReference]) -> None:
self._refs = refs
def reference(self, strategy_id, forward_days):
return self._refs.get(strategy_id)
def test_ingest_reconciliation_gate_passes_when_forward_tracks_backtest() -> None:
repo = _setup()
at = dt.datetime(2026, 6, 20, 23, 30)
# 22 clean days (past bybit_4edge's 21-day min window), ~+4.5% — tracks/outperforms the backtest.
_seed_days_list(repo, "bybit_4edge", [0.002] * 22, at)
bt = _StubBacktestRef({"bybit_4edge": BacktestReference(expected_window_return=0.032)})
ForwardIngestService(repo, backtest_ref=bt).ingest(at=at)
summaries = {r.strategy_id: r for r in repo.all_summaries()}
assert summaries["bybit_4edge"].gate_status == "PASS"
def test_ingest_reconciliation_gate_catches_the_mirage() -> None:
# The KEY end-to-end test: backtest "should" be strongly positive, but the forward track is strongly
# negative (Binance's +7097% backtest is -36% live) → WAIT past the min window, never PASS.
repo = _setup()
at = dt.datetime(2026, 6, 24, 23, 30)
# 22 clean days (past the 21-day min), ~-36% — a sustained collapse that contradicts the backtest.
_seed_days_list(repo, "bybit_4edge", [-0.02] * 22, at)
bt = _StubBacktestRef({"bybit_4edge": BacktestReference(expected_window_return=1.0)})
ForwardIngestService(repo, backtest_ref=bt).ingest(at=at)
summaries = {r.strategy_id: r for r in repo.all_summaries()}
s = summaries["bybit_4edge"]
assert s.gate_status == "WAIT" and "diverg" in s.gate_reason.lower()
def test_ingest_reconciliation_gate_flags_execution_gap() -> None:
repo = _setup()
at = dt.datetime(2026, 6, 20, 23, 30)
_seed_days_list(repo, "bybit_4edge", [0.002] * 22, at, gap_after=7) # >=21 days, but a hole in the record
bt = _StubBacktestRef({"bybit_4edge": BacktestReference(expected_window_return=0.032)})
ForwardIngestService(repo, backtest_ref=bt).ingest(at=at)
summaries = {r.strategy_id: r for r in repo.all_summaries()}
s = summaries["bybit_4edge"]
assert s.gate_status == "WAIT" and ("gap" in s.gate_reason.lower() or "execution" in s.gate_reason.lower())
def test_ingest_isolates_a_failing_tracker(tmp_path, monkeypatch):
"""A raising tracker must not abort the batch — later tracks in registry-iteration order still get
ingested. STRATEGY_REGISTRY iterates `xsfunding` before `unlock`, so `xsfunding` is the one made to
raise here (the failing tracker must iterate FIRST for this test to prove anything)."""
from fxhnt.application import forward_ingest as fi
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/iso.db")
repo.migrate()
at = dt.datetime(2026, 7, 8, 23, 30)
# seed two reconciliation tracks with DB summaries + nav rows
for sid in ("xsfunding", "unlock"):
repo.upsert_rows([NavRowDTO(strategy_id=sid, date="2026-06-01", ret=0.001, nav=1.001)], at=at)
repo.upsert_summary(ForwardSummary(strategy_id=sid, as_of="2026-06-01", days=1, nav=1.001,
total_return=0.001, sharpe=0.0, maxdd=0.0),
gate_status="WAIT", gate_reason="building", at=at)
# make the gate verdict RAISE for the first-iterated of the two, leave the other working
real = fi._gate_verdict
def flaky(summary, rows, gate_spec, backtest_ref, sid):
if sid == "xsfunding":
raise RuntimeError("boom")
return real(summary, rows, gate_spec, backtest_ref, sid)
monkeypatch.setattr(fi, "_gate_verdict", flaky)
n = fi.ForwardIngestService(repo).ingest(at=at)
assert n == 1 # the failing tracker is skipped; the other is still ingested (batch not aborted)
def test_ingest_computes_gate_from_db_record(tmp_path):
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/ing.db")
repo.migrate()
at = dt.datetime(2026, 7, 8, 23, 30)
sid = "xsfunding" # a reconciliation-gate track
repo.upsert_rows([NavRowDTO(strategy_id=sid, date=f"2026-06-{d:02d}", ret=0.001, nav=1.0 + 0.001 * i)
for i, d in enumerate(range(1, 6))], at=at)
repo.upsert_summary(ForwardSummary(strategy_id=sid, as_of="2026-06-05", days=5, nav=1.005,
total_return=0.005, sharpe=0.5, maxdd=-0.001),
gate_status="WAIT", gate_reason="building", at=at)
n = ForwardIngestService(repo).ingest(at=at)
assert n >= 1
got = {s.strategy_id: s for s in repo.all_summaries()}[sid]
# 5 days < min_forward_days(14) → reconciliation gate stays WAIT (WAIT-safe), verdict written from DB
assert got.gate_status == "WAIT"
def test_ingest_stamps_policy_on_verdict(tmp_path):
from fxhnt.application.gate_policy import POLICY, POLICY_VERSION, policy_hash
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/st.db")
repo.migrate()
at = dt.datetime(2026, 7, 9, 23, 30)
sid = "xsfunding"
repo.upsert_rows([NavRowDTO(strategy_id=sid, date="2026-06-01", ret=0.001, nav=1.001)], at=at)
repo.upsert_summary(ForwardSummary(strategy_id=sid, as_of="2026-06-01", days=1, nav=1.001,
total_return=0.001, sharpe=0.0, maxdd=0.0),
"WAIT", "building", at)
ForwardIngestService(repo).ingest(at=at)
row = {s.strategy_id: s for s in repo.all_summaries()}[sid]
assert row.policy_version == POLICY_VERSION
assert row.policy_hash == policy_hash(POLICY, POLICY_VERSION)