Files
fxhnt/tests/integration/test_paper_sim_web.py

169 lines
7.3 KiB
Python

"""Web smoke for the interactive simulator: /paper/sim renders the config form + an equity curve;
/paper/sim/run recomputes for a config and returns the partial (SVG + metrics + #sim-data JSON);
different configs yield different metrics; empty/unknown sleeves degrade gracefully (200, not 500)."""
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
def _seeded_repo() -> PaperRepo:
"""A repo with ~120 days of per-sleeve returns across the replayable sleeves so the overlay's
trailing windows have data to size from and the sim produces a non-trivial curve."""
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, "stablecoin_rotation": 0.0015,
"xsfunding": 0.0008}
for i in range(120):
d = (base + dt.timedelta(days=i)).isoformat()
for sleeve, amp in sleeves.items():
# Deterministic mildly-oscillating return so the curve + drawdowns are non-flat.
ret = amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8)
repo.upsert_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 _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_renders_config_form_instantly_and_lazy_loads_result() -> None:
"""The page paints the config form WITHOUT running the (expensive) book sim — the result is fetched
lazily by HTMX from /paper/sim/run on load. So the form + controls are present, but the computed curve
/metrics are NOT inlined: they arrive via the hx-get trigger."""
c = _client(_seeded_repo())
r = c.get("/paper/sim")
assert r.status_code == 200
assert 'id="sim-form"' in r.text # the config panel
assert 'type="range"' in r.text # Kelly/cost sliders (form controls, painted instantly)
# The result is lazy-loaded, not computed inline: the wrap div triggers an hx-get on load.
assert 'id="sim-result-wrap"' in r.text
assert 'hx-get="/paper/sim/run' in r.text
assert 'hx-trigger="load"' in r.text
# The expensive sim output (curve SVG + metrics) is NOT inlined on the page itself.
assert "<svg" not in r.text
assert "Sharpe" not in r.text
def test_sim_run_fragment_has_the_curve_and_metrics() -> None:
"""The lazy-loaded fragment (same default config the page's hx-get uses) carries the curve + metrics."""
c = _client(_seeded_repo())
r = c.get("/paper/sim/run",
params={"sleeves": "crypto_tstrend,unlock,stablecoin_rotation,xsfunding"})
assert r.status_code == 200
assert "<svg" in r.text # the equity curve
assert "Sharpe" in r.text # metrics row
def test_sim_run_returns_partial_with_svg_metrics_and_data() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim/run",
params={"capital": 50000, "sleeves": "crypto_tstrend,unlock", "kelly": 0.5})
assert r.status_code == 200
assert "<svg" in r.text
assert "CAGR" in r.text and "max DD" in r.text
data = _sim_data(r.text)
assert data["dates"] and data["equity"]
# Equity reflects capital=50000: the curve starts near 50k (first day compounds off the base).
assert 40000 < data["equity"][0] < 60000
def test_different_configs_yield_different_metrics() -> None:
c = _client(_seeded_repo())
two = c.get("/paper/sim/run",
params={"capital": 100000, "sleeves": "crypto_tstrend,unlock,stablecoin_rotation"})
three = c.get("/paper/sim/run",
params={"capital": 100000,
"sleeves": "crypto_tstrend,unlock,stablecoin_rotation,xsfunding"})
assert two.status_code == 200 and three.status_code == 200
assert _sim_data(two.text)["equity"][-1] != _sim_data(three.text)["equity"][-1]
def test_kelly_and_cost_change_the_curve() -> None:
c = _client(_seeded_repo())
cheap = c.get("/paper/sim/run",
params={"capital": 100000, "sleeves": "crypto_tstrend,unlock",
"kelly": 1.0, "cost_bps": 0})
pricey = c.get("/paper/sim/run",
params={"capital": 100000, "sleeves": "crypto_tstrend,unlock",
"kelly": 1.0, "cost_bps": 20})
assert _sim_data(cheap.text)["equity"][-1] != _sim_data(pricey.text)["equity"][-1]
def test_empty_sleeves_is_graceful() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"capital": 100000, "sleeves": ""})
assert r.status_code == 200
assert _sim_data(r.text)["dates"] == []
def test_unknown_sleeves_is_graceful() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"capital": 100000, "sleeves": "does_not_exist,nope"})
assert r.status_code == 200
assert _sim_data(r.text)["dates"] == []
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_page_exposes_book_configs_presets() -> None:
"""The sim page's composition presets are the named BOOK_CONFIGS views (2-edge / 3-edge / xsfunding),
so an analyst can render each derived track. 2-edge is the trusted-book composition; the presets are a
single source of truth with the live wiring."""
from fxhnt.application.paper_sleeves import BOOK_CONFIGS
c = _client(_seeded_repo())
r = c.get("/paper/sim")
assert r.status_code == 200
for name, sleeves in BOOK_CONFIGS.items():
# each preset renders a button carrying its exact sleeve tuple (comma-joined).
assert f'data-sleeves="{",".join(sleeves)}"' in r.text, f"missing preset button for {name}"
# xsfunding-solo (the carry comparison track) is now exposed too.
assert 'data-sleeves="xsfunding"' in r.text
def test_sleeve_returns_are_cached_not_reread_every_request() -> None:
"""_sim_returns memoizes the per-sleeve return table (a nightly-changing read) in-process with a TTL,
so repeated sim requests don't re-read the whole history from the DB each time. Count the underlying
repo reads across several requests: the first populates the cache, the rest are served from it."""
repo = _seeded_repo()
calls = {"n": 0}
real = repo.sleeve_returns
def counting(*a, **k):
calls["n"] += 1
return real(*a, **k)
repo.sleeve_returns = counting # type: ignore[method-assign]
c = _client(repo)
for _ in range(5):
assert c.get("/paper/sim").status_code == 200
assert c.get("/paper/sim/run",
params={"sleeves": "crypto_tstrend,unlock"}).status_code == 200
# 10 requests that each need the table, but the read happens once (cache TTL still warm).
assert calls["n"] == 1, f"expected 1 cached DB read, got {calls['n']}"