Root cause of the 'half' inconsistency: two name paths disagreed — the fleet (dashboard_service) read the raw DB strategy_registry.display_name (seeded from the registry), while other views used display_name()'s _OVERRIDES map (checked first). So multistrat showed 'Adaptive multi-strat book (SPY/IEF/GLD/PDBC/DBMF)' in the fleet and 'Multi-asset ETF portfolio' elsewhere. Fix: fold the overrides INTO the registry display_name (multistrat->'ETF Portfolio', vrp->'Options income (put spreads)'), delete _OVERRIDES, and route the fleet/detail through the ONE display_name() resolver (registry SSOT, always fresh — no seed-lag). Every surface now shows the same name. Locally verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
9.0 KiB
Python
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 "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
|