164 lines
7.8 KiB
Python
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
|