- 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>
170 lines
8.7 KiB
Python
170 lines
8.7 KiB
Python
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"
|
|
|
|
|
|
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
|