Files
fxhnt/tests/integration/test_ibkr_sim_web.py
jgrusewski 97b530de10 fix(vrp): hide archived VRP from EVERY cockpit render path (not just Overview)
The first pass only filtered the Overview fleet(); the Backtest tab still showed
vrp as "Options income (put spreads)" + a "Live forward — Equity VRP" section,
and /strategy/vrp still rendered. Fix at the SOURCE so every registry-driven path
inherits the hide, then patch the pages that bypass the source.

- forward_nav.ForwardNavRepo.registry(): SINGLE authoritative filter — exclude
  rows whose STRATEGY_REGISTRY entry is `archived: True`. Every caller (fleet,
  overview gate specs, detail()'s reg lookup) now drops vrp/vrp_exec automatically.
  DB rows stay seeded (code + OPRA data kept) — only hidden from the UI.
- dashboard_service.detail(): explicit archived guard -> returns None so
  /strategy/vrp 404s (belt-and-suspenders over the registry() filter).
- dashboard_service: _active_ibkr_account_sids() helper excludes archived sids;
  the IBKR account per-strategy breakdown + recent-fills roll-up iterate it, so
  vrp never renders a row/label on the account page.
- app.py: _SIM_BOOKS excludes archived books -> no vrp Backtest pill, and a
  ?book=vrp request falls back to the default book (folded "Live forward" section
  can't surface vrp either). _SIM_BOOK_LABELS derives from the filtered list.
- fleet() keeps its archived-check as defense-in-depth (registry() is now the
  authoritative filter).

Render paths fixed: Overview, Backtest (/paper/sim + /paper/sim/run pills,
selector, folded Live-forward), /strategy/<sid> detail, IBKR account per-strategy
breakdown. Verified end-to-end: no "vrp"/"Equity VRP"/"Options income" on any page,
detail("vrp"/"vrp_exec")=None, /strategy/vrp=404, raw DB rows still seeded.

Tests: registry()/detail()/sim-book-list/IBKR-breakdown all assert vrp ABSENT;
test_cockpit_vrp_render flipped to assert 404; test_ibkr_sim_web asserts vrp
excluded from pills + falls back; account-view/detail-breakdown tests use a
synthetic non-archived `newstrat` for the dimmed/scale rows.

uv run pytest -q: 2006 passed.

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

192 lines
9.0 KiB
Python

"""Web smoke for the IBKR paper book (multistrat) in the cockpit Backtest (Task 1/D1): /paper/sim
generalizes beyond the Bybit-only venue to also render this recompute-replay book's honest-cost curve, read
from the PRECOMPUTED `sim_curve_ret` table (never recomputed on the request path) — mirrors the Bybit
measured-cost path's shape (curve + metrics + Live forward), with plain-language pill labels (never the raw
registry id "multistrat").
NOTE: vrp was ARCHIVED/FALSIFIED (shelved 2026-07-15) — its `STRATEGY_REGISTRY` entry carries
`archived: True`, so it is EXCLUDED from the Backtest book pills (`_SIM_BOOKS`) and a `?book=vrp` request
falls back to the default book. The recompute-replay machinery itself still supports vrp (code + OPRA data
kept); this test file now asserts vrp is HIDDEN from the sim UI (see the archived-book tests below).
Asserts:
* /paper/sim shows the multistrat pill labelled in plain language, not the raw id, and NO vrp pill;
* /paper/sim/run?book=multistrat renders a real curve + metrics from the precomputed sim_curve_ret series;
* capital scales the curve linearly (same contract as the Bybit measured path);
* the request path never recomputes the replay (a tripwire on ForwardStrategy.advance would fire if it did —
proven implicitly: the seeded repo carries no build_strategy at all, so a recompute is structurally
impossible; the curve still renders from sim_curve_ret alone);
* the Live forward section renders for multistrat (its own forward track), not just bybit_4edge;
* an archived book (vrp) is not selectable — the request falls back to the default book, never rendering vrp;
* absent precompute -> the graceful 'precomputing' note (never a 500, never the Bybit-specific caption).
NO network — in-memory SQLite repos, seeded directly.
"""
from __future__ import annotations
import datetime as dt
import json
import re
from fastapi.testclient import TestClient
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.adapters.web.app import create_app
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
from fxhnt.application.forward_models import ForwardSummary
def _seed_ibkr_curve(repo: PaperRepo, book: str, *, start: dt.date = dt.date(2023, 1, 1),
days: int = 250) -> None:
"""Persist a synthetic precomputed recompute-replay curve into sim_curve_ret — the nightly
`_persist_track_backtest_ref` write /paper/sim reads for its IBKR books."""
at = dt.datetime(2026, 7, 1, tzinfo=dt.UTC)
for i in range(days):
d = (start + dt.timedelta(days=i)).isoformat()
ret = 0.002 * (1.0 if (i + hash(book)) % 3 else -0.7)
repo.upsert_sim_curve_ret(book, d, ret, at=at)
def _seeded_paper_repo(*, books: tuple[str, ...] = ("multistrat", "vrp")) -> PaperRepo:
repo = PaperRepo("sqlite://")
repo.migrate()
for b in books:
_seed_ibkr_curve(repo, b)
return repo
def _seeded_forward_repo(strategy_id: str = "multistrat") -> ForwardNavRepo:
fwd = ForwardNavRepo("sqlite://")
fwd.migrate()
at = dt.datetime(2026, 6, 24)
base = dt.date(2026, 4, 1)
nav = 1.0
rows = []
for i in range(22):
nav *= 1.0008
rows.append(NavRowDTO(strategy_id=strategy_id,
date=(base + dt.timedelta(days=i)).isoformat(), ret=0.0008, nav=nav))
fwd.upsert_rows(rows, at=at)
sm = ForwardSummary(strategy_id=strategy_id, as_of="2026-04-22", days=22, nav=nav,
total_return=nav - 1.0, sharpe=0.9, maxdd=-0.015)
fwd.upsert_summary(sm, gate_status="WAIT", gate_reason="22/20 forward days", at=at)
return fwd
def _empty_forward_repo() -> ForwardNavRepo:
fwd = ForwardNavRepo("sqlite://")
fwd.migrate()
return fwd
def _client(paper: PaperRepo, fwd: ForwardNavRepo | None = None) -> TestClient:
return TestClient(create_app(fwd or _empty_forward_repo(), paper_repo=paper))
def _sim_data(html: str) -> dict:
m = re.search(r'<script type="application/json" id="sim-data">(.*?)</script>', html, re.DOTALL)
assert m, "embedded #sim-data JSON not found"
return json.loads(m.group(1))
def test_sim_page_shows_multistrat_pill_and_hides_archived_vrp() -> None:
c = _client(_seeded_paper_repo())
html = c.get("/paper/sim").text
assert "Multi-asset ETF portfolio" in html
# vrp is ARCHIVED — neither its pill label nor its raw id may appear anywhere on the Backtest page.
assert "Options income (put spreads)" not in html
assert "Equity VRP" not in html
assert 'data-book="vrp"' not in html and "book=vrp" not in html
# the raw registry id must never leak as pill TEXT (it is fine as a query-string value).
assert ">multistrat<" not in html and ">vrp<" not in html
def test_multistrat_book_is_selectable_and_lazy_loads() -> None:
c = _client(_seeded_paper_repo())
r = c.get("/paper/sim", params={"book": "multistrat"})
assert r.status_code == 200
assert 'data-book="multistrat"' in r.text
assert 'hx-get="/paper/sim/run' in r.text and "book=multistrat" in r.text
assert "<svg" not in r.text # curve is lazy-loaded, not inline on the page
def test_multistrat_run_renders_curve_and_metrics_from_precomputed_sim_curve_ret() -> None:
c = _client(_seeded_paper_repo())
r = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 100000})
assert r.status_code == 200
assert "<svg" in r.text and "CAGR" in r.text and "Sharpe" in r.text
assert "Backtest (honest replay cost)" in r.text
data = _sim_data(r.text)
assert data["dates"] and data["equity"]
assert 80000 < data["equity"][0] < 120000 # curve starts near the configured capital
def test_archived_vrp_run_falls_back_and_never_renders_vrp() -> None:
"""An archived book (vrp) is not in `_SIM_BOOKS`, so `/paper/sim/run?book=vrp` falls back to the default
book (bybit_4edge) — it renders that book's view (or the graceful pending note), never vrp's curve/label."""
c = _client(_seeded_paper_repo())
r = c.get("/paper/sim/run", params={"book": "vrp", "capital": 100000})
assert r.status_code == 200 # graceful fallback, never a 500
assert "Options income (put spreads)" not in r.text and "Equity VRP" not in r.text
assert 'data-book="vrp"' not in r.text # the effective book is the default, not vrp
def test_ibkr_capital_scales_the_curve_linearly() -> None:
c = _client(_seeded_paper_repo())
small = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 50000})
big = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 200000})
e_small = _sim_data(small.text)["equity"][-1]
e_big = _sim_data(big.text)["equity"][-1]
assert abs(e_big / e_small - 4.0) < 0.01
def test_ibkr_run_never_recomputes_the_replay(monkeypatch) -> None:
"""Tripwire: the sim's IBKR path must read the precomputed sim_curve_ret series and never build/advance a
ForwardStrategy on the request path."""
import fxhnt.application.paper_sim as psim
real_naive = psim.simulate_naive_eqwt
calls = {"n": 0}
def _spy(*a, **k):
calls["n"] += 1
return real_naive(*a, **k)
monkeypatch.setattr(psim, "simulate_naive_eqwt", _spy)
c = _client(_seeded_paper_repo())
r = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 100000})
assert r.status_code == 200
assert _sim_data(r.text)["dates"]
assert calls["n"] == 1 # the cheap compound-only pass, no strategy replay
def test_ibkr_view_shows_live_forward_track_with_gate() -> None:
c = _client(_seeded_paper_repo(), _seeded_forward_repo("multistrat"))
r = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 100000})
assert r.status_code == 200
assert "Live forward" in r.text
assert "22/20 forward days" in r.text
assert "WAIT" in r.text
def test_ibkr_missing_precompute_shows_pending_note_not_bybit_caption() -> None:
repo = PaperRepo("sqlite://")
repo.migrate() # no sim_curve_ret rows for multistrat
c = _client(repo)
r = c.get("/paper/sim/run", params={"book": "multistrat", "capital": 100000})
assert r.status_code == 200
assert "precomputing" in r.text.lower() and "nightly" in r.text.lower()
assert "Corwin" not in r.text and "L1 quoted spread" not in r.text # never the Bybit-specific caption
assert 'data-cost-mode="flat"' not in r.text
def test_sim_book_switch_covers_visible_books_and_excludes_archived_vrp() -> None:
c = _client(_seeded_paper_repo())
# the three VISIBLE books each render their own page...
for book in ("bybit_4edge", "bybit_4edge_levered", "multistrat"):
r = c.get("/paper/sim", params={"book": book})
assert r.status_code == 200 and f'data-book="{book}"' in r.text
# ...but the ARCHIVED vrp is not selectable: it falls back to the default book (200, never vrp).
r = c.get("/paper/sim", params={"book": "vrp"})
assert r.status_code == 200 and 'data-book="vrp"' not in r.text