Files
fxhnt/tests/integration/test_sim_compare_web.py

170 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 OVERLAP window, overlaid on one chart with a side-by-side
metrics table — apples-to-apples (the live equity figures are misleading: Binance is 4.5y/broad, Bybit ~1
day). The compare charges a per-COIN MEASURED cost (taker fee + half the CorwinSchultz spread), NOT a flat
cost — the measured-cost de-inflation is covered in test_compare_measured_cost.py; this file is the window /
overlay / graceful-degradation web smoke.
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);
* the flat cost_bps knob is INERT on the compare path (the measured per-coin cost replaced it) and both
books are 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_ignores_the_flat_cost_bps_knob() -> None:
"""The compare now charges a per-COIN MEASURED cost (taker fee + half the CorwinSchultz spread), NOT
the flat `cost_bps`. So changing `cost_bps` must NOT move either book's final equity — the flat knob was
dropped from the compare path (it remains on the single-book sim modes). With no warehouse high/low
seeded here, both books measure the fee floor, so the curves are stable regardless of `cost_bps`."""
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
# the measured per-coin cost replaces the flat knob → cost_bps is inert on the compare path.
assert cf == pf
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