The Backtest reduces to book + capital + period -> the precomputed MEASURED (real per-coin L1 spread) curve. A configurable cost is a fund foot-gun (drag it to 0 and you resurrect the +7097% mirage); the real cost is a MEASURED FACT, not a setting. Removed (the fake-cost / sandbox knobs): - cost (bps) slider + cost-mode (measured/flat) select - sleeve-composition presets/checkboxes, Kelly slider, controller select - the single-book flat what-if as a user path (`_sim_context`, `_COST_MODES`, `_SIM_PRESETS`); the flat helper is kept only for the compare fallback Kept (the honest, configurable bits): - book selector (bybit_4edge deploy default · binance_combined research · compare A/B) - capital (linearly rescales the cached curve — per-bp cost is capital-robust) - period start/end (windows the cached curve and RECOMPUTES CAGR/Sharpe/maxDD on the window; drawdown peak resets at the window start) - equity curve + metrics + drag-slider scrubber + forward-track section - the data-cost-mode="measured" marker + the honest real-spread caption paper_sim_run reduces to book/capital/start/end. Cache-absent degrades to a short "measured curve precomputing — check back after the nightly job" note (never a flat slider, never a 500). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
9.1 KiB
Python
177 lines
9.1 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 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 Corwin–Schultz 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_falls_back_to_flat_cost_when_no_cache() -> None:
|
||
"""With no precomputed measured-cost artifact (no `compare-measured-precompute` run), the compare falls
|
||
back to the LIGHT flat-cost compare so the page NEVER OOMs. The configurable-cost knob is GONE — the
|
||
fallback charges a FIXED realistic live-track cost (5.5bp), so any (now-ignored) cost param leaves the
|
||
finals unchanged; the pending note is shown."""
|
||
c = _client(_seeded_repo())
|
||
a = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000})
|
||
# an ignored cost_bps param must NOT change the curve (the slider is removed).
|
||
b = c.get("/paper/sim/run", params={"book": "compare", "capital": 100000, "cost_bps": 20})
|
||
assert a.status_code == 200 and b.status_code == 200
|
||
assert "pending precompute" in a.text.lower() # the flat-cost fallback note
|
||
|
||
def _finals(html: str) -> list[str]:
|
||
return re.findall(r'data-final="([-0-9.]+)"', html)
|
||
|
||
af, bf = _finals(a.text), _finals(b.text)
|
||
assert len(af) == 2 and len(bf) == 2, (af, bf) # both books reported
|
||
# the cost is no longer a knob → the (ignored) cost_bps param leaves both books' finals identical.
|
||
assert af == bf
|
||
|
||
|
||
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:
|
||
"""No cache here → the flat-cost fallback. Its honest note says the measured view is pending precompute
|
||
and this is the light flat-cost view (both books over the same window, same start capital)."""
|
||
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 "pending precompute" in txt and "flat-cost" in txt # the honest fallback caveat
|
||
|
||
|
||
def test_single_book_modes_render_their_own_way_not_the_compare_table() -> None:
|
||
"""binance_combined and bybit_4edge render their own single-book way (NO compare table). This repo seeds
|
||
no measured cache, so each single book shows the graceful 'precomputing' note (the honest curve is read
|
||
from the measured artifact, not recomputed on the request path) — never the dual compare table."""
|
||
c = _client(_seeded_repo())
|
||
bin_run = c.get("/paper/sim/run", params={"book": "binance_combined", "capital": 100000})
|
||
assert bin_run.status_code == 200
|
||
assert "precomputing" in bin_run.text.lower() # single-book pending note, not a compare table
|
||
assert bin_run.text.count("<polyline") == 0 # no dual-overlay compare chart
|
||
by_run = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
|
||
assert by_run.status_code == 200
|
||
assert "precomputing" in by_run.text.lower()
|
||
assert "Live forward" in by_run.text # the bybit single-book view keeps its forward track
|