Files
fxhnt/tests/integration/test_web_overview_nav.py
jgrusewski 375cd108bc feat(0b): remove the vestigial dual-venue PaperRepo abstraction (Task 7f)
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>
2026-07-14 15:20:57 +02:00

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)