- 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>
129 lines
6.5 KiB
Python
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
|