Files
fxhnt/tests/integration/test_web_overview_nav.py
jgrusewski e077521571 fix(gate): dispersion-adaptive recon band + 21d window for lumpy bybit_4edge
The 4-edge book's gains arrive in irregular clusters (big +days a median ~17 /
mean ~36 days apart), so a fixed 14-day cumulative-return band trips a false
"diverging (below expected)" WAIT ~15% of the time purely by spike-timing — the
edge is fine, no spike just landed in the window.

reconciliation_gate: make the shortfall band dispersion-adaptive —
  band = max(tol_frac*|expected|, min_band, RECON_DISPERSION_K * std(fwd)*sqrt(days))
with RECON_DISPERSION_K=2.0 (a shortfall must exceed ~2 window-sigma to count as
divergence; the historical dry/lumpy 14d 5th-pct ~-2.5% ≈ 1.65-sigma is absorbed).
The max() guarantees the band NEVER narrows below the old fixed floor — a steady
low-dispersion bleed still trips exactly as before, and the daily-return correlation
sub-check (RECON_MIN_CORR=0) remains the primary mirage-catcher, unaffected by
lumpiness.

registry: bump bybit_4edge min_forward_days 14 -> 21 (that book ONLY); a lumpy edge
needs a slightly longer window (median ~17d between spikes) to be representative.

Tests: 3 new safety cases (no false-negative on a lumpy-but-real window; mirage-catch
intact on an anti-correlated lumpy window; band never narrows below fixed + a true
sustained-shortfall+neg-corr mirage still WAITs). Updated the e2e + forward-ingest +
web tests for the 21-day bybit_4edge window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:13:25 +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://", venue="bybit")
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)