Files
fxhnt/tests/integration/test_sim_compare_web.py
jgrusewski c4cd4f4f1f feat(cockpit): Backtest = honest real-cost curve (book/capital/period) — drop the configurable-cost what-if sandbox
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>
2026-06-27 09:18:52 +02:00

177 lines
9.1 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_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