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'
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 %} + +| book | +Sharpe | +CAGR | +total return | +max DD | +final $ | +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 %} +