"""FastAPI composition root for the cockpit. Read-only, server-rendered (Jinja2 + HTMX). create_app(repo)
takes the repo so tests can inject a seeded in-memory DB; create_app() builds it from settings."""
from __future__ import annotations

import datetime as dt
import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any

from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
from fxhnt.adapters.web.charts import equity_curve, nav_sparkline, overlay_curve
from fxhnt.application.backtest_refs import registry_backtest_kind
from fxhnt.application.dashboard_service import DashboardService
from fxhnt.application.display_names import display_name
from fxhnt.application.paper_book import PaperBookService
from fxhnt.application.sim_curves import SIM_BOOKS, sim_returns_for
from fxhnt.ports.dashboard import DashboardReadModel
from fxhnt.ports.live_price import LivePrice
from fxhnt.registry import STRATEGY_REGISTRY

if TYPE_CHECKING:
    from fxhnt.adapters.persistence.paper_repo import PaperRepo

_HERE = Path(__file__).parent
_log = logging.getLogger(__name__)


def create_app(repo: ForwardNavRepo, svc: DashboardReadModel | None = None,
               paper_repo: PaperRepo | None = None,
               bybit_paper_repo: PaperRepo | None = None,
               bybit_live: LivePrice | None = None,
               feature_store: Any | None = None,
               ibkr_account_repo: IbkrAccountRepo | None = None) -> FastAPI:
    app = FastAPI(title="fxhnt cockpit")
    # gzip ALL responses (HTML + the big inline-SVG curve payloads + htmx.js): typically ~85% smaller on the
    # wire (e.g. /paper 56KB -> ~8KB, /paper/sim/run 131KB -> ~15KB). Compression, NOT caching.
    app.add_middleware(GZipMiddleware, minimum_size=500)
    app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static")
    templates = Jinja2Templates(directory=str(_HERE / "templates"))
    # Thousands-grouped formatters (Jinja's `%`-based `format` filter can't do `,` grouping).
    templates.env.filters["money"] = lambda x: f"{x:,.0f}"
    templates.env.filters["money_signed"] = lambda x: f"{x:+,.0f}"
    templates.env.filters["qty"] = lambda x: f"{x:,.4f}"
    # The plain-language global constraint: every template reaches for `display_name(id)` instead of ever
    # printing a raw registry id — a single Jinja global so no template needs its own import.
    templates.env.globals["display_name"] = display_name
    svc = svc or DashboardService(repo, ibkr_account_repo)

    def _paper_view() -> Any:
        """Build the LIVE Bybit paper read model — the cockpit's only paper-book venue (the legacy venue
        surface was removed from the web layer in Task 7a; the 4-edge Bybit book is the fund). The LivePrice
        adapter never raises, but guard the whole path so a missing/empty paper book or price source degrades
        to an empty view (never 500)."""
        from fxhnt.config import get_settings
        capital = get_settings().paper_capital
        _repo = bybit_paper_repo
        if _repo is None:
            from fxhnt.adapters.persistence.paper_repo import PaperRepo
            _repo = PaperRepo(get_settings().operational_dsn)
            _repo.migrate()
        _live = bybit_live
        if _live is None:
            # Mark the bybit book to the LATEST bybit-perp close in the warehouse (read-only, bounded to
            # the open book's symbols) — the real Bybit mark, no network.
            from fxhnt.adapters.exchange.warehouse_live_price import WarehouseLivePrice
            from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
            _live = WarehouseLivePrice(
                TimescaleFeatureStore(get_settings().operational_dsn, table="bybit_features"))
        return PaperBookService(_repo, capital).view(_live)

    def _now() -> str:
        return dt.datetime.now(dt.UTC).strftime("%Y-%m-%d %H:%M UTC")

    @app.get("/healthz")
    def healthz() -> dict[str, str]:
        repo.all_summaries()                       # touches the DB; fails loudly if unreachable
        return {"status": "ok"}

    def _overview_headline(nav: list[Any] | None = None) -> tuple[dict[str, Any], str]:
        """The CONNECTED fund headline: the bybit deploy book's multi-year BACKFILL record (Sharpe / total
        return / maxDD / final equity from the persisted paper_nav series) + a small live-readiness chip (the
        forward gate days/min · status). The backfill is the REAL record; the gate chip is the readiness. So
        the page NEVER leads with the not-started forward track's 0.00 / 0 forward days. Returns the headline
        context dict + the pre-rendered multi-year curve SVG (empty when no backfill is persisted yet).

        PERF: the nav-derived stats + curve are MATERIALIZED (see `refresh_nav_summary` /
        `paper_nav_summary`): a fresh stored summary is served from ONE 1-row read, never the full ~3650-row
        nav, and is byte-for-byte identical to the live compute. Only the forward readiness chip + title stay
        live (a cheap forward_summary read that changes independently of the nav). On ANY failure this falls
        back to the live compute (using the passed-in `nav` when provided), so the headline NEVER 500s and its
        numbers/curve are NEVER wrong.

        `nav` may be passed in by a caller that already read the bybit nav series (the /paper page does); it is
        used ONLY by the live-compute fallback."""

        def _headline(summary: dict[str, Any], curve: str) -> tuple[dict[str, Any], str]:
            # The live forward readiness for the SAME book (days/min · status) + the title — the secondary
            # chip, not the lead. Cheap (a forward_summary single-row read), and it changes independently of
            # the nav, so it is NOT materialized; only the nav-derived stats + curve are.
            fwd = _forward_track("bybit_4edge")
            gate = {"days": fwd["days"] if fwd else 0,
                    "min_days": fwd["gate_min_days"] if fwd else 0,
                    "status": fwd["gate_status"] if fwd else "WAIT"}
            headline = {
                "title": (fwd["display_name"] if fwd else "Bybit book"),
                "has_record": summary["has_record"],
                "final_equity": summary["final_equity"], "total_return_pct": summary["total_return_pct"],
                "sharpe": summary["sharpe"], "max_dd_pct": summary["max_dd_pct"],
                "since": summary["since"], "gate": gate,
            }
            return headline, curve

        # FAST PATH (materialized): cheap version query (the nav's latest run_date — 1 row, NOT the ~3650-row
        # series), then read the stored summary. When its version matches the live latest run_date, serve it
        # WITHOUT reading the full nav; else refresh once (self-heal, the only full-nav read, at most once per
        # nav change). Any failure → the live compute below (never 500, numbers/curve never wrong).
        try:
            from fxhnt.application.nav_summary import refresh_nav_summary

            repo = _venue_repo()
            latest_rd = repo.latest_nav_run_date() or ""
            summary = repo.read_nav_summary()
            if summary is None or summary.get("nav_run_date") != latest_rd:
                summary = refresh_nav_summary(repo)
            return _headline(summary, summary["curve_svg"])
        except Exception:  # noqa: BLE001 — materialization must never 500 / show wrong numbers; live-compute below
            _log.warning("overview headline: materialized summary unavailable, computing live", exc_info=True)

        from fxhnt.application.nav_stats import nav_backfill_stats

        nav = nav if nav is not None else _nav_history()  # reuse the caller's read when provided
        equities = [p.equity for p in nav]
        stats = nav_backfill_stats(equities)
        live_summary = {
            "has_record": len(equities) >= 2,
            "final_equity": stats.final_equity, "total_return_pct": stats.total_return_pct,
            "sharpe": stats.sharpe, "max_dd_pct": stats.max_dd_pct,
            "since": nav[0].run_date[:4] if nav else "",   # the backfill's first year, e.g. "2021"
        }
        curve = nav_sparkline(equities, width=600, height=160) if len(equities) > 1 else ""
        return _headline(live_summary, curve)

    # The headline DEPLOY book — the one strategy whose total IS the fund headline card (so it is not also
    # rendered as one of its own constituent rows). Everything else is grouped purely by the registry `tier`.
    _HEADLINE_BOOK = "bybit_4edge"

    def _deploy_individual_edges() -> list[dict[str, Any]]:
        """The DEPLOY book's constituent edges — the per-sleeve breakdown of the Bybit 4-edge book (each its
        own standalone Bybit vol-targeted metric). Reuses `_sleeve_breakdown` (the same precomputed
        bybit_sleeve_ret basis the detail page uses) — no recompute. Guarded: a missing/empty precomputed
        table degrades to [] (the headline + deploy track rows still render) — never 500.

        The display name comes from `_SLEEVE_DISPLAY_NAMES` (bybit_book_eval), NOT STRATEGY_REGISTRY: the 4
        book sleeves are a namespace of their own, separate from the forward-track strategy_id registry, so
        retiring a standalone registry entry that happens to share a sleeve's string id (e.g. crypto_tstrend,
        Phase 0b Task 4) can never blank a still-live sleeve's label. `linkable`/`strategy_id` instead signal
        whether THIS sleeve id also happens to be a real registry entry with a working `/strategy/{id}` page
        (unlock/xsfunding/positioning today) — the template renders the link only when `linkable` is True, so
        it never has to guess."""
        try:
            rows = _sleeve_breakdown(_HEADLINE_BOOK)
        except Exception:  # noqa: BLE001 — a missing/empty precomputed table must never 500 the overview
            return []
        from fxhnt.application.bybit_book_eval import _SLEEVE_DISPLAY_NAMES

        out: list[dict[str, Any]] = []
        for r in rows:
            sid = r["sleeve"]
            out.append({**r, "strategy_id": sid,
                        "display_name": _SLEEVE_DISPLAY_NAMES.get(sid, sid),
                        "linkable": sid in STRATEGY_REGISTRY})
        return out

    @app.get("/", response_class=HTMLResponse)
    def cockpit(request: Request) -> HTMLResponse:
        """At-a-glance OVERVIEW, rendered strictly from the registry SSOT and grouped by `tier`. DEPLOY first
        (the fund): the Bybit 4-edge book's CONNECTED backfill headline (real Sharpe/return/maxDD — never a
        not-started 0.00) + its individual constituent edges + the other deploy-tier tracks, each PAPER-badged.
        RESEARCH below, dimmed ('not the fund'): every research-tier tracker grouped by venue. Adding a
        registry entry makes it appear here automatically — no hardcoded ordering. Reuses svc.fleet() +
        _sleeve_breakdown + the same nav_sparkline the other routes use; no new edge metric is computed."""
        histories = repo.all_nav_histories()

        def spark(sid: str) -> str:
            return nav_sparkline([p.nav for p in histories.get(sid, [])])

        headline, headline_curve = _overview_headline()
        fleet = svc.fleet()
        overview = svc.overview(fleet)
        # The deployment meter's denominator is the FUND'S capital (fund_config SSOT, falling back to
        # settings.paper_capital) — NOT overview.ceiling, which is the separate allocation-engine
        # gross-headroom cap ($1M paper / $35k live human gate). Mixing the two understated the fund's
        # deployment % (e.g. $75k/$1M = 7%) against every other surface's honest $75k/$100k = 75%.
        from fxhnt.application.fund_config import read_fund_config
        from fxhnt.config import get_settings
        fund_capital = read_fund_config(get_settings().operational_dsn).capital
        twin_sids = svc.exec_twin_sids()
        # the executed twins are research-tier; they nest under their modeled parent, so exclude them from BOTH
        # the top-level "all forward tracks" list AND the research grouping (never a separate row).
        fleet = [f for f in fleet if f.strategy_id not in twin_sids]
        fleet_top = fleet
        # Pure registry-tier grouping (no hardcoded list). DEPLOY = the fund; its headline book total is the
        # headline card, so it is excluded from the deploy track rows (its constituents are shown instead).
        deploy_rows = [f for f in fleet if f.tier == "deploy" and f.strategy_id != _HEADLINE_BOOK]
        individual_edges = _deploy_individual_edges()
        # RESEARCH grouped by venue, venues in first-seen registry order (stable, no hardcoded ordering).
        research_groups: list[tuple[str, list[Any]]] = []
        by_venue: dict[str, list[Any]] = {}
        for f in fleet:
            if f.tier != "research":
                continue
            if f.venue not in by_venue:
                by_venue[f.venue] = []
                research_groups.append((f.venue, by_venue[f.venue]))
            by_venue[f.venue].append(f)
        return templates.TemplateResponse(request, "cockpit.html",
                                          {"headline": headline, "headline_curve": headline_curve,
                                           "deploy_rows": deploy_rows, "individual_edges": individual_edges,
                                           "research_groups": research_groups, "fleet": fleet,
                                           "overview": overview, "fleet_top": fleet_top,
                                           "fund_capital": fund_capital,
                                           "spark": spark, "now": _now()})

    # Book-type strategies whose detail page shows a per-sleeve backtest breakdown (the constituents +
    # each standalone Sharpe/return), computed from the precomputed bybit_sleeve_ret table — NEVER recomputed
    # on the request path. Single-sleeve trackers (positioning, xvenue_carry, ...) get no breakdown.
    _BOOK_STRATEGIES = frozenset({"bybit_4edge"})

    def _sleeve_breakdown(strategy_id: str) -> list[dict[str, Any]]:
        """The per-sleeve STANDALONE, VOL-TARGETED backtest metrics for a book-type strategy, from the
        precomputed bybit_sleeve_ret table (the same `_sim_returns('bybit_4edge')` the sim reads). Each entry:
        {sleeve, sharpe, ann_return_pct, max_dd_pct}. The RAW cumulative return was misleading (unsized, and
        sleeves span different histories so the totals aren't comparable); each series is instead scaled to a
        common 20% annual vol then annualised (≈ Sharpe × 0.20) with a maxDD on that consistent basis. The
        book itself combines the sleeves naive eq-wt. Read-only; restricted to the 4 book constituents so an
        auxiliary reference series (e.g. xvenue_carry) in the same table never leaks into the breakdown."""
        if strategy_id not in _BOOK_STRATEGIES:
            return []
        from fxhnt.application.bybit_book_eval import _DEFAULT_BYBIT_SLEEVES, vol_targeted_metrics

        returns = _sim_returns("bybit_4edge")   # {sleeve: {epoch_day: ret}} — precomputed, cached
        rows: list[dict[str, Any]] = []
        for sleeve in _DEFAULT_BYBIT_SLEEVES:
            series = returns.get(sleeve)
            if not series or len(series) < 2:
                continue
            m = vol_targeted_metrics(series)
            rows.append({"sleeve": sleeve, "sharpe": m["sharpe"],
                         "ann_return_pct": 100.0 * m["ann_return"],
                         "max_dd_pct": 100.0 * m["max_dd"]})
        return rows

    # Single-edge strategies whose precomputed STANDALONE backtest lives in the bybit_sleeve_ret table.
    # Maps strategy_id -> the sleeve key to read. positioning has a young forward track, so its
    # detail page would otherwise show only "0 forward days · +0.00%" — this surfaces the real backtest edge.
    _BACKTEST_REFERENCE_SLEEVE = {
        "positioning": "positioning",
    }

    def _backtest_reference(strategy_id: str) -> dict[str, Any] | None:
        """The single-edge STANDALONE backtest reference (vol-targeted @ 20%, the SAME basis as the book
        sleeve breakdown): {sharpe, ann_return_pct, max_dd_pct, vol_target_pct}. Read from the precomputed
        bybit_sleeve_ret table — NO recompute on the request path. None when the strategy has no backtest
        source or the series is too short (the section is simply omitted, never an error)."""
        sleeve = _BACKTEST_REFERENCE_SLEEVE.get(strategy_id)
        if sleeve is None:
            return None
        from fxhnt.application.bybit_book_eval import vol_targeted_metrics

        series = _sim_returns("bybit_4edge").get(sleeve)
        if not series or len(series) < 2:
            return None
        m = vol_targeted_metrics(series)
        return {"sharpe": m["sharpe"], "ann_return_pct": 100.0 * m["ann_return"],
                "max_dd_pct": 100.0 * m["max_dd"], "vol_target_pct": 100.0 * m["vol_target"]}

    @app.get("/strategy/{strategy_id}", response_class=HTMLResponse)
    def strategy(request: Request, strategy_id: str) -> HTMLResponse:
        d = svc.detail(strategy_id)
        if d is None:
            raise HTTPException(status_code=404, detail="unknown strategy")
        curve = nav_sparkline([p.nav for p in d.history], width=600, height=160)
        exec_slippage_bps = None
        if d.exec_twin_sid == "bybit_4edge_exec":
            try:
                from fxhnt.adapters.persistence.bybit_testnet_repo import BybitTestnetRepo
                from fxhnt.config import get_settings
                nav = BybitTestnetRepo(get_settings().operational_dsn).latest_testnet_nav()
                exec_slippage_bps = nav.mean_exec_slippage_bps if nav else None
            except Exception:  # noqa: BLE001 — slippage is a nice-to-have; never break the detail page
                exec_slippage_bps = None
        # D2.2/2.3 — the REAL IBKR paper-account "value over time" curve: the account's real NLV series with
        # the modeled plan (d.history's own nav) overlaid dashed. Only when the account has captured data.
        account_curve = ""
        if d.account_nav:
            account_curve = overlay_curve([nlv for _, nlv in d.account_nav], [p.nav for p in d.history],
                                          width=600, height=160)
        return templates.TemplateResponse(request, "strategy.html",
                                          {"d": d, "curve": curve, "account_curve": account_curve,
                                           "sleeve_breakdown": _sleeve_breakdown(strategy_id),
                                           "backtest_reference": _backtest_reference(strategy_id),
                                           "exec_slippage_bps": exec_slippage_bps,
                                           "now": _now()})

    @app.get("/paper/crypto", response_class=HTMLResponse)
    def paper_crypto(request: Request) -> HTMLResponse:
        # The Bybit paper book's OWN holdings detail (D4, Task 5 moved this off the consolidated /paper
        # landing onto its own card's target — the DATA/BEHAVIOR here is UNCHANGED from the former /paper,
        # only its URL). The cockpit's only crypto venue (the legacy venue toggle was removed in Task 7a; any
        # stray `?venue=` query param is simply ignored). The page mirrors the Overview's SSOT pattern:
        # the SAME `headline_card` the Overview leads with + the unique live per-symbol book/trades + the
        # individual constituent edges + the forward reconciliation gate, all PAPER-badged. NEVER any
        # per-coin / warehouse compute on this path.
        view = _paper_view()
        # LIGHT read: the persisted bybit nav series (one indexed query, ~2000 rows). The live block leads
        # with the COMPOUNDED multi-year record (the latest nav equity); the live mark-to-market is kept as
        # a secondary detail.
        nav_history = _nav_history()
        # The DEPLOY fund: the SAME connected-backfill headline the Overview leads with (real
        # Sharpe/return/maxDD — never a not-started 0.00) + the per-sleeve constituent edges + the forward
        # reconciliation gate. Reuses the Overview's own helpers — one consistent product. Pass nav_history
        # in (it IS the bybit nav for this page) so the headline doesn't re-read it.
        headline, headline_curve = _overview_headline(nav_history)
        ctx: dict[str, Any] = {"v": view,
                               "nav": _nav_headline(nav_history, view), "now": _now(),
                               "headline": headline, "headline_curve": headline_curve,
                               "individual_edges": _deploy_individual_edges(),
                               "forward": _forward_track("bybit_4edge")}
        return templates.TemplateResponse(request, "paper_crypto.html", ctx)

    @app.get("/paper", response_class=HTMLResponse)
    def paper_books(request: Request) -> HTMLResponse:
        """D4 (Task 5) — the consolidated "Paper books" landing: BOTH paper books as clickable summary
        cards — the crypto 4-edge book (simulated, `PAPER`) linking to `/paper/crypto`, and the IBKR
        paper account (also `PAPER`) linking to its holdings detail (`/strategy/multistrat`, Task 3's
        `strategy.html`). The crypto card reuses the SAME connected-backfill headline the Overview/
        `/paper/crypto` lead with (no duplicate compute); the IBKR card reuses `svc.paper_books()` — the
        already-computed `IbkrAccountRollup` (Task 4, no duplicate `exec_vs_sim` math) + the account-wide
        view for its mini curve/holdings count. An unwired/empty IBKR account renders a clean "not connected
        yet" card, never a 500."""
        headline, headline_curve = _overview_headline(_nav_history())
        books = svc.paper_books()
        ibkr_view = books["ibkr_view"]
        ibkr_curve = _ibkr_card_curve(ibkr_view)
        # The IBKR card's clickable target: the first (alphabetically, deterministic) strategy trading on
        # the shared account — its holdings-detail page (Task 3) already shows the whole account regardless
        # of which strategy id you land on (IBKR is one physical account). "" when the account has no
        # strategies registered at all (never a dead link).
        ibkr_href = (f"/strategy/{ibkr_view.per_strategy[0].strategy_id}"
                    if (ibkr_view and ibkr_view.per_strategy) else "")
        return templates.TemplateResponse(request, "paper.html",
                                          {"headline": headline, "headline_curve": headline_curve,
                                           "ibkr_rollup": books["ibkr_rollup"], "ibkr_view": ibkr_view,
                                           "ibkr_curve": ibkr_curve, "ibkr_href": ibkr_href, "now": _now()})

    def _ibkr_card_curve(ibkr_view: Any) -> str:
        """The IBKR account card's mini curve: the account's real NLV series with the PLAN overlaid (dashed)
        when exactly one strategy is executing (today: multistrat) — the same `overlay_curve` the holdings
        detail page uses. Falls back to a plain nav sparkline when no single executing plan is available
        (e.g. before any strategy has captured data). "" when there's no account data at all."""
        if ibkr_view is None or len(ibkr_view.total_nav_series) < 2:
            return ""
        nlvs = [nlv for _, nlv in ibkr_view.total_nav_series]
        executing = [r for r in ibkr_view.per_strategy if r.executing]
        if len(executing) == 1:
            plan = svc.detail(executing[0].strategy_id)
            if plan is not None and len(plan.history) > 1:
                return overlay_curve(nlvs, [p.nav for p in plan.history], width=600, height=120)
        return nav_sparkline(nlvs, width=600, height=120)

    @app.get("/paper/live", response_class=HTMLResponse)
    def paper_live(request: Request) -> HTMLResponse:
        view = _paper_view()
        # Same headline sourcing as /paper so the 45s HTMX refresh keeps the compounded-record headline.
        return templates.TemplateResponse(request, "_paper_live.html",
                                          {"v": view, "nav": _nav_headline(_nav_history(), view),
                                           "now": _now()})

    def _paper_repo() -> PaperRepo:
        """The paper read-model store: the injected repo (tests) or one built from settings."""
        if paper_repo is not None:
            return paper_repo
        from fxhnt.adapters.persistence.paper_repo import PaperRepo as _PaperRepo
        from fxhnt.config import get_settings
        r = _PaperRepo(get_settings().operational_dsn)
        r.migrate()
        return r

    def _book_at(prepo: PaperRepo, date: str) -> dict[str, Any]:
        """Build the read context for one replay date: that date's nav point, positions (marked vs entry
        using the stored entry/close — historical, so PnL is the held lot's mark) and trades."""
        nav = next((p for p in prepo.nav_history() if p.run_date == date), None)
        positions = prepo.positions_at(date)
        trades = prepo.trades_on(date)
        return {"date": date, "nav": nav, "positions": positions, "trades": trades}

    @app.get("/paper/replay", response_class=HTMLResponse)
    def paper_replay(request: Request, track: str = "naive") -> HTMLResponse:
        # Replay the RECORDED day-by-day track. track=naive → the Bybit paper book (positions scrubbed
        # day-by-day — the cockpit's only venue). track=levered → the OBSERVE-ONLY levered shadow
        # (bybit_4edge_levered): a return-defined forward_nav curve with NO positions (like carry), so it is
        # shown position-less. Default + fallback = naive (the DEPLOY 4-edge book — consistent with
        # /paper + /paper/sim).
        track = track if track in ("naive", "levered") else "naive"
        if track == "levered":
            # The levered shadow track's recorded forward_nav curve (via the DashboardService). No positions —
            # it is return-defined, so we render the curve + a note, never the scrubber.
            d = svc.detail("bybit_4edge_levered")
            navs = [p.nav for p in d.history] if d else []
            curve = nav_sparkline(navs, width=600, height=160) if len(navs) >= 2 else ""
            return templates.TemplateResponse(request, "replay.html",
                                              {"track": "levered", "hist": [],
                                               "curve": curve, "levered_days": len(navs),
                                               "gate_reason": (d.gate_reason if d else None), "now": _now()})
        prepo = _venue_repo()
        hist = prepo.nav_history()
        if not hist:
            return templates.TemplateResponse(request, "replay.html",
                                              {"track": "naive", "hist": [], "now": _now()})
        curve = nav_sparkline([p.equity for p in hist], width=600, height=160)
        latest = hist[-1].run_date
        return templates.TemplateResponse(request, "replay.html",
                                          {"track": "naive", "hist": hist, "curve": curve,
                                           "at": _book_at(prepo, latest), "now": _now()})

    @app.get("/paper/replay/at", response_class=HTMLResponse)
    def paper_replay_at(request: Request, date: str) -> HTMLResponse:
        prepo = _venue_repo()
        return templates.TemplateResponse(request, "_replay_at.html",
                                          {"at": _book_at(prepo, date), "now": _now()})

    # ---- Backtest: the honest real-cost curve ---------------------------------------------------------
    # The Backtest is book + capital + period -> the precomputed MEASURED (real per-coin cost) curve. There is
    # NO configurable-cost sandbox: cost is a MEASURED FACT (the real L1 spread we store), not a slider — a
    # draggable cost is a fund foot-gun (drag it to 0 and you resurrect the +7097% mirage). Sleeve composition
    # / Kelly / controller were knobs of the old flat what-if path (removed); sleeve-level exploration is a
    # research concern, not the fund cockpit.

    # The books the sim can configure. bybit_4edge = the precomputed 4-edge Bybit backtest (naive eq-wt —
    # NOT the overlay, since the A/B proved naive is best OOS), sized from bybit_sleeve_ret. bybit_4edge_levered
    # = the observe-only levered shadow. multistrat (Task 1/D1) = the IBKR paper strategies' precomputed
    # recompute-replay curve (sim_curve_ret) — generalizes the sim beyond the Bybit-only venue. Order = render
    # order of the book-switch pills: the deploy book leads (and is the default).
    # Exclude ARCHIVED books (their STRATEGY_REGISTRY entry carries `archived: True` — e.g. a
    # shelved/FALSIFIED strategy): they never appear as a Backtest book pill, and a request for an
    # archived book falls back to `_DEFAULT_BOOK` (so the folded "Live forward" section can't surface an
    # archived sleeve either).
    _SIM_BOOKS = tuple(b for b in SIM_BOOKS if not STRATEGY_REGISTRY.get(b, {}).get("archived"))
    _DEFAULT_BOOK = "bybit_4edge"
    # Plain-language pill labels for the sim's book switch — NEVER the raw registry id. Sourced from the
    # shared `display_names` SSOT (Task 3), which absorbed the interim local map that used to live here.
    _SIM_BOOK_LABELS: dict[str, str] = {b: display_name(b) for b in _SIM_BOOKS}
    # The per-turnover taker fee the live forward tracks charge (build_*_forward_nav cost_bps=5.5, the
    # Bybit-perp realistic cost). Only a fallback default when a measured payload predates the taker_fee_bps
    # field — every measured artifact carries its own real fee, so this is rarely hit.
    _SIM_DEFAULT_COST_BPS = 5.5

    # In-process TTL cache for the per-sleeve return tables. These are PRECOMPUTED nightly (the sim never
    # recomputes an edge on the request path), so re-reading the whole history from the DB on every request
    # is pure waste. A short TTL keeps the page from going stale-forever: the nightly snapshot lands within
    # one TTL window, and a manual book-switch is at most TTL seconds behind the DB. Keyed by book.
    _SIM_RETURNS_TTL_S = 300.0
    _sim_returns_cache: dict[str, tuple[float, dict[str, dict[int, float]]]] = {}

    def _ibkr_sim_curve(book: str) -> dict[str, dict[int, float]]:
        """The precomputed recompute-replay (IBKR) book curve for `book` (multistrat), as
        `{book: {epoch_day: ret}}` — read from `sim_curve_ret` (written nightly by
        `_persist_track_backtest_ref`/the `fxhnt-backtest-refs` Job from the SAME inception rows the
        reconciliation-gate ref is derived from). NEVER recomputes the replay on this request path."""
        return {book: _paper_repo().sim_curve_returns(book)}

    def _sim_returns(book: str = _DEFAULT_BOOK) -> dict[str, dict[int, float]]:
        """Cached per-sleeve returns for `book`, dispatched by the registry's `backtest.kind` (Task 1/D1):
        `report`-kind Bybit books (bybit_4edge / bybit_4edge_levered) read the precomputed bybit_sleeve_ret
        table; `recompute-replay`-kind IBKR books (multistrat) read the precomputed sim_curve_ret
        table via `_ibkr_sim_curve`. NEVER recomputes any edge on the request path — both sources are
        nightly-precomputed. Memoized in-process with a 300s TTL, keyed by book (returns change only nightly)."""
        now = time.monotonic()
        hit = _sim_returns_cache.get(book)
        if hit is not None and (now - hit[0]) < _SIM_RETURNS_TTL_S:
            return hit[1]
        returns = sim_returns_for(book, bybit_returns=_paper_repo().bybit_sleeve_returns,
                                  ibkr_curve=_ibkr_sim_curve)
        _sim_returns_cache[book] = (now, returns)
        return returns

    def _forward_track(strategy_id: str) -> dict[str, Any] | None:
        """The live forward track view for `strategy_id` (T0, gate status/days, the NAV curve) — read from
        the cockpit forward_nav via the DashboardService. None if the strategy is unknown. The bybit sim view
        uses this to show the LIVE FORWARD record beside the configurable backtest (clearly distinguished)."""
        d = svc.detail(strategy_id)
        if d is None:
            return None
        navs = [p.nav for p in d.history]
        spec = STRATEGY_REGISTRY.get(strategy_id, {}).get("gate_spec", {})
        recon = spec.get("gate_type") == "reconciliation"
        min_days = int(spec.get("min_forward_days", 0)) if recon else int(spec.get("min_days", 0))
        reason = (d.gate_reason or "").lower()
        # Derive the three reconciliation-gate criteria for a legible card. The gate_reason names the FAILING
        # condition (PASS => all three hold); days-met is structural, the other two are read off the verdict.
        gap_fail = "gap" in reason or "execution" in reason
        diverge_fail = "diverg" in reason or "corr" in reason
        return {
            "strategy_id": d.strategy_id, "display_name": d.display_name, "venue": d.venue,
            "days": d.days, "gate_status": d.gate_status, "gate_reason": d.gate_reason,
            "as_of": d.as_of, "total_return_pct": d.total_return_pct, "sharpe": d.sharpe,
            "t0": d.history[0].date if d.history else "",
            "curve": nav_sparkline(navs, width=600, height=180) if navs else "",
            "n_points": len(navs),
            # reconciliation-gate criteria for the card (only meaningful when `recon_gate` is True).
            "recon_gate": recon, "gate_min_days": min_days,
            "days_ok": d.days >= min_days,
            "reconciles_ok": d.gate_status == "PASS" or not diverge_fail,
            "clean_exec_ok": d.gate_status == "PASS" or not gap_fail,
        }

    def _parse_ord(value: str | None) -> int | None:
        """An ISO date query param → proleptic-Gregorian ordinal (the epoch_day key sleeve_returns
        uses, == date.toordinal()). Unparseable/empty → None (open window)."""
        if not value:
            return None
        try:
            return dt.date.fromisoformat(value).toordinal()
        except ValueError:
            return None

    def _parse_num(value: str | None, default: float) -> float:
        """Lenient float for the sim form fields (capital/kelly/cost). Strips commas + whitespace and falls
        back to `default` on empty/garbage so a cleared field, a thousands-separator, or an odd format never
        422s the backtest — a config form should degrade gracefully, not error."""
        if value is None:
            return default
        s = str(value).strip().replace(",", "").replace(" ", "")
        if not s:
            return default
        try:
            return float(s)
        except ValueError:
            return default

    def _measured_pending_context(book: str) -> dict[str, Any]:
        """Graceful degrade when the precomputed curve is ABSENT: a short 'precomputing — check back after the
        nightly job' note (NOT a flat slider, never a 500). The live forward track still renders for bybit_4edge
        and for the IBKR recompute-replay books (multistrat) — each has its own real forward track."""
        is_ibkr = registry_backtest_kind(book) == "recompute-replay"
        return {
            "measured_pending": True,
            "n_points": 0,
            "cfg": {"capital": 0.0, "start": "", "end": "", "book": book},
            "books": _SIM_BOOKS,
            "book_labels": _SIM_BOOK_LABELS,
            "is_bybit": book == "bybit_4edge",
            "is_ibkr": is_ibkr,
            "forward": _forward_track(book) if (book == "bybit_4edge" or is_ibkr) else None,
            "now": _now(),
        }

    def _ibkr_measured_context(book: str, *, capital: float, start: str | None = None,
                               end: str | None = None) -> dict[str, Any] | None:
        """The IBKR recompute-replay books' (multistrat) Backtest view: compound the PRECOMPUTED per-day
        honest-cost return series (`_sim_returns(book)`, written nightly from the SAME replay basis the
        reconciliation gate reconciles the forward track against) into an equity curve via the pure
        `simulate_naive_eqwt` engine — no per-coin cost model here (that is the Bybit-only measured-cost path;
        the IBKR replay's cost is already baked into its own per-day return). Windowed by [start, end] like
        the Bybit measured path. None when the precompute is empty/absent → the caller shows the graceful
        'precomputing' note (never a 500)."""
        from fxhnt.application.paper_sim import simulate_naive_eqwt

        series = _sim_returns(book).get(book)
        if not series or len(series) < 2:
            return None
        result = simulate_naive_eqwt({book: series}, capital=capital, sleeves=[book],
                                     start=_parse_ord(start), end=_parse_ord(end))
        if not result.dates:
            return None
        iso = [dt.date.fromordinal(d).isoformat() for d in result.dates]
        sim_data = {
            "dates": iso,
            "equity": [round(e, 2) for e in result.equity],
            "drawdown": [round(d, 6) for d in result.drawdown],
            "leverage": [round(lv, 4) for lv in result.leverage],
            "killed": result.killed,
            "n_active": [len(w) for w in result.weights],
        }
        curve = equity_curve(result.equity, width=600, height=180)
        # For the LEVERED shadow: the honest financing SENSITIVITY BAND — the verdict (levered Sharpe) hinges
        # on the IBKR margin rate (a benchmark+tier assumption), so show it across a rate range + the break-even
        # instead of one assumed number. Computed from the BASE (multistrat) series (leverage is set by the
        # base vol -> vol target; only the financing drag varies with the rate).
        sensitivity = None
        if book == "multistrat_levered":
            from fxhnt.application.paper_strategies import leverage_financing_sensitivity
            base = _sim_returns("multistrat").get("multistrat")
            if base and len(base) >= 2:
                p = STRATEGY_REGISTRY["multistrat_levered"]["definition"]["params"]
                sensitivity = leverage_financing_sensitivity(
                    [base[d] for d in sorted(base)], target_vol=p["target_vol"],
                    max_leverage=p["max_leverage"], current_bps=p["financing_bps"])
        return {
            "curve": curve,
            "metrics": result.metrics,
            "sim_data": sim_data,
            "n_points": len(iso),
            "cfg": {"capital": capital, "start": start or iso[0], "end": end or iso[-1], "book": book},
            "books": _SIM_BOOKS,
            "book_labels": _SIM_BOOK_LABELS,
            "is_bybit": False,
            "is_ibkr": True,
            "sensitivity": sensitivity,
            "forward": _forward_track(book),
            "cost_drag_bps": 0.0,
            "median_spread_bps": 0.0,
            "taker_fee_bps": _SIM_DEFAULT_COST_BPS,
            "used_real_spread": False,
            "now": _now(),
        }

    def _single_measured_context(book: str, *, capital: float, start: str | None = None,
                                 end: str | None = None) -> dict[str, Any] | None:
        """The single-book Backtest view: READ the precomputed per-COIN MEASURED-cost curve/metrics for `book`
        (the honest real-cost number) and render it scaled by `capital` and windowed by [start, end]. The
        cockpit NEVER runs the heavy per-coin compute on a web
        request (it OOM-killed the 4Gi dashboard); the own-memory `fxhnt compare-measured-precompute` Job
        persists these artifacts. None when no artifact exists → the caller shows the 'precomputing' note.

        CAPITAL: the measured curve was compounded from the precompute's start capital; equity (and end_value)
        scale LINEARLY, so a different requested `capital` simply rescales them (per-bp cost is capital-robust;
        Sharpe/CAGR/maxDD are scale-free). PERIOD: start/end slice the cached arrays to the window and the
        metrics (CAGR/Sharpe/maxDD) are RECOMPUTED on the window (drawdown peak resets at the window start)."""
        from fxhnt.application.compare_measured_cost import read_cached_single_measured
        from fxhnt.application.paper_sim import _metrics

        payload = read_cached_single_measured(_paper_repo(), book)
        if payload is None:
            return None
        pcap = payload.get("capital", capital) or capital
        scale = (capital / pcap) if pcap else 1.0
        iso_full = payload["dates_iso"]
        equity_full = [e * scale for e in payload["equity"]]
        dd_full = payload["drawdown"]
        lev_full = payload["leverage"]
        killed_full = payload["killed"]
        nact_full = payload["n_active"]

        # Window by ISO date (lexicographic == chronological for ISO dates). [lo, hi).
        lo = next((i for i, d in enumerate(iso_full) if d >= start), len(iso_full)) if start else 0
        hi = next((i for i, d in enumerate(iso_full) if d > end), len(iso_full)) if end else len(iso_full)
        if hi < lo:
            hi = lo
        windowed = lo != 0 or hi != len(iso_full)

        iso = iso_full[lo:hi]
        equity = equity_full[lo:hi]
        lev = lev_full[lo:hi]
        killed = killed_full[lo:hi]
        nact = nact_full[lo:hi]

        if windowed:
            # A windowed backtest starts FRESH at `capital` on the window's first day — it is "backtest this
            # strategy from this date with this much money", NOT a slice of the full 2021→2026 cumulative curve.
            # So REBASE the window: divide out the cumulative equity ENTERING the window and re-anchor to capital,
            # then compound the sub-period's own returns. (Without this, start=2026 with $350k showed equity
            # entering at the cumulative value — ~$1.6M — which is nonsensical, the bug the user caught.)
            entry = capital if lo == 0 else equity_full[lo - 1]   # cumulative equity entering the window
            rebase = (capital / entry) if entry else 1.0
            equity = [e * rebase for e in equity]                 # window now begins at ~capital
            net_rets, drawdown, prev, peak = [], [], capital, capital
            for e in equity:
                net_rets.append((e / prev - 1.0) if prev else 0.0)
                peak = max(peak, e)
                drawdown.append((e - peak) / peak if peak > 0 else 0.0)
                prev = e
            epoch = [(dt.date.fromisoformat(d) - dt.date(1970, 1, 1)).days for d in iso]
            metrics = _metrics(epoch, equity, net_rets, drawdown, capital)
        else:
            drawdown = dd_full
            metrics = dict(payload["metrics"])
            if "end_value" in metrics:
                metrics["end_value"] = metrics["end_value"] * scale

        sim_data = {
            "dates": iso,
            "equity": [round(e, 2) for e in equity],
            "drawdown": [round(d, 6) for d in drawdown],
            "leverage": [round(lv_i, 4) for lv_i in lev],
            "killed": killed,
            "n_active": nact,
        }
        curve = equity_curve(equity, width=600, height=180)
        return {
            "curve": curve,
            "metrics": metrics,
            "sim_data": sim_data,
            "n_points": len(iso),
            "cfg": {"capital": capital, "start": start or (iso[0] if iso else ""),
                    "end": end or (iso[-1] if iso else ""), "book": book},
            "books": _SIM_BOOKS,
            "book_labels": _SIM_BOOK_LABELS,
            "is_bybit": book == "bybit_4edge",
            "forward": _forward_track("bybit_4edge") if book == "bybit_4edge" else None,
            "cost_drag_bps": payload.get("cost_drag_bps", 0.0),
            "median_spread_bps": payload.get("median_spread_bps", 0.0),
            "taker_fee_bps": payload.get("taker_fee_bps", _SIM_DEFAULT_COST_BPS),
            # The Bybit liquid book costs the REAL L1 quoted spread (forward-realistic) when a refresh has run;
            # the template captions the current-snapshot caveat (NOT hidden). Absent a refresh this is False
            # (falls back to the Corwin–Schultz measured-spread proxy).
            "used_real_spread": bool(payload.get("used_real_spread", False)),
            "now": _now(),
        }

    @app.get("/paper/sim", response_class=HTMLResponse)
    def paper_sim(request: Request, book: str = _DEFAULT_BOOK) -> HTMLResponse:
        """Render the (tiny) book/capital/period form INSTANTLY, then let HTMX fetch the precomputed curve
        from /paper/sim/run on load. The page never runs a sim before first paint — it reads the honest
        precomputed curve. No configurable-cost sandbox: cost is a measured/replay fact, not a knob."""
        from fxhnt.config import get_settings
        book = book if book in _SIM_BOOKS else _DEFAULT_BOOK
        capital = get_settings().paper_capital
        # Pre-fill AND bound the period inputs with the book's full precomputed date range, so the date fields
        # are never blank ("dates not set") and the picker can't go off the data. Bybit books read their range
        # from the per-coin measured cache; IBKR recompute-replay books (multistrat, Task 1/D1) read it
        # from their own precomputed sim_curve_ret series (`_sim_returns`).
        if registry_backtest_kind(book) == "recompute-replay":
            _days = sorted(_sim_returns(book).get(book) or {})
            lo = dt.date.fromordinal(_days[0]).isoformat() if _days else ""
            hi = dt.date.fromordinal(_days[-1]).isoformat() if _days else ""
        else:
            from fxhnt.application.compare_measured_cost import read_cached_single_measured
            _payload = read_cached_single_measured(_paper_repo(), book)
            _iso = (_payload or {}).get("dates_iso") or []
            lo, hi = (_iso[0], _iso[-1]) if _iso else ("", "")
        cfg = {"capital": capital, "start": lo, "end": hi, "book": book, "range_min": lo, "range_max": hi}
        # NB: format capital as a plain decimal (NOT :g — which emits scientific "1e+06" whose "+" decodes to a
        # space in the query string and 422s). Lenient parsing on the run side is the backstop.
        result_url = f"/paper/sim/run?capital={capital:.2f}&book={book}"
        ctx = {"cfg": cfg, "books": _SIM_BOOKS, "book_labels": _SIM_BOOK_LABELS,
               "result_url": result_url, "now": _now()}
        return templates.TemplateResponse(request, "sim.html", ctx)

    @app.get("/paper/sim/run", response_class=HTMLResponse)
    def paper_sim_run(request: Request, capital: str | None = None, start: str | None = None,
                      end: str | None = None, book: str = _DEFAULT_BOOK) -> HTMLResponse:
        """The honest Backtest: book + capital + period -> the precomputed curve. Capital is parsed leniently
        (a cleared/comma'd/odd capital must not 422). Bybit books (report-kind) render the per-coin MEASURED-
        cost cache scaled by capital + windowed by period; IBKR recompute-replay books (multistrat, Task
        1/D1) compound their own precomputed honest-cost return series the same way. Absent precompute ->
        a graceful 'precomputing' note (never the flat slider, never a 500)."""
        book = book if book in _SIM_BOOKS else _DEFAULT_BOOK
        capital_f = _parse_num(capital, 100_000.0)
        cap = capital_f if capital_f > 0 else 0.0
        if registry_backtest_kind(book) == "recompute-replay":
            mctx = _ibkr_measured_context(book, capital=cap, start=start, end=end)
        else:
            mctx = _single_measured_context(book, capital=cap, start=start, end=end)
        if mctx is None:
            mctx = _measured_pending_context(book)
        return templates.TemplateResponse(request, "_sim_result.html", mctx)

    def _venue_repo() -> PaperRepo:
        """The Bybit PaperRepo — the cockpit's only paper-book venue (the injected one, or one built from
        settings). A LIGHT read-model store; used only for the persisted nav-history read, no
        exchange/warehouse compute."""
        if bybit_paper_repo is not None:
            return bybit_paper_repo
        from fxhnt.config import get_settings
        from fxhnt.adapters.persistence.paper_repo import PaperRepo as _PaperRepo
        r = _PaperRepo(get_settings().operational_dsn)
        r.migrate()
        return r

    def _nav_history() -> list[Any]:
        """The bybit book's persisted equity series ascending — a LIGHT, read-only DB read (no compute).
        Guarded so a missing table degrades to an empty series (never 500)."""
        try:
            return _venue_repo().nav_history()
        except Exception:  # noqa: BLE001 — a missing/empty nav table must never 500 /paper
            return []

    def _nav_headline(nav_history: list[Any], view: Any) -> dict[str, Any]:
        """The headline figures: the COMPOUNDED backfilled record (latest persisted nav equity) as the
        primary 'paper equity', its return vs the $capital start, plus the live mark-to-market kept as a
        secondary detail. When no nav history exists yet, the live mark IS the headline (no compounded
        record to lead with)."""
        capital = view.capital
        if nav_history:
            equity = nav_history[-1].equity
            return {"has_history": True, "equity": equity, "capital": capital,
                    "pnl": equity - capital, "live_equity": view.equity}
        return {"has_history": False, "equity": view.equity, "capital": capital,
                "pnl": view.equity - capital, "live_equity": view.equity}

    return app


def create_app_from_settings() -> FastAPI:
    from fxhnt.adapters.persistence.paper_repo import PaperRepo
    from fxhnt.config import get_settings
    settings = get_settings()
    repo = ForwardNavRepo(settings.operational_dsn)
    repo.migrate()
    paper_repo = PaperRepo(settings.operational_dsn)
    paper_repo.migrate()
    # The REAL IBKR paper-account section on the multistrat detail pages (D2.2/2.3) — a light
    # schema-ensure migrate (create_all + a guarded additive ALTER), same shape as repo/paper_repo above.
    ibkr_account_repo = IbkrAccountRepo(settings.operational_dsn)
    ibkr_account_repo.migrate()
    # The Bybit LIVE paper book reads the SAME cockpit DB (bybit is PaperRepo's sole venue); its live mark is
    # the latest bybit-perp close in the warehouse (read-only, bounded to the open book's symbols — no network).
    bybit_paper_repo = PaperRepo(settings.operational_dsn)
    bybit_paper_repo.migrate()
    try:
        from fxhnt.adapters.exchange.warehouse_live_price import WarehouseLivePrice
        from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
        bybit_live: object | None = WarehouseLivePrice(
            TimescaleFeatureStore(settings.operational_dsn, table="bybit_features"))
    except Exception:  # noqa: BLE001 — never let the bybit mark source block startup
        bybit_live = None
    # NB: the measured-cost COMPARE no longer reads the warehouse on a request — it reads the precomputed
    # `compare_measured_cache` artifact (written by `fxhnt compare-measured-precompute`), so the cockpit does
    # NOT open a `features` warehouse store here. The heavy per-coin measured compute lives in the own-memory
    # precompute Job (it OOM-killed the 4Gi dashboard when run live).
    return create_app(repo, paper_repo=paper_repo,
                      bybit_paper_repo=bybit_paper_repo, bybit_live=bybit_live,
                      ibkr_account_repo=ibkr_account_repo)
