Files
fxhnt/tests/integration/test_overview_headline_materialized.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

135 lines
5.9 KiB
Python

"""The Overview/`/paper` headline is MATERIALIZED: a fresh `paper_nav_summary` row is served from a 1-row
read (NOT the full ~3650-row paper_nav), the read path self-heals when the summary is missing/stale, and ANY
failure in the materialized path falls back to the live compute (never a 500, numbers/curve never wrong). The
rendered headline numbers + curve are identical to the live compute on the same data. All in-memory — no DB,
no network."""
from __future__ import annotations
import datetime as dt
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
from fxhnt.application.nav_summary import refresh_nav_summary
_AT = dt.datetime(2026, 6, 24, 12, 0)
_PTS = [100_000.0, 140_000.0, 120_000.0, 210_000.0, 300_000.0, 360_000.0, 393_955.0]
class _SpyRepo(PaperRepo):
"""A bybit PaperRepo that counts full-nav reads, so a test can assert the fast path never reads the
~3650-row series. Optionally forces `read_nav_summary` to raise (the fallback test)."""
def __init__(self, dsn: str, *, raise_on_read: bool = False) -> None:
super().__init__(dsn)
self.nav_history_calls = 0
self._raise_on_read = raise_on_read
def nav_history(self): # type: ignore[override]
self.nav_history_calls += 1
return super().nav_history()
def read_nav_summary(self): # type: ignore[override]
if self._raise_on_read:
raise RuntimeError("boom")
return super().read_nav_summary()
def _seed_bybit(repo: PaperRepo) -> None:
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)
def _forward_repo() -> ForwardNavRepo:
repo = ForwardNavRepo("sqlite://")
repo.migrate()
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/14 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)
return repo
def test_fresh_summary_served_without_full_nav_read() -> None:
bybit = _SpyRepo("sqlite://")
bybit.migrate()
_seed_bybit(bybit)
refresh_nav_summary(bybit) # pre-materialize (the proactive nightly hook)
bybit.nav_history_calls = 0 # reset; the fast path must not touch the full nav
client = TestClient(create_app(_forward_repo(), bybit_paper_repo=bybit))
r = client.get("/")
assert r.status_code == 200
assert "393,955" in r.text # the real record, from the stored summary
assert "<polyline" in r.text # the pre-rendered curve, from the stored summary
assert bybit.nav_history_calls == 0, "fast path read the full nav instead of the 1-row summary"
def test_missing_summary_self_heals() -> None:
bybit = _SpyRepo("sqlite://")
bybit.migrate()
_seed_bybit(bybit) # nav present, NO summary stored yet
client = TestClient(create_app(_forward_repo(), bybit_paper_repo=bybit))
r = client.get("/")
assert r.status_code == 200
assert "393,955" in r.text # self-heal refreshed + rendered the real record
# The summary is now stored + versioned to the live latest run_date (so the next request is fast).
stored = bybit.read_nav_summary()
assert stored is not None and stored["nav_run_date"] == bybit.latest_nav_run_date()
def test_stale_summary_is_refreshed_not_served() -> None:
bybit = _SpyRepo("sqlite://")
bybit.migrate()
_seed_bybit(bybit)
# A STALE summary: an old version + deliberately WRONG numbers. The read path must detect the version
# mismatch and refresh, so the page shows the REAL current record — never the stale stored numbers.
bybit.upsert_nav_summary({
"nav_run_date": "1999-01-01", "final_equity": 7.0, "total_return_pct": 0.0, "sharpe": 0.0,
"max_dd_pct": 0.0, "since": "1999", "has_record": True, "curve_svg": "<svg></svg>"}, at=_AT)
client = TestClient(create_app(_forward_repo(), bybit_paper_repo=bybit))
r = client.get("/")
assert r.status_code == 200
assert "393,955" in r.text
assert "$7" not in r.text # the stale wrong number is NOT served
assert bybit.read_nav_summary()["nav_run_date"] == bybit.latest_nav_run_date()
def test_exception_falls_back_to_live_compute() -> None:
bybit = _SpyRepo("sqlite://", raise_on_read=True) # the materialized path always throws
bybit.migrate()
_seed_bybit(bybit)
client = TestClient(create_app(_forward_repo(), bybit_paper_repo=bybit))
r = client.get("/")
assert r.status_code == 200 # never a 500
assert "393,955" in r.text # live compute still shows the real record
assert "<polyline" in r.text
assert bybit.nav_history_calls >= 1 # the fallback read the nav live
def test_paper_and_overview_show_the_same_headline() -> None:
bybit = _SpyRepo("sqlite://")
bybit.migrate()
_seed_bybit(bybit)
refresh_nav_summary(bybit)
client = TestClient(create_app(_forward_repo(), bybit_paper_repo=bybit))
home = client.get("/")
paper = client.get("/paper?venue=bybit")
assert home.status_code == 200 and paper.status_code == 200
for needle in ("393,955", "<polyline"):
assert needle in home.text and needle in paper.text