169 lines
7.3 KiB
Python
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']}"
|