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.
169 lines
8.4 KiB
Python
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)
|