Files
fxhnt/tests/integration/test_sim_compare_web.py

164 lines
7.8 KiB
Python

"""Web smoke for the cockpit Backtest COMPARE mode (/paper/sim?book=compare): run the Binance combined
book AND the Bybit 4-edge book over the SAME window with the SAME cost, overlaid on one chart with a
side-by-side metrics table — so they're apples-to-apples (the live equity figures are misleading: Binance
is 4.5y/broad/under-costed, Bybit is ~1 day).
Asserts:
* /paper/sim?book=compare renders a third book pill ("Compare A/B") + lazy-loads the compare result;
* /paper/sim/run?book=compare overlays BOTH equity curves (dual SVG) + a metrics table with both books'
Sharpe / CAGR / maxDD over the same window;
* the default window is the OVERLAP: start=max(first_binance, first_bybit), end=min(last_*) — both books
have data the whole window;
* an explicit start/end overrides the overlap default;
* a no-overlap window degrades gracefully (200, not 500);
* both books are charged the SAME cost_bps and both normalized to the SAME start capital;
* the honest note about Binance's broader/illiquid universe is present;
* the single-book modes are untouched (binance_combined / bybit_4edge still render their own way).
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
# Binance sleeves span an EARLIER, LONGER window; Bybit a LATER, SHORTER window. The overlap is the
# intersection, which is what compare must default to.
_BINANCE_START = dt.date(2021, 1, 1)
_BINANCE_DAYS = 400
_BYBIT_START = dt.date(2021, 7, 1) # starts later
_BYBIT_DAYS = 300 # ends earlier than binance's 400-day run
_BINANCE_SLEEVES = {"crypto_tstrend": 0.004, "unlock": 0.0025}
_BYBIT_SLEEVES = {"crypto_tstrend": 0.004, "unlock": 0.0025, "xsfunding": 0.001, "positioning": 0.0018}
def _seeded_repo() -> PaperRepo:
repo = PaperRepo("sqlite://")
repo.migrate()
at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
for i in range(_BINANCE_DAYS):
d = (_BINANCE_START + dt.timedelta(days=i)).isoformat()
for sleeve, amp in _BINANCE_SLEEVES.items():
repo.upsert_sleeve_ret(d, sleeve, amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8), at=at)
for i in range(_BYBIT_DAYS):
d = (_BYBIT_START + dt.timedelta(days=i)).isoformat()
for sleeve, amp in _BYBIT_SLEEVES.items():
repo.upsert_bybit_sleeve_ret(d, sleeve, amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8), at=at)
return repo
def _client(repo: PaperRepo) -> TestClient:
fwd = ForwardNavRepo("sqlite://")
fwd.migrate()
return TestClient(create_app(fwd, paper_repo=repo))
def _overlap() -> tuple[str, str]:
start = max(_BINANCE_START, _BYBIT_START)
end = min(_BINANCE_START + dt.timedelta(days=_BINANCE_DAYS - 1),
_BYBIT_START + dt.timedelta(days=_BYBIT_DAYS - 1))
return start.isoformat(), end.isoformat()
def test_compare_page_renders_third_pill_and_lazy_loads() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim", params={"book": "compare"})
assert r.status_code == 200
assert "Compare A/B" in r.text # the third book pill
assert 'hx-get="/paper/sim/run' in r.text and "book=compare" in r.text
assert "<svg" not in r.text # result is lazy-loaded
def test_compare_run_overlays_both_curves_and_metrics_table() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
assert r.status_code == 200
# one dual-series overlay chart: two polylines in a single SVG.
assert r.text.count("<polyline") >= 2
# side-by-side metrics table naming BOTH books, with Sharpe/CAGR/maxDD rows.
assert "Binance" in r.text and "Bybit" in r.text
assert "Sharpe" in r.text and "CAGR" in r.text and ("max DD" in r.text or "maxDD" in r.text)
def test_compare_defaults_to_the_overlap_window() -> None:
"""With no explicit start/end, compare auto-picks max(starts)->min(ends) so both books have data the
whole window. The echoed cfg shows the overlap dates."""
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
assert r.status_code == 200
ov_start, ov_end = _overlap()
assert ov_start in r.text and ov_end in r.text
def test_compare_explicit_window_overrides_overlap() -> None:
c = _client(_seeded_repo())
explicit_start, explicit_end = "2021-09-01", "2021-12-01"
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000,
"start": explicit_start, "end": explicit_end})
assert r.status_code == 200
ov_start, _ = _overlap()
assert explicit_start in r.text and explicit_end in r.text
# the auto-overlap start should NOT have been substituted in (the user's window won).
assert explicit_start != ov_start
def test_compare_no_overlap_window_degrades_gracefully() -> None:
"""A user window with no data for one/both books must not 500 — say so gracefully."""
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000,
"start": "1990-01-01", "end": "1990-02-01"})
assert r.status_code == 200 # no 500
assert "no overlap" in r.text.lower() or "no simulated" in r.text.lower()
def test_compare_charges_both_books_the_same_cost() -> None:
"""The single cost_bps applies to BOTH books — changing it moves both final equities."""
c = _client(_seeded_repo())
cheap = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000, "cost_bps": 0})
pricey = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000, "cost_bps": 20})
assert cheap.status_code == 200 and pricey.status_code == 200
def _finals(html: str) -> list[str]:
return re.findall(r'data-final="([-0-9.]+)"', html)
cf, pf = _finals(cheap.text), _finals(pricey.text)
assert len(cf) == 2 and len(pf) == 2, (cf, pf) # both books reported
# cost moves BOTH books' final equity (same cost charged to each).
assert cf[0] != pf[0] and cf[1] != pf[1]
def test_compare_both_normalized_to_same_capital() -> None:
"""Both overlaid curves start from the SAME configured capital (so shapes compare directly)."""
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
assert r.status_code == 200
starts = re.findall(r'data-start-equity="([-0-9.]+)"', r.text)
assert len(starts) == 2
assert abs(float(starts[0]) - 100000.0) < 1.0 and abs(float(starts[1]) - 100000.0) < 1.0
def test_compare_has_honest_note() -> None:
c = _client(_seeded_repo())
r = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
assert r.status_code == 200
txt = r.text.lower()
assert "same window" in txt and "same" in txt and "cost" in txt
assert "flatter" in txt or "illiquid" in txt or "broader" in txt # the honest caveat
def test_single_book_modes_untouched() -> None:
"""binance_combined and bybit_4edge still render their own single-book way (no compare table)."""
c = _client(_seeded_repo())
bin_run = c.get("/paper/sim/run", params={"capital": 100000, "sleeves": "crypto_tstrend,unlock"})
assert bin_run.status_code == 200
assert bin_run.text.count("<svg") == 1 # the single equity_curve (with cursor)
by_run = c.get("/paper/sim/run", params={"book": "bybit_4edge",
"sleeves": "crypto_tstrend,unlock,xsfunding,positioning"})
assert by_run.status_code == 200
assert "Backtest (configurable)" in by_run.text # the bybit single-book label still there