_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>
83 lines
3.6 KiB
Python
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
|