613 lines
33 KiB
Python
613 lines
33 KiB
Python
"""The cockpit Backtest charges a per-COIN MEASURED cost on BOTH single books by DEFAULT (/paper/sim).
|
||
|
||
The single-book views used to charge a single FLAT slider cost, so `binance_combined` showed the cost-blind
|
||
mirage (the measured-cost Compare proved it is ≈ -36% with real Corwin–Schultz spreads). The one-off "honest
|
||
caption" band-aid on the Binance view is REPLACED by REAL measured costs on BOTH books, so each shows its
|
||
honest de-inflated number, consistently:
|
||
|
||
cost_bps(coin) = taker_fee_bps + 0.5 · effective_spread_bps(coin) (NO assumed market impact)
|
||
|
||
The heavy per-coin compute runs in the own-memory `fxhnt compare-measured-precompute` Job (it OOM-killed the
|
||
4Gi dashboard live); the cockpit ONLY reads the small precomputed per-book artifacts and falls back to the
|
||
flat slider when absent (never a 500).
|
||
|
||
Tests (TDD):
|
||
* `_bybit_measured_net` — thin-spread fixture → cost drag near the fee floor; wide-spread → much higher;
|
||
deterministic;
|
||
* precompute persists BOTH single-book measured curves; round-trips (cached == direct compute on a fixture);
|
||
* Backtest measured DEFAULT — binance_combined de-inflated (measured total return < flat) with NO
|
||
special-case caption; flat mode reproduces the slider number; bybit_4edge measured = its real costed
|
||
number; fallback to flat when the cache is absent (no 500).
|
||
NO network — in-memory SQLite repos + feature stores, seeded directly.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import math
|
||
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.warehouse.timescale_feature_store import TimescaleFeatureStore
|
||
from fxhnt.adapters.web.app import create_app
|
||
from fxhnt.application.compare_measured_cost import (
|
||
SINGLE_CACHE_KEYS,
|
||
_binance_measured_net,
|
||
_bybit_measured_net,
|
||
precompute_single_book_measured,
|
||
read_cached_single_measured,
|
||
)
|
||
from fxhnt.application.paper_book import sleeve_daily_return
|
||
from fxhnt.application.paper_sim import simulate_book
|
||
from fxhnt.domain.paper import Position
|
||
|
||
_AT = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
|
||
_DAY = 86_400
|
||
|
||
|
||
# --- fixtures ------------------------------------------------------------------------------------
|
||
|
||
def _ohlc(close: float, spread_frac: float, phase: float) -> tuple[float, float]:
|
||
"""(high, low) bracketing `close` with a KNOWN proportional half-spread + a decorrelated wobble so the
|
||
Corwin–Schultz estimator (which cancels volatility across independent days) recovers the spread."""
|
||
vol = 0.0005 * (0.5 + 0.5 * math.cos(7.0 * phase))
|
||
half = 0.5 * spread_frac + vol
|
||
return close * (1.0 + half), close * (1.0 - half)
|
||
|
||
|
||
def _seed_bybit_features(store: TimescaleFeatureStore, *, days: int = 200,
|
||
spread_frac: float = 0.0005) -> list[str]:
|
||
"""Carry (xsfunding) coins on bybit_features with a KNOWN measured spread. A NEAR-CONSTANT close (px=100)
|
||
is used on purpose so the Corwin–Schultz estimator (which cancels day-to-day vol) recovers the injected
|
||
spread cleanly; the carry sleeve trades off the funding cross-section, which day-to-day creates turnover
|
||
so the per-coin measured cost is actually charged. Mirrors the bybit liquidity-sweep carry fixture."""
|
||
funding = {"AAAUSDT": 0.001, "BBBUSDT": 0.001, "CCCUSDT": 0.001,
|
||
"XXXUSDT": -0.001, "YYYUSDT": -0.001, "ZZZUSDT": -0.001}
|
||
for idx, (sym, fund) in enumerate(funding.items()):
|
||
rows = []
|
||
for d in range(days):
|
||
f = fund + 0.0005 * math.cos(0.5 * d + idx)
|
||
high, low = _ohlc(100.0, spread_frac, phase=0.5 * d + idx)
|
||
rows.append((d * _DAY, {"funding": f, "close": 100.0, "spot_close": 100.0,
|
||
"high": high, "low": low, "turnover": 50_000_000.0}))
|
||
store.write_features(sym, rows)
|
||
return list(funding)
|
||
|
||
|
||
def _seed_binance_shadow(repo: PaperRepo, store: TimescaleFeatureStore, *, start: dt.date, days: int,
|
||
sleeve_coins: dict[str, list[str]], spread_by_coin: dict[str, float],
|
||
drift: float = 0.001, at: dt.datetime) -> None:
|
||
"""Seed a Binance combined book the way the nightly backfill does: per date the UNIT shadow book, each
|
||
sleeve's realized return from the PRIOR shadow marked to today's close, and high/low/close for CS."""
|
||
coins = sorted({c for cs in sleeve_coins.values() for c in cs})
|
||
closes_by_day: dict[int, dict[str, float]] = {(start + dt.timedelta(days=i)).toordinal(): {}
|
||
for i in range(days)}
|
||
for ci, coin in enumerate(coins):
|
||
sign = 1.0 if ci % 2 == 0 else -1.0
|
||
px = 100.0
|
||
rows = []
|
||
for i in range(days):
|
||
px *= (1.0 + sign * drift)
|
||
high, low = _ohlc(px, spread_by_coin.get(coin, 0.0005), phase=0.4 * i + ci)
|
||
rows.append(((start + dt.timedelta(days=i)).toordinal() * _DAY,
|
||
{"close": px, "high": high, "low": low}))
|
||
closes_by_day[(start + dt.timedelta(days=i)).toordinal()][coin] = px
|
||
store.write_features(coin, rows)
|
||
|
||
prior: list[Position] = []
|
||
for i in range(days):
|
||
d = (start + dt.timedelta(days=i)).isoformat()
|
||
close_d = closes_by_day[(start + dt.timedelta(days=i)).toordinal()]
|
||
by_sleeve: dict[str, list[Position]] = {}
|
||
for p in prior:
|
||
by_sleeve.setdefault(p.sleeve, []).append(p)
|
||
for sname, plist in by_sleeve.items():
|
||
r = sleeve_daily_return(plist, close_d)
|
||
if r is not None:
|
||
repo.upsert_sleeve_ret(d, sname, r, at=at)
|
||
shadow: list[Position] = []
|
||
for sname, cs in sleeve_coins.items():
|
||
present = [c for c in cs if c in close_d and close_d[c] > 0]
|
||
if not present:
|
||
continue
|
||
w = 1.0 / len(present)
|
||
for c in present:
|
||
shadow.append(Position(sname, c, (w * 1.0) / close_d[c], close_d[c], d))
|
||
repo.replace_shadow_positions(d, shadow, at=at)
|
||
prior = shadow
|
||
|
||
|
||
def _seed_bybit_sleeve_ret(repo: PaperRepo, *, start: dt.date, days: int, at: dt.datetime) -> None:
|
||
sleeves = {"crypto_tstrend": 0.004, "unlock": 0.0025, "xsfunding": 0.001, "positioning": 0.0018}
|
||
for i in range(days):
|
||
d = (start + dt.timedelta(days=i)).isoformat()
|
||
for sleeve, amp in sleeves.items():
|
||
repo.upsert_bybit_sleeve_ret(d, sleeve, amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8), at=at)
|
||
|
||
|
||
def _bybit_store(spread_frac: float) -> TimescaleFeatureStore:
|
||
s = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
_seed_bybit_features(s, spread_frac=spread_frac)
|
||
return s
|
||
|
||
|
||
# --- 1) _bybit_measured_net: thin vs wide measured spread ----------------------------------------
|
||
|
||
def test_bybit_measured_net_thin_spread_near_fee() -> None:
|
||
"""A deep (thin-spread) Bybit universe → the per-coin measured cost drag sits near the published taker
|
||
fee floor (fee + half a tiny measured spread)."""
|
||
store = _bybit_store(spread_frac=0.0004)
|
||
view = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
store.close()
|
||
assert view["dates"], view
|
||
assert view["cost_drag_bps"] < 15.0, view["cost_drag_bps"] # ~fee + a few bp
|
||
|
||
|
||
def test_bybit_measured_net_wide_spread_much_higher() -> None:
|
||
"""A thin (wide-spread) Bybit universe → the measured cost drag is FAR above the fee floor (half the wide
|
||
Corwin–Schultz spread dominates), strictly more than the deep universe's."""
|
||
deep = _bybit_store(spread_frac=0.0004)
|
||
wide = _bybit_store(spread_frac=0.02) # 200 bps measured spread
|
||
deep_view = _bybit_measured_net(deep, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
wide_view = _bybit_measured_net(wide, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
deep.close()
|
||
wide.close()
|
||
assert wide_view["cost_drag_bps"] > 50.0, wide_view["cost_drag_bps"]
|
||
assert wide_view["cost_drag_bps"] > deep_view["cost_drag_bps"] * 3, (
|
||
wide_view["cost_drag_bps"], deep_view["cost_drag_bps"])
|
||
|
||
|
||
def test_bybit_measured_net_is_deterministic() -> None:
|
||
store = _bybit_store(spread_frac=0.01)
|
||
a = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
b = _bybit_measured_net(store, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
store.close()
|
||
assert a["equity"] == b["equity"]
|
||
assert a["cost_drag_bps"] == b["cost_drag_bps"]
|
||
|
||
|
||
def test_bybit_measured_dates_render_as_real_years_not_0057() -> None:
|
||
"""Regression: `per_coin_book_returns` keys days by UNIX epoch-day (~20627 for 2026), but the cockpit
|
||
renders dates via `date.fromordinal` — so `_bybit_measured_net` MUST emit proleptic ORDINALS, else 2026
|
||
shows as year 0057 (the ~1969-year Unix-epoch shift). Seed REALISTIC 2026 epoch-days and assert the
|
||
rendered years are {2026}; without the conversion they'd be {56, 57}."""
|
||
base = (dt.date(2026, 1, 1) - dt.date(1970, 1, 1)).days # Unix epoch-day of 2026-01-01 (~20454)
|
||
s = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
||
for idx, (sym, fund) in enumerate({"AAAUSDT": 0.001, "XXXUSDT": -0.001}.items()):
|
||
rows = []
|
||
for k in range(120): # 2026-01-01 .. ~2026-04-30 (all in 2026)
|
||
d = base + k
|
||
high, low = _ohlc(100.0, 0.01, phase=0.5 * k + idx)
|
||
rows.append((d * _DAY, {"funding": fund + 0.0005 * math.cos(0.5 * k + idx), "close": 100.0,
|
||
"spot_close": 100.0, "high": high, "low": low, "turnover": 50_000_000.0}))
|
||
s.write_features(sym, rows)
|
||
view = _bybit_measured_net(s, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, sleeves=["xsfunding"], unlock_events=[])
|
||
s.close()
|
||
assert view["dates"], view
|
||
years = {dt.date.fromordinal(d).year for d in view["dates"]} # the render path uses fromordinal
|
||
assert years == {2026}, years # NOT {56, 57} — the 0057 bug
|
||
|
||
|
||
# --- 2) precompute persists BOTH single-book measured curves + round-trips ------------------------
|
||
|
||
def _seeded_all(spread_frac: float = 0.02) -> tuple[PaperRepo, TimescaleFeatureStore,
|
||
TimescaleFeatureStore]:
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
store = TimescaleFeatureStore("sqlite://", table="features")
|
||
_seed_binance_shadow(repo, store, start=dt.date(2021, 1, 1), days=200,
|
||
sleeve_coins={"crypto_tstrend": ["AAAUSDT", "BBBUSDT"],
|
||
"unlock": ["CCCUSDT", "DDDUSDT"]},
|
||
spread_by_coin={c: 0.02 for c in ["AAAUSDT", "BBBUSDT", "CCCUSDT", "DDDUSDT"]},
|
||
at=_AT)
|
||
bybit = _bybit_store(spread_frac=spread_frac)
|
||
_seed_bybit_sleeve_ret(repo, start=dt.date(2021, 3, 1), days=120, at=_AT)
|
||
return repo, store, bybit
|
||
|
||
|
||
def test_precompute_persists_both_single_book_curves() -> None:
|
||
repo, store, bybit = _seeded_all()
|
||
out = precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT,
|
||
unlock_events=[])
|
||
store.close()
|
||
bybit.close()
|
||
assert set(out) == {"binance_combined", "bybit_4edge", "bybit_4edge_levered"}
|
||
# both keyed rows are readable individually.
|
||
for book in ("binance_combined", "bybit_4edge"):
|
||
payload = read_cached_single_measured(repo, book)
|
||
assert payload is not None, book
|
||
assert payload["book"] == book
|
||
assert payload["dates_iso"] and payload["equity"]
|
||
assert len(payload["equity"]) == len(payload["dates_iso"]) == len(payload["leverage"])
|
||
assert SINGLE_CACHE_KEYS["binance_combined"] != SINGLE_CACHE_KEYS["bybit_4edge"]
|
||
|
||
|
||
def test_single_book_payload_round_trips_direct_compute() -> None:
|
||
"""The cached single-book metrics == computing `_binance_measured_net` / `_bybit_measured_net` directly
|
||
on the same fixture (the precompute is a faithful cache, no drift)."""
|
||
repo, store, bybit = _seeded_all()
|
||
bin_returns = repo.sleeve_returns(before="2999-01-01")
|
||
days = sorted({d for r in bin_returns.values() for d in r})
|
||
bin_direct = _binance_measured_net(repo, store, capital=100_000.0,
|
||
sleeves=sorted(bin_returns.keys()),
|
||
win_lo=days[0], win_hi=days[-1], taker_fee_bps=5.5)
|
||
by_direct = _bybit_measured_net(bybit, capital=100_000.0, win_lo=None, win_hi=None,
|
||
taker_fee_bps=5.5, unlock_events=[])
|
||
precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
store.close()
|
||
bybit.close()
|
||
|
||
bin_cached = read_cached_single_measured(repo, "binance_combined")
|
||
by_cached = read_cached_single_measured(repo, "bybit_4edge")
|
||
assert bin_cached["equity"] == bin_direct["equity"]
|
||
assert by_cached["equity"] == by_direct["equity"]
|
||
for k in ("sharpe", "cagr", "total_return", "max_dd", "end_value"):
|
||
assert bin_cached["metrics"][k] == bin_direct["metrics"][k], k
|
||
assert by_cached["metrics"][k] == by_direct["metrics"][k], k
|
||
assert bin_cached["cost_drag_bps"] == bin_direct["cost_drag_bps"]
|
||
assert by_cached["cost_drag_bps"] == by_direct["cost_drag_bps"]
|
||
|
||
|
||
def test_precompute_single_book_is_idempotent() -> None:
|
||
repo, store, bybit = _seeded_all()
|
||
p1 = precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
p2 = precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
store.close()
|
||
bybit.close()
|
||
assert p1["binance_combined"]["equity"] == p2["binance_combined"]["equity"]
|
||
assert read_cached_single_measured(repo, "bybit_4edge")["equity"] == p1["bybit_4edge"]["equity"]
|
||
|
||
|
||
def test_precompute_also_writes_the_levered_book_measured_curve() -> None:
|
||
"""The precompute ALSO caches the LEVERED shadow book at the SAME measured cost model — a distinct
|
||
'bybit_4edge_levered' entry (carry-weighted, gross-capped) whose curve differs from the naive book. This is
|
||
how the cockpit Backtest can show the levered curve at the honest measured cost (never flat)."""
|
||
from fxhnt.application.bybit_forward_track import _LEVERED_SLEEVE_SHAPE, MAX_BOOK_GROSS
|
||
|
||
repo, store, bybit = _seeded_all()
|
||
lev_direct = _bybit_measured_net(bybit, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
unlock_events=[], leverage_shape=_LEVERED_SLEEVE_SHAPE,
|
||
max_gross=MAX_BOOK_GROSS)
|
||
out = precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
store.close()
|
||
bybit.close()
|
||
|
||
lev_cached = read_cached_single_measured(repo, "bybit_4edge_levered")
|
||
assert lev_cached is not None
|
||
assert lev_cached["equity"] == lev_direct["equity"]
|
||
assert "bybit_4edge_levered" in out
|
||
assert lev_cached["equity"] != out["bybit_4edge"]["equity"] # distinct from the naive book
|
||
|
||
|
||
# --- 3) the Backtest measured default / flat what-if / fallback ----------------------------------
|
||
|
||
def _client(repo: PaperRepo) -> TestClient:
|
||
fwd = ForwardNavRepo("sqlite://")
|
||
fwd.migrate()
|
||
return TestClient(create_app(fwd, paper_repo=repo))
|
||
|
||
|
||
# --- synthetic measured-cache seeder (the honest curve the Backtest reads) ------------------------
|
||
# The Backtest page reads ONLY the precomputed per-coin measured curve. For the UI tests (capital-scale,
|
||
# period-window, no-controls) we seed a small deterministic payload directly — exactly the shape
|
||
# `precompute_single_book_measured` persists — so we don't run the heavy per-coin compute on the request path.
|
||
|
||
def _seed_measured_cache(repo: PaperRepo, book: str, *, start: dt.date = dt.date(2022, 1, 1),
|
||
days: int = 400, capital: float = 100_000.0) -> dict:
|
||
"""Persist a synthetic single-book MEASURED-cost payload (the cockpit's honest curve) with a known,
|
||
multi-year, drawdown-bearing equity path so window/scale assertions are exact."""
|
||
from fxhnt.application.compare_measured_cost import SINGLE_CACHE_KEYS
|
||
from fxhnt.application.paper_sim import _metrics
|
||
|
||
iso, equity, drawdown, net_rets, epoch = [], [], [], [], []
|
||
eq, peak = capital, capital
|
||
for i in range(days):
|
||
d = start + dt.timedelta(days=i)
|
||
# A deterministic oscillation that compounds up overall but has a deep local dip mid-series.
|
||
r = 0.004 if (i % 5) else -0.05
|
||
eq *= (1.0 + r)
|
||
peak = max(peak, eq)
|
||
iso.append(d.isoformat())
|
||
equity.append(eq)
|
||
net_rets.append(r)
|
||
drawdown.append((eq - peak) / peak if peak > 0 else 0.0)
|
||
epoch.append((d - dt.date(1970, 1, 1)).days)
|
||
metrics = _metrics(epoch, equity, net_rets, drawdown, capital)
|
||
payload = {
|
||
"book": book, "dates_iso": iso, "equity": equity, "drawdown": drawdown,
|
||
"leverage": [1.0] * days, "killed": [False] * days, "n_active": [4] * days,
|
||
"metrics": metrics, "median_spread_bps": 1.0, "cost_drag_bps": 6.5,
|
||
"used_real_spread": book == "bybit_4edge", "capital": capital, "taker_fee_bps": 5.5,
|
||
}
|
||
repo.upsert_compare_measured_cache(SINGLE_CACHE_KEYS[book], payload, at=_AT)
|
||
return payload
|
||
|
||
|
||
def _equity(html: str) -> list[float]:
|
||
m = re.search(r'"equity":\s*\[([-0-9.,\s]+)\]', html)
|
||
assert m, html[:500]
|
||
return [float(x) for x in m.group(1).split(",")]
|
||
|
||
|
||
def test_capital_scales_the_measured_curve() -> None:
|
||
"""The Backtest reduces to book/capital/period. Capital rescales the cached curve LINEARLY (per-bp cost is
|
||
capital-robust): 2x capital -> 2x equity, point for point."""
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
_seed_measured_cache(repo, "bybit_4edge", capital=100_000.0)
|
||
c = _client(repo)
|
||
one = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
|
||
two = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 200_000})
|
||
assert one.status_code == 200 and two.status_code == 200
|
||
e1, e2 = _equity(one.text), _equity(two.text)
|
||
assert len(e1) == len(e2) and len(e1) > 0
|
||
for a, b in zip(e1, e2, strict=True):
|
||
assert abs(b - 2.0 * a) < 1.0, (a, b)
|
||
|
||
|
||
def test_period_windows_the_curve_and_recomputes_metrics() -> None:
|
||
"""Period start/end WINDOWS the cached curve (fewer points) and the metrics (CAGR/Sharpe/maxDD) are
|
||
RECOMPUTED on the window — not the full-history numbers sliced. The full curve has a deep mid-series dip;
|
||
a window AFTER the dip must show a shallower max DD than the full history."""
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
_seed_measured_cache(repo, "bybit_4edge", start=dt.date(2022, 1, 1), days=400, capital=100_000.0)
|
||
c = _client(repo)
|
||
full = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
|
||
win = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000,
|
||
"start": "2022-11-01", "end": "2022-12-15"})
|
||
assert full.status_code == 200 and win.status_code == 200
|
||
n_full, n_win = len(_equity(full.text)), len(_equity(win.text))
|
||
assert 0 < n_win < n_full # the window has strictly fewer points
|
||
|
||
def _maxdd(html: str) -> float:
|
||
m = re.search(r'"drawdown":\s*\[([-0-9.,\s eE]+)\]', html)
|
||
assert m, html[:500]
|
||
return min(float(x) for x in m.group(1).split(","))
|
||
|
||
# the window resets its drawdown peak at the window start, so its worst DD is shallower than the full
|
||
# history's (which includes the deep mid-series dip) — proof the metrics are recomputed on the window.
|
||
assert _maxdd(win.text) > _maxdd(full.text)
|
||
# and the rendered window dates are bounded by the requested period.
|
||
data = _sim_data_dates(win.text)
|
||
assert data[0] >= "2022-11-01" and data[-1] <= "2022-12-15"
|
||
|
||
|
||
def test_windowed_backtest_rebases_to_capital_not_the_cumulative_tail() -> None:
|
||
"""The bug the user caught: with $350k from a LATE start date, the equity must BEGIN at ~$350k on that date
|
||
(a fresh backtest), NOT the tail of the full cumulative curve. The buggy behaviour entered the window at the
|
||
2021->date compounded value scaled by capital — e.g. ~$1.6M on $350k, which is impossible. A window REBASES
|
||
to `capital`; equity[0] is one day's return from capital, never the multi-year compounded value."""
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
seeded = _seed_measured_cache(repo, "bybit_4edge", start=dt.date(2022, 1, 1), days=400, capital=100_000.0)
|
||
lo = next(i for i, d in enumerate(seeded["dates_iso"]) if d >= "2022-11-01")
|
||
old_rendered = seeded["equity"][lo] * (350_000 / 100_000) # what the OLD buggy code spliced in as eq[0]
|
||
c = _client(repo)
|
||
win = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 350_000,
|
||
"start": "2022-11-01", "end": "2022-12-15"})
|
||
assert win.status_code == 200
|
||
eq = _equity(win.text)
|
||
assert len(eq) > 0
|
||
# equity STARTS at ~capital (within one day's return) — a fresh backtest from the start date.
|
||
assert abs(eq[0] - 350_000) < 350_000 * 0.06, eq[0]
|
||
# and it is CLEARLY NOT the old spliced-in cumulative-tail value (the $1.6M-on-$350k class of bug).
|
||
assert abs(eq[0] - old_rendered) > 350_000 * 0.4, (eq[0], old_rendered)
|
||
|
||
|
||
def _sim_data_dates(html: str) -> list[str]:
|
||
import json
|
||
m = re.search(r'<script type="application/json" id="sim-data">(.*?)</script>', html, re.DOTALL)
|
||
assert m
|
||
return json.loads(m.group(1))["dates"]
|
||
|
||
|
||
def test_backtest_page_has_no_cost_flat_sleeve_kelly_controller_controls() -> None:
|
||
"""The honest Backtest is book/capital/period ONLY. The configurable-cost sandbox is GONE: no cost (bps)
|
||
slider, no measured/flat cost-mode select, no sleeve-composition presets/checkboxes, no Kelly slider, no
|
||
controller select. Drag-to-0-cost can no longer resurrect the +7097% mirage."""
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate()
|
||
_seed_measured_cache(repo, "bybit_4edge")
|
||
c = _client(repo)
|
||
page = c.get("/paper/sim").text
|
||
assert 'name="cost_bps"' not in page and "cost (bps)" not in page # no cost slider
|
||
assert 'name="cost_mode"' not in page and "flat (what-if)" not in page # no cost-mode select
|
||
assert 'name="sleeve"' not in page and "sim-preset" not in page # no sleeve presets/checkboxes
|
||
assert 'name="kelly"' not in page and 'name="controller"' not in page # no Kelly / controller
|
||
assert 'type="range"' not in page # no sliders at all
|
||
# the honest knobs remain.
|
||
assert 'name="capital"' in page and 'name="start"' in page and 'name="end"' in page
|
||
# the curve fragment carries the measured marker (the honest real-cost view).
|
||
run = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100_000})
|
||
assert 'data-cost-mode="measured"' in run.text
|
||
|
||
|
||
def test_cache_absent_shows_pending_note_not_flat_not_500() -> None:
|
||
"""With NO precomputed measured artifact the Backtest degrades to a short 'precomputing' note — NOT a flat
|
||
slider, NEVER a 500. (Previously this fell through to the flat what-if; that path is removed.)"""
|
||
repo = PaperRepo("sqlite://")
|
||
repo.migrate() # nothing seeded
|
||
c = _client(repo)
|
||
for book in ("bybit_4edge", "binance_combined"):
|
||
r = c.get("/paper/sim/run", params={"book": book, "capital": 100_000})
|
||
assert r.status_code == 200, (book, r.text[:300])
|
||
assert 'data-cost-mode="flat"' not in r.text # NOT the flat what-if
|
||
assert "precomputing" in r.text.lower() # the graceful pending note
|
||
assert "nightly" in r.text.lower()
|
||
|
||
|
||
def test_binance_measured_default_is_deinflated_and_has_no_caption() -> None:
|
||
"""book=binance_combined in MEASURED mode (the DEFAULT) renders the de-inflated per-coin measured curve
|
||
(its measured total return is BELOW the flat-cost number) with the measured cost-mode indicator and NO
|
||
special-case "≈ -36% / Compare A/B" caption band-aid."""
|
||
repo, store, bybit = _seeded_all()
|
||
precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
# the flat single-book number (the OLD slider path) for the de-inflation comparison.
|
||
bin_returns = repo.sleeve_returns(before="2999-01-01")
|
||
days = sorted({d for r in bin_returns.values() for d in r})
|
||
flat = simulate_book(bin_returns, capital=100_000.0, sleeves=sorted(bin_returns.keys()),
|
||
start=days[0], end=days[-1], controller="killswitch", kelly_fraction=1.0,
|
||
cost_bps=5.5)
|
||
store.close()
|
||
bybit.close()
|
||
|
||
measured = read_cached_single_measured(repo, "binance_combined")["metrics"]
|
||
# DE-INFLATION: the per-coin measured cost (wide spreads) drags the book below the flat-cost number.
|
||
assert measured["total_return"] < flat.metrics["total_return"] - 1e-9, (
|
||
measured["total_return"], flat.metrics["total_return"])
|
||
|
||
c = _client(repo)
|
||
r = c.get("/paper/sim/run", params={"book": "binance_combined", "capital": 100000})
|
||
assert r.status_code == 200
|
||
assert 'data-cost-mode="measured"' in r.text
|
||
# the special-case caption band-aid is GONE.
|
||
assert "real measured costs" not in r.text and "-36%" not in r.text
|
||
assert "Compare A/B" not in r.text and "broad/illiquid" not in r.text
|
||
|
||
|
||
def test_bybit_measured_default_shows_real_costed_number() -> None:
|
||
"""book=bybit_4edge in MEASURED mode (default) renders the precomputed per-coin measured curve (its real
|
||
costed number), with the measured cost-mode indicator."""
|
||
repo, store, bybit = _seeded_all()
|
||
precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[])
|
||
store.close()
|
||
bybit.close()
|
||
by_cached = read_cached_single_measured(repo, "bybit_4edge")
|
||
assert by_cached is not None and by_cached["equity"]
|
||
|
||
c = _client(repo)
|
||
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
|
||
assert r.status_code == 200
|
||
assert 'data-cost-mode="measured"' in r.text
|
||
# the rendered curve's final equity == the precomputed measured curve (at the same capital).
|
||
m = re.search(r'"equity":\s*\[([-0-9.,\s]+)\]', r.text)
|
||
assert m
|
||
rendered_final = float(m.group(1).split(",")[-1])
|
||
assert abs(rendered_final - by_cached["equity"][-1]) < 1.0, (rendered_final, by_cached["equity"][-1])
|
||
|
||
|
||
def test_compare_uses_measured_bybit_when_store_supplied() -> None:
|
||
"""The Compare's Bybit side runs the per-coin MEASURED cost when a `bybit_features` store is supplied
|
||
(its cost drag reflects the measured spread, not the flat fee) — and stays flat without one."""
|
||
from fxhnt.application.compare_measured_cost import compare_measured_cost
|
||
|
||
repo, store, bybit = _seeded_all(spread_frac=0.02) # wide bybit spread
|
||
measured = compare_measured_cost(repo, store, capital=100_000.0, bybit_store=bybit,
|
||
unlock_events=[])
|
||
flat_bybit = compare_measured_cost(repo, store, capital=100_000.0) # no bybit store → flat
|
||
store.close()
|
||
bybit.close()
|
||
m_by = {r["book"]: r for r in measured["rows"]}["bybit_4edge"]
|
||
f_by = {r["book"]: r for r in flat_bybit["rows"]}["bybit_4edge"]
|
||
# measured bybit cost drag (wide spread) >> the flat 5.5bp fee.
|
||
assert m_by["cost_drag_bps"] > 50.0, m_by["cost_drag_bps"]
|
||
assert f_by["cost_drag_bps"] == 5.5
|
||
|
||
|
||
# --- 4) REAL L1 quoted-spread override (the core cost-model fix) ----------------------------------
|
||
# The Bybit liquid book was costed off a daily-high/low Corwin–Schultz proxy that over-states the true quoted
|
||
# spread ~104× (1.4bp real vs 143.6bp CS). With the REAL spread the cost drag drops and the net Sharpe rises;
|
||
# Binance stays on CS (its broad/illiquid mirage finding stands).
|
||
|
||
_BYBIT_BOOK_COINS = ["AAAUSDT", "BBBUSDT", "CCCUSDT", "XXXUSDT", "YYYUSDT", "ZZZUSDT"]
|
||
|
||
|
||
def test_bybit_measured_real_spread_lowers_cost_and_lifts_sharpe() -> None:
|
||
"""real_spread override (2bp) ≪ the ~200bp CS proxy → lower cost drag AND higher net Sharpe than CS-only
|
||
(the core fix). The GROSS book is unchanged — only the per-bp cost model's spread input changes."""
|
||
cs_store = _bybit_store(spread_frac=0.02)
|
||
real_store = _bybit_store(spread_frac=0.02)
|
||
cs = _bybit_measured_net(cs_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
sleeves=["xsfunding"], unlock_events=[])
|
||
real = _bybit_measured_net(real_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
sleeves=["xsfunding"], unlock_events=[],
|
||
real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
|
||
cs_store.close()
|
||
real_store.close()
|
||
assert cs["used_real_spread"] is False and real["used_real_spread"] is True
|
||
assert real["cost_drag_bps"] < cs["cost_drag_bps"], (real["cost_drag_bps"], cs["cost_drag_bps"])
|
||
assert real["metrics"]["sharpe"] > cs["metrics"]["sharpe"], (
|
||
real["metrics"]["sharpe"], cs["metrics"]["sharpe"])
|
||
# GROSS is untouched: the real cost drag still sits at the exact taker_fee + 0.5·real_spread floor.
|
||
assert abs(real["cost_drag_bps"] - (5.5 + 0.5 * 2.0)) < 1e-6, real["cost_drag_bps"]
|
||
|
||
|
||
def test_bybit_measured_partial_override_falls_back_to_cs() -> None:
|
||
"""A coin with NO stored real spread falls back to its CS estimate: partial (1 real, rest CS) cost drag
|
||
sits strictly between all-real (cheap) and all-CS (expensive)."""
|
||
full_store = _bybit_store(spread_frac=0.02)
|
||
part_store = _bybit_store(spread_frac=0.02)
|
||
cs_store = _bybit_store(spread_frac=0.02)
|
||
full = _bybit_measured_net(full_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
sleeves=["xsfunding"], unlock_events=[],
|
||
real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
|
||
part = _bybit_measured_net(part_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
sleeves=["xsfunding"], unlock_events=[],
|
||
real_spread_by_coin={_BYBIT_BOOK_COINS[0]: 2.0})
|
||
cs = _bybit_measured_net(cs_store, capital=100_000.0, win_lo=None, win_hi=None, taker_fee_bps=5.5,
|
||
sleeves=["xsfunding"], unlock_events=[])
|
||
full_store.close()
|
||
part_store.close()
|
||
cs_store.close()
|
||
assert full["cost_drag_bps"] < part["cost_drag_bps"] < cs["cost_drag_bps"], (
|
||
full["cost_drag_bps"], part["cost_drag_bps"], cs["cost_drag_bps"])
|
||
|
||
|
||
def test_precompute_threads_real_spread_for_bybit_not_binance() -> None:
|
||
"""precompute passes the real spread through for bybit_4edge (cost drag drops) and NOT for
|
||
binance_combined (its broad/illiquid CS mirage stands — unchanged)."""
|
||
repo1, store1, bybit1 = _seeded_all(spread_frac=0.02)
|
||
base = precompute_single_book_measured(repo1, store1, bybit1, capital=100_000.0, at=_AT, unlock_events=[])
|
||
store1.close()
|
||
bybit1.close()
|
||
|
||
repo2, store2, bybit2 = _seeded_all(spread_frac=0.02)
|
||
withreal = precompute_single_book_measured(repo2, store2, bybit2, capital=100_000.0, at=_AT,
|
||
unlock_events=[],
|
||
bybit_real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
|
||
store2.close()
|
||
bybit2.close()
|
||
|
||
# bybit cost drag DROPS with the real spread + flags it; binance is UNCHANGED (stays on CS).
|
||
assert withreal["bybit_4edge"]["used_real_spread"] is True
|
||
assert base["bybit_4edge"]["used_real_spread"] is False
|
||
assert withreal["bybit_4edge"]["cost_drag_bps"] < base["bybit_4edge"]["cost_drag_bps"]
|
||
assert withreal["binance_combined"]["used_real_spread"] is False
|
||
assert withreal["binance_combined"]["cost_drag_bps"] == base["binance_combined"]["cost_drag_bps"]
|
||
|
||
|
||
def test_bybit_caption_shows_real_spread_and_keeps_measured_marker() -> None:
|
||
"""When the bybit book used real spreads the caption says so (forward-realistic / current-snapshot caveat
|
||
NOT hidden) while the existing data-cost-mode="measured" marker stays intact; Binance keeps the CS caption."""
|
||
repo, store, bybit = _seeded_all(spread_frac=0.02)
|
||
precompute_single_book_measured(repo, store, bybit, capital=100_000.0, at=_AT, unlock_events=[],
|
||
bybit_real_spread_by_coin={c: 2.0 for c in _BYBIT_BOOK_COINS})
|
||
store.close()
|
||
bybit.close()
|
||
c = _client(repo)
|
||
|
||
r = c.get("/paper/sim/run", params={"book": "bybit_4edge", "capital": 100000})
|
||
assert r.status_code == 200
|
||
assert 'data-cost-mode="measured"' in r.text # marker preserved
|
||
assert "real L1 quoted spread" in r.text
|
||
assert "forward-realistic" in r.text and "2021-22 spreads were wider" in r.text
|
||
|
||
rb = c.get("/paper/sim/run", params={"book": "binance_combined", "capital": 100000})
|
||
assert rb.status_code == 200
|
||
assert 'data-cost-mode="measured"' in rb.text
|
||
assert "real L1 quoted spread" not in rb.text # Binance stays on CS
|
||
|
||
|
||
# NB: the old `test_measured_default_falls_back_to_flat_when_cache_absent` (cache-absent => flat slider) is
|
||
# replaced by `test_cache_absent_shows_pending_note_not_flat_not_500` above — the flat what-if is no longer a
|
||
# user path; an absent measured cache now degrades to a "precomputing" note, never the flat slider.
|