feat(cockpit): /paper/sim compare mode — Binance vs Bybit, same period + same cost, overlaid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -60,3 +60,52 @@ def equity_curve(equity: list[float], *, width: int = 600, height: int = 180,
|
||||
+ f'<circle id="sim-cursor-dot" cx="{last_x:.1f}" cy="{last_y:.1f}" r="3.5" '
|
||||
'fill="#58a6ff" />'
|
||||
+ "</svg>")
|
||||
|
||||
|
||||
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'<polyline fill="none" stroke="{color}" stroke-width="1.5" points="{pts}" />'
|
||||
|
||||
|
||||
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'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
||||
'xmlns="http://www.w3.org/2000/svg" style="border:1px solid #21262d;border-radius:8px">')
|
||||
vals = [v for s in (series_a, series_b) for v in s]
|
||||
if not vals:
|
||||
return box + "</svg>"
|
||||
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'<rect x="8" y="8" width="10" height="10" fill="{color_a}" />'
|
||||
f'<text x="22" y="17" font-size="11" fill="#c9d1d9" '
|
||||
'font-family="system-ui,sans-serif">{}</text>'.format(label_a))
|
||||
parts.append(
|
||||
f'<rect x="8" y="24" width="10" height="10" fill="{color_b}" />'
|
||||
f'<text x="22" y="33" font-size="11" fill="#c9d1d9" '
|
||||
'font-family="system-ui,sans-serif">{}</text>'.format(label_b))
|
||||
parts.append("</svg>")
|
||||
return "".join(parts)
|
||||
|
||||
54
src/fxhnt/adapters/web/templates/_sim_compare.html
Normal file
54
src/fxhnt/adapters/web/templates/_sim_compare.html
Normal file
@@ -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. #}
|
||||
<div id="sim-result">
|
||||
{% if no_overlap %}
|
||||
<p class="muted">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.</p>
|
||||
{% else %}
|
||||
|
||||
<div class="muted" style="font-size:12px; margin-bottom:8px">
|
||||
Same window <b>{{ window_start }} → {{ window_end }}</b>
|
||||
· same flat cost <b>{{ '%.1f'|format(cfg.cost_bps) }}bp</b>
|
||||
· both normalized to <b>${{ cfg.capital|money }}</b> start capital.
|
||||
</div>
|
||||
|
||||
<div>{{ curve|safe }}</div>
|
||||
|
||||
<table style="border-collapse:collapse; margin:14px 0; font-size:13px">
|
||||
<thead>
|
||||
<tr style="text-align:right; color:#8b949e">
|
||||
<th style="text-align:left; padding:4px 14px 4px 0">book</th>
|
||||
<th style="padding:4px 14px">Sharpe</th>
|
||||
<th style="padding:4px 14px">CAGR</th>
|
||||
<th style="padding:4px 14px">total return</th>
|
||||
<th style="padding:4px 14px">max DD</th>
|
||||
<th style="padding:4px 14px">final $</th>
|
||||
<th style="padding:4px 14px">n days</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr style="text-align:right; border-top:1px solid #21262d"
|
||||
data-book="{{ row.book }}" data-final="{{ '%.2f'|format(row.end_value) }}"
|
||||
data-start-equity="{{ '%.2f'|format(row.start_equity) }}">
|
||||
<td style="text-align:left; padding:4px 14px 4px 0; color:#c9d1d9"><b>{{ row.label }}</b></td>
|
||||
<td style="padding:4px 14px">{{ '%.2f'|format(row.sharpe) }}</td>
|
||||
<td style="padding:4px 14px" class="{{ 'pos' if row.cagr >= 0 else 'neg' }}">{{ '%.1f'|format(100*row.cagr) }}%</td>
|
||||
<td style="padding:4px 14px" class="{{ 'pos' if row.total_return >= 0 else 'neg' }}">{{ '%.1f'|format(100*row.total_return) }}%</td>
|
||||
<td style="padding:4px 14px" class="neg">{{ '%.1f'|format(100*row.max_dd) }}%</td>
|
||||
<td style="padding:4px 14px">${{ row.end_value|money }}</td>
|
||||
<td style="padding:4px 14px">{{ row.n_days }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="muted" style="font-size:12px; max-width:760px">
|
||||
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.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="/paper/sim?book={{ b }}"
|
||||
style="text-decoration:none; padding:6px 14px; border-radius:6px; border:1px solid #30363d;
|
||||
{% if b == cfg.book %}background:#1f6feb22; color:#58a6ff; border-color:#1f6feb{% else %}background:#161b22; color:#8b949e{% endif %}">
|
||||
{% if b == 'bybit_4edge' %}Bybit 4-edge{% else %}Binance combined{% endif %}</a>
|
||||
{% if b == 'bybit_4edge' %}Bybit 4-edge{% elif b == 'compare' %}Compare A/B{% else %}Binance combined{% endif %}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
163
tests/integration/test_sim_compare_web.py
Normal file
163
tests/integration/test_sim_compare_web.py
Normal file
@@ -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 "<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
|
||||
@@ -1,7 +1,7 @@
|
||||
"""nav_sparkline renders a self-contained inline SVG polyline from a NAV series (no JS, no external libs)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fxhnt.adapters.web.charts import nav_sparkline
|
||||
from fxhnt.adapters.web.charts import dual_equity_curve, nav_sparkline
|
||||
|
||||
|
||||
def test_sparkline_is_svg_with_points() -> 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 "<polyline" in svg # flat line, no exception
|
||||
|
||||
|
||||
def test_dual_equity_curve_renders_two_distinct_series() -> 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("<svg") and svg.rstrip().endswith("</svg>")
|
||||
# exactly two data polylines
|
||||
assert svg.count("<polyline") == 2
|
||||
# both colors present (distinct series)
|
||||
assert "#58a6ff" in svg and "#d29922" in svg
|
||||
# tiny legend with both labels
|
||||
assert "Binance" in svg and "Bybit" in svg
|
||||
|
||||
|
||||
def test_dual_equity_curve_shares_one_scale() -> 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("<polyline") == 2 # both drawn, no divide-by-zero
|
||||
|
||||
|
||||
def test_dual_equity_curve_empty_is_safe() -> None:
|
||||
svg = dual_equity_curve([], [], width=100, height=20)
|
||||
assert svg.startswith("<svg") and "polyline" not in svg
|
||||
|
||||
|
||||
def test_dual_equity_curve_is_relative_url_leak_clean() -> 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
|
||||
|
||||
Reference in New Issue
Block a user