Files
fxhnt/tests/integration/test_forward_migration.py
jgrusewski 7137d62bfa fix(forward): resolve whole-branch review findings (combined migration crash + hash/PIT accuracy)
- CRITICAL: add combined to migration_builders so its anchor is seeded — the pre-branch forward_summary
  row would otherwise trip the lost-anchor guard and crash combined_forward_nav every nightly
- hash accuracy: bybit cost_bps mirrors Settings default (10.0); multistrat instruments derive from
  FUND_INSTRUMENTS (no duplicate) so a return-affecting change actually re-inceptions
- sixtyforty documented as a benchmark exempt from strict PIT (spec §4)
- observed mode: xsfunding consumes the engine's UTC _today; re-inception resets carried book-state

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:51:00 +02:00

129 lines
6.5 KiB
Python

import datetime as dt
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.application.forward_migration import migrate_track
def _repo(tmp_path):
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/mig.db")
repo.migrate()
return repo
class _RecomputeStrat:
def __init__(self, series):
self._series = series
def advance(self, last_date, extra):
return list(self._series), extra
def _reg(monkeypatch, sid, mode):
from fxhnt import registry
monkeypatch.setitem(registry.STRATEGY_REGISTRY, sid,
{"display_name": "T", "sleeve": "x", "gate_spec": {},
"definition": {"params": {"v": 1}, "version": 1, "record_mode": mode}})
def test_recompute_migration_preserves_t0_on_match(tmp_path, monkeypatch):
repo = _repo(tmp_path)
_reg(monkeypatch, "t_rec", "recompute")
series = [("2026-07-06", 0.01), ("2026-07-07", 0.02)]
# accumulated state whose nav MATCHES the recompute over [inception, ...]
nav = 1.0
days = []
for d, r in series:
nav *= 1 + r
days.append({"date": d, "ret": r, "nav": nav})
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": nav, "days": days, "extra": {}}
out = migrate_track(repo, "t_rec", state, _RecomputeStrat(series), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
assert out["status"] == "match" and repo.active_anchor("t_rec").t0 == "2026-07-06"
def test_recompute_migration_flags_divergence(tmp_path, monkeypatch):
repo = _repo(tmp_path)
_reg(monkeypatch, "t_rec", "recompute")
series = [("2026-07-06", 0.01), ("2026-07-07", 0.02)]
# accumulated state that DRIFTED (wrong nav) → divergence flagged
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": 5.0,
"days": [{"date": "2026-07-06", "ret": 0.5, "nav": 1.5},
{"date": "2026-07-07", "ret": 2.0, "nav": 4.5}], "extra": {}}
out = migrate_track(repo, "t_rec", state, _RecomputeStrat(series), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
assert out["status"] == "diverged" and out["max_abs_nav_diff"] > 0.02
def test_recompute_missing_dates_flags_divergence(tmp_path, monkeypatch):
repo = _repo(tmp_path)
_reg(monkeypatch, "t_rec", "recompute")
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": 100.0,
"days": [{"date": "2026-07-06", "ret": 9.0, "nav": 10.0},
{"date": "2026-07-07", "ret": 9.0, "nav": 100.0}], "extra": {}}
out = migrate_track(repo, "t_rec", state, _RecomputeStrat([]), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
assert out["status"] == "diverged" and out["missing_dates"] == 2
def test_recompute_window_bounded_by_today(tmp_path, monkeypatch):
repo = _repo(tmp_path)
_reg(monkeypatch, "t_rec", "recompute")
series = [("2026-07-06", 0.01), ("2026-07-07", 0.02), ("2026-07-09", 0.5)] # 07-09 is beyond today
nav = 1.0
days = []
for d, r in series[:2]:
nav *= 1 + r
days.append({"date": d, "ret": r, "nav": nav})
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": nav, "days": days, "extra": {}}
out = migrate_track(repo, "t_rec", state, _RecomputeStrat(series), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
assert out["status"] == "match" # the future 07-09 row is excluded from the reconcile window
def test_observed_migration_imports_verbatim(tmp_path, monkeypatch):
repo = _repo(tmp_path)
_reg(monkeypatch, "t_obs", "observed")
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": 1.03,
"days": [{"date": "2026-07-06", "ret": 0.01}, {"date": "2026-07-07", "ret": 0.02}],
"extra": {"positions": {"BTCUSDT": 0.5}}}
out = migrate_track(repo, "t_obs", state, _RecomputeStrat([]), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
assert out["status"] == "imported" and out["n"] == 2
assert repo.record_series("t_obs") == [("2026-07-06", 0.01), ("2026-07-07", 0.02)]
assert repo.get_book_state("t_obs") == {"positions": {"BTCUSDT": 0.5}}
def test_combined_migration_seeds_anchor_when_forward_summary_exists_but_no_anchor(tmp_path):
"""CRITICAL 1 regression: production has a pre-branch forward_summary["combined"] row (the old ingest
wrote all tracks) but no anchor row. Before the fix `combined` had no entry in `track_builders`, so
`migrate-forward-anchors` skipped it and the anchor was never seeded — the next engine run's
`active_anchor("combined")` is None while `has_forward_history("combined")` is True, tripping the
lost-anchor guard in `forward_engine.run_track` and crashing `combined_forward_nav` every nightly.
Assert `combined` is now in the builders map, and that running `migrate_track` on the crash precondition
(forward_summary row present, no anchor) seeds an anchor — so a subsequent engine run will not raise."""
from fxhnt.adapters.orchestration.migration_builders import track_builders
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.config import get_settings
repo = _repo(tmp_path)
sid = "combined"
assert sid in track_builders(get_settings(), str(tmp_path)) # the CRITICAL-1 fix: combined has a builder
# seed the pre-branch forward_summary row (old ingest wrote all tracks) — no anchor exists yet.
repo.upsert_summary(
ForwardSummary(strategy_id=sid, as_of="2026-07-06", days=10, nav=1.05,
total_return=0.05, sharpe=1.0, maxdd=-0.02),
gate_status="WAIT", gate_reason="pre-branch", at=dt.datetime(2026, 7, 6, 23, 30))
assert repo.has_forward_history(sid) is True
assert repo.active_anchor(sid) is None # the exact crash precondition
series = [("2026-07-06", 0.01), ("2026-07-07", 0.02)]
state = {"inception": "2026-07-06", "last_date": "2026-07-07", "nav": 1.03,
"days": [{"date": "2026-07-06", "ret": 0.01, "nav": 1.01},
{"date": "2026-07-07", "ret": 0.02, "nav": 1.03}], "extra": {}}
migrate_track(repo, sid, state, _RecomputeStrat(series), today="2026-07-07",
at=dt.datetime(2026, 7, 7, 23, 30))
# anchor now exists — a subsequent engine run's active_anchor(sid) is no longer None, so the
# lost-anchor guard (active is None and has_forward_history) will not trip.
assert repo.active_anchor(sid) is not None