Task 7e only renamed venue="binance" -> "legacy" to pass the substring guard; the dual-venue repo machinery was left in place even though bybit is the ONLY live venue. PaperRepo now hardcodes every discriminated read/write to a _BYBIT_VENUE = "bybit" module constant instead of a constructor venue param (removed) -- reads/writes ONLY the bybit rows, so the orphaned legacy/binance rows from the retired combined-crypto book (Task 7d) are never touched. nav_summary's venue arg is dropped the same way (every caller always passed "bybit"). cockpit_models keeps the venue column (dropping it from the PK is a separate prod schema migration, documented in the report for the merge runbook) with reworded comments reflecting the single-venue reality. Test fallout: deleted test_paper_repo_venue.py (tested the now-removed dual-venue isolation) and 3 "does-not-touch-binance" tests that constructed PaperRepo(venue="binance"); mechanically dropped the venue kwarg everywhere else. Added a guard assertion that PaperRepo.__init__ has no venue param. Full suite green (1853 passed); binance substring guard still 0 hits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
188 lines
8.1 KiB
Python
188 lines
8.1 KiB
Python
"""Cockpit Overview redesign: the fund headline is CONNECTED to the bybit book's multi-year BACKFILL record
|
|
(real Sharpe/return, never a not-started 0.00), short pre-gate forward Sharpes are kept but visibly DIMMED,
|
|
the clutter (prose paragraphs + duplicate footer nav) is gone, and every page stays leak-clean (relative
|
|
URLs only) so the nginx tailnet proxy never exposes the internal address. All seeded in-memory — no DB, no
|
|
net."""
|
|
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
|
|
from fxhnt.application.forward_models import BacktestSummary
|
|
from fxhnt.application.forward_models import ForwardNavRow as Row
|
|
from fxhnt.application.forward_models import ForwardSummary
|
|
|
|
# Every nav link in base.html. Each must resolve 200 (forward track empty is fine — routes degrade).
|
|
_NAV_LINKS = ["/", "/paper", "/paper/replay", "/paper/sim"]
|
|
|
|
|
|
def _bybit_backfill_repo() -> PaperRepo:
|
|
"""A bybit-venue PaperRepo seeded with a multi-year BACKFILL nav series ending at $393,955 (+294% off
|
|
$100k). In-memory shared cache so the same DB is visible to the app's own repo handle."""
|
|
repo = PaperRepo("sqlite://")
|
|
repo.migrate()
|
|
at = dt.datetime(2026, 6, 24, 12, 0)
|
|
# A coarse multi-year climb 100k -> 393,955 (monotone-ish with one dip so maxDD is non-zero).
|
|
pts = [100_000.0, 140_000.0, 120_000.0, 210_000.0, 300_000.0, 360_000.0, 393_955.0]
|
|
for i, eq in enumerate(pts):
|
|
rd = dt.date(2021, 1, 1) + dt.timedelta(days=120 * i)
|
|
repo.upsert_nav(rd.isoformat(), capital=100_000.0, realized=0.0, unrealized=0.0,
|
|
equity=eq, at=at)
|
|
return repo
|
|
|
|
|
|
def _client(*, seed_headline: bool = True, seed_backfill: bool = True) -> TestClient:
|
|
repo = ForwardNavRepo("sqlite://")
|
|
repo.migrate()
|
|
if seed_headline:
|
|
at = dt.datetime(2026, 6, 24, 12, 0)
|
|
# A SHORT (pre-gate) bybit_4edge forward track: 8 days < the 21-day reconciliation gate window, so
|
|
# its forward Sharpe is statistically provisional and must render DIMMED.
|
|
rows = [Row("bybit_4edge", f"2026-06-{5 + i:02d}", 0.004, 1.0 + 0.004 * (i + 1)) for i in range(8)]
|
|
repo.upsert_rows(rows, at)
|
|
repo.upsert_summary(
|
|
ForwardSummary("bybit_4edge", "2026-06-12", 8, 1.032, 0.032, 1.4, -0.02),
|
|
"WAIT", "building (8/21 days)", at)
|
|
repo.upsert_backtest_summary(BacktestSummary(
|
|
"bybit_4edge", "2026-05-30", cagr=0.30, ann_vol=0.20, sharpe=1.5, max_drawdown=-0.16,
|
|
passed=True, dsr=0.7, is_sharpe=1.6, oos_sharpe=1.3, pvalue=0.01), at)
|
|
bybit_repo = _bybit_backfill_repo() if seed_backfill else None
|
|
return TestClient(create_app(repo, bybit_paper_repo=bybit_repo))
|
|
|
|
|
|
# ---- single tidy nav (no grouped Live/Backtest labels, no duplicate footer) ---------------------------
|
|
|
|
def test_nav_is_single_tidy_row() -> None:
|
|
r = _client().get("/")
|
|
assert r.status_code == 200
|
|
for label in ("Overview", "Paper", "Replay", "Backtest"):
|
|
assert label in r.text, f"nav label {label!r} missing"
|
|
|
|
|
|
def test_every_nav_link_resolves() -> None:
|
|
c = _client()
|
|
for href in _NAV_LINKS:
|
|
assert c.get(href).status_code == 200, f"nav link {href} did not resolve"
|
|
|
|
|
|
def test_no_duplicate_footer_nav() -> None:
|
|
# The old "Go to" footer (a second copy of the Paper/Replay/Backtest links) is removed.
|
|
assert "Go to" not in _client().get("/").text
|
|
|
|
|
|
def test_nav_is_hx_boosted() -> None:
|
|
# The nav bar is hx-boost'ed so links swap the body via htmx (SPA-smooth) instead of full page loads.
|
|
# Verified in a real browser: the per-page IIFEs (replay scrubber, sim form/result) re-init after a
|
|
# boosted swap because htmx 1.x runs inline <script> tags in swapped content.
|
|
txt = _client().get("/").text
|
|
assert '<nav class="bar" hx-boost="true">' in txt
|
|
|
|
|
|
# ---- headline connected to the bybit BACKFILL record --------------------------------------------------
|
|
|
|
def test_headline_shows_backfill_record_not_zero() -> None:
|
|
r = _client().get("/")
|
|
assert r.status_code == 200
|
|
# The COMPOUNDED backfill, not the not-started forward track: $393,955 / +294% / a REAL Sharpe.
|
|
assert "393,955" in r.text
|
|
assert "+294%" in r.text or "+294.0%" in r.text or "+293.96%" in r.text
|
|
# Never lead with the not-started forward Sharpe / 0 forward days.
|
|
assert "0 forward days" not in r.text
|
|
assert "Sharpe 0.00" not in r.text and "Sharpe ~0.00" not in r.text
|
|
|
|
|
|
def test_headline_renders_backfill_curve() -> None:
|
|
r = _client().get("/")
|
|
assert "<svg" in r.text and "<polyline" in r.text # the multi-year nav curve, inline SVG, no JS lib
|
|
|
|
|
|
def test_headline_shows_live_gate_chip() -> None:
|
|
r = _client().get("/")
|
|
# The small secondary readiness chip: gate days/min + status (the forward track is what is WAITing).
|
|
assert "8/21" in r.text and "WAIT" in r.text
|
|
|
|
|
|
def test_headline_degrades_without_backfill() -> None:
|
|
# No backfill seeded: home must still render (never 500) and not invent a record.
|
|
r = _client(seed_backfill=False).get("/")
|
|
assert r.status_code == 200
|
|
assert "Fund status" in r.text
|
|
|
|
|
|
# ---- provisional (pre-gate) forward Sharpes are kept but DIMMED ---------------------------------------
|
|
|
|
def test_pregate_forward_sharpe_is_dimmed_provisional() -> None:
|
|
r = _client().get("/")
|
|
assert r.status_code == 200
|
|
# The pre-gate marker is present (kept "to watch" but visibly not authoritative) and a caption explains it.
|
|
assert "pre-gate" in r.text
|
|
assert "provisional" in r.text.lower()
|
|
|
|
|
|
def test_edges_table_present_with_links() -> None:
|
|
r = _client().get("/")
|
|
# The SSOT console groups deploy (the fund) and research (not the fund); both render fleet tables with
|
|
# /strategy links.
|
|
assert "Deploy" in r.text and "Research" in r.text
|
|
assert 'class="fleet"' in r.text
|
|
assert "/strategy/" in r.text
|
|
|
|
|
|
# ---- declutter: the internal "class <sleeve>" dev row is gone, Research is collapsed ------------------
|
|
|
|
def test_no_class_sleeve_dev_row() -> None:
|
|
# The edge cards used to render the internal sleeve id as "class <sleeve_name>" (on mobile the fleet
|
|
# table reflows to cards and the data-label="class" cell read e.g. "class crypto_tstrend"). That's dev
|
|
# info, not user info — removed. The human title + real metrics (Sharpe/return/maxDD) stay.
|
|
txt = _client().get("/").text
|
|
assert 'data-label="class"' not in txt
|
|
assert ">class<" not in txt
|
|
# the real metrics + human-facing structure remain
|
|
assert "Sharpe" in txt and "maxDD" in txt
|
|
|
|
|
|
def test_research_section_is_collapsed_details() -> None:
|
|
# The long "Research — not the fund" block is collapsible (collapsed by default) so the main fund
|
|
# overview isn't a wall of experimental edges; the content is intact inside the <details>.
|
|
txt = _client().get("/").text
|
|
assert "<details>" in txt
|
|
# the summary clearly labels the collapsed section
|
|
assert "<summary" in txt
|
|
assert "not the fund (experimental edges)" in txt
|
|
# the research table is still inside (just collapsed), not deleted
|
|
assert 'id="research-fleet"' in txt
|
|
|
|
|
|
# ---- clutter cut: the two prose paragraphs are gone ---------------------------------------------------
|
|
|
|
def test_prose_paragraphs_removed() -> None:
|
|
txt = _client().get("/").text
|
|
assert "Forward track not started yet" not in txt
|
|
assert "is a Phase-1 paper-forward verdict" not in txt
|
|
assert "too little history to chart" not in txt
|
|
|
|
|
|
# ---- leak-clean guard (protects the nginx tailnet proxy) ----------------------------------------------
|
|
|
|
_ABS_URL = re.compile(r"https?://(?!www\.w3\.org/)[^\s\"'<>]+")
|
|
|
|
|
|
def _assert_leak_clean(html: str) -> None:
|
|
hits = _ABS_URL.findall(html)
|
|
assert not hits, f"leak-clean violation: absolute URLs in rendered HTML: {hits}"
|
|
|
|
|
|
def test_home_is_leak_clean() -> None:
|
|
_assert_leak_clean(_client().get("/").text)
|
|
|
|
|
|
def test_base_chrome_is_leak_clean() -> None:
|
|
c = _client()
|
|
for href in _NAV_LINKS:
|
|
_assert_leak_clean(c.get(href).text)
|