"""TGTC live trading engine.

Phase sequence (dry-run, no Alpaca orders in V1):
  09:20 ET  pre_screen      – prior-day enrichment + universe filter
  09:29:30  collect_snapshot – Yahoo fetch loop (every 20s until 10:00)
  10:00     finalize_cands   – rank features, score, hard filter → DB candidates
  10:00+    entry_check      – every 5m: VWAP pullback reclaim signal
  10:05+    stop_check       – every 5m: trend_health, stop, partial exit
  15:55     eod_exit         – close all virtual positions
  16:00     post_close       – daily snapshot
"""
from __future__ import annotations

import asyncio
import datetime as dt
import logging
import uuid
from typing import Any
from zoneinfo import ZoneInfo

from apps.tgtc_trader.models import (
    TGTCCandidateRow,
    TGTCDailySnapshotRow,
    TGTCPositionRow,
    TGTCTradeRow,
)
from apps.tgtc_trader.state import TGTCStateManager
from libs.tgtc.domain import TGTCConfig

log = logging.getLogger(__name__)

_TZ_ET = ZoneInfo("America/New_York")


class TGTCEngine:
    """Per-session TGTC intraday engine (dry-run V1)."""

    def __init__(
        self,
        session_id: str,
        config: TGTCConfig,
        state: TGTCStateManager,
    ) -> None:
        self._session_id = session_id
        self._cfg = config
        self._state = state
        self._params = config.tgtc_strategy
        self._log = logging.getLogger(f"tgtc.engine.{session_id}")
        self._snapshots: list[dict] = []  # accumulated during collection
        self._candidates: list[dict] = []
        self._enrichment: dict[str, dict] = {}
        self._bars_by_symbol: dict[str, list[dict]] = {}
        self._prev_closes: dict[str, float] = {}
        self._open_positions: dict[str, TGTCPositionRow] = {}  # symbol → position

    # ── Pre-screen ────────────────────────────────────────────────────────────

    def run_pre_screen(self, date_str: str) -> None:
        """09:20 ET: load universe, run prior-day enrichment."""
        self._state.update_phase(self._session_id, date_str, "pre_screen")
        self._log.info("TGTC pre_screen %s", date_str)
        try:
            self._do_pre_screen(date_str)
        except Exception as exc:
            self._log.error("TGTC pre_screen failed: %s", exc, exc_info=True)

    def _do_pre_screen(self, date_str: str) -> None:
        from apps.orb_trader.screener import load_universe
        from libs.intraday.cache import IntradayCache
        from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc

        universe_source = getattr(self._cfg, "universe", "midlarge")
        tickers = load_universe(universe_source)
        self._log.info("TGTC universe: %d tickers", len(tickers))

        # Load prev_close and enrichment from IntradayCache (prior trading days' 5m bars).
        # This avoids relying on Alpaca daily bars (which don't exist on AlpacaBroker)
        # and daily Parquet cache (which may be empty).
        intraday_cache = IntradayCache()
        date = dt.date.fromisoformat(date_str)

        # Collect up to 20 prior trading days' intraday data for ATR + prev_close
        prior_days: list[str] = []
        for delta in range(1, 30):
            d = (date - dt.timedelta(days=delta)).isoformat()
            prior_days.append(d)
            if len(prior_days) >= 20:
                break

        # 16:00 ET cutoff in naive UTC = 20:00 UTC (EDT = UTC-4)
        close_et_utc_hour = 20

        enrichment_built: dict[str, dict] = {}
        prev_closes_built: dict[str, float] = {}
        miss_count = 0

        for sym in tickers:
            day_closes: list[float] = []  # daily close prices from intraday last bar
            day_ranges: list[float] = []  # daily H-L range for ATR

            for d in prior_days:
                try:
                    bars = intraday_cache.get(sym, d)
                    if not bars:
                        continue
                    # Find last bar at or before 16:00 ET close
                    d_parts = [int(x) for x in d.split("-")]
                    close_cutoff = dt.datetime(d_parts[0], d_parts[1], d_parts[2],
                                              close_et_utc_hour, 0)
                    close_bar = None
                    hi = lo = None
                    for b in bars:
                        b_ts = _bar_ts_naive_utc(b)
                        if b_ts <= close_cutoff:
                            close_bar = b
                            h = float(b.get("high") or 0)
                            l = float(b.get("low") or 0)
                            hi = max(hi, h) if hi is not None else h
                            lo = min(lo, l) if lo is not None else l
                    if close_bar:
                        c = float(close_bar.get("close") or 0)
                        if c > 0:
                            day_closes.append(c)
                        if hi and lo and hi > lo:
                            day_ranges.append(hi - lo)
                except Exception:
                    continue

            if not day_closes:
                miss_count += 1
                continue

            prev_close = day_closes[0]  # most recent prior day
            if prev_close <= 0:
                miss_count += 1
                continue

            prev_closes_built[sym] = prev_close
            atr14 = (sum(day_ranges[:14]) / len(day_ranges[:14])) if day_ranges else None
            last30_closes = day_closes[:30]
            avg_dv = None  # cannot compute dollar vol from intraday last-bar alone
            enrichment_built[sym] = {
                "atr_14": atr14,
                "avg_dollar_vol_30d": avg_dv,
                "avg_dollar_vol_20d": avg_dv,
            }

        self._enrichment = enrichment_built
        self._prev_closes = prev_closes_built
        self._log.info(
            "TGTC pre_screen: %d symbols enriched, %d prev_closes loaded, %d missing",
            len(self._enrichment), len(self._prev_closes), miss_count,
        )

    # ── Snapshot collection ────────────────────────────────────────────────────

    async def run_collect_snapshot(self, date_str: str) -> None:
        """09:29:30–10:00 ET: fetch Yahoo snapshots every interval_seconds."""
        self._state.update_phase(self._session_id, date_str, "collect_snapshot")
        col = self._params.collection
        interval = col.interval_seconds
        self._snapshots = []

        end_et_parts = [int(p) for p in col.end_et.split(":")]
        date = dt.date.fromisoformat(date_str)
        end_et = dt.datetime(date.year, date.month, date.day,
                             end_et_parts[0], end_et_parts[1],
                             tzinfo=_TZ_ET)

        self._log.info("TGTC collect_snapshot starting until %s ET", col.end_et)
        tick_count = 0
        while True:
            now_et = dt.datetime.now(tz=_TZ_ET)
            if now_et >= end_et:
                break

            captured_at = now_et.strftime("%Y-%m-%dT%H:%M:%S")
            try:
                from apps.tgtc_trader.yahoo_gainers import fetch_day_gainers
                from apps.tgtc_trader.snapshot_store import save_snapshot_parquet
                quotes = await fetch_day_gainers()
                if quotes:
                    tick_count += 1
                    rows = []
                    from apps.tgtc_trader.models import TGTCSnapshotRow
                    for q in quotes:
                        snap_row = TGTCSnapshotRow(
                            session_id=self._session_id,
                            date=date_str,
                            captured_at=captured_at,
                            symbol=q.symbol,
                            rank=q.rank,
                            price=q.price,
                            pct_change=q.pct_change,
                            volume=q.volume,
                            market_cap=q.market_cap,
                        )
                        self._snapshots.append({
                            "captured_at": captured_at,
                            "symbol": q.symbol,
                            "rank": q.rank,
                            "price": q.price,
                            "pct_change": q.pct_change,
                            "volume": q.volume,
                            "market_cap": q.market_cap,
                        })
                        rows.append(snap_row)
                    self._state.save_snapshot_batch(rows)
                    save_snapshot_parquet(quotes, date_str, captured_at)
                    self._log.debug("TGTC tick %d: %d gainers @ %s", tick_count, len(quotes), captured_at)
            except Exception as exc:
                self._log.warning("TGTC snapshot fetch error: %s", exc)

            await asyncio.sleep(interval)

        self._log.info("TGTC collect_snapshot done: %d ticks", tick_count)

    # ── Finalize candidates ────────────────────────────────────────────────────

    def run_finalize_candidates(self, date_str: str) -> None:
        """10:00 ET: compute rank features, scores, apply hard filters."""
        self._state.update_phase(self._session_id, date_str, "finalize_candidates")
        self._log.info("TGTC finalize_candidates %s (%d snapshot rows)", date_str, len(self._snapshots))
        try:
            self._do_finalize_candidates(date_str)
        except Exception as exc:
            self._log.error("TGTC finalize_candidates failed: %s", exc, exc_info=True)

    def _do_finalize_candidates(self, date_str: str) -> None:
        # V1 rule-based signals removed in TGTC V2 transition.
        # V2 candidate scoring uses ML selector (libs/tgtc/v2_features.py).
        raise NotImplementedError(
            "V1 finalize_candidates uses deleted signals. "
            "V2 engine to be implemented post selector kill-test (Gate 1)."
        )
        from libs.tgtc.v2_features import compute_rank_features
        from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc
        import datetime as dt

        if not self._snapshots:
            self._log.warning("TGTC finalize_candidates: no snapshots collected")
            return

        rank_features = compute_rank_features(self._snapshots)

        # Fetch intraday bars for rank_features symbols
        candidates_raw = list(rank_features.keys())
        if not candidates_raw:
            return

        # Fetch 5m intraday bars: try IntradayCache first, then live oracle for today
        date = dt.date.fromisoformat(date_str)
        cutoff_utc = dt.datetime(date.year, date.month, date.day, 14, 0)  # 10:00 ET ≈ 14:00 UTC
        syms_to_fetch = list(set(candidates_raw) | {"QQQ"})
        bars_raw: dict[str, list[dict]] = {}
        today_str = dt.date.today().isoformat()
        try:
            from libs.intraday.cache import IntradayCache
            intraday_cache = IntradayCache()
            for sym in syms_to_fetch:
                bars = intraday_cache.get(sym, date_str)
                if bars:
                    bars_raw[sym] = bars
            self._log.info("TGTC finalize: %d/%d symbols from intraday cache",
                           len(bars_raw), len(syms_to_fetch))
        except Exception as exc:
            self._log.warning("TGTC finalize: intraday cache failed: %s", exc)

        # For today's live bars not yet in cache, fetch from oracle
        missing = [s for s in syms_to_fetch if s not in bars_raw]
        if missing and date_str == today_str:
            try:
                from libs.oracle_client.alpaca import get_multi_intraday_bars_today
                live = get_multi_intraday_bars_today(tickers=missing)
                bars_raw.update(live)
                self._log.info("TGTC finalize: %d/%d symbols from live oracle",
                               len(live), len(missing))
            except Exception as exc:
                self._log.warning("TGTC finalize: live oracle fetch failed: %s", exc)

        self._bars_by_symbol = bars_raw

        # QQQ pct change at 10:00
        qqq_bars = self._bars_by_symbol.get("QQQ", [])
        qqq_pct_at_10 = None
        qqq_prev = self._prev_closes.get("QQQ")
        if qqq_bars and qqq_prev and qqq_prev > 0:
            bar10 = None
            for b in qqq_bars:
                b_ts = _bar_ts_naive_utc(b)
                if b_ts <= cutoff_utc:
                    bar10 = b
            if bar10:
                qqq_pct_at_10 = (float(bar10["close"]) - qqq_prev) / qqq_prev

        flt = self._params.filters
        sw = self._params.score_weights
        now_str = dt.datetime.now(tz=_TZ_ET).isoformat()

        candidate_rows: list[TGTCCandidateRow] = []
        self._candidates = []

        for sym, rf in rank_features.items():
            bars = self._bars_by_symbol.get(sym, [])
            if not bars:
                continue
            prev_close = self._prev_closes.get(sym, 0.0)
            if prev_close <= 0:
                continue

            # Find 10:00 ET bar
            bar10 = None
            bar10_idx = -1
            for i, b in enumerate(bars):
                b_ts = _bar_ts_naive_utc(b)
                if b_ts <= cutoff_utc:
                    bar10 = b
                    bar10_idx = i
            if bar10 is None:
                continue

            price_at_10 = float(bar10["close"])
            pct_at_10 = (price_at_10 - prev_close) / prev_close

            # Hard filters
            if price_at_10 < flt.min_price:
                continue
            if pct_at_10 < flt.min_day_change_at_10 or pct_at_10 > flt.max_day_change_at_10:
                continue

            enr = self._enrichment.get(sym, {})
            avg_dv = enr.get("avg_dollar_vol_30d") or enr.get("avg_dollar_vol_20d") or 0.0

            vwap10 = get_bar_vwap(bars, bar10_idx)
            above_vwap = bool(vwap10 and price_at_10 >= vwap10)
            if flt.must_be_above_vwap and not above_vwap:
                continue

            hod = max(float(b["high"]) for b in bars[:bar10_idx + 1]) if bar10_idx >= 0 else price_at_10
            if hod > 0 and (hod - price_at_10) / hod > flt.max_pullback_from_hod:
                continue

            ps = compute_price_structure_score(bars, bar10_idx)
            vq = compute_volume_quality(bars, bar10_idx, avg_dv if avg_dv > 0 else None)
            rs = compute_relative_strength(pct_at_10, qqq_pct_at_10)

            score = compute_tgtc_score(
                rank_persistence=rf["rank_persistence"],
                rank_velocity=max(0.0, rf["rank_velocity"]),
                price_structure=ps,
                volume_quality=vq,
                relative_strength=rs,
                weights=sw,
            )

            candidate_rows.append(TGTCCandidateRow(
                session_id=self._session_id,
                date=date_str,
                symbol=sym,
                score=score,
                rank_persistence=rf["rank_persistence"],
                rank_velocity=rf["rank_velocity"],
                price_structure=ps,
                volume_quality=vq,
                relative_strength=rs,
                pct_change_at_10=pct_at_10,
                price_at_10=price_at_10,
                vwap_at_10=vwap10,
                above_vwap=above_vwap,
                decided_at=now_str,
                status="pending",
            ))
            self._candidates.append({
                "symbol": sym,
                "score": score,
                "rank_persistence": rf["rank_persistence"],
                "rank_velocity": rf["rank_velocity"],
                "price_structure": ps,
                "volume_quality": vq,
                "relative_strength": rs,
                "pct_change_at_10": pct_at_10,
                "price_at_10": price_at_10,
                "vwap_at_10": vwap10,
                "above_vwap": above_vwap,
                "bar_idx_10": bar10_idx,
                "atr_intraday": enr.get("atr_14"),
            })

        candidate_rows.sort(key=lambda r: r.score or 0.0, reverse=True)
        self._candidates.sort(key=lambda c: c["score"], reverse=True)

        if candidate_rows:
            self._state.save_candidates(candidate_rows)
        self._log.info("TGTC finalize: %d candidates saved", len(candidate_rows))

    # ── Entry check ───────────────────────────────────────────────────────────

    def run_entry_check(self, date_str: str) -> None:
        """10:00–15:30 ET every 5m: look for VWAP pullback reclaim entries."""
        try:
            self._do_entry_check(date_str)
        except Exception as exc:
            self._log.error("TGTC entry_check failed: %s", exc, exc_info=True)

    def _do_entry_check(self, date_str: str) -> None:
        # V1 entry signals removed in TGTC V2 transition.
        raise NotImplementedError(
            "V1 entry_check uses deleted signals. "
            "V2 entry to be implemented post selector kill-test (Gate 1)."
        )
        from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc

        rsk = self._params.risk
        ent = self._params.entry
        now_et = dt.datetime.now(tz=_TZ_ET)
        date = dt.date.fromisoformat(date_str)
        cutoff_utc_now = dt.datetime(
            date.year, date.month, date.day,
            now_et.hour, now_et.minute
        ) + dt.timedelta(hours=4)  # rough ET→UTC

        if len(self._open_positions) >= rsk.max_positions:
            return

        # Refresh intraday bars for candidates
        candidate_syms = [c["symbol"] for c in self._candidates[:rsk.max_positions * 3]
                          if c["symbol"] not in self._open_positions]
        if not candidate_syms:
            return

        today_str = dt.date.today().isoformat()
        try:
            from libs.intraday.cache import IntradayCache
            intraday_cache = IntradayCache()
            from_cache: list[str] = []
            not_in_cache: list[str] = []
            for sym in candidate_syms:
                bars = intraday_cache.get(sym, date_str)
                if bars:
                    self._bars_by_symbol[sym] = bars
                    from_cache.append(sym)
                else:
                    not_in_cache.append(sym)
            # For today's live bars, fall back to oracle
            if not_in_cache and date_str == today_str:
                from libs.oracle_client.alpaca import get_multi_intraday_bars_today
                live = get_multi_intraday_bars_today(tickers=not_in_cache)
                self._bars_by_symbol.update(live)
        except Exception as exc:
            self._log.warning("TGTC entry_check: bar refresh failed: %s", exc)
            return

        equity = self._state.get_equity(self._session_id) or rsk.initial_equity

        # Build set of symbols already touched today (open OR closed) — entry-once guard.
        # Mirrors libs/tgtc/simulator.py:281 logic. Checks both tgtc_positions and
        # tgtc_trades so a stop-out in a prior cycle cannot be re-entered.
        traded_today: set[str] = set()
        for p in self._state.get_all_positions(self._session_id):
            if p.get("date") == date_str:
                traded_today.add(p["symbol"])
        for t in self._state.get_trades(self._session_id):
            if t.get("date") == date_str:
                traded_today.add(t["symbol"])

        for cand in self._candidates:
            if len(self._open_positions) >= rsk.max_positions:
                break
            sym = cand["symbol"]
            if sym in self._open_positions or sym in traded_today:
                continue

            bars = self._bars_by_symbol.get(sym, [])
            if not bars:
                continue

            # as_of bar index up to now
            as_of_idx = -1
            for i, b in enumerate(bars):
                if _bar_ts_naive_utc(b) <= cutoff_utc_now:
                    as_of_idx = i
                else:
                    break
            if as_of_idx < cand["bar_idx_10"] + 2:
                continue

            setup = detect_vwap_pullback_reclaim(
                bars=bars,
                start_bar_idx=cand["bar_idx_10"],
                as_of_bar_idx=as_of_idx,
                params=ent,
                prev_close=self._prev_closes.get(sym, 0.0),
                atr_intraday=cand.get("atr_intraday"),
            )
            if setup is None:
                continue

            entry_price = setup["entry_price"]
            stop_price = setup["stop_price"]
            risk_per_share = entry_price - stop_price
            if risk_per_share <= 0:
                continue

            risk_dollars = equity * (rsk.risk_per_trade_pct / 100.0)
            shares = max(1, int(risk_dollars / risk_per_share))

            pos = TGTCPositionRow(
                session_id=self._session_id,
                date=date_str,
                symbol=sym,
                entry_price=entry_price,
                stop_price=stop_price,
                current_stop=stop_price,
                shares=shares,
                entered_at=now_et.isoformat(),
                peak_price=entry_price,
                is_dry_run=True,
            )
            self._state.save_position(pos)
            self._state.update_candidate_status(self._session_id, date_str, sym, "filled")
            self._open_positions[sym] = pos
            self._log.info("TGTC DRY-RUN ENTRY: %s @ %.2f stop=%.2f shares=%d",
                           sym, entry_price, stop_price, shares)

    # ── Stop check ─────────────────────────────────────────────────────────────

    def run_stop_check(self, date_str: str) -> None:
        """Every 5m: check stops and trend health for open positions."""
        try:
            self._do_stop_check(date_str)
        except Exception as exc:
            self._log.error("TGTC stop_check failed: %s", exc, exc_info=True)

    def _do_stop_check(self, date_str: str) -> None:
        # V1 trend health signals removed in TGTC V2 transition.
        raise NotImplementedError(
            "V1 stop_check uses deleted signals. "
            "V2 hazard-based exit to be implemented post exit kill-test (Gate 2)."
        )
        from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc

        if not self._open_positions:
            return

        ex = self._params.exit
        equity = self._state.get_equity(self._session_id) or self._params.risk.initial_equity
        now_et = dt.datetime.now(tz=_TZ_ET)
        date = dt.date.fromisoformat(date_str)
        cutoff_utc_now = dt.datetime(
            date.year, date.month, date.day, now_et.hour, now_et.minute
        ) + dt.timedelta(hours=4)

        for sym in list(self._open_positions):
            pos = self._open_positions[sym]
            bars = self._bars_by_symbol.get(sym, [])
            if not bars:
                continue

            as_of_idx = -1
            for i, b in enumerate(bars):
                if _bar_ts_naive_utc(b) <= cutoff_utc_now:
                    as_of_idx = i
                else:
                    break
            if as_of_idx < 0:
                continue

            bar = bars[as_of_idx]
            cur_price = float(bar["close"])
            cur_low = float(bar["low"])

            # Peak tracking
            if cur_price > pos.peak_price:
                pos.peak_price = cur_price
                self._state.save_position(pos)

            risk_per_share = pos.entry_price - pos.stop_price

            # Partial exit at 1R
            if not pos.partial_taken and risk_per_share > 0:
                target_1r = pos.entry_price + risk_per_share
                if cur_price >= target_1r:
                    partial_shares = max(1, int(pos.shares * ex.partial_at_1r))
                    partial_pnl = (cur_price - pos.entry_price) * partial_shares
                    pos.shares -= partial_shares
                    pos.partial_taken = True
                    if ex.stop_to_be_after_1r:
                        pos.current_stop = pos.entry_price
                        pos.be_stop_active = True
                    equity += partial_pnl
                    self._state.save_position(pos)
                    self._log.info("TGTC DRY-RUN PARTIAL: %s +%.2f", sym, partial_pnl)

            # Stop hit
            if cur_low <= pos.current_stop:
                exit_price = min(pos.current_stop, float(bar["open"]))
                self._close_position(pos, exit_price, "stop_loss", now_et.isoformat(), date_str)
                continue

            # Trend health exit
            trend = compute_trend_health(bars, as_of_idx)
            if trend <= 1:
                self._close_position(pos, cur_price, "trend_health_exit", now_et.isoformat(), date_str)
                continue

    def _close_position(self, pos: TGTCPositionRow, exit_price: float,
                        reason: str, exit_time: str, date_str: str) -> None:
        """Mark position closed and write trade record."""
        risk_per_share = pos.entry_price - pos.stop_price
        pnl = round((exit_price - pos.entry_price) * pos.shares, 2)
        r_mult = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0

        pos.exit_price = exit_price
        pos.exit_reason = reason
        pos.exited_at = exit_time
        pos.pnl = pnl
        pos.r_multiple = r_mult
        pos.status = "closed"
        self._state.save_position(pos)

        trade = TGTCTradeRow(
            trade_id=str(uuid.uuid4())[:8],
            session_id=pos.session_id,
            date=date_str,
            symbol=pos.symbol,
            entry_price=pos.entry_price,
            exit_price=exit_price,
            entered_at=pos.entered_at,
            exited_at=exit_time,
            shares=pos.shares,
            pnl=pnl,
            r_multiple=r_mult,
            exit_reason=reason,
            is_dry_run=pos.is_dry_run,
        )
        self._state.save_trade(trade)
        self._open_positions.pop(pos.symbol, None)
        self._log.info("TGTC DRY-RUN EXIT: %s @ %.2f reason=%s pnl=%.2f",
                       pos.symbol, exit_price, reason, pnl)

    # ── EOD exit ──────────────────────────────────────────────────────────────

    def run_eod_exit(self, date_str: str) -> None:
        """15:55 ET: close all remaining positions at last price."""
        self._state.update_phase(self._session_id, date_str, "eod_exit")
        self._log.info("TGTC eod_exit: closing %d positions", len(self._open_positions))
        now_iso = dt.datetime.now(tz=_TZ_ET).isoformat()

        from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc
        date = dt.date.fromisoformat(date_str)
        eod_utc = dt.datetime(date.year, date.month, date.day, 19, 55)  # 15:55 ET ≈ 19:55 UTC

        for sym in list(self._open_positions):
            pos = self._open_positions[sym]
            bars = self._bars_by_symbol.get(sym, [])
            exit_price = pos.entry_price
            if bars:
                for b in reversed(bars):
                    if _bar_ts_naive_utc(b) <= eod_utc:
                        exit_price = float(b["close"])
                        break
            self._close_position(pos, exit_price, "eod_exit", now_iso, date_str)

    # ── Post close ────────────────────────────────────────────────────────────

    def run_post_close(self, date_str: str) -> None:
        """16:00 ET: write daily snapshot."""
        self._state.update_phase(self._session_id, date_str, "done")
        trades = self._state.get_trades(self._session_id)
        today_trades = [t for t in trades if t.get("date") == date_str]
        daily_pnl = sum(t["pnl"] for t in today_trades if t.get("pnl") is not None)
        all_snap = self._state.get_daily_snapshots(self._session_id, limit=1000)
        total_pnl = sum(s.get("daily_pnl", 0.0) or 0.0 for s in all_snap) + daily_pnl
        equity = (self._params.risk.initial_equity + total_pnl)

        snap = TGTCDailySnapshotRow(
            session_id=self._session_id,
            date=date_str,
            equity=round(equity, 2),
            daily_pnl=round(daily_pnl, 2),
            total_pnl=round(total_pnl, 2),
            trades_taken=len(today_trades),
            phase="done",
        )
        self._state.save_daily_snapshot(snap)
        self._log.info("TGTC post_close %s: pnl=%.2f equity=%.2f trades=%d",
                       date_str, daily_pnl, equity, len(today_trades))


# ── Helpers ───────────────────────────────────────────────────────────────────

def _bars_to_enrichment_fmt(bars: dict) -> dict[str, list[dict]]:
    """Convert AlpacaBroker.get_multi_daily_bars() Bar list to enrich_daily_bars() format."""
    result: dict[str, list[dict]] = {}
    for sym, bar_list in bars.items():
        result[sym] = [
            {
                "date": b.date,
                "open": b.open,
                "high": b.high,
                "low": b.low,
                "close": b.close,
                "volume": b.volume,
            }
            for b in bar_list
        ]
    return result


def make_tgtc_engine(
    session_id: str,
    config_path: str,
    db_path: str | None = None,
) -> TGTCEngine:
    """Factory: load config, create state manager, return engine."""
    from libs.tgtc.domain import load_tgtc_config

    cfg = load_tgtc_config(config_path)
    state = TGTCStateManager(db_path)
    return TGTCEngine(session_id=session_id, config=cfg, state=state)
