"""DashboardService — reads the cockpit DB into view-models. Every registry entry appears in the fleet even
with no data yet (shown as 0 days / WAIT), so the cockpit never hides a tracker just because its first
ingest has not landed."""
from __future__ import annotations

from typing import Any

from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
from fxhnt.adapters.persistence.ibkr_account_repo import IbkrAccountRepo
from fxhnt.application.allocation_policy import resolve_allocation_policy
from fxhnt.application.display_names import asset_description, display_name
from fxhnt.application.exec_reconcile import exec_vs_sim
from fxhnt.application.forward_ingest import CockpitBacktestRefProvider
from fxhnt.application.periods import PERIODS, aggregate
from fxhnt.config import get_settings
from fxhnt.ports.dashboard import (
    FleetRow,
    IbkrAccountRollup,
    IbkrAccountView,
    OverviewModel,
    StrategyDetail,
    StratExecRow,
)
from fxhnt.registry import STRATEGY_REGISTRY

# The strategies with a REAL IBKR paper account behind them (D2.1's `IbkrAccountRepo` captures fills/nav
# tagged under these MODELED sids — see `multistrat_exec_record.plan_and_record`'s
# `persist_execution(..., strategy_id="multistrat", ...)`). Their detail page gets the holdings/recent-
# trades/account-nav/recon section (D2.2/2.3); every other strategy simply doesn't have that data.
_IBKR_ACCOUNT_SIDS = frozenset({"multistrat"})


def _expected_book_symbols() -> frozenset[str]:
    """The symbols the multi-strat ETF book is ALLOWED to hold — its US instruments (`FUND_INSTRUMENTS`) plus
    their UCITS remaps (the EU DU account executes via UCITS: CSPX/CBU0/IDTM/IGLN/ICOM/DBMF). Derived from the
    SAME two SSOTs the executor uses (never a hardcoded duplicate), so a held symbol that ISN'T here — a legacy
    IBIT, a stray manual position — is FLAGGED in the holdings view instead of silently inflating the NLV."""
    from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS
    syms = set(FUND_INSTRUMENTS)
    try:
        # Include BOTH the cosmetic ticker AND the symbol IBKR actually REPORTS the holding under
        # (`ibkr_symbol`, which diverges for the IEF line: ticker CBU0 -> reported CSBGU0, probe 2026-07-20).
        # account_snapshot reads `p.contract.symbol`, so a legitimate book holding IBKR reports as CSBGU0 must
        # be in this set or it is wrongly flagged a stray and dropped from the book's NLV.
        for listing in get_settings().ucits.map.values():
            syms.add(listing.ticker)
            syms.add(listing.ibkr_symbol)
    except Exception:  # noqa: BLE001 — config-less test path: the US instruments alone still guard the check
        pass
    return frozenset(syms)


def _active_ibkr_account_sids() -> list[str]:
    """The IBKR-account sids that are still cockpit-visible — `_IBKR_ACCOUNT_SIDS` minus any ARCHIVED entry
    (`STRATEGY_REGISTRY` `archived: True`, e.g. a shelved/FALSIFIED strategy). Sorted for a deterministic render
    order. Every account-wide aggregation (the per-strategy breakdown, the recent-fills roll-up) iterates
    THIS, so an archived book never surfaces a row/fill on the IBKR account page even though its fills are
    still captured in the repo."""
    return sorted(s for s in _IBKR_ACCOUNT_SIDS if not STRATEGY_REGISTRY.get(s, {}).get("archived"))

# modeled sid -> its executed twin sid (reverse of C1/C2's exec->modeled reconciliation alias).
_EXEC_TWIN = {modeled: exec_sid for exec_sid, modeled in CockpitBacktestRefProvider._REF_ALIAS.items()}

# Preference order for the home-page headline book: the live deploy-venue forward track first, then
# whatever the fleet's first row is. The "passive benchmark" sleeve is shown as an edge only if nothing
# else exists (it is not an alpha edge).
_HEADLINE_PREFERENCE = ("bybit_4edge",)
_BENCHMARK_SLEEVES = frozenset({"beta"})
# The venues we ACTUALLY execute on: Bybit (the crypto deploy book) + IBKR (the US-equity leg — the prior
# fractional-broker leg was decommissioned, Phase 0b Task 5). Every other venue (legacy-venue discovery/
# reference tracks, the glbx 60/40 benchmark) is REFERENCE-ONLY — observed on the gate, never traded live.
# The legacy venue is no longer an active execution venue.
_ACTIVE_VENUES = frozenset({"bybit-perp", "ibkr-equity"})


def _gate_min_days(gate_spec: dict | None) -> int:
    """The forward-window target for a gate_spec: reconciliation gates use `min_forward_days` (~2wk),
    absolute gates use `min_days`. 0 when absent (no countdown / never warming)."""
    spec = gate_spec or {}
    if spec.get("gate_type") == "reconciliation":
        return int(spec.get("min_forward_days", 0))
    return int(spec.get("min_days", 0))


class DashboardService:
    def __init__(self, repo: ForwardNavRepo, ibkr_repo: IbkrAccountRepo | None = None) -> None:
        self._repo = repo
        # Optional: absent in most callers/tests (the ordinary crypto/registry tracks never touch it); when
        # given, powers the IBKR real-account section on multistrat's detail pages (D2.2/2.3).
        self._ibkr_repo = ibkr_repo

    def _safe_allocs(self) -> dict:
        """Gracefully read allocations, returning {} if the table is absent or unreachable."""
        try:
            return {a.strategy_id: a for a in self._repo.all_allocations()}
        except Exception:  # noqa: BLE001 — a missing/absent table must never break the dashboard
            return {}

    def _safe_funds(self) -> dict:
        """Gracefully read funding state, returning {} if the table is absent or unreachable."""
        try:
            return {f.strategy_id: f for f in self._repo.all_funding()}
        except Exception:  # noqa: BLE001 — a missing/absent table must never break the dashboard
            return {}

    def _safe_health(self) -> dict:
        """Gracefully read the runtime health axis, returning {} if the table is absent or unreachable (a
        pre-migration DB → every track defaults to HEALTHY, never a spurious badge)."""
        try:
            return {h.strategy_id: h for h in self._repo.all_health()}
        except Exception:  # noqa: BLE001 — a missing/absent table must never break the dashboard
            return {}

    @staticmethod
    def _health_fields(health: dict, sid: str) -> dict:
        """health/health_stale_days/health_reason for a sid; HEALTHY defaults when no row (no false badge)."""
        h = health.get(sid)
        return {"health": h.health if h else "HEALTHY",
                "health_stale_days": h.stale_days if h else 0,
                "health_reason": h.reason if h else ""}

    def _safe_book_venue(self, twin_sid: str) -> str | None:
        """Execution venue recorded on the exec twin's book-state; None if absent/unreachable (fallback)."""
        if not twin_sid:
            return None
        try:
            return self._repo.get_book_state(f"{twin_sid}_pos").get("venue") or None
        except Exception:  # noqa: BLE001 — a missing book-state must never break the dashboard
            return None

    def exec_twin_sids(self) -> set[str]:
        """The executed-twin sids (research-tier observed tracks) folded into their modeled parent on the
        Overview — so they are NOT rendered as their own top-level rows."""
        return set(_EXEC_TWIN.values())

    @staticmethod
    def _exec_pair(sid, summaries):
        """exec_twin_sid / exec_return_pct / exec_divergence_pct for a MODELED sid whose executed twin has a
        forward summary; empty dict-of-defaults otherwise (so **-splat is a no-op for non-paired tracks)."""
        twin = _EXEC_TWIN.get(sid)
        if twin is None or twin not in summaries or sid not in summaries:
            return {"exec_twin_sid": "", "exec_return_pct": None, "exec_divergence_pct": None}
        exec_r = 100.0 * summaries[twin].total_return
        modeled_r = 100.0 * summaries[sid].total_return
        return {"exec_twin_sid": twin, "exec_return_pct": exec_r,
                "exec_divergence_pct": exec_r - modeled_r}

    def fleet(self) -> list[FleetRow]:
        summaries = {r.strategy_id: r for r in self._repo.all_summaries()}
        backtests = {r.strategy_id: r for r in self._repo.all_backtest_summaries()}
        promoted = self._repo.promoted_strategy_ids()   # research tracks the nightly promoted to deploy
        allocs = self._safe_allocs()
        funds = self._safe_funds()
        health = self._safe_health()
        out: list[FleetRow] = []
        for reg in self._repo.registry():
            s = summaries.get(reg.strategy_id)
            b = backtests.get(reg.strategy_id)   # most strategies have no backtest verdict -> None
            meta = STRATEGY_REGISTRY.get(reg.strategy_id, {})   # honest not-live flag lives in the Python registry
            if meta.get("archived"):   # defense-in-depth: `registry()` already excludes archived rows (the
                continue                # authoritative filter), so an archived sleeve never reaches here.
            min_days = _gate_min_days(reg.gate_spec)   # warming = fewer forward days than the gate needs
            out.append(FleetRow(
                strategy_id=reg.strategy_id, sleeve=reg.sleeve,
                display_name=display_name(reg.strategy_id, reg.display_name),
                days=s.days if s else 0,
                total_return_pct=100.0 * s.total_return if s else 0.0,
                sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
                gate_status=s.gate_status if s else "WAIT",
                gate_reason=s.gate_reason if s else "no data yet",
                as_of=s.as_of if s else "",
                bt_status=("PASS" if b.passed else "FAIL") if b else "",
                bt_sharpe=b.sharpe if b else None,
                bt_dsr=b.dsr if b else None,
                bt_cagr=b.cagr if b else None,
                bt_maxdd=b.max_drawdown if b else None,
                bt_as_of=b.as_of if b else "",
                status=str(meta.get("status", "")), note=str(meta.get("note", "")),
                warming=(s.days if s else 0) < min_days,
                gate_min_days=min_days,
                # registry SSOT metadata — drives the Overview's deploy/research grouping + paper/live badge.
                # A track the nightly PROMOTED (gate PASS while `promote_on_pass`) shows deploy — the persisted
                # promotion overrides the registry's starting tier.
                venue=reg.venue,
                tier=("deploy" if reg.strategy_id in promoted else str(meta.get("tier", "research"))),
                execution=str(meta.get("execution", "paper")),
                reference_only=reg.venue not in _ACTIVE_VENUES,   # not an active execution venue → reference
                alloc_dollars=(allocs[reg.strategy_id].target_dollars if reg.strategy_id in allocs else None),
                alloc_weight=(allocs[reg.strategy_id].target_weight if reg.strategy_id in allocs else None),
                alloc_policy_version=(allocs[reg.strategy_id].policy_version if reg.strategy_id in allocs else None),
                funding_state=(funds[reg.strategy_id].state if reg.strategy_id in funds else ""),
                funding_label=(funds[reg.strategy_id].label if reg.strategy_id in funds else ""),
                funding_decay_run=(funds[reg.strategy_id].decay_run if reg.strategy_id in funds else 0),
                funding_recovery_run=(funds[reg.strategy_id].recovery_run if reg.strategy_id in funds else 0),
                funding_first_pass_date=(funds[reg.strategy_id].first_pass_date if reg.strategy_id in funds else None),
                funding_policy_version=(funds[reg.strategy_id].policy_version if reg.strategy_id in funds else None),
                **self._health_fields(health, reg.strategy_id),
                **self._exec_pair(reg.strategy_id, summaries),
                **self._ibkr_exec_status(reg.strategy_id),
            ))
        return out

    def _ibkr_exec_status(self, strategy_id: str) -> dict[str, Any]:
        """D3 (Task 4) — the Overview's "real trades" sub-row signal: `exec_status="EXEC"` + the shared
        `recon` (Task 3's `exec_vs_sim`, no duplicate math) + honest trade/holdings counts, for a strategy
        that is one of the REAL IBKR paper-account books (`_IBKR_ACCOUNT_SIDS`, same gating as
        `_ibkr_account_fields`) AND has real captured exec data. `{}` (every FleetRow default: `exec_status=
        ""`, `recon=None`) for every other strategy, an IBKR book with no captured data yet, no `IbkrAccountRepo`
        wired in, or on ANY read failure — this signal is additive and must never break the fleet."""
        if strategy_id not in _IBKR_ACCOUNT_SIDS or self._ibkr_repo is None:
            return {}
        try:
            recon = exec_vs_sim(strategy_id, exec_repo=self._ibkr_repo, forward_repo=self._repo)
            if not recon.has_exec:
                return {}
            return {"exec_status": "EXEC", "recon": recon,
                    "exec_trade_count": len(self._ibkr_repo.fills_for(strategy_id, limit=10_000)),
                    "exec_holdings_count": len(self._ibkr_repo.latest_positions())}
        except Exception:  # noqa: BLE001 — a missing/unreachable table must never break the fleet
            return {}

    def overview(self, fleet: list[FleetRow] | None = None) -> OverviewModel:
        """At-a-glance fund state for the home page. Reuses `fleet()` (no new metric is computed) and the
        registry's `gate_spec` for the forward-gate countdown. The headline book is the live deploy-venue
        forward track if present, else the combined book, else the first fleet row; the remaining sleeves
        (minus the passive benchmark) are the edges."""
        fleet = fleet if fleet is not None else self.fleet()
        by_id = {f.strategy_id: f for f in fleet}
        headline = next((by_id[sid] for sid in _HEADLINE_PREFERENCE if sid in by_id), None)
        if headline is None and fleet:
            headline = fleet[0]

        gate_specs = {r.strategy_id: r.gate_spec for r in self._repo.registry()}
        min_days = 0
        t0 = ""
        days_remaining = 0
        if headline is not None:
            spec = gate_specs.get(headline.strategy_id) or {}
            min_days = _gate_min_days(spec)
            days_remaining = max(0, min_days - headline.days)
            hist = self._repo.nav_history(headline.strategy_id)
            t0 = hist[0].date if hist else ""

        edges = [f for f in fleet
                 if (headline is None or f.strategy_id != headline.strategy_id)
                 and f.sleeve not in _BENCHMARK_SLEEVES]
        # Derive overview meter from the already-built fleet
        gross = sum(abs(f.alloc_dollars) for f in fleet if f.alloc_dollars is not None)
        n_funded = sum(1 for f in fleet if f.funding_state == "funded")
        n_defunded = sum(1 for f in fleet if f.funding_state == "de-funded")
        return OverviewModel(
            headline=headline, headline_t0=t0, headline_gate_min_days=min_days,
            headline_days_remaining=days_remaining, edges=edges,
            gross_deployed=gross, ceiling=resolve_allocation_policy(get_settings()).capital_ceiling,
            n_funded=n_funded, n_defunded=n_defunded, ibkr_rollup=self._ibkr_rollup(fleet))

    def _ibkr_rollup(self, fleet: list[FleetRow]) -> IbkrAccountRollup | None:
        """D3 (Task 4) account-wide rollup across the REAL IBKR paper-account books, REUSING each fleet
        row's already-computed `recon` (no duplicate `exec_vs_sim` math). None when no `IbkrAccountRepo` is
        wired or the account has no NAV snapshot yet — never a fabricated zero. NOT rendered as its own card
        on the Overview (Task 5's consolidated Paper-books page is the account home)."""
        if self._ibkr_repo is None:
            return None
        try:
            nav_series = self._ibkr_repo.nav_series()
        except Exception:  # noqa: BLE001 — a missing/unreachable table must never break the overview
            return None
        if not nav_series:
            return None
        executing = [f for f in fleet if f.exec_status == "EXEC" and f.recon is not None]
        agg_divergence = (sum(f.recon.divergence for f in executing) / len(executing)) if executing else 0.0
        return IbkrAccountRollup(total_nav=nav_series[-1][1], n_executing=len(executing),
                                 agg_divergence=agg_divergence)

    def _ibkr_per_strategy_rows(self) -> list[StratExecRow]:
        """D4 (Task 5) — the IBKR account's per-strategy breakdown: one row per strategy id registered
        against the shared real account (`_IBKR_ACCOUNT_SIDS`, sorted for a deterministic render order).
        `executing=True` (with `recon`) is a strategy whose exec twin has captured real data; a strategy
        with no captured data yet renders `executing=False`/`recon=None` — the template's dimmed "not
        started yet" row, proving the table SCALES BY ROW (adding a strategy id to the account set adds
        exactly one more row here) rather than a redesign. A single strategy's read failure degrades that
        one row to `executing=False` rather than breaking every other row's render."""
        rows: list[StratExecRow] = []
        for sid in _active_ibkr_account_sids():
            try:
                recon = exec_vs_sim(sid, exec_repo=self._ibkr_repo, forward_repo=self._repo)
                fills = self._ibkr_repo.fills_for(sid, limit=5000)
            except Exception:  # noqa: BLE001 — one strategy's read must never break another's row
                rows.append(StratExecRow(strategy_id=sid, display_name=display_name(sid), executing=False))
                continue
            rows.append(StratExecRow(
                strategy_id=sid, display_name=display_name(sid), executing=recon.has_exec,
                recon=recon if recon.has_exec else None,
                position_count=len({f.symbol for f in fills}), fill_count=len(fills)))
        return rows

    def ibkr_account_view(self) -> IbkrAccountView | None:
        """D4 (Task 5) — the REAL IBKR paper account's account-wide view: backs the consolidated /paper
        landing card (account value / N strategies trading / mini curve / holdings count) and the account
        detail page's per-strategy breakdown table. IBKR is ONE physical account shared by every strategy
        trading on it, so `total_nav_series`/`all_positions` are account-wide (mirrors `_ibkr_account_fields`'s
        own reads) — only `per_strategy` is scoped per strategy-tagged `exec_fills`. None when no
        `IbkrAccountRepo` is wired or the account has no NAV snapshot yet (mirrors `_ibkr_rollup`'s guard) —
        never a fabricated empty account; the caller renders a clean empty state, never a 500."""
        if self._ibkr_repo is None:
            return None
        try:
            nav_series = self._ibkr_repo.nav_series()
        except Exception:  # noqa: BLE001 — a missing/unreachable table must never break the page
            return None
        if not nav_series:
            return None
        try:
            positions = self._ibkr_repo.latest_positions()
        except Exception:  # noqa: BLE001 — a missing/unreachable table must never break the page
            positions = {}

        recent_fills: list[dict[str, Any]] = []
        for sid in _active_ibkr_account_sids():
            try:
                fills = self._ibkr_repo.fills_for(sid, limit=200)
            except Exception:  # noqa: BLE001 — one strategy's read must never break the others'
                continue
            recent_fills.extend({
                "at": f.at, "symbol": f.symbol, "description": asset_description(f.symbol),
                "action": self._trade_action(f.side), "qty": f.qty, "price": f.price, "fee": f.fee,
                "strategy_id": sid, "strategy_display_name": display_name(sid),
            } for f in fills)
        recent_fills.sort(key=lambda f: f["at"], reverse=True)

        return IbkrAccountView(total_nav_series=nav_series, all_positions=positions,
                               recent_fills=recent_fills[:20], per_strategy=self._ibkr_per_strategy_rows())

    def paper_books(self, fleet: list[FleetRow] | None = None) -> dict[str, Any]:
        """D4 (Task 5) — the consolidated /paper landing's data: the IBKR paper account's rollup (Task 4's
        `IbkrAccountRollup`, REUSING the fleet's already-computed `recon` — no duplicate `exec_vs_sim` math)
        + its account-wide view (mini curve / holdings count / per-strategy breakdown). Both `None` when no
        `IbkrAccountRepo` is wired or the account has no NAV snapshot yet — the route renders a clean empty
        card, never a 500. The crypto 4-edge book's OWN simulated summary is Bybit-specific and lives outside
        this service (the route reuses the SAME `_overview_headline`/`_paper_view` helpers the Overview and
        the book's own detail page already use) — it is NOT duplicated here."""
        fleet = fleet if fleet is not None else self.fleet()
        overview = self.overview(fleet)
        return {"ibkr_rollup": overview.ibkr_rollup, "ibkr_view": self.ibkr_account_view()}

    @staticmethod
    def _trade_action(side: str) -> str:
        """Plain Buy/Sell action word for an ib-async execution side ("BOT"/"SLD") — never the raw side
        code. Falls back to the raw side string only for an unrecognized value (never raises)."""
        u = side.upper()
        if u in ("BOT", "BUY"):
            return "Buy"
        if u in ("SLD", "SELL"):
            return "Sell"
        return side

    def _ibkr_account_fields(self, strategy_id: str) -> dict[str, Any]:
        """The REAL IBKR paper-account section (D2.2/2.3): holdings/recent_trades/account_nav/recon for the
        two IBKR real-account books (`_IBKR_ACCOUNT_SIDS`). `{}` (every StrategyDetail default: empty lists,
        `recon=None`) for every other strategy, when no `IbkrAccountRepo` was wired in, or on ANY read
        failure — this section is additive and must never break the rest of the detail page."""
        if strategy_id not in _IBKR_ACCOUNT_SIDS or self._ibkr_repo is None:
            return {}
        try:
            recon = exec_vs_sim(strategy_id, exec_repo=self._ibkr_repo, forward_repo=self._repo)
            positions = self._ibkr_repo.latest_positions()
            account_nav = self._ibkr_repo.nav_series()
            # newest-first (IbkrAccountRepo.fills_for's own order); also the source for each symbol's last
            # known executed price, since the account has no live mark — a large limit so a symbol not
            # among the most recent 20 trades still resolves a real (not fabricated) price.
            all_fills = self._ibkr_repo.fills_for(strategy_id, limit=5000)
        except Exception:  # noqa: BLE001 — a missing/unreachable table must never break the detail page
            return {}

        last_price: dict[str, float] = {}
        last_trade: dict[str, str] = {}
        for f in sorted(all_fills, key=lambda f: f.at):   # ascending -> later fills overwrite earlier ones
            last_price[f.symbol] = f.price
            last_trade[f.symbol] = f.at.date().isoformat()
        expected = _expected_book_symbols()
        holdings = [{"symbol": sym, "description": asset_description(sym),
                    "qty": qty, "value": qty * last_price.get(sym, 0.0),
                    # the value is marked at the last real fill price (no live mark) — surface WHEN so a
                    # stale mark isn't over-trusted; "" when the symbol has no recorded fill.
                    "as_of": last_trade.get(sym, ""),
                    # a held symbol outside the book's allowed universe (legacy IBIT, a stray) is flagged, not
                    # silently carried in the account NLV — surfaced as a warning in the holdings view.
                    "in_book": sym in expected}
                   for sym, qty in positions.items()]
        # GROSS exposure denominator: a defined-risk book (e.g. a put-credit-spread book = short put + long
        # wing) holds mixed-sign legs, so a signed total nets toward the small net credit and makes "% of
        # book" blow past 100% / go negative. % of GROSS keeps every row's signed value but a sane [0,100]
        # share that sums to ~100.
        gross_value = sum(abs(h["value"]) for h in holdings)
        for h in holdings:
            h["pct_of_book"] = (abs(h["value"]) / gross_value) if gross_value else 0.0
        holdings.sort(key=lambda h: -abs(h["value"]))

        recent_trades = [{
            "at": f.at.strftime("%Y-%m-%d %H:%M"), "symbol": f.symbol,
            "description": asset_description(f.symbol), "action": self._trade_action(f.side),
            "qty": f.qty, "price": f.price,
            # HONEST cost-to-trade as a % (not bps) — real whole-share MARKET orders carry NO limit price,
            # so `shortfall_bps` is genuinely 0.0 for them (Task 2); shown as-is, never fabricated.
            "cost_to_trade_pct": f.shortfall_bps / 100.0, "fee": f.fee,
        } for f in all_fills[:20]]

        # Held symbols (nonzero qty) outside the book's allowed universe — a legacy/stray position (e.g. the
        # 2026-07 IBIT leftover from the pre-UCITS US book) the cockpit must not carry silently.
        stray_holdings = sorted({h["symbol"] for h in holdings if h["qty"] and not h["in_book"]})
        return {"holdings": holdings, "recent_trades": recent_trades, "account_nav": account_nav,
                "recon": recon, "per_strategy": self._ibkr_per_strategy_rows(),
                "stray_holdings": stray_holdings}

    def detail(self, strategy_id: str) -> StrategyDetail | None:
        # An ARCHIVED sleeve (STRATEGY_REGISTRY `archived: True` — e.g. a shelved/FALSIFIED strategy) must
        # never render its detail page: a direct hit to its `/strategy/<id>` returns None so the route 404s. This
        # is belt-and-suspenders — `registry()` below already excludes archived rows (single source) — but it
        # keeps the hide explicit and independent of the repo filter.
        if STRATEGY_REGISTRY.get(strategy_id, {}).get("archived"):
            return None
        reg = {r.strategy_id: r for r in self._repo.registry()}.get(strategy_id)
        if reg is None:
            return None
        summaries = {r.strategy_id: r for r in self._repo.all_summaries()}
        s = summaries.get(strategy_id)
        b = {r.strategy_id: r for r in self._repo.all_backtest_summaries()}.get(strategy_id)
        meta = STRATEGY_REGISTRY.get(strategy_id, {})   # honest not-live flag lives in the Python registry
        allocs = self._safe_allocs()
        funds = self._safe_funds()
        health = self._safe_health()
        history = self._repo.nav_history(strategy_id)
        dates = [p.date for p in history]
        rets = [p.ret for p in history]
        periods = {p: aggregate(dates, rets, p) for p in PERIODS}   # raw daily/weekly/monthly, no normalization
        pair = self._exec_pair(strategy_id, summaries)
        return StrategyDetail(
            strategy_id=strategy_id, sleeve=reg.sleeve, venue=reg.venue,
            display_name=display_name(strategy_id, reg.display_name),
            days=s.days if s else 0, total_return_pct=100.0 * s.total_return if s else 0.0,
            sharpe=s.sharpe if s else 0.0, maxdd=s.maxdd if s else 0.0,
            gate_status=s.gate_status if s else "WAIT",
            gate_reason=s.gate_reason if s else "no data yet", as_of=s.as_of if s else "",
            history=history, periods=periods,
            bt_status=("PASS" if b.passed else "FAIL") if b else "",
            bt_cagr=b.cagr if b else None, bt_ann_vol=b.ann_vol if b else None,
            bt_sharpe=b.sharpe if b else None, bt_maxdd=b.max_drawdown if b else None,
            bt_dsr=b.dsr if b else None, bt_oos_sharpe=b.oos_sharpe if b else None,
            bt_as_of=b.as_of if b else "",
            status=str(meta.get("status", "")), note=str(meta.get("note", "")),
            alloc_dollars=(allocs[strategy_id].target_dollars if strategy_id in allocs else None),
            alloc_weight=(allocs[strategy_id].target_weight if strategy_id in allocs else None),
            alloc_policy_version=(allocs[strategy_id].policy_version if strategy_id in allocs else None),
            funding_state=(funds[strategy_id].state if strategy_id in funds else ""),
            funding_label=(funds[strategy_id].label if strategy_id in funds else ""),
            funding_decay_run=(funds[strategy_id].decay_run if strategy_id in funds else 0),
            funding_recovery_run=(funds[strategy_id].recovery_run if strategy_id in funds else 0),
            funding_first_pass_date=(funds[strategy_id].first_pass_date if strategy_id in funds else None),
            funding_policy_version=(funds[strategy_id].policy_version if strategy_id in funds else None),
            **self._health_fields(health, strategy_id),
            **pair,
            exec_venue=self._safe_book_venue(pair["exec_twin_sid"]),
            **self._ibkr_account_fields(strategy_id),
        )
