Files
fxhnt/tests/integration/test_ibkr_sim_web.py

194 lines
9.1 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 has been FULLY REMOVED from the codebase (shelved 2026-07-15, code+data deleted in the
`chore/remove-vrp-code` cleanup) — there is no longer a `STRATEGY_REGISTRY` entry, `_SIM_BOOKS` pill, or
recompute-replay support for it at all. This test file uses a synthetic non-existent book id
(`no-such-book`) as an honest stand-in to probe the SAME fallback behavior an archived/unknown book must
still get: never selectable, never rendered, always a graceful fallback to the default book (see the
archived-book tests below).
Asserts:
* /paper/sim shows the multistrat pill labelled in plain language, not the raw id, and NO stray pill for an
unknown book;
* /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 unknown/archived book id is not selectable — the request falls back to the default book, never
rendering that id;
* 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", "no-such-book")) -> 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_unknown_book() -> None:
c = _client(_seeded_paper_repo())
html = c.get("/paper/sim").text
assert "ETF Portfolio" in html
# a book id absent from `_SIM_BOOKS` (unknown or archived) must never get a pill on the Backtest page.
assert 'data-book="no-such-book"' not in html and "book=no-such-book" 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 ">no-such-book<" 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_unknown_book_run_falls_back_and_never_renders_it() -> None:
"""A book id absent from `_SIM_BOOKS` (unknown or archived) makes `/paper/sim/run?book=no-such-book`
fall back to the default book (bybit_4edge) — it renders that book's view (or the graceful pending
note), never the unknown id's curve/label."""
c = _client(_seeded_paper_repo())
r = c.get("/paper/sim/run", params={"book": "no-such-book", "capital": 100000})
assert r.status_code == 200 # graceful fallback, never a 500
assert 'data-book="no-such-book"' not in r.text # the effective book is the default, not the unknown id
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_unknown_book() -> 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 an unknown/archived book id is not selectable: it falls back to the default book (200, never it).
r = c.get("/paper/sim", params={"book": "no-such-book"})
assert r.status_code == 200 and 'data-book="no-such-book"' not in r.text