Roll the Overview's SSOT cockpit pattern onto the Paper page so the whole cockpit reads as one product. Presentation-only — no book data/returns or gate logic changed. - Bybit = the DEPLOY fund (default): leads with the SAME `headline_card` the Overview uses (connected backfill record + live-gate chip), then the detail /paper uniquely provides — the live per-symbol positions + recent trades — then the individual constituent edges via the SAME `deploy_constituent_row` macro. PAPER `exec_badge` on the book. - Binance = research/secondary: the dimmed `section research` wrapper (same as the Overview's research block), PAPER-badged, visibly not co-equal. - Cut the walls of muted prose (long "paper only — derived from…" / "backtest only — precomputed nightly…" paragraphs) → one-line captions + title= tooltips, matching the Overview's restraint. - Reuses the Overview's own helpers (`_overview_headline`, `_deploy_individual_edges`, `_forward_track`); removed the now-dead `_bybit_book`. Tests (TDD): /paper?venue=bybit renders headline_card ($394,085/+294%/Sharpe 1.45, no tilde) + PAPER badge + live positions/trades, old prose gone; /paper?venue=binance research-framed + PAPER; both venues 200, leak-clean relative URLs, venue switch intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
464 lines
23 KiB
Python
464 lines
23 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 DEPLOY fund view (the deploy venue), 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 DEPLOY book is shown with its BYBIT badge (mark-to-market, per-symbol) + a PAPER exec badge.
|
|
assert "Bybit paper book" in r.text
|
|
assert ">BYBIT<" in r.text
|
|
assert "mark-to-market" in r.text
|
|
assert "badge-exec paper" in r.text
|
|
# ALL four constituent edges listed in the individual-edges breakdown (incl. positioning), consistent
|
|
# with the Overview's deploy constituents.
|
|
for sleeve in ("crypto_tstrend", "unlock", "xsfunding", "positioning"):
|
|
assert sleeve in r.text
|
|
# individual edges rendered via the SAME deploy_constituent_row macro the Overview uses (the "Sh" metric).
|
|
assert "individual edges" in r.text
|
|
assert "Sh " 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
|
|
|
|
|
|
# --- Real backfilled nav curve + compounded-equity headline ------------------------------------------
|
|
# /paper now leads with the COMPOUNDED backfilled record (latest persisted paper_nav equity) and draws the
|
|
# real multi-point nav curve (the same chart /paper/replay uses) instead of the MVP 2-point spark. The live
|
|
# mark-to-market is kept as a clearly-labeled secondary line. A fresh book (no nav history) falls back to
|
|
# the 2-point spark and the live mark IS the headline. LIGHT read only — one persisted nav query, no compute.
|
|
|
|
|
|
def _svg_point_count(svg: str) -> int:
|
|
"""Number of points in the FIRST <polyline ... points="..."> of an inline SVG (the nav curve)."""
|
|
import re
|
|
m = re.search(r'<polyline[^>]*points="([^"]*)"', svg)
|
|
if not m:
|
|
return 0
|
|
return len([p for p in m.group(1).split(" ") if p.strip()])
|
|
|
|
|
|
def _seed_nav(repo: PaperRepo, *, n: int, start: float, end: float) -> None:
|
|
"""Persist `n` ascending paper_nav rows from `start` to `end` (compounding record)."""
|
|
at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
|
|
base = dt.date(2021, 1, 1)
|
|
for i in range(n):
|
|
d = (base + dt.timedelta(days=i)).isoformat()
|
|
eq = start + (end - start) * (i / (n - 1)) if n > 1 else end
|
|
repo.upsert_nav(d, capital=start, realized=0.0, unrealized=0.0, equity=eq, at=at)
|
|
|
|
|
|
def test_paper_draws_real_multi_point_nav_curve_when_history_exists() -> None:
|
|
# Binance book with a real backfilled nav series → the curve has many points (NOT the 2-point spark).
|
|
repo = _seeded_repo()
|
|
_seed_nav(repo, n=200, start=100_000.0, end=393_955.0)
|
|
c = _client_with_forward(repo, with_track=True)
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert _svg_point_count(r.text) > 2 # a real multi-point curve, not the placeholder spark
|
|
|
|
|
|
def test_paper_falls_back_to_two_point_spark_for_fresh_book() -> None:
|
|
# No nav history persisted → the old 2-point baseline->equity spark (don't break a fresh book).
|
|
repo = _seeded_repo() # has positions but NO paper_nav rows
|
|
c = _client_with_forward(repo, with_track=True)
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert _svg_point_count(r.text) == 2 # baseline -> equity
|
|
|
|
|
|
def _client_both_repos(binance: PaperRepo, bybit: PaperRepo, *, bybit_live):
|
|
"""A TestClient with explicit binance + bybit paper repos and a fixed bybit live-price source."""
|
|
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
|
fwd = ForwardNavRepo("sqlite://")
|
|
fwd.migrate()
|
|
return TestClient(create_app(fwd, paper_repo=binance, live=FakeLivePrice({}),
|
|
bybit_paper_repo=bybit, bybit_live=bybit_live))
|
|
|
|
|
|
def test_paper_headline_leads_with_compounded_nav_and_keeps_live_mark_secondary() -> None:
|
|
# The page MUST lead with the compounded multi-year record ($393,955) and ALSO show the live mark
|
|
# ($99,959) as a clearly-labeled secondary detail. Bybit branch.
|
|
bybit = PaperRepo("sqlite://", venue="bybit")
|
|
bybit.migrate()
|
|
# Live book: a short BTC lot so the live mark moves with price (kept distinct from the nav record).
|
|
PaperBookService(bybit, capital=100_000.0).derive_and_persist(
|
|
run_date="2026-06-24", sleeve_weights={"positioning": 1.0},
|
|
symbol_weights_by_sleeve={"positioning": {"BTCUSDT": -0.01}},
|
|
prices={"BTCUSDT": 50_000.0}, at=dt.datetime(2026, 6, 24, tzinfo=dt.UTC))
|
|
# The LATEST persisted nav row is the compounded record ($393,955). NOTE: view.equity =
|
|
# latest_nav_equity + intraday MTM, so to make the live mark distinct (~$99,959) the latest nav row
|
|
# must be the LIVE baseline; we therefore seed the long compounding record but pin the FINAL row to
|
|
# $99,959 so latest_nav_equity()==99,959 (the live mark) while the curve still climbs to the record.
|
|
_seed_nav(bybit, n=199, start=100_000.0, end=393_955.0)
|
|
bybit.upsert_nav("2021-07-20", capital=100_000.0, realized=0.0, unrealized=0.0,
|
|
equity=393_955.0, at=dt.datetime(2026, 6, 24, tzinfo=dt.UTC))
|
|
|
|
binance = PaperRepo("sqlite://")
|
|
binance.migrate()
|
|
# No intraday move (price == entry) so the live mark == latest_nav_equity == the compounded record.
|
|
c = _client_both_repos(binance, bybit, bybit_live=FakeLivePrice({"BTCUSDT": 50_000.0}))
|
|
r = c.get("/paper?venue=bybit")
|
|
assert r.status_code == 200
|
|
# Compounded record is the headline.
|
|
assert "393,955" in r.text
|
|
# And it's the PRIMARY "paper equity" headline, with a real multi-point curve.
|
|
assert "paper equity" in r.text
|
|
assert _svg_point_count(r.text) > 2
|
|
|
|
|
|
def test_paper_headline_shows_live_mark_as_secondary_detail() -> None:
|
|
# When the live mark differs from the compounded record, BOTH are rendered: the record as the headline,
|
|
# the live mark labeled "marked to live prices now". A SHORT BTC lot + a price DROP profits → live mark
|
|
# rises above the nav baseline, so the two figures differ and both must appear.
|
|
bybit = PaperRepo("sqlite://", venue="bybit")
|
|
bybit.migrate()
|
|
PaperBookService(bybit, capital=100_000.0).derive_and_persist(
|
|
run_date="2026-06-24", sleeve_weights={"positioning": 1.0},
|
|
symbol_weights_by_sleeve={"positioning": {"BTCUSDT": -1.0}},
|
|
prices={"BTCUSDT": 100.0}, at=dt.datetime(2026, 6, 24, tzinfo=dt.UTC))
|
|
_seed_nav(bybit, n=50, start=100_000.0, end=120_000.0) # compounded record = $120,000
|
|
|
|
binance = PaperRepo("sqlite://")
|
|
binance.migrate()
|
|
# Price falls 100 -> 90: the short lot profits, so the live mark = 120,000 + intraday gain > 120,000.
|
|
c = _client_both_repos(binance, bybit, bybit_live=FakeLivePrice({"BTCUSDT": 90.0}))
|
|
r = c.get("/paper?venue=bybit")
|
|
assert r.status_code == 200
|
|
assert "120,000" in r.text # compounded record headline
|
|
assert "marked to live prices now" in r.text # the live mark, clearly labeled secondary
|
|
|
|
|
|
def test_paper_handler_does_no_heavy_compute_only_light_nav_read() -> None:
|
|
# The /paper handler must source the headline + curve from a LIGHT persisted nav read (one indexed
|
|
# query), NEVER any heavy per-coin / warehouse compute (the 4Gi dashboard OOMs on that). Assert the
|
|
# repo's nav_history is the read used, and no warehouse/per-coin path is hit, by spying on the repo.
|
|
binance = PaperRepo("sqlite://")
|
|
binance.migrate()
|
|
_seed_nav(binance, n=120, start=100_000.0, end=200_000.0)
|
|
|
|
calls = {"nav_history": 0}
|
|
orig = binance.nav_history
|
|
|
|
def _spy() -> list:
|
|
calls["nav_history"] += 1
|
|
return orig()
|
|
|
|
binance.nav_history = _spy # type: ignore[method-assign]
|
|
bybit = PaperRepo("sqlite://", venue="bybit")
|
|
bybit.migrate()
|
|
c = _client_both_repos(binance, bybit, bybit_live=FakeLivePrice({}))
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert calls["nav_history"] >= 1 # the light persisted read happened
|
|
assert _svg_point_count(r.text) > 2 # and drove the real curve
|
|
|
|
|
|
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}"
|
|
|
|
|
|
# --- SSOT rollout: /paper adopts the Overview's visual language ---------------------------------------
|
|
# The Paper page rolls the same SSOT pattern the Overview leads with: BYBIT is the DEPLOY fund (the SAME
|
|
# `headline_card` — connected backfill record + live-gate chip — then the unique live per-symbol book +
|
|
# trades + the individual constituent edges, all PAPER-badged); BINANCE is the dimmed RESEARCH/secondary
|
|
# view. The walls of muted prose are gone. The two pages now look like one product.
|
|
|
|
|
|
def _engineered_headline_nav() -> list[float]:
|
|
"""A deterministic bybit backfill equity series whose nav_backfill_stats render the canonical headline
|
|
figures EXACTLY: final $394,085 / +294% total return / Sharpe 1.45 (annualised, the repo convention).
|
|
Alternating up/down daily returns (mean/std fixed for Sharpe), then rescaled so the final lands on the
|
|
headline equity. Pure arithmetic — same maths nav_backfill_stats runs."""
|
|
m, d, g = 309, 0.03955, 0.003001845
|
|
nav = [100_000.0]
|
|
for _ in range(m):
|
|
nav.append(nav[-1] * (1 + g + d))
|
|
nav.append(nav[-1] * (1 + g - d))
|
|
k = 394_085.0 / nav[-1]
|
|
return [x * k for x in nav]
|
|
|
|
|
|
def _seed_engineered_headline(repo: PaperRepo) -> None:
|
|
at = dt.datetime(2026, 6, 24, tzinfo=dt.UTC)
|
|
base = dt.date(2021, 1, 1)
|
|
for i, eq in enumerate(_engineered_headline_nav()):
|
|
repo.upsert_nav((base + dt.timedelta(days=i)).isoformat(), capital=100_000.0,
|
|
realized=0.0, unrealized=0.0, equity=eq, at=at)
|
|
|
|
|
|
def test_paper_bybit_renders_headline_card_and_keeps_positions_trades_prose_gone() -> None:
|
|
# The DEPLOY view leads with the SAME headline_card the Overview uses (exact backfill record, Sharpe to
|
|
# 2 decimals with NO tilde), carries a PAPER exec badge, KEEPS the unique live positions + trades, and
|
|
# the old walls of muted prose are GONE.
|
|
binance = _seeded_bybit_paper_repo() # carries bybit_sleeve_ret for the constituent edges
|
|
bybit = PaperRepo("sqlite://", venue="bybit")
|
|
bybit.migrate()
|
|
# A live book (positions + trades) at the backfill's FIRST date, then overwrite that date's nav with the
|
|
# engineered backfill series so nav_history == the engineered series exactly (the headline maths is clean).
|
|
PaperBookService(bybit, capital=100_000.0).derive_and_persist(
|
|
run_date="2021-01-01", sleeve_weights={"positioning": 1.0},
|
|
symbol_weights_by_sleeve={"positioning": {"BTCUSDT": -0.01}},
|
|
prices={"BTCUSDT": 50_000.0}, at=dt.datetime(2026, 6, 24, tzinfo=dt.UTC))
|
|
_seed_engineered_headline(bybit)
|
|
|
|
c = _client_both_repos(binance, bybit, bybit_live=FakeLivePrice({"BTCUSDT": 50_000.0}))
|
|
r = c.get("/paper?venue=bybit")
|
|
assert r.status_code == 200
|
|
# The headline_card (the SAME component the Overview leads with), exact figures.
|
|
assert "headline" in r.text # the headline_card wrapper class
|
|
assert "$394,085" in r.text
|
|
assert "+294%" in r.text
|
|
assert "Sharpe 1.45" in r.text # 2 decimals, the macro format
|
|
assert "~1.45" not in r.text and "~$394,085" not in r.text # NO tilde — it is the real record
|
|
# PAPER exec badge on the deploy book.
|
|
assert "badge-exec paper" in r.text
|
|
# The unique /paper detail is KEPT: the live per-symbol book + recent trades.
|
|
assert "open positions" in r.text
|
|
assert "recent trades" in r.text
|
|
assert "BTCUSDT" in r.text
|
|
# The old walls of muted prose are GONE.
|
|
assert "derived from the daily bybit book rebalance" not in r.text
|
|
assert "precomputed nightly from the survivorship-free Bybit feature store" not in r.text
|
|
assert "backtest only" not in r.text
|
|
|
|
|
|
def test_paper_binance_is_research_framed_with_paper_badge() -> None:
|
|
# The BINANCE view is the dimmed RESEARCH/secondary framing (the SAME wrapper the Overview's research
|
|
# block uses), PAPER-badged, with the old long prose gone.
|
|
c = _client(FakeLivePrice({"USDTUSD": 1.9}))
|
|
r = c.get("/paper?venue=binance")
|
|
assert r.status_code == 200
|
|
assert "Binance paper book" in r.text
|
|
assert "section research" in r.text # the dimmed/secondary wrapper, same as the Overview
|
|
assert "not the fund" in r.text
|
|
assert "badge-exec paper" in r.text # PAPER exec badge
|
|
# the live book is still shown (it's a paper book, just secondary)
|
|
assert "USDTUSD" in r.text
|
|
# the old long prose is GONE
|
|
assert "derived from the daily book rebalance" not in r.text
|
|
assert "no broker execution" not in r.text
|
|
|
|
|
|
def test_paper_both_venues_consistent_one_product() -> None:
|
|
# Both venue views render 200, carry the PAPER badge, and use the SSOT section wrappers (deploy vs
|
|
# research) — the venue switch works and the two pages read as one consistent product.
|
|
c = _client_with_forward(_seeded_bybit_paper_repo(), with_track=True)
|
|
rb = c.get("/paper?venue=bybit")
|
|
rn = c.get("/paper?venue=binance")
|
|
assert rb.status_code == 200 and rn.status_code == 200
|
|
assert "section deploy" in rb.text # bybit = the fund
|
|
assert "section research" in rn.text # binance = research
|
|
assert "badge-exec paper" in rb.text and "badge-exec paper" in rn.text
|
|
# relative-URL venue switch present on both
|
|
for body in (rb.text, rn.text):
|
|
assert 'href="/paper?venue=bybit"' in body and 'href="/paper?venue=binance"' in body
|