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>
129 lines
5.3 KiB
Python
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
|