Files
fxhnt/tests/integration/test_positioning_forward_track.py
jgrusewski f821929ddf refactor(policy): gate functions take ResolvedPolicy; remove scattered gate constants (B1)
evaluate_reconciliation_gate + evaluate_gate now take a ResolvedPolicy (resolve_policy(POLICY,
gate_spec)); the module constants (MIN_FORWARD_DAYS / RECON_* ) + gate.py inline defaults are
removed — thresholds now come from the single GatePolicy. Verdict logic + reason strings
unchanged → byte-identical verdicts (full suite 1873 green proves it). _gate_verdict resolves once.

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

144 lines
7.2 KiB
Python

"""Live FORWARD PAPER TRACK for the POSITIONING sleeve (contrarian long/short account-ratio) as its OWN
edge. Asserts (mirroring the bybit_4edge forward track):
* the snapshot builds forward_nav from the positioning sleeve's daily return on a synthetic store, run
through the deterministic engine (DB anchor + recompute);
* the snapshot advances (recomputes more forward days) and is idempotent;
* the gate is WAIT while building (reconciliation gate, ~2wk window), with T0 recorded;
* the driver is READ-ONLY against the warehouse (a WriteTripwire never trips);
* the daily return matches the single-sleeve `naive_eqwt_daily_returns(..., ['positioning'])` path.
NO network — in-memory SQLite store seeded directly.
"""
from __future__ import annotations
import datetime as dt
import math
from fxhnt.adapters.orchestration.assets import build_positioning_forward_nav
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
from fxhnt.application.bybit_book_eval import sleeve_returns_from_store
from fxhnt.application.bybit_forward_track import BybitFourEdgeStrategy, naive_eqwt_daily_returns
from fxhnt.registry import STRATEGY_REGISTRY
_DAY = 86_400
_AT = dt.datetime(2026, 1, 1)
def _repo(tmp_path) -> ForwardNavRepo: # noqa: ANN001
repo = ForwardNavRepo(f"sqlite:///{tmp_path}/x.db")
repo.migrate()
return repo
def _inception_t0(store, **kwargs) -> str: # noqa: ANN001, ANN003
"""The strategy's OWN latest known date at inception time — passed as `today` on the first `run_track`
call so the anchor's T0 matches what the old JSON `ForwardTracker` would have frozen as inception."""
rows, _ = BybitFourEdgeStrategy(store, sleeves=["positioning"], **kwargs).advance(None, {})
return rows[-1][0]
class _WriteTripwireStore:
"""Read-only proxy: forwards reads, but ANY write/persist call trips an assertion."""
_FORBIDDEN = frozenset({
"write_features", "write_features_bulk", "upsert_feature_rows", "upsert_membership",
"replace_positions", "upsert_nav", "upsert_sleeve_ret", "replace_shadow_positions",
"replace_trades", "_create_schema", "_bulk_upsert",
})
def __init__(self, inner: TimescaleFeatureStore) -> None:
object.__setattr__(self, "_inner", inner)
def __getattr__(self, name: str):
if name in _WriteTripwireStore._FORBIDDEN:
raise AssertionError(f"READ-ONLY violation: tracker called write method {name!r}")
return getattr(self._inner, name)
def _seed_positioning(store: TimescaleFeatureStore, *, days: int = 130) -> None:
"""A long/short-account-ratio fixture where fading retail is profitable: AAA is over-LONG (contrarian
SHORT it) and falls; CCC is over-SHORT (contrarian LONG it) and rises; BBB is at the mean."""
pa, pb, pc = 100.0, 100.0, 100.0
for d in range(days):
pa *= 0.999 # over-long crowd loses → short wins
pc *= 1.001 # over-short crowd gains → long wins
store.write_features("AAAUSDT", [(d * _DAY, {"long_ratio": 0.70, "close": pa})])
store.write_features("BBBUSDT", [(d * _DAY, {"long_ratio": 0.50, "close": pb})])
store.write_features("CCCUSDT", [(d * _DAY, {"long_ratio": 0.30, "close": pc})])
def _store(days: int) -> TimescaleFeatureStore:
s = TimescaleFeatureStore("sqlite://", table="bybit_features")
_seed_positioning(s, days=days)
return s
def test_daily_return_matches_single_sleeve_naive_eqwt() -> None:
store = _store(130)
series = sleeve_returns_from_store(store, "positioning", cost_bps=0.0)
store.close()
assert series # the positioning sleeve produced a return series
daily = naive_eqwt_daily_returns({"positioning": series}, ["positioning"], cost_bps=0.0)
# single sleeve at weight 1/1 → the book return IS the sleeve return per day.
for d, r in daily:
assert math.isclose(r, series[d], rel_tol=1e-12, abs_tol=1e-15)
def test_snapshot_freezes_t0_then_books_forward(tmp_path) -> None:
"""First run freezes T0 at the strategy's latest known date; recompute mode books `[T0, today]`
INCLUSIVE of T0 itself (day 1 on first run) — unlike the retired JSON `ForwardTracker`, which froze T0
but booked strictly-AFTER days only. The real invariant preserved is T0-freeze + a deterministic day
count that GROWS as more data arrives, and re-running unchanged data is idempotent."""
repo = _repo(tmp_path)
early = _store(120)
t0 = _inception_t0(early, cost_bps=0.0)
out = build_positioning_forward_nav(repo, early, today=t0, at=_AT, cost_bps=0.0)
early.close()
assert out["t0"] == t0 == out["last_date"] and out["days"] == 1 # T0 frozen, inclusive day 1
later = _store(130)
out2 = build_positioning_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0)
assert out2["t0"] == t0 # T0 never moves
assert out2["days"] > out["days"] # the new days got recomputed forward
out3 = build_positioning_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0) # re-run same data
later.close()
assert out3["days"] == out2["days"] # idempotent
assert out3["last_date"] == out2["last_date"]
def test_gate_is_wait_while_building(tmp_path) -> None:
repo = _repo(tmp_path)
early = _store(120)
t0 = _inception_t0(early, cost_bps=0.0)
build_positioning_forward_nav(repo, early, today=t0, at=_AT, cost_bps=0.0)
early.close()
later = _store(130)
out = build_positioning_forward_nav(repo, later, today=t0, at=_AT, cost_bps=0.0)
later.close()
from fxhnt.application.forward_models import ForwardSummary
from fxhnt.application.gate_policy import POLICY
from fxhnt.application.gate_policy_resolve import resolve_policy
from fxhnt.application.reconciliation_gate import evaluate_reconciliation_gate
summary = ForwardSummary(strategy_id="positioning", as_of=out["last_date"], days=out["days"],
nav=out["nav"], total_return=out["total_return"], sharpe=out["sharpe"],
maxdd=out["maxdd"])
rows = repo.nav_history("positioning")
spec = STRATEGY_REGISTRY["positioning"]["gate_spec"]
# reconciliation gate (~2wk window) replaces the blind 60-day calendar wait.
assert spec["gate_type"] == "reconciliation" and spec["min_forward_days"] == 14
v = evaluate_reconciliation_gate(summary, rows, None, resolve_policy(POLICY, spec))
assert v.status == "WAIT" and "building" in v.reason.lower() # only ~10 forward days booked
assert rows and all(r.strategy_id == "positioning" for r in rows)
def test_driver_is_read_only(tmp_path) -> None:
repo = _repo(tmp_path)
store = _store(130)
t0 = _inception_t0(store, cost_bps=0.0)
guarded = _WriteTripwireStore(store)
out = build_positioning_forward_nav(repo, guarded, today=t0, at=_AT, cost_bps=0.0) # tripwire must not fire
store.close()
assert out["t0"] # produced a real result
assert repo.nav_history("positioning") # persisted to the DB (allowed)