194 lines
9.1 KiB
Python
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
|