diff --git a/src/fxhnt/adapters/web/app.py b/src/fxhnt/adapters/web/app.py index 51b36c5..1cbc943 100644 --- a/src/fxhnt/adapters/web/app.py +++ b/src/fxhnt/adapters/web/app.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo -from fxhnt.adapters.web.charts import equity_curve, nav_sparkline +from fxhnt.adapters.web.charts import dual_equity_curve, equity_curve, nav_sparkline from fxhnt.application.dashboard_service import DashboardService from fxhnt.application.paper_book import PaperBookService from fxhnt.ports.dashboard import DashboardReadModel @@ -249,7 +249,11 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None, # The two books the sim can configure. binance_combined = the existing live Binance paper book # (overlay-driven, sized from paper_sleeve_ret). bybit_4edge = the precomputed 4-edge Bybit backtest # (naive eq-wt — NOT the overlay, since the A/B proved naive is best OOS), sized from bybit_sleeve_ret. - _SIM_BOOKS = ("binance_combined", "bybit_4edge") + # "compare" is a THIRD virtual book: it runs binance_combined AND bybit_4edge over the SAME window + + # SAME cost, overlaid, so they're apples-to-apples (the live equity figures are misleading — Binance is + # 4.5y/broad/under-costed, Bybit ~1 day). It is NOT a real book — _sim_returns is never keyed by it. + _SIM_BOOKS = ("binance_combined", "bybit_4edge", "compare") + _COMPARE_BOOK = "compare" _DEFAULT_BOOK = "binance_combined" # The backtest's DEFAULT cost — matches what the live forward tracks charge (build_*_forward_nav # cost_bps=5.5, the Bybit-perp realistic per-turnover cost). The sim defaults to this (NOT 0) so a @@ -367,6 +371,88 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None, "now": _now(), } + def _book_first_last(returns: dict[str, dict[int, float]]) -> tuple[int | None, int | None]: + """The (first, last) epoch-day present across all sleeves of a returns table, or (None, None).""" + days = [d for r in returns.values() for d in r] + return (min(days), max(days)) if days else (None, None) + + def _compare_summary(book: str, label: str, res: Any, capital: float) -> dict[str, Any]: + """One row of the side-by-side compare table for a single book's SimResult over the shared window: + Sharpe, CAGR, total return %, maxDD, final equity, n_days. data-* attrs carry machine-readable + final/start equity so a test (and any client) can read them without parsing formatted text.""" + m = res.metrics + n_days = len(res.dates) + # Both curves compound from `capital` on day 1, so the honest "start equity" for the comparison is + # the configured capital itself — both books are normalized to the SAME base. + return { + "book": book, + "label": label, + "sharpe": m.get("sharpe", 0.0), + "cagr": m.get("cagr", 0.0), + "total_return": m.get("total_return", 0.0), + "max_dd": m.get("max_dd", 0.0), + "end_value": m.get("end_value", capital), + "n_days": n_days, + "start_equity": capital, + } + + def _compare_context(*, capital: float, start: str | None, end: str | None, + cost_bps: float) -> dict[str, Any]: + """COMPARE mode: run BOTH books over the SAME window + SAME cost, overlaid, with a side-by-side + metrics table — so Binance vs Bybit is apples-to-apples (the live equity figures are misleading). + + Window default = the OVERLAP: start = max(first_binance, first_bybit), end = min(last_*), so both + books have data the whole window. The user's explicit start/end override. Both books are charged the + SAME `cost_bps` and both curves are normalized to the SAME start `capital` (so shapes compare + directly). A no-overlap window degrades gracefully (a note, no 500).""" + from fxhnt.application.paper_sim import simulate_book, simulate_naive_eqwt + + bin_returns = _sim_returns("binance_combined") + by_returns = _sim_returns("bybit_4edge") + + # Auto-overlap unless the user pinned a side. max(starts) -> min(ends) is the window both cover. + bin_lo, bin_hi = _book_first_last(bin_returns) + by_lo, by_hi = _book_first_last(by_returns) + user_lo = _parse_ord(start) + user_hi = _parse_ord(end) + auto_lo = max(x for x in (bin_lo, by_lo) if x is not None) if (bin_lo and by_lo) else None + auto_hi = min(x for x in (bin_hi, by_hi) if x is not None) if (bin_hi and by_hi) else None + win_lo = user_lo if user_lo is not None else auto_lo + win_hi = user_hi if user_hi is not None else auto_hi + + bin_sleeves = sorted(bin_returns.keys()) + by_sleeves = sorted(by_returns.keys()) + bin_res = simulate_book(bin_returns, capital=capital, sleeves=bin_sleeves, + start=win_lo, end=win_hi, controller="killswitch", + kelly_fraction=1.0, cost_bps=cost_bps) + by_res = simulate_naive_eqwt(by_returns, capital=capital, sleeves=by_sleeves, + start=win_lo, end=win_hi, cost_bps=cost_bps) + + no_overlap = not bin_res.dates or not by_res.dates + curve = dual_equity_curve(bin_res.equity, by_res.equity, width=600, height=180, + label_a="Binance", label_b="Bybit") + rows = [ + _compare_summary("binance_combined", "Binance", bin_res, capital), + _compare_summary("bybit_4edge", "Bybit", by_res, capital), + ] + win_lo_iso = dt.date.fromordinal(win_lo).isoformat() if win_lo is not None else "" + win_hi_iso = dt.date.fromordinal(win_hi).isoformat() if win_hi is not None else "" + return { + "compare": True, + "curve": curve, + "rows": rows, + "no_overlap": no_overlap, + "window_start": win_lo_iso, + "window_end": win_hi_iso, + "cfg": {"capital": capital, "start": win_lo_iso, "end": win_hi_iso, + "sleeves": [], "kelly": 1.0, "controller": "killswitch", + "cost_bps": cost_bps, "book": _COMPARE_BOOK}, + "available_sleeves": [], + "presets": _SIM_PRESETS, + "books": _SIM_BOOKS, + "now": _now(), + } + @app.get("/paper/sim", response_class=HTMLResponse) def paper_sim(request: Request, book: str = _DEFAULT_BOOK) -> HTMLResponse: """Render the config form + controls INSTANTLY, then let HTMX fetch the (expensive) sim result from @@ -375,17 +461,26 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None, page, so the lazy-loaded result is identical to what the eager render produced.""" from fxhnt.config import get_settings book = book if book in _SIM_BOOKS else _DEFAULT_BOOK - returns = _sim_returns(book) - avail = sorted(returns.keys()) capital = get_settings().paper_capital - # The form's echoed config + the URL HTMX loads on first paint. Defaults mirror /paper/sim/run's - # whole-book default: all available sleeves, kelly=1.0, killswitch, cost 0. - cfg = {"capital": capital, "start": "", "end": "", "sleeves": avail, "kelly": 1.0, - "controller": "killswitch", "cost_bps": _SIM_DEFAULT_COST_BPS, "book": book} - # NB: format capital as a plain decimal (NOT :g — which emits scientific "1e+06" whose "+" decodes to - # a space in the query string and 422s). Lenient parsing on the run side is the backstop. - result_url = (f"/paper/sim/run?capital={capital:.2f}&sleeves={','.join(avail)}" - f"&kelly=1.0&controller=killswitch&cost_bps={_SIM_DEFAULT_COST_BPS}&book={book}") + # compare is a virtual book: no single returns table / sleeve list. Its result URL carries only the + # shared knobs (capital, cost, and an empty start/end so the run picks the OVERLAP default). + if book == _COMPARE_BOOK: + avail: list[str] = [] + cfg = {"capital": capital, "start": "", "end": "", "sleeves": [], "kelly": 1.0, + "controller": "killswitch", "cost_bps": _SIM_DEFAULT_COST_BPS, "book": book} + result_url = (f"/paper/sim/run?capital={capital:.2f}" + f"&cost_bps={_SIM_DEFAULT_COST_BPS}&book={book}") + else: + returns = _sim_returns(book) + avail = sorted(returns.keys()) + # The form's echoed config + the URL HTMX loads on first paint. Defaults mirror /paper/sim/run's + # whole-book default: all available sleeves, kelly=1.0, killswitch, cost 0. + cfg = {"capital": capital, "start": "", "end": "", "sleeves": avail, "kelly": 1.0, + "controller": "killswitch", "cost_bps": _SIM_DEFAULT_COST_BPS, "book": book} + # NB: format capital as a plain decimal (NOT :g — which emits scientific "1e+06" whose "+" decodes + # to a space in the query string and 422s). Lenient parsing on the run side is the backstop. + result_url = (f"/paper/sim/run?capital={capital:.2f}&sleeves={','.join(avail)}" + f"&kelly=1.0&controller=killswitch&cost_bps={_SIM_DEFAULT_COST_BPS}&book={book}") ctx = {"cfg": cfg, "available_sleeves": avail, "presets": _SIM_PRESETS, "books": _SIM_BOOKS, "result_url": result_url, "now": _now()} return templates.TemplateResponse(request, "sim.html", ctx) @@ -400,12 +495,15 @@ def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None, capital_f = _parse_num(capital, 100_000.0) kelly_f = _parse_num(kelly, 1.0) cost_f = _parse_num(cost_bps, _SIM_DEFAULT_COST_BPS) + cap = capital_f if capital_f > 0 else 0.0 + if book == _COMPARE_BOOK: + ctx = _compare_context(capital=cap, start=start, end=end, cost_bps=cost_f) + return templates.TemplateResponse(request, "_sim_compare.html", ctx) returns = _sim_returns(book) # Empty sleeves: default to the whole book for bybit (so a bare book switch shows the curve), but # keep binance's existing "empty = empty" contract (its tests rely on it). parsed = [s.strip() for s in sleeves.split(",") if s.strip()] if sleeves else [] sel = parsed if (parsed or book != "bybit_4edge") else sorted(returns.keys()) - cap = capital_f if capital_f > 0 else 0.0 ctx = _sim_context(returns, capital=cap, sleeves=sel, start=start, end=end, kelly=kelly_f, controller=controller, cost_bps=cost_f, book=book) return templates.TemplateResponse(request, "_sim_result.html", ctx) diff --git a/src/fxhnt/adapters/web/charts.py b/src/fxhnt/adapters/web/charts.py index 5c6228a..ca0162d 100644 --- a/src/fxhnt/adapters/web/charts.py +++ b/src/fxhnt/adapters/web/charts.py @@ -60,3 +60,52 @@ def equity_curve(equity: list[float], *, width: int = 600, height: int = 180, + f'' + "") + + +def _polyline(series: list[float], *, width: int, height: int, pad: float, lo: float, + span: float, color: str) -> str: + """One self-contained polyline scaled to a SHARED (lo, span) so two series compare on one axis.""" + n = len(series) + step = width / (n - 1) if n > 1 else 0.0 + + def _y(v: float) -> float: + return height - (v - lo) / span * (height - 2 * pad) - pad + + pts = " ".join(f"{i * step:.1f},{_y(v):.1f}" for i, v in enumerate(series)) + return f'' + + +def dual_equity_curve(series_a: list[float], series_b: list[float], *, width: int = 600, + height: int = 180, color_a: str = "#58a6ff", color_b: str = "#d29922", + label_a: str = "Binance", label_b: str = "Bybit") -> str: + """Two equity curves overlaid on one inline SVG, scaled to a SHARED min/max so their SHAPES compare + directly (both series are pre-normalized to the same start capital by the caller). Distinct stroke + colors + a tiny in-SVG legend ("Binance / Bybit"). Self-contained string, no JS, no external refs — + same scaling/viewBox style as `equity_curve`.""" + pad = 2.0 + box = (f'') + vals = [v for s in (series_a, series_b) for v in s] + if not vals: + return box + "" + lo, hi = min(vals), max(vals) + span = (hi - lo) or 1.0 + + parts = [box] + if series_a: + parts.append(_polyline(series_a, width=width, height=height, pad=pad, lo=lo, span=span, + color=color_a)) + if series_b: + parts.append(_polyline(series_b, width=width, height=height, pad=pad, lo=lo, span=span, + color=color_b)) + # Tiny legend (top-left): a colored swatch + label per series. Plain SVG text/rect — no external refs. + parts.append( + f'' + f'{}'.format(label_a)) + parts.append( + f'' + f'{}'.format(label_b)) + parts.append("") + return "".join(parts) diff --git a/src/fxhnt/adapters/web/templates/_sim_compare.html b/src/fxhnt/adapters/web/templates/_sim_compare.html new file mode 100644 index 0000000..3bed675 --- /dev/null +++ b/src/fxhnt/adapters/web/templates/_sim_compare.html @@ -0,0 +1,54 @@ +{# Compare result partial: Binance combined vs Bybit 4-edge over the SAME window + SAME cost, overlaid on + one dual-series 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 ~1 day). NO per-day slider here — the + point is the de-inflated A/B, not scrubbing one curve. #} +
+{% if no_overlap %} +

no overlap between the two books for this window — Binance and Bybit don't both have data + between {{ cfg.start or '(open)' }} and {{ cfg.end or '(open)' }}. Clear the dates to use the + auto-detected overlap window, or pick a range both books cover.

+{% else %} + +
+ Same window {{ window_start }} → {{ window_end }} + · same flat cost {{ '%.1f'|format(cfg.cost_bps) }}bp + · both normalized to ${{ cfg.capital|money }} start capital. +
+ +
{{ curve|safe }}
+ + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
bookSharpeCAGRtotal returnmax DDfinal $n days
{{ row.label }}{{ '%.2f'|format(row.sharpe) }}{{ '%.1f'|format(100*row.cagr) }}%{{ '%.1f'|format(100*row.total_return) }}%{{ '%.1f'|format(100*row.max_dd) }}%${{ row.end_value|money }}{{ row.n_days }}
+ +

+ Same window + same flat cost; but the Binance book trades a broader/illiquid universe whose REAL costs + are higher (see the liquidity sweep), so this still flatters Binance. +

+{% endif %} +
diff --git a/src/fxhnt/adapters/web/templates/sim.html b/src/fxhnt/adapters/web/templates/sim.html index b53192e..cde0f05 100644 --- a/src/fxhnt/adapters/web/templates/sim.html +++ b/src/fxhnt/adapters/web/templates/sim.html @@ -16,7 +16,7 @@ - {% if b == 'bybit_4edge' %}Bybit 4-edge{% else %}Binance combined{% endif %} + {% if b == 'bybit_4edge' %}Bybit 4-edge{% elif b == 'compare' %}Compare A/B{% else %}Binance combined{% endif %} {% endfor %} diff --git a/tests/integration/test_sim_compare_web.py b/tests/integration/test_sim_compare_web.py new file mode 100644 index 0000000..42d168c --- /dev/null +++ b/tests/integration/test_sim_compare_web.py @@ -0,0 +1,163 @@ +"""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 " 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("= 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(" None: @@ -18,3 +18,42 @@ def test_sparkline_empty_series_is_safe() -> None: def test_sparkline_flat_series_does_not_divide_by_zero() -> None: svg = nav_sparkline([1.0, 1.0, 1.0], width=100, height=20) assert " None: + """Two equity series overlaid in one inline SVG: two polylines with distinct stroke colors + a legend.""" + a = [100000.0, 101000.0, 99000.0, 103000.0] + b = [100000.0, 100500.0, 101000.0, 100200.0] + svg = dual_equity_curve(a, b, width=600, height=180, + color_a="#58a6ff", color_b="#d29922", + label_a="Binance", label_b="Bybit") + assert svg.startswith("") + # exactly two data polylines + assert svg.count(" None: + """Both series are scaled to the SAME min/max so shapes compare directly on one axis.""" + a = [100.0, 200.0] # spans 100..200 + b = [100.0, 100.0] # flat at the bottom of the shared scale + svg = dual_equity_curve(a, b, width=100, height=20) + assert svg.count(" None: + svg = dual_equity_curve([], [], width=100, height=20) + assert svg.startswith(" None: + """No external network references baked into the SVG (no fetched hrefs/srcs). The SVG xmlns is the XML + namespace identifier, not a network load, so it's allowed; href/src/url() loaders are not.""" + svg = dual_equity_curve([1.0, 2.0, 1.5], [1.0, 1.2, 1.4], width=120, height=40) + assert "href" not in svg and "src=" not in svg and "url(" not in svg + # the only http occurrence is the SVG namespace declaration, never an https fetch + assert "https://" not in svg + assert svg.count("http://") == 1 and 'xmlns="http://www.w3.org/2000/svg"' in svg