Bybit is the deploy venue, so it must be a REAL live paper book (per-symbol positions, marked to live prices, trades), not only a returns/forward-NAV track. - Persistence: add a `venue` discriminator to the 6 paper tables + PaperRepo(venue=...) with a guarded in-place ALTER migration; the binance book is bit-identical (default venue="binance"), bybit rows coexist isolated. - Per-symbol weight extraction: surface each of the 4 Bybit sleeves' per-symbol target weights per day by reusing the EXACT engine logic (weights_and_contributions on the trend/unlock/positioning/carry runners). Reconciliation test proves Σ_symbol weight·return == the sleeve's gross (cost-free) bybit_sleeve_ret per day — the live book and returns book are the SAME book, no edge drift. - Service (bybit_paper_book.py): combine the 4 sleeves naive eq-wt (25% each) → target positions on the latest Bybit close → diff→trades → mark-to-market → persist via the shared persist_paper_book seam under venue="bybit"; cost at 5.5bp. - Dagster: @asset bybit_paper_book depending on bybit_warehouse_refresh (READ-ONLY against the warehouse); registered + definitions count bumped 16→17. - Cockpit: /paper?venue=bybit now renders the LIVE book (equity, per-sleeve summary, positions + trades in bounded scroll boxes) via the SAME read-model path as binance, keeping the BYBIT badge + forward-track gate card; backtest reference moved below. WarehouseLivePrice marks the bybit book to the latest warehouse close (no network). - CLI: `fxhnt bybit-paper-book` seeds the live book once (own-memory). Full suite green (1393 passed). NO network in tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
10 KiB
Python
234 lines
10 KiB
Python
"""Web smoke for the paper cockpit: /paper renders equity + a position; /paper/live returns updated MTM;
|
|
empty live prices degrade gracefully (never 500)."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from fxhnt.adapters.persistence.paper_repo import PaperRepo
|
|
from fxhnt.adapters.web.app import create_app
|
|
from fxhnt.application.paper_book import PaperBookService
|
|
from fxhnt.ports.live_price import FakeLivePrice
|
|
|
|
|
|
def _seeded_repo() -> PaperRepo:
|
|
repo = PaperRepo("sqlite://")
|
|
repo.migrate()
|
|
svc = PaperBookService(repo, capital=100_000.0)
|
|
svc.derive_and_persist(
|
|
run_date="2026-06-21",
|
|
sleeve_weights={"stablecoin_rotation": 0.5},
|
|
symbol_weights_by_sleeve={"stablecoin_rotation": {"USDTUSD": -1.0}},
|
|
prices={"USDTUSD": 2.0},
|
|
at=dt.datetime(2026, 6, 21, tzinfo=dt.UTC))
|
|
return repo
|
|
|
|
|
|
def _client(live: FakeLivePrice) -> TestClient:
|
|
repo = _seeded_repo()
|
|
# Forward-nav repo only feeds the existing fleet routes; the paper routes use paper_repo + live.
|
|
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
|
fwd = ForwardNavRepo("sqlite://")
|
|
fwd.migrate()
|
|
return TestClient(create_app(fwd, paper_repo=repo, live=live))
|
|
|
|
|
|
def test_paper_page_renders_equity_and_position() -> None:
|
|
c = _client(FakeLivePrice({"USDTUSD": 1.9})) # short profits as price fell
|
|
r = c.get("/paper?venue=binance") # equity + per-symbol position live on the binance view
|
|
assert r.status_code == 200
|
|
assert "Paper" in r.text
|
|
assert "USDTUSD" in r.text
|
|
assert "equity" in r.text.lower()
|
|
|
|
|
|
def test_paper_live_partial_returns_updated_mtm() -> None:
|
|
c = _client(FakeLivePrice({"USDTUSD": 1.9}))
|
|
r = c.get("/paper/live")
|
|
assert r.status_code == 200
|
|
assert "equity" in r.text.lower()
|
|
assert "USDTUSD" in r.text
|
|
|
|
|
|
def test_paper_degrades_gracefully_with_empty_prices() -> None:
|
|
c = _client(FakeLivePrice({})) # no live prices at all
|
|
r = c.get("/paper")
|
|
assert r.status_code == 200
|
|
r2 = c.get("/paper/live")
|
|
assert r2.status_code == 200
|
|
|
|
|
|
def test_fleet_page_links_to_paper() -> None:
|
|
c = _client(FakeLivePrice({"USDTUSD": 1.9}))
|
|
r = c.get("/")
|
|
assert r.status_code == 200
|
|
assert "/paper" in r.text
|
|
|
|
|
|
# --- Venue switch on /paper --------------------------------------------------------------------------
|
|
# /paper shows ONE book at a time, chosen by the `venue` query param (default + fallback = bybit, the
|
|
# deploy venue). venue=bybit -> the Bybit 4-edge returns book (4 sleeves + forward NAV/gate); venue=binance
|
|
# -> the Binance live paper book (positions/trades). The bybit section reads the precomputed bybit_sleeve_ret
|
|
# table — never recomputes — and the registry-seeded forward track. NO network: in-memory SQLite seeded.
|
|
|
|
_BYBIT_SLEEVES = {"crypto_tstrend": 0.004, "unlock": 0.0025, "xsfunding": 0.001, "positioning": 0.0018}
|
|
|
|
|
|
def _seeded_bybit_paper_repo() -> PaperRepo:
|
|
"""A paper repo with the USDTUSD binance position AND ~120 days of precomputed Bybit per-sleeve
|
|
returns in bybit_sleeve_ret (so /paper can render either venue's book)."""
|
|
repo = _seeded_repo()
|
|
at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
|
|
base = dt.date(2026, 1, 1)
|
|
for i in range(120):
|
|
d = (base + dt.timedelta(days=i)).isoformat()
|
|
for sleeve, amp in _BYBIT_SLEEVES.items():
|
|
ret = amp * (1.0 if (i + hash(sleeve)) % 3 else -0.8)
|
|
repo.upsert_bybit_sleeve_ret(d, sleeve, ret, at=at)
|
|
return repo
|
|
|
|
|
|
def _client_with_forward(paper: PaperRepo, *, with_track: bool):
|
|
"""A TestClient whose forward repo has (or lacks) a bybit_4edge forward NAV track."""
|
|
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
|
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
|
|
from fxhnt.application.forward_models import ForwardSummary
|
|
|
|
fwd = ForwardNavRepo("sqlite://")
|
|
fwd.migrate()
|
|
if with_track:
|
|
at = dt.datetime(2026, 6, 24)
|
|
base = dt.date(2026, 4, 1)
|
|
nav = 1.0
|
|
rows = []
|
|
for i in range(15):
|
|
nav *= 1.001
|
|
rows.append(NavRowDTO(strategy_id="bybit_4edge",
|
|
date=(base + dt.timedelta(days=i)).isoformat(), ret=0.001, nav=nav))
|
|
fwd.upsert_rows(rows, at=at)
|
|
sm = ForwardSummary(strategy_id="bybit_4edge", as_of="2026-04-15", days=15, nav=nav,
|
|
total_return=nav - 1.0, sharpe=1.2, maxdd=-0.02)
|
|
fwd.upsert_summary(sm, gate_status="WAIT", gate_reason="15/60 forward days", at=at)
|
|
# An empty bybit live book (a separate in-memory venue="bybit" repo) + a no-op bybit mark — the live
|
|
# bybit view renders cleanly (no positions) while the backtest reference reads the shared bybit_sleeve_ret.
|
|
bybit_paper = PaperRepo("sqlite://", venue="bybit")
|
|
bybit_paper.migrate()
|
|
return TestClient(create_app(fwd, paper_repo=paper, live=FakeLivePrice({"USDTUSD": 1.9}),
|
|
bybit_paper_repo=bybit_paper, bybit_live=FakeLivePrice({})))
|
|
|
|
|
|
def test_paper_defaults_to_bybit_live_book_with_badge_and_backtest_reference() -> None:
|
|
# No `venue` param → defaults to the Bybit LIVE paper book (the deploy venue), now the PRIMARY view.
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
r = c.get("/paper")
|
|
assert r.status_code == 200
|
|
# The Bybit LIVE book is shown with its BYBIT badge (mark-to-market, per-symbol).
|
|
assert "Bybit paper book" in r.text
|
|
assert ">BYBIT<" in r.text
|
|
assert "mark-to-market" in r.text
|
|
# ALL four sleeves listed in the backtest reference (incl. positioning).
|
|
for sleeve in ("crypto_tstrend", "unlock", "xsfunding", "positioning"):
|
|
assert sleeve in r.text
|
|
# naive eq-wt weight (1/4 = 25%) is shown in the backtest reference.
|
|
assert "25" in r.text
|
|
# vol-targeted backtest metrics columns are present.
|
|
assert "Sharpe" in r.text
|
|
# The binance positions table is NOT shown on the bybit view (USDTUSD is the binance book's symbol).
|
|
assert "USDTUSD" not in r.text
|
|
|
|
|
|
def test_paper_default_bybit_shows_forward_gate_and_curve() -> None:
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
r = c.get("/paper")
|
|
assert r.status_code == 200
|
|
assert "WAIT" in r.text # forward gate
|
|
assert "15/60 forward days" in r.text # gate reason / days
|
|
assert "<svg" in r.text # the forward NAV curve
|
|
|
|
|
|
def test_paper_binance_shows_live_book_with_badge() -> None:
|
|
# venue=binance → the Binance live book (positions/trades) with its BINANCE badge.
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert "Binance paper book" in r.text
|
|
assert ">BINANCE<" in r.text
|
|
assert "USDTUSD" in r.text # the per-symbol position is shown
|
|
# The bybit live-book section is NOT shown on the binance view.
|
|
assert "Bybit paper book" not in r.text
|
|
|
|
|
|
def test_paper_venue_toggle_present_and_active_highlighted() -> None:
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
# Default (bybit): both pills link relative; bybit pill highlighted.
|
|
rb = c.get("/paper").text
|
|
assert 'href="/paper?venue=binance"' in rb
|
|
assert 'href="/paper?venue=bybit"' in rb
|
|
# Binance view: both pills present; binance pill highlighted.
|
|
rn = c.get("/paper?venue=binance").text
|
|
assert 'href="/paper?venue=binance"' in rn
|
|
assert 'href="/paper?venue=bybit"' in rn
|
|
# The highlight (active border colour) must be applied to a different pill on each view.
|
|
assert rb != rn
|
|
|
|
|
|
def test_paper_garbage_venue_defaults_to_bybit() -> None:
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
r = c.get("/paper?venue=garbage")
|
|
assert r.status_code == 200
|
|
assert "Bybit paper book" in r.text # fell back to bybit
|
|
assert "USDTUSD" not in r.text # not the binance book
|
|
|
|
|
|
def test_paper_binance_view_keeps_bounded_scroll_box() -> None:
|
|
# The mobile-height fix (bounded scroll box on the trades table) survives on the binance view.
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert "max-height:420px" in r.text and "overflow-y:auto" in r.text
|
|
|
|
|
|
def test_paper_live_partial_on_both_views() -> None:
|
|
# The /paper/live auto-refresh block now drives the live book on BOTH venues (the venue is passed so the
|
|
# bybit fragment polls the bybit book).
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
assert 'hx-get="/paper/live"' in c.get("/paper?venue=binance").text
|
|
assert 'hx-get="/paper/live?venue=bybit"' in c.get("/paper?venue=bybit").text
|
|
|
|
|
|
def test_paper_bybit_young_forward_track_shows_building() -> None:
|
|
# Registry seeds bybit_4edge but NO forward nav rows → young/empty forward track, but the backtest
|
|
# sleeves still render as the validated reference (never a 500).
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=False)
|
|
r = c.get("/paper")
|
|
assert r.status_code == 200
|
|
assert "Bybit paper book" in r.text
|
|
assert "positioning" in r.text # backtest-reference sleeves still listed
|
|
assert "WAIT" in r.text # young track still gates WAIT
|
|
|
|
|
|
def test_paper_bybit_degrades_gracefully_without_sleeve_data() -> None:
|
|
# No bybit_sleeve_ret rows at all → the bybit section renders empty/"no data", never a 500.
|
|
c = _client_with_forward(_seeded_repo(), with_track=False)
|
|
r = c.get("/paper")
|
|
assert r.status_code == 200
|
|
assert "Bybit paper book" in r.text # section header still shown
|
|
# And the binance view also degrades gracefully (no sleeve data, still 200).
|
|
r2 = c.get("/paper?venue=binance")
|
|
assert r2.status_code == 200
|
|
assert "USDTUSD" in r2.text
|
|
|
|
|
|
def test_paper_is_relative_url_leak_clean() -> None:
|
|
import re
|
|
abs_url = re.compile(r"https?://(?!www\.w3\.org/)[^\s\"'<>]+")
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
# Both venue views must be leak-clean (only the SVG xmlns namespace is a legitimate absolute URL).
|
|
for url in ("/paper", "/paper?venue=binance"):
|
|
r = c.get(url)
|
|
assert r.status_code == 200
|
|
hits = abs_url.findall(r.text)
|
|
assert not hits, f"leak-clean violation on {url}: {hits}"
|