Following the /paper blurb removal, sweep the remaining pages:
- Backtest (sim.html): drop the paragraph-long verdict banner; keep a one-line
plain subtitle ("a what-if on capital & period at real trading cost").
- Measured-cost caption (_sim_result.html): visible text now plain
("real trading cost, per coin — fee + spread · avg drag X%"); the technical
detail (L1 quoted spread / current-snapshot caveat / "not a slider") moves to
the hover tooltip. IBKR book caption likewise plain.
- Hard-coded "vol-targeted @ 20%" label (Overview, paper_crypto, strategy) ->
"each shown on its own" (drops the hard-coded 20% + the jargon).
Tests updated to the new plain copy; technical strings that stay in tooltips
(real L1 quoted spread, the snapshot caveat) are still asserted there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
10 KiB
Python
199 lines
10 KiB
Python
"""Web smoke for the simplified cockpit Backtest: /paper/sim renders a tiny book/capital/period form and
|
|
lazy-loads the precomputed real-cost (MEASURED) curve from /paper/sim/run. The configurable-cost SANDBOX is
|
|
GONE — no cost (bps) slider, no measured/flat cost-mode select, no sleeve-composition presets/checkboxes, no
|
|
Kelly slider, no controller select. The cost is a measured FACT, not a knob (drag it to 0 and you'd resurrect
|
|
the +7097% mirage). When the measured artifact has not been precomputed the page degrades to a graceful
|
|
'precomputing' note (never the flat slider, never a 500). Bybit is the cockpit's only venue (Task 7a removed
|
|
the Binance venue toggle + the binance-vs-bybit "compare" view — see test_cockpit_bybit_only.py).
|
|
|
|
The measured-curve behaviour (capital-scale, period-window, the measured marker, cache-absent note) is
|
|
covered in test_backtest_measured_single_book.py; this file is the page-skin / form / routing smoke.
|
|
NO network — in-memory SQLite repos, seeded directly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
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
|
|
|
|
|
|
def _seeded_repo() -> PaperRepo:
|
|
"""A repo with ~120 days of precomputed BYBIT per-sleeve returns. No measured cache, so the single-book
|
|
Backtest shows the graceful 'precomputing' note — the honest curve is read, never recomputed on the
|
|
request path."""
|
|
repo = PaperRepo("sqlite://")
|
|
repo.migrate()
|
|
at = dt.datetime(2026, 6, 22, tzinfo=dt.UTC)
|
|
base = dt.date(2026, 1, 1)
|
|
sleeves = {"crypto_tstrend": 0.004, "unlock": 0.0025, "positioning": 0.0015,
|
|
"xsfunding": 0.0008}
|
|
for i in range(120):
|
|
d = (base + dt.timedelta(days=i)).isoformat()
|
|
for sleeve, amp in sleeves.items():
|
|
ret = amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8)
|
|
repo.upsert_bybit_sleeve_ret(d, sleeve, ret, at=at)
|
|
return repo
|
|
|
|
|
|
def _client(repo: PaperRepo) -> TestClient:
|
|
fwd = ForwardNavRepo("sqlite://")
|
|
fwd.migrate()
|
|
return TestClient(create_app(fwd, paper_repo=repo))
|
|
|
|
|
|
def test_sim_page_renders_book_capital_period_form_and_lazy_loads_result() -> None:
|
|
"""The page paints the tiny book/capital/period form WITHOUT running anything — the curve is fetched
|
|
lazily by HTMX from /paper/sim/run on load. The configurable-cost sandbox controls are absent."""
|
|
c = _client(_seeded_repo())
|
|
r = c.get("/paper/sim")
|
|
assert r.status_code == 200
|
|
assert 'id="sim-form"' in r.text # the (tiny) config panel
|
|
# the honest knobs: book/capital/period only.
|
|
assert 'name="capital"' in r.text and 'name="start"' in r.text and 'name="end"' in r.text
|
|
# the sandbox knobs are GONE.
|
|
assert 'type="range"' not in r.text # no Kelly / cost sliders
|
|
assert 'name="cost_bps"' not in r.text and 'name="cost_mode"' not in r.text
|
|
assert 'name="sleeve"' not in r.text and 'name="kelly"' not in r.text
|
|
assert 'name="controller"' not in r.text and "sim-preset" not in r.text
|
|
# the result is lazy-loaded, not computed inline.
|
|
assert 'id="sim-result-wrap"' in r.text
|
|
assert 'hx-get="/paper/sim/run' in r.text and 'hx-trigger="load"' in r.text
|
|
# the form refreshes via HTMX-NATIVE wiring on change (NOT a fragile inline <script> — the live bug was the
|
|
# IIFE never executing in this template's content path, so adjusting capital/period did nothing).
|
|
assert 'hx-trigger="submit, change' in r.text # Enter/submit AND field change re-fetch (htmx catches Enter → no native page reload)
|
|
assert 'keyup changed delay' in r.text # capital updates live as you TYPE (debounced), not just on blur
|
|
assert 'type="hidden" name="book"' in r.text # book travels with the htmx GET
|
|
assert "<svg" not in r.text and "Sharpe" not in r.text
|
|
|
|
|
|
def test_sim_run_cache_absent_shows_pending_note_not_flat() -> None:
|
|
"""The default lazy-load fragment, with no measured artifact seeded, is the graceful 'precomputing' note —
|
|
NOT a flat what-if slider, NOT a 500."""
|
|
c = _client(_seeded_repo())
|
|
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
|
|
assert r.status_code == 200
|
|
assert "precomputing" in r.text.lower() and "nightly" in r.text.lower()
|
|
assert 'data-cost-mode="flat"' not in r.text
|
|
|
|
|
|
def test_sim_page_works_with_empty_repo() -> None:
|
|
repo = PaperRepo("sqlite://")
|
|
repo.migrate()
|
|
c = _client(repo)
|
|
r = c.get("/paper/sim")
|
|
assert r.status_code == 200
|
|
assert 'id="sim-form"' in r.text
|
|
|
|
|
|
def test_sim_run_capital_field_is_lenient_never_422() -> None:
|
|
"""Regression: a cleared / comma-formatted / odd capital must NOT 422 the backtest — the run endpoint
|
|
parses capital leniently (empty/garbage -> default)."""
|
|
c = _client(_seeded_repo())
|
|
for cap in ["", "100,000", "250000", "abc", " 50000 "]:
|
|
r = c.get("/paper/sim/run", params={"capital": cap, "book": "bybit_4edge"})
|
|
assert r.status_code == 200, f"capital={cap!r} should render, not 422"
|
|
|
|
|
|
def test_sim_page_result_url_capital_is_plain_decimal_not_scientific() -> None:
|
|
"""The lazy-load hx-get must format capital as a plain decimal (NOT :g scientific like 1e+06, whose '+'
|
|
decodes to a space in the query string and 422s)."""
|
|
c = _client(_seeded_repo())
|
|
html = c.get("/paper/sim").text
|
|
m = re.search(r'/paper/sim/run\?capital=([^&"]+)', html)
|
|
assert m, "result_url with capital not found"
|
|
cap = m.group(1)
|
|
assert "e" not in cap.lower() and "+" not in cap, f"capital must be plain decimal, got {cap!r}"
|
|
|
|
|
|
def test_sim_defaults_to_bybit_deploy_book() -> None:
|
|
"""The Backtest must DEFAULT to the Bybit 4-edge deploy book. So a bare /paper/sim leads with the honest
|
|
deployable curve: the form data-book + the lazy-load result_url are bybit."""
|
|
c = _client(_seeded_repo())
|
|
html = c.get("/paper/sim").text
|
|
assert 'data-book="bybit_4edge"' in html # the form runs the bybit book by default
|
|
m = re.search(r'hx-get="(/paper/sim/run\?[^"]+)"', html)
|
|
assert m, "lazy-load result URL not found"
|
|
result_url = m.group(1)
|
|
assert "book=bybit_4edge" in result_url
|
|
|
|
|
|
def test_sim_page_title_is_backtest_not_paper_sim() -> None:
|
|
"""The page title is 'Backtest' (consistent with the nav), not the old 'Paper sim'."""
|
|
c = _client(_seeded_repo())
|
|
html = c.get("/paper/sim").text
|
|
assert "<title>Backtest · fxhnt</title>" in html
|
|
assert "Paper sim" not in html
|
|
|
|
|
|
def test_sim_page_caption_is_honest_real_cost_not_configurable_cost() -> None:
|
|
"""The subtitle reflects the honest real-cost framing in PLAIN language (no jargon block): a what-if on
|
|
capital & period at real trading cost. The old 'choose ... cost' / sleeves sandbox prose stays gone."""
|
|
c = _client(_seeded_repo())
|
|
html = c.get("/paper/sim").text
|
|
assert "a what-if on capital" in html # the what-if framing (plain subtitle)
|
|
assert "real trading cost" in html # honest-cost framing, plain (no "taker fee"/"L1")
|
|
assert "choose book / capital / sleeves / period / cost" not in html # the old sandbox prose is gone
|
|
assert "configurable what-if" not in html
|
|
|
|
|
|
def test_caption_band_aid_is_gone() -> None:
|
|
"""The old special-case "≈ -36% / see Compare A/B" caption band-aid is REMOVED (both the Binance venue
|
|
and the Compare A/B view are gone as of Task 7a). With no measured artifact the single-book view shows
|
|
the graceful pending note (not the old caption, not the flat slider)."""
|
|
c = _client(_seeded_repo())
|
|
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
|
|
assert r.status_code == 200
|
|
assert "real measured costs" not in r.text and "-36%" not in r.text
|
|
assert "Compare A/B" not in r.text
|
|
assert "precomputing" in r.text.lower()
|
|
assert 'data-cost-mode="flat"' not in r.text
|
|
|
|
|
|
def test_capital_input_does_not_reject_round_numbers() -> None:
|
|
"""Regression: min=1 step=1000 made the browser reject 100000 (not 1+n·1000) with 'enter a valid number'
|
|
and block submit. The capital input must use step='any' so any amount is accepted client-side."""
|
|
c = _client(_seeded_repo())
|
|
html = c.get("/paper/sim").text
|
|
m = re.search(r'<input[^>]*name="capital"[^>]*>', html)
|
|
assert m, "capital input not found"
|
|
tag = m.group(0)
|
|
assert 'step="any"' in tag and 'step="1000"' not in tag, tag
|
|
|
|
|
|
def test_sim_book_switch_routes_all_200() -> None:
|
|
"""The 2-way book switch (Bybit deploy / Bybit levered shadow) renders; a stale binance_combined or
|
|
compare book param (the removed Task-7a surfaces) degrades gracefully to the default rather than 500ing."""
|
|
c = _client(_seeded_repo())
|
|
for book in ("bybit_4edge", "bybit_4edge_levered"):
|
|
assert c.get("/paper/sim", params={"book": book}).status_code == 200
|
|
for stale_book in ("binance_combined", "compare"):
|
|
r = c.get("/paper/sim", params={"book": stale_book})
|
|
assert r.status_code == 200
|
|
assert 'data-book="bybit_4edge"' in r.text # degraded to the default deploy book
|
|
|
|
|
|
def test_sim_shows_the_levered_shadow_pill_and_runs_gracefully() -> None:
|
|
"""The Backtest exposes the levered shadow as a 4th book pill, and its lazy /run renders gracefully (the
|
|
measured pending note when the levered cache is absent — never a 500, never a flat slider)."""
|
|
c = _client(_seeded_repo())
|
|
page = c.get("/paper/sim", params={"book": "bybit_4edge_levered"})
|
|
assert page.status_code == 200
|
|
assert "Bybit 4-edge levered" in page.text and "shadow" in page.text
|
|
assert 'data-book="bybit_4edge_levered"' in page.text or "book=bybit_4edge_levered" in page.text
|
|
run = c.get("/paper/sim/run", params={"book": "bybit_4edge_levered", "capital": 100000})
|
|
assert run.status_code == 200
|
|
assert "precomputing" in run.text.lower() and 'data-cost-mode="flat"' not in run.text
|
|
|
|
|
|
_SIM_ABS_URL = re.compile(r"https?://(?!www\.w3\.org/)[^\s\"'<>]+")
|
|
|
|
|
|
def test_sim_page_is_leak_clean() -> None:
|
|
hits = _SIM_ABS_URL.findall(_client(_seeded_repo()).get("/paper/sim").text)
|
|
assert not hits, f"leak-clean violation: {hits}"
|