Files
fxhnt/tests/integration/test_paper_sim_web.py
jgrusewski 6d04c584f5 feat(paper): decouple TRACKED_SLEEVES (persist all returns) from TRUSTED 2-edge book; 3-edge/xsfunding as derived forward tracks
Introduce TRACKED_SLEEVES (all four sleeves whose daily returns are persisted
to paper_sleeve_ret — the raw material), BOOK_CONFIGS (named composition views:
2-edge/3-edge/xsfunding), and TRUSTED_BOOK=2-edge (the config that composes the
persisted paper_nav + /paper). REPLAYABLE_SLEEVES is kept as a back-compat alias
== the trusted book's sleeves (2-edge).

Backfill + snapshot now persist returns for ALL TRACKED_SLEEVES (so xsfunding +
stablecoin accumulate forward) via a new book_sleeves param, but compose the
trusted nav/positions + the risk overlay from BOOK_CONFIGS[TRUSTED_BOOK] only.
The overlay's returns history + active set are filtered to the book sleeves so a
tracked-but-not-book sleeve in paper_sleeve_ret can't perturb the killswitch
regime signal — the 2-edge book stays bit-identical to a book-only run. Carry
accounting (funding+basis+worst_basis) still applies when computing xsfunding's
persisted return, so its derived forward track is honest.

The sim page presets are now derived from BOOK_CONFIGS (adds the xsfunding-solo
preset); 3-edge/xsfunding remain simulate_book views over the accumulating
paper_sleeve_ret (no new nav tables).

Tests: paper_sleeve_ret holds all tracked sleeves while paper_nav/positions
reflect only 2-edge; directional returns + 2-edge nav/positions bit-identical to
a book-only run (golden); simulate_book reproduces the trusted 2-edge return
series and computes 3-edge/xsfunding tracks; live==replay preserved for the
trusted book. Full suite green (843 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:44:16 +02:00

129 lines
5.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_and_curve() -> None:
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 "<svg" in r.text # the equity curve
assert 'type="range"' in r.text # Kelly/cost sliders + the timeline scrubber
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