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:
jgrusewski
2026-06-25 20:35:38 +02:00
6 changed files with 418 additions and 15 deletions

View File

@@ -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)

View File

@@ -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)

View 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>

View File

@@ -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>

View 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

View File

@@ -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