Files
fxhnt/tests/unit/test_nav_summary.py
jgrusewski dfe001f969 perf(cockpit): materialize the headline nav-summary (stats + curve) — read 1 row, not 3650, per request
_overview_headline re-read the full ~3650-row bybit paper_nav series and re-ran
nav_backfill_stats + the multi-year curve SVG render on EVERY / and /paper load.
Under shared-DB contention (postgres is shared with dagster/jobs) that read spikes
and pushes the pages over the 1s budget. The stats+curve only change when the nav
changes (nightly).

MATERIALIZATION, not a cache: a new paper_nav_summary table (venue PK) stores the
stats + since/has_record + the pre-rendered curve SVG, versioned by the nav's latest
run_date. refresh_nav_summary computes it ONCE via the EXACT same helpers
(nav_backfill_stats + nav_sparkline), so the stored headline + curve are byte-for-byte
identical to the live compute. The read path runs a cheap 1-row version query (latest
run_date) and serves the stored row when the version matches — no full-nav read — and
self-heals (refreshes, the only full-nav read, at most once per nav change) when the
summary is missing/stale. The nightly nav-write path (build_bybit_paper_book +
bybit-paper-backfill CLI) refreshes proactively so the first request after a nav
update is already fast. ANY failure in the materialized path falls back to the current
live compute, so the headline never 500s and its numbers/curve are never wrong.

Verified byte-identical rendered / and /paper HTML (fast + self-heal paths) vs before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:12:37 +02:00

83 lines
3.6 KiB
Python

"""refresh_nav_summary: MATERIALIZE the bybit headline nav-summary (stats + pre-rendered curve) from the
persisted paper_nav series, keyed by the nav's latest run_date (the version). The stored payload must be
byte-for-byte identical to the live `_overview_headline` compute (same `nav_backfill_stats` + `nav_sparkline`
helpers). In-memory SQLite — no network, no Postgres."""
from __future__ import annotations
import datetime as dt
from fxhnt.adapters.persistence.paper_repo import PaperRepo
from fxhnt.adapters.web.charts import nav_sparkline
from fxhnt.application.nav_stats import nav_backfill_stats
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]
def _seeded_repo(pts: list[float] = _PTS) -> PaperRepo:
repo = PaperRepo("sqlite://", venue="bybit")
repo.migrate()
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 test_refresh_matches_live_stats_and_curve() -> None:
repo = _seeded_repo()
payload = refresh_nav_summary(repo, "bybit")
# Live compute via the EXACT helpers _overview_headline uses, on the same nav.
nav = repo.nav_history()
equities = [p.equity for p in nav]
stats = nav_backfill_stats(equities)
assert payload["final_equity"] == stats.final_equity
assert payload["total_return_pct"] == stats.total_return_pct
assert payload["sharpe"] == stats.sharpe
assert payload["max_dd_pct"] == stats.max_dd_pct
assert payload["curve_svg"] == nav_sparkline(equities, width=600, height=160)
assert payload["since"] == "2021"
assert payload["has_record"] is True
assert payload["nav_run_date"] == nav[-1].run_date
def test_read_back_equals_written_payload() -> None:
repo = _seeded_repo()
written = refresh_nav_summary(repo, "bybit")
read = repo.read_nav_summary("bybit")
assert read is not None
for k in ("nav_run_date", "final_equity", "total_return_pct", "sharpe", "max_dd_pct",
"since", "has_record", "curve_svg"):
assert read[k] == written[k], k
def test_version_is_latest_run_date_and_goes_stale_on_new_nav() -> None:
repo = _seeded_repo()
refresh_nav_summary(repo, "bybit")
# Fresh: stored version == the live latest run_date.
assert repo.read_nav_summary("bybit")["nav_run_date"] == repo.latest_nav_run_date()
# A new nav day → the stored summary is now stale (its version no longer matches).
repo.upsert_nav("2024-01-01", capital=100_000.0, realized=0.0, unrealized=0.0, equity=420_000.0, at=_AT)
assert repo.read_nav_summary("bybit")["nav_run_date"] != repo.latest_nav_run_date()
# Re-refresh re-matches and picks up the new final equity.
refresh_nav_summary(repo, "bybit")
assert repo.read_nav_summary("bybit")["nav_run_date"] == repo.latest_nav_run_date()
assert repo.read_nav_summary("bybit")["final_equity"] == 420_000.0
def test_empty_nav_is_zeroed_summary() -> None:
repo = PaperRepo("sqlite://", venue="bybit")
repo.migrate()
payload = refresh_nav_summary(repo, "bybit")
assert payload["nav_run_date"] == ""
assert payload["since"] == ""
assert payload["has_record"] is False
assert payload["curve_svg"] == ""
assert payload["final_equity"] == 0.0
def test_missing_summary_reads_none() -> None:
repo = _seeded_repo()
assert repo.read_nav_summary("bybit") is None # nothing stored until refresh runs