import datetime as dt from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo from fxhnt.application.forward_engine import run_track def _repo(tmp_path): repo = ForwardNavRepo(f"sqlite:///{tmp_path}/eng.db") repo.migrate() return repo class _RecomputeStrat: """A recompute strategy: full series every call (independent of last_date).""" def __init__(self, series): self._series = series def advance(self, last_date, extra): return list(self._series), extra def test_first_run_inceptions_at_today(tmp_path, monkeypatch): repo = _repo(tmp_path) # register a throwaway recompute track definition from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "recompute"}}) strat = _RecomputeStrat([("2026-07-06", 0.01), ("2026-07-07", 0.02), ("2026-07-08", 0.03)]) out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) a = repo.active_anchor("t_rec") assert a.t0 == "2026-07-08" and out["reinception"] is True # recompute filtered to >= t0 → only the 07-08 row is booked assert out["days"] == 1 and repo.nav_history("t_rec")[-1].date == "2026-07-08" def test_unchanged_hash_preserves_t0_and_recomputes(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "recompute"}}) # seed an anchor at an earlier T0 by running "in the past" strat = _RecomputeStrat([("2026-07-06", 0.01), ("2026-07-07", 0.02), ("2026-07-08", 0.03)]) run_track(repo, "t_rec", strat, today="2026-07-06", at=dt.datetime(2026, 7, 6, 23, 30)) out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert repo.active_anchor("t_rec").t0 == "2026-07-06" and out["reinception"] is False assert out["days"] == 3 # recompute over [2026-07-06, today] = all 3 rows def test_param_change_reinceptions(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "recompute"}}) strat = _RecomputeStrat([("2026-07-06", 0.01), ("2026-07-08", 0.03)]) run_track(repo, "t_rec", strat, today="2026-07-06", at=dt.datetime(2026, 7, 6, 23, 30)) # change a param → hash changes → re-inception at today registry.STRATEGY_REGISTRY["t_rec"]["definition"]["params"] = {"v": 2} out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert out["reinception"] is True and repo.active_anchor("t_rec").t0 == "2026-07-08" def test_inception_honors_override_date_not_today(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry # a faithful re-home pins its pre-re-home OOS start; inception must use it, not today, so the recovered # window is recomputed (this is what a DB rebuild reproduces from git instead of a manual anchor backdate). monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 2, "record_mode": "recompute", "inception_t0": {"date": "2026-06-20", "version": 2}}}) strat = _RecomputeStrat([("2026-06-20", 0.01), ("2026-07-07", 0.02), ("2026-07-08", 0.03)]) out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert repo.active_anchor("t_rec").t0 == "2026-06-20" and out["reinception"] is True assert out["days"] == 3 # recompute over [2026-06-20, today] = all 3 rows, not just today def test_inception_ignores_stale_override_on_version_bump(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry # the override is declared for v2, but the live definition is v3 (a genuine re-tune) → the OOS clock must # legitimately restart at today; the version-stale pin is ignored. monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 3}, "version": 3, "record_mode": "recompute", "inception_t0": {"date": "2026-06-20", "version": 2}}}) strat = _RecomputeStrat([("2026-06-20", 0.01), ("2026-07-08", 0.03)]) out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert repo.active_anchor("t_rec").t0 == "2026-07-08" and out["reinception"] is True assert out["days"] == 1 def test_inception_override_in_the_future_falls_back_to_today(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry # a pin LATER than today (clock skew, or a not-yet-reached inception) must not incept in the future — # it falls back to today so the OOS clock starts sanely, never negative. monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 2, "record_mode": "recompute", "inception_t0": {"date": "2026-12-31", "version": 2}}}) strat = _RecomputeStrat([("2026-07-08", 0.03)]) out = run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert repo.active_anchor("t_rec").t0 == "2026-07-08" # today, not the future pin assert out["days"] == 1 class _ObservedStrat: """An observed strategy: books TODAY on the carried book, returns updated positions.""" def __init__(self, ret_by_day): self._r = ret_by_day def advance(self, last_date, extra): # book today's return; carry a trivial position day = extra.get("_today") return [(day, self._r[day])], {"positions": {"X": 1.0}, "_today": day} def test_observed_mode_appends_and_carries_state(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_obs", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "observed"}}) strat = _ObservedStrat({"2026-07-07": 0.001, "2026-07-08": 0.002}) # NOTE: run_track injects today into extra as "_today" for observed strategies (see impl) run_track(repo, "t_obs", strat, today="2026-07-07", at=dt.datetime(2026, 7, 7, 23, 30)) out = run_track(repo, "t_obs", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert repo.record_series("t_obs") == [("2026-07-07", 0.001), ("2026-07-08", 0.002)] assert repo.get_book_state("t_obs")["positions"] == {"X": 1.0} assert out["days"] == 2 class _CapturingObservedStrat: """An observed strategy that records the `extra` it was called with, so a test can assert whether carried book-state (e.g. `positions`) was reset before booking.""" def __init__(self, ret_by_day): self._r = ret_by_day self.seen_extra: dict | None = None def advance(self, last_date, extra): self.seen_extra = dict(extra) day = extra.get("_today") return [(day, self._r[day])], {"positions": {"Y": 2.0}, "_today": day} def test_observed_reinception_resets_carried_book_state(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_obs", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "observed"}}) strat = _ObservedStrat({"2026-07-07": 0.001}) run_track(repo, "t_obs", strat, today="2026-07-07", at=dt.datetime(2026, 7, 7, 23, 30)) assert repo.get_book_state("t_obs")["positions"] == {"X": 1.0} # carried book-state from day 1 # param change → re-inception; the carried book-state (positions built under the OLD definition) must # be cleared before the next booking, not carried forward into the fresh log. registry.STRATEGY_REGISTRY["t_obs"]["definition"]["params"] = {"v": 2} strat2 = _CapturingObservedStrat({"2026-07-08": 0.002}) run_track(repo, "t_obs", strat2, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert "positions" not in strat2.seen_extra # empty carry — the stale {"X": 1.0} was reset assert repo.get_book_state("t_obs")["positions"] == {"Y": 2.0} # the new definition's own booking def test_reinception_purges_stale_nav_rows(tmp_path, monkeypatch): repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "recompute"}}) strat = _RecomputeStrat([("2026-07-06", 0.01), ("2026-07-08", 0.03)]) run_track(repo, "t_rec", strat, today="2026-07-06", at=dt.datetime(2026, 7, 6, 23, 30)) assert [r.date for r in repo.nav_history("t_rec")] == ["2026-07-06", "2026-07-08"] # param change → re-inception at t0=2026-07-08; the stale 2026-07-06 row must be purged registry.STRATEGY_REGISTRY["t_rec"]["definition"]["params"] = {"v": 2} run_track(repo, "t_rec", strat, today="2026-07-08", at=dt.datetime(2026, 7, 8, 23, 30)) assert [r.date for r in repo.nav_history("t_rec")] == ["2026-07-08"] # clean start, no stale row def test_invalid_record_mode_raises_before_persisting_anchor(tmp_path, monkeypatch): import pytest repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_bad", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "bogus"}}) with pytest.raises(ValueError): run_track(repo, "t_bad", _RecomputeStrat([]), today="2026-07-08", at=dt.datetime(2026, 7, 8)) assert repo.active_anchor("t_bad") is None # no broken anchor persisted def test_anchor_loss_fails_loud_not_silent_reinception(tmp_path, monkeypatch): import pytest from sqlalchemy.orm import Session from fxhnt.adapters.persistence.cockpit_models import StrategyAnchorRow repo = _repo(tmp_path) from fxhnt import registry monkeypatch.setitem(registry.STRATEGY_REGISTRY, "t_rec", {"display_name": "T", "sleeve": "x", "gate_spec": {}, "definition": {"params": {"v": 1}, "version": 1, "record_mode": "recompute"}}) strat = _RecomputeStrat([("2026-07-06", 0.01)]) run_track(repo, "t_rec", strat, today="2026-07-06", at=dt.datetime(2026, 7, 6, 23, 30)) # inception + record # simulate anchor-row loss (bad DB restore) while the forward record persists with Session(repo._engine) as s: s.query(StrategyAnchorRow).filter_by(strategy_id="t_rec").delete() s.commit() with pytest.raises(RuntimeError): run_track(repo, "t_rec", strat, today="2026-07-07", at=dt.datetime(2026, 7, 7, 23, 30)) # and confirm no fresh anchor was written by the failed call assert repo.active_anchor("t_rec") is None