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>
144 lines
7.2 KiB
Python
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)
|