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>
135 lines
5.9 KiB
Python
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
|