# FILE-SIZE-OK: Watchdog logic (Issue #107) is tightly coupled with main loop
# INIT-MONOLITH-OK
"""Layer 3 live streaming sidecar for open deviation bar construction (Issue #91).

Long-running sidecar that streams live trades from Binance WebSocket,
constructs open deviation bars in real-time with full 58-column microstructure
features, and writes them to ClickHouse.

Architecture:
    Binance WS (per symbol) → Rust StreamManager → Python → ClickHouse

The Rust engine handles WebSocket → trade → bar entirely without GIL.
StreamManager wraps LiveBarEngine with adaptive rate limiting (Issue #162),
trade-ID continuity tracking, and shadow validation diagnostics.

NOTE(#279): Current Rust engine uses per-symbol WebSocket connections.
Empirical testing (2026-03-16) showed combined streams (2x9 hybrid)
deliver 4x throughput with zero recv timeouts. Migration planned in #279.
Python only crosses the boundary per completed bar (~1-6/sec), then
writes to ClickHouse via existing ``store_bars_batch()`` path.

Usage
-----
Start sidecar for specific symbols:

>>> from opendeviationbar.sidecar import run_sidecar, SidecarConfig
>>> config = SidecarConfig(symbols=["BTCUSDT"], thresholds=[250, 500])
>>> run_sidecar(config)  # Blocks until interrupted

"""

from __future__ import annotations

import contextlib
import logging
import os
import signal
import time
from collections import defaultdict
from typing import Any

from opendeviationbar.config.sidecar import SidecarConfig  # re-export (Issue #194)
from opendeviationbar.phi_accrual import PhiAccrualDetector

from .ch_writer_flag import (
    _shadow_compare,
    _should_python_write,
    _should_shadow_compare,
    _validate_rust_ch_writer_mode,
)

# Re-export all submodule public names for backward compatibility
from .checkpoint import (
    _EXPECTED_KEY_PARTS as _EXPECTED_KEY_PARTS,
)
from .checkpoint import (
    _checkpoint_dir as _checkpoint_dir,
)
from .checkpoint import (
    _checkpoint_path as _checkpoint_path,
)
from .checkpoint import (
    _extract_checkpoints,
    _inject_checkpoints,
    _load_checkpoint,
    _save_checkpoint,
)
from .clickhouse_write import (
    _write_bars_batch_to_clickhouse as _write_bars_batch_to_clickhouse,
)
from .clickhouse_write import (
    _write_batch_with_retry,
)
from .config_helpers import (
    _backfill_cfg as _backfill_cfg,
)
from .config_helpers import (
    _notify_seeding_failure as _notify_seeding_failure,
)
from .config_helpers import (
    _resilience_cfg as _resilience_cfg,
)
from .config_helpers import (
    _seed_trade_ids_from_clickhouse,
    _sidecar_cfg,
)
from .dead_letter import (
    _DEAD_LETTER_DIR,
)
from .dead_letter import (
    _OVERLAP_HEAL_INTERVAL_S as _OVERLAP_HEAL_INTERVAL_S,
)
from .dead_letter import (
    _niffler_dead_letter as _niffler_dead_letter,
)
from .dead_letter import (
    _niffler_immediate_replay as _niffler_immediate_replay,
)
from .dead_letter import (
    _niffler_notify_dead_letter as _niffler_notify_dead_letter,
)
from .delete_strategy import (
    _aion_delete_after_resume as _aion_delete_after_resume,
)
from .exchange_gap_floor import (
    _clamp_resume_tid_above_exchange_gaps as _clamp_resume_tid_above_exchange_gaps,
)
from .http_server import (
    _SSE_CLIENT_QUEUE_SIZE as _SSE_CLIENT_QUEUE_SIZE,
)
from .http_server import (
    _ariadne_bucket as _ariadne_bucket,
)
from .http_server import (
    _broadcast_bar_event,
    _broadcast_checkpoint_event,
    _broadcast_gap_event,
    _health_state,
    _sse_clients,
    _sse_clients_lock,
    _start_forming_bar_broadcaster,
    _start_health_server,
    _update_health_metrics,
    _update_stream_signal,
)
from .http_server import (
    _broadcast_forming_event as _broadcast_forming_event,
)
from .http_server import (
    _catchup_bucket as _catchup_bucket,
)
from .http_server import (
    _gap_fill_bucket as _gap_fill_bucket,
)
from .http_server import (
    _junction_state as _junction_state,
)
from .http_server import (
    _register_sse_client as _register_sse_client,
)
from .http_server import (
    _TokenBucket as _TokenBucket,
)
from .http_server import (
    _unregister_sse_client as _unregister_sse_client,
)
from .junction_telemetry import (
    _log_junction_telemetry as _log_junction_telemetry,
)
from .notifications import (
    _notify_argus_stale as _notify_argus_stale,
)
from .notifications import (
    _notify_charon_replay,
    _notify_lethe_overflow,
    _notify_restart_failure,
    _notify_watchdog_trigger,
)
from .parquet_fill import (
    _parquet_prefill_aion as _parquet_prefill_aion,
)
from .recovery import _restart_engine_with_recovery
from .sync_flush import (
    _sync_flush_rest_bars,
)

logger = logging.getLogger(__name__)

__all__ = ["SidecarConfig", "run_sidecar"]

_STARTUP_SYMBOL_PREVIEW = 5  # Max symbols shown in startup Telegram
_ARGUS_TRADE_RECENCY_S = 60  # Trades within this age = "quiet" (not "dead")
_BURST_DRAIN_MAX = 256  # Issue #273: max bars to drain atomically from ring buffer


# =============================================================================
# Engine creation helper (Issue #107: watchdog restart)
# =============================================================================


def _create_engine(  # noqa: PLR0915
    config: SidecarConfig,
    trace_id: str,
    gap_fill_results: dict | None,
    valid_pairs: set[tuple[str, int]] | None = None,
) -> tuple[object, int, dict[tuple[str, int], int], dict[str, str], object, str]:
    """Create and configure a streaming engine.

    Returns (engine, checkpoints_loaded, min_tid_guard, symbol_modes, ch_cache,
    rust_ch_writer_mode).

    Issue #161: Replaced LiveBarEngine with StreamManager for:
    - Adaptive rate limiting on REST gap-fill (Issue #162)
    - Trade-ID continuity tracking seeded from ClickHouse
    - Shadow validation diagnostics

    Issue #178: Feature-flagged OdbEngine (unified hot-path/cold-path engine).
    Set OPENDEVIATIONBAR_ENGINE=unified to opt in. Default: legacy (StreamManager).

    Phase 16-02: Branches startup path based on per-symbol ouroboros_mode.
    Aion symbols use checkpoint-resume; Day symbols use clean-slate DELETE.
    """
    import os

    # Issue #128: Wire per-feature computation toggles from Settings
    from opendeviationbar.config import Settings

    pop = Settings.get().population
    engine_mode = os.environ.get("OPENDEVIATIONBAR_ENGINE", "legacy")

    # Issue #318: Read Rust CH writer feature flag exactly once at startup.
    rust_ch_writer_mode = _validate_rust_ch_writer_mode(
        os.environ.get("OPENDEVIATIONBAR_USE_RUST_CH_WRITER", "off")
    )
    logger.info("Rust CH writer mode: %s", rust_ch_writer_mode)

    # All symbols are Aion (day-mode fully removed).
    symbol_modes: dict[str, str] = dict.fromkeys(config.symbols, "aion")
    aion_symbols = list(config.symbols)
    logger.info("symbol modes: %d aion", len(aion_symbols))

    # ALLOC-01: Single cache instance for all startup phases + main loop.
    # Do NOT use as context manager — __exit__ calls close(), killing the connection.
    from opendeviationbar.clickhouse.cache import OpenDeviationBarCache

    ch_cache = OpenDeviationBarCache()

    if engine_mode == "unified":
        from opendeviationbar._core import OdbEngine

        engine = OdbEngine(
            config.symbols,
            config.thresholds,
            config.include_microstructure,
            compute_tier2=pop.compute_tier2,
            compute_tier3=pop.compute_tier3,
            compute_hurst=pop.compute_hurst or None,
            compute_permutation_entropy=pop.compute_permutation_entropy or None,
        )
        logger.info("Using unified OdbEngine (Issue #178)")
    else:
        from opendeviationbar._core import StreamManager

        engine = StreamManager(
            config.symbols,
            config.thresholds,
            config.include_microstructure,
            compute_tier2=pop.compute_tier2,
            compute_tier3=pop.compute_tier3,
            compute_hurst=pop.compute_hurst or None,
            compute_permutation_entropy=pop.compute_permutation_entropy or None,
            ws_mode=config.ws_mode,
            symbol_modes=symbol_modes,
        )
        logger.info("WS mode: %s", config.ws_mode)

    # Issue #286: Start WS connections FIRST — buffer trades immediately.
    if hasattr(engine, "start_ws"):
        engine.start_ws()
        logger.info("WS connections started (buffered mode) — loading checkpoints next")

    # Phase 16-02: Branch startup path based on per-symbol ouroboros_mode.
    # Aion symbols: DELETE today first, then checkpoint-resume + CH seeding

    needs_fill = hasattr(engine, "fill_from_rest")
    loaded, loaded_pairs = 0, set()

    if needs_fill:
        # --- Aion path: resume from last committed bar ---
        # Aion = one continuous stream per symbol. No resets.
        # Resume point = min(max(last_agg_trade_id)) across all thresholds
        # per symbol. This is the threshold furthest behind — filling from
        # there covers all thresholds since they share the same trade stream.
        # No checkpoints needed: CH committed bars are the source of truth.
        if aion_symbols:
            from opendeviationbar.symbol_registry import (
                get_thresholds_for_symbol,
            )

            # Step 1: Kill mutations (clean queue before DELETE)
            try:
                ch_cache.client.command(
                    "KILL MUTATION WHERE database = 'opendeviationbar_cache'"
                )
            except (OSError, RuntimeError, ConnectionError):
                logger.warning("aion: kill mutations failed (non-fatal)", exc_info=True)

            # Step 2: Find resume TID per symbol from ClickHouse.
            # Uses max(max(last_agg_trade_id)) across thresholds. With repair
            # checkpoints (Step 4), per-threshold seeding + min() REST start
            # is used. Without checkpoints, max() avoids fresh-processor
            # junction gaps for the leading threshold.
            per_threshold_tids: dict[str, dict[int, int]] = {}
            resume_tids: dict[str, int | None] = {}
            t_resume_start = time.monotonic()
            for sym in aion_symbols:
                try:
                    result = ch_cache.client.query(
                        "SELECT threshold_decimal_bps, max(last_agg_trade_id) AS max_tid"
                        "  FROM opendeviationbar_cache.open_deviation_bars FINAL"
                        f"  WHERE symbol = '{sym}' AND first_agg_trade_id > 0"
                        "  GROUP BY threshold_decimal_bps"
                    )
                    thr_tids = {
                        int(row[0]): int(row[1])
                        for row in result.result_rows
                        if row[1] is not None and row[1] > 0
                    }
                    if thr_tids:
                        per_threshold_tids[sym] = thr_tids
                        # Default to max() — safe without checkpoints.
                        # Step 4 switches to min() if checkpoints are available.
                        resume_tids[sym] = max(thr_tids.values())
                    else:
                        resume_tids[sym] = None
                except (OSError, RuntimeError):
                    logger.warning(
                        "aion resume query failed for %s", sym, exc_info=True
                    )
                    resume_tids[sym] = None

            t_resume_elapsed = time.monotonic() - t_resume_start
            seeded_syms = [
                f"{s}@{t}" for s, t in sorted(resume_tids.items()) if t is not None
            ]
            logger.info(
                "aion: resume TIDs for %d/%d symbols in %.1fs: %s",
                len(seeded_syms),
                len(aion_symbols),
                t_resume_elapsed,
                ", ".join(seeded_syms),
            )

            # Step 2.5: Clamp resume TIDs above known exchange gaps (RESUME-01).
            # Exchange gaps are permanent TID ranges where the exchange had no
            # trades. Raising resume_tid past them avoids futile REST fills.
            resume_tids = _clamp_resume_tid_above_exchange_gaps(
                ch_cache, resume_tids
            )

            # Step 3: Load repair checkpoints BEFORE DELETE decision.
            # CRITICAL ordering: checkpoints determine the final resume TID
            # which determines the DELETE scope. Loading checkpoints AFTER
            # DELETE (the old ordering) caused a min/max flip that orphaned
            # bars and created cascading gaps on every redeploy.
            from pathlib import Path as _Path

            from platformdirs import user_cache_dir as _ucd

            _ckpt_dir = _Path(_ucd("opendeviationbar")) / "repair_checkpoints"
            _ckpts_loaded = 0
            for sym in aion_symbols:
                sym_thr_tids = per_threshold_tids.get(sym, {})
                fallback_tid = resume_tids.get(sym)
                if fallback_tid is None and not sym_thr_tids:
                    continue
                for thr in config.thresholds:
                    seed_tid = sym_thr_tids.get(thr, fallback_tid)
                    if seed_tid is not None:
                        engine.seed_trade_id(sym, thr, seed_tid)
                    # Try to load repair checkpoint for this (sym, thr)
                    ckpt_file = _ckpt_dir / f"{sym}_{thr}.json"
                    if ckpt_file.exists():
                        try:
                            import json as _json

                            ckpt_data = _json.loads(ckpt_file.read_text())
                            engine.set_checkpoint(sym, thr, ckpt_data)
                            _ckpts_loaded += 1
                        except (OSError, RuntimeError, ValueError, KeyError):
                            logger.debug(
                                "aion: repair checkpoint load failed for %s@%d",
                                sym, thr,
                            )

            if _ckpts_loaded > 0:
                # Checkpoints loaded → use per-threshold min() for both
                # DELETE scope and REST fill start. This is safe because
                # checkpoints carry the forming bar state for each threshold.
                for sym in aion_symbols:
                    thr_tids = per_threshold_tids.get(sym, {})
                    if thr_tids:
                        resume_tids[sym] = min(thr_tids.values())
                logger.info(
                    "aion: loaded %d repair checkpoints — per-threshold min() resume",
                    _ckpts_loaded,
                )
            else:
                # No checkpoints → keep max() resume (fewer junction gaps)
                logger.info(
                    "aion: no repair checkpoints — max() resume (junction gaps expected)"
                )
            logger.info(
                "aion: seeded %d symbols from CH resume TIDs",
                len(seeded_syms),
            )

            # Step 4: DELETE bars after resume_tid (not date-based — Aion has no days).
            # Now uses the FINAL resume TIDs (post-checkpoint loading), so
            # DELETE scope matches what fill_from_rest will actually fill.
            # Skip-if-clean optimization (RESUME-04): pre-check max(last_agg_trade_id)
            # per partition and skip no-ops to avoid ~8.5s synchronous scans each.
            _aion_delete_after_resume(
                ch_cache, aion_symbols, resume_tids, get_thresholds_for_symbol,
                per_threshold_tids=per_threshold_tids,
            )

            # Step 4.1: Bulk Parquet population for empty symbols.
            # On clean-slate deploy (table empty or symbol has 0 bars), run
            # repair_direct_parquet-style bulk processing from local Parquet
            # ticks. This eliminates the need for manual intervention and
            # makes clean-slate deploys populate ALL historical data in one go.
            empty_syms = [s for s in aion_symbols if resume_tids.get(s) is None]
            if empty_syms:
                try:
                    from .bulk_populate import _bulk_populate_from_parquet

                    bulk_results = _bulk_populate_from_parquet(
                        ch_cache, empty_syms, config.thresholds,
                    )
                    if bulk_results:
                        # Re-query resume TIDs for populated symbols
                        for sym in bulk_results:
                            try:
                                result = ch_cache.client.query(
                                    "SELECT threshold_decimal_bps, max(last_agg_trade_id) AS max_tid"
                                    "  FROM opendeviationbar_cache.open_deviation_bars FINAL"
                                    f"  WHERE symbol = '{sym}' AND first_agg_trade_id > 0"
                                    "  GROUP BY threshold_decimal_bps"
                                )
                                thr_tids = {
                                    int(row[0]): int(row[1])
                                    for row in result.result_rows
                                    if row[1] is not None and row[1] > 0
                                }
                                if thr_tids:
                                    per_threshold_tids[sym] = thr_tids
                                    resume_tids[sym] = max(thr_tids.values())
                                    for thr, tid in thr_tids.items():
                                        engine.seed_trade_id(sym, thr, tid)
                            except (OSError, RuntimeError):
                                pass
                        logger.info(
                            "bulk-populate: seeded %d symbols, re-queried resume TIDs",
                            len(bulk_results),
                        )
                except Exception:
                    logger.warning(
                        "bulk-populate: failed (non-fatal, REST will fill)",
                        exc_info=True,
                    )

            # Step 4.5: Parquet pre-fill (RESUME-02) -- read local tick cache before REST
            try:
                parquet_seeds = _parquet_prefill_aion(
                    ch_cache, aion_symbols, resume_tids, config.thresholds,
                )
                if parquet_seeds:
                    for sym, seed_tid in parquet_seeds.items():
                        for thr in config.thresholds:
                            engine.seed_trade_id(sym, thr, seed_tid)
                    logger.info(
                        "parquet-prefill: advanced %d/%d aion symbols: %s",
                        len(parquet_seeds),
                        len(aion_symbols),
                        ", ".join(
                            f"{s}@{tid}"
                            for s, tid in sorted(parquet_seeds.items())
                        ),
                    )
            except (OSError, RuntimeError, ConnectionError):
                logger.warning(
                    "parquet-prefill: failed (non-fatal)", exc_info=True
                )

        # STREAM-01: fill_from_rest processes trades entirely in Rust.
        # Returns bar count (u64). Bars retrieved via drain_bars() in _sync_flush_rest_bars().
        # No trade DataFrame is materialized in Python during this path.
        # Handles both Aion and Day symbols — Rust uses per-symbol mode from symbol_modes.
        try:
            rest_bars = engine.fill_from_rest()
            logger.info(
                "fill_from_rest: %d bars produced (processors preserved for WS)",
                rest_bars,
            )
        except (RuntimeError, OSError) as e:
            logger.warning(
                "fill_from_rest failed: %s (falling back to WS-only start)", e
            )
            rest_bars = 0

        # Check ring buffer overflow — if bars were dropped during REST fill, log LOUD warning
        if hasattr(engine, "get_metrics"):
            post_fill_metrics = engine.get_metrics()
            post_fill_dropped = post_fill_metrics.get("dropped_bars", 0)
            if post_fill_dropped > 0:
                logger.warning(
                    "fill_from_rest: %d bars DROPPED due to ring buffer overflow "
                    "(capacity may need increase via OPENDEVIATIONBAR_MAX_PENDING_BARS)",
                    post_fill_dropped,
                )

        # (#296) Sync flush: drain REST bars from ring buffer and write to CH
        if rest_bars > 0:
            logger.info(
                "sync_flush: draining %d REST bars from ring buffer...", rest_bars
            )
            flushed = _sync_flush_rest_bars(
                engine, valid_pairs, symbol_modes=symbol_modes, cache=ch_cache,
                rust_ch_writer_mode=rust_ch_writer_mode,
            )
            logger.info("sync_flush: drained and wrote %d bars to CH", flushed)

        # Step 5.5: Heal aion junction gaps inline (RESUME-05).
        # max() resume creates small gaps for trailing thresholds. Heal them
        # immediately via fill_gap() so no gaps persist until kintsugi.
        if aion_symbols and ch_cache is not None:
            try:
                from .aion_junction_heal import _heal_aion_junction_gaps

                healed = _heal_aion_junction_gaps(
                    engine, aion_symbols, config.thresholds, ch_cache
                )
                if healed > 0:
                    heal_flushed = _sync_flush_rest_bars(
                        engine, valid_pairs, symbol_modes=symbol_modes, cache=ch_cache,
                        rust_ch_writer_mode=rust_ch_writer_mode,
                    )
                    logger.info(
                        "aion-junction-heal: flushed %d healed bars to CH",
                        heal_flushed,
                    )
            except (OSError, RuntimeError, ConnectionError):
                logger.warning(
                    "aion-junction-heal failed (non-fatal)", exc_info=True
                )

        # Step 5.6: Junction telemetry (RESUME-03)
        if aion_symbols:
            try:
                _log_junction_telemetry(engine, aion_symbols, resume_tids)
            except (OSError, RuntimeError, ValueError, KeyError, TypeError):
                logger.warning("junction telemetry failed (non-fatal)", exc_info=True)

        # Step 5.7: Startup self-test — verify zero Stathera anomalies.
        # Convergence loop: OPTIMIZE for overlaps, log gaps for kintsugi.
        # Must pass before entering main loop. (#318 zero-tolerance deploy)
        if ch_cache is not None:
            try:
                from .startup_selftest import _run_startup_selftest

                selftest_clean = _run_startup_selftest(
                    ch_cache, valid_pairs=valid_pairs,
                )
                if not selftest_clean:
                    logger.warning(
                        "startup-selftest: anomalies remain — entering main loop anyway"
                    )
            except Exception:
                logger.warning("startup-selftest failed (non-fatal)", exc_info=True)
    else:
        # Non-fill_from_rest path (OdbEngine or legacy)
        loaded, loaded_pairs = _inject_checkpoints(
            engine,
            config.symbols,
            config.thresholds,
            trace_id,
            gap_fill_results=gap_fill_results,
            valid_pairs=valid_pairs,
        )
        _seed_trade_ids_from_clickhouse(engine, config.symbols, config.thresholds)

    # (#286 fix) Build minimum-TID guard for pairs that started without a checkpoint.
    min_tid_guard: dict[tuple[str, int], int] = {}
    all_pairs = (
        valid_pairs
        if valid_pairs is not None
        else {(sym, thr) for sym in config.symbols for thr in config.thresholds}
    )
    unloaded_pairs = all_pairs - loaded_pairs
    if unloaded_pairs:
        try:
            for symbol, threshold in unloaded_pairs:
                try:
                    _, ch_last_tid = ch_cache.get_latest_bar_with_trade_id(
                        symbol, threshold
                    )
                    if ch_last_tid and ch_last_tid > 0:
                        min_tid_guard[(symbol, threshold)] = ch_last_tid
                except (OSError, RuntimeError):
                    pass
        except (OSError, RuntimeError):
            pass
        if min_tid_guard:
            logger.info(
                "min-TID guard active for %d pairs (no checkpoint loaded): %s",
                len(min_tid_guard),
                ", ".join(f"{s}@{t}" for s, t in sorted(min_tid_guard)),
            )

    engine.start()
    return engine, loaded, min_tid_guard, symbol_modes, ch_cache, rust_ch_writer_mode


# =============================================================================
# Main sidecar loop
# =============================================================================


def run_sidecar(config: SidecarConfig) -> None:  # noqa: PLR0915
    """Main sidecar entry point. Blocks until interrupted.

    Parameters
    ----------
    config
        Sidecar configuration. Use ``SidecarConfig.from_env()`` for
        env-var-driven setup, or construct directly.

    Issue #96 Task #144 Phase 3: Streaming latency optimization
    - Batches bar writes to reduce ClickHouse roundtrips
    - Accumulates bars by (symbol, threshold) pair
    - Flushes on timeout or batch size threshold
    """
    # Issue #126: SIGHUP reloads config (enables runtime ouroboros_mode switching)

    def _sighup_handler(*_args: object) -> None:
        from opendeviationbar.config import Settings

        Settings.reload_and_clear()
        new_mode = Settings.get().population.ouroboros_mode
        logger.info("SIGHUP: config reloaded (ouroboros_mode=%s)", new_mode)
        try:
            from opendeviationbar.notify.telegram import send_telegram

            send_telegram(
                f"<b>SIGHUP: sidecar config reloaded</b>\n"
                f"<b>ouroboros_mode:</b> {new_mode}\n"
                f"<b>PID:</b> {os.getpid()}",
                disable_notification=True,
            )
        except (ImportError, OSError):
            logger.debug("SIGHUP: Telegram notification failed (non-fatal)")

    signal.signal(signal.SIGHUP, _sighup_handler)

    from pathlib import Path

    from opendeviationbar.logging import generate_trace_id

    trace_id = generate_trace_id("sdc")
    sidecar_start_time = time.monotonic()
    _health_state["start_monotonic"] = sidecar_start_time
    _health_state["ws_mode"] = config.ws_mode
    _health_state["ws_symbols_count"] = len(config.symbols)

    # DEPL-04: Log git SHA at startup for deploy verification
    try:
        import subprocess as _sp

        _git_sha = _sp.check_output(
            ["git", "rev-parse", "--short", "HEAD"],
            cwd=str(Path(__file__).resolve().parent.parent.parent),
            text=True,
            timeout=5,
        ).strip()
    except Exception:
        _git_sha = "unknown"
    from opendeviationbar import __version__

    logger.info(
        "sidecar_startup: version=%s git_sha=%s trace=%s",
        __version__,
        _git_sha,
        trace_id,
    )

    if not config.symbols:
        msg = (
            "No symbols configured. "
            "Enable symbols in python/opendeviationbar/data/symbols.toml "
            "(set enabled=true) or pass symbols list directly. "
            "OPENDEVIATIONBAR_STREAMING_SYMBOLS env var overrides the registry."
        )
        raise ValueError(msg)

    from opendeviationbar.threshold import get_min_threshold_for_symbol

    valid_pairs: set[tuple[str, int]] = set()
    for sym in config.symbols:
        min_thr = get_min_threshold_for_symbol(sym)
        for thr in config.thresholds:
            if thr >= min_thr:
                valid_pairs.add((sym, thr))
            else:
                logger.info(
                    "skipping %s@%d (below min_threshold %d)", sym, thr, min_thr
                )

    if not valid_pairs:
        msg = "No valid (symbol, threshold) pairs after filtering"
        raise ValueError(msg)

    try:
        from opendeviationbar.symbol_registry import filter_pairs_by_registry

        filtered = filter_pairs_by_registry(valid_pairs)
        dropped = valid_pairs - set(filtered)
        if dropped:
            for sym, thr in sorted(dropped):
                logger.info("skipping %s@%d (disabled in symbol registry)", sym, thr)
        valid_pairs = set(filtered)
    except ImportError:
        pass

    if not valid_pairs:
        msg = "No valid pairs after symbol registry gate (all symbols disabled?)"
        raise ValueError(msg)

    sym_thresholds: dict[str, list[int]] = defaultdict(list)
    for sym, thr in valid_pairs:
        sym_thresholds[sym].append(thr)
    for _sym, thrs in sym_thresholds.items():
        thrs.sort()

    logger.info(
        "valid pairs: %d symbols, %d total (symbol, threshold) pairs",
        len(sym_thresholds),
        len(valid_pairs),
    )
    for sym, thrs in sorted(sym_thresholds.items()):
        logger.info("  %s: %s", sym, thrs)

    env_symbols_str = os.environ.get("OPENDEVIATIONBAR_STREAMING_SYMBOLS", "")
    from opendeviationbar.symbol_registry import normalize_symbol

    env_symbols = [normalize_symbol(s) for s in env_symbols_str.split(",") if s.strip()]
    config_upper = {normalize_symbol(s) for s in config.symbols}
    if env_symbols and config_upper != set(env_symbols):
        missing = sorted(set(env_symbols) - config_upper)
        coverage_msg = (
            f"Symbol mismatch: {len(config.symbols)}/{len(env_symbols)} symbols. "
            f"Missing: {missing}. Use --from-env."
        )
        logger.warning(coverage_msg)
        try:
            from opendeviationbar.hooks import HookEvent, emit_hook

            emit_hook(
                HookEvent.SYMBOL_COVERAGE_MISMATCH,
                symbol="*",
                active=len(config.symbols),
                expected=len(env_symbols),
                missing=missing,
            )
        except (ImportError, OSError, RuntimeError, ValueError):
            logger.debug("failed to emit SYMBOL_COVERAGE_MISMATCH hook", exc_info=True)
        try:
            import sys

            from opendeviationbar.notify.telegram import (
                _build_context_block,
                send_telegram,
            )

            msg = (
                "<b>SIDECAR PARTIAL COVERAGE</b>\n"
                f"<b>Active:</b> {len(config.symbols)}/{len(env_symbols)} symbols\n"
                f"<b>Missing:</b> {', '.join(missing)}\n"
                "<b>Env var:</b> OPENDEVIATIONBAR_STREAMING_SYMBOLS\n"
                "<b>Fix:</b> use <code>--from-env</code> flag\n"
                f"<b>Cmdline:</b> <code>{' '.join(sys.argv)}</code>"
                + _build_context_block("sidecar")
            )
            send_telegram(msg, disable_notification=False)
        except (ImportError, OSError, RuntimeError):
            logger.debug(
                "failed to send coverage mismatch alert",
                exc_info=True,
            )

    logger.info(
        "starting sidecar: symbols=%s, thresholds=%s, microstructure=%s, "
        "watchdog_timeout_s=%d, max_watchdog_restarts=%d",
        config.symbols,
        config.thresholds,
        config.include_microstructure,
        config.watchdog_timeout_s,
        config.max_watchdog_restarts,
    )

    health_server = _start_health_server(
        config.health_port, sse_enabled=config.sse_enabled
    )

    try:
        from opendeviationbar.notify.telegram import set_start_time

        set_start_time(sidecar_start_time)
    except ImportError:
        pass
    try:
        from opendeviationbar.hooks import HookEvent, emit_hook

        emit_hook(
            HookEvent.SIDECAR_STARTED,
            symbol="*",
            symbols=config.symbols,
            thresholds=config.thresholds,
            health_port=config.health_port,
            pid=os.getpid(),
        )
    except (ImportError, OSError, RuntimeError, ValueError):
        logger.debug("failed to emit SIDECAR_STARTED hook", exc_info=True)
    try:
        from opendeviationbar.notify.telegram import _build_context_block, send_telegram

        preview = ", ".join(config.symbols[:_STARTUP_SYMBOL_PREVIEW])
        ellipsis = "..." if len(config.symbols) > _STARTUP_SYMBOL_PREVIEW else ""
        thresholds_summary = "; ".join(
            f"{sym}:{thrs}" for sym, thrs in sorted(sym_thresholds.items())
        )
        startup_msg = (
            "<b>SIDECAR STARTED</b>\n"
            f"<b>Symbols:</b> {len(config.symbols)} "
            f"({preview}{ellipsis})\n"
            f"<b>Coverage:</b> {thresholds_summary}\n"
            f"<b>Health:</b> :{config.health_port}/health\n"
            f"<b>PID:</b> {os.getpid()}" + _build_context_block("sidecar")
        )
        send_telegram(startup_msg, disable_notification=True)
    except (ImportError, OSError, RuntimeError):
        logger.debug("failed to send sidecar startup Telegram alert", exc_info=True)

    gap_fill_results = None

    if _DEAD_LETTER_DIR.exists() and any(_DEAD_LETTER_DIR.glob("*.parquet")):
        try:
            from opendeviationbar.kintsugi import replay_dead_letters

            charon_replayed = replay_dead_letters()
            if charon_replayed > 0:
                _notify_charon_replay(charon_replayed, "startup")
                logger.info(
                    "Charon: replayed %d dead-letter bars on startup", charon_replayed
                )
        except (ImportError, OSError, RuntimeError, ConnectionError):
            logger.exception("Charon: startup replay failed (non-fatal)")

    engine, checkpoints_loaded, min_tid_guard, symbol_modes, ch_cache, rust_ch_writer_mode = _create_engine(
        config,
        trace_id,
        gap_fill_results,
        valid_pairs=valid_pairs,
    )
    logger.info(
        "live bar engine started (checkpoints_loaded=%d)",
        checkpoints_loaded,
    )

    _health_state["engine"] = engine

    _update_stream_signal("CONNECT")
    _update_stream_signal("FIRST_DATA")

    forming_bar_watcher = engine.take_forming_bar_watcher()
    forming_bar_stop = None
    if config.sse_enabled and config.forming_bar_interval_s > 0:
        if forming_bar_watcher is not None:
            forming_bar_stop = _start_forming_bar_broadcaster(
                forming_bar_watcher,
                valid_pairs,
                interval_s=config.forming_bar_interval_s,
                stale_s=config.forming_bar_stale_s,
            )
        else:
            logger.warning("forming bar watcher not available — broadcaster disabled")

    gap_watcher = (
        engine.take_gap_event_watcher()
        if hasattr(engine, "take_gap_event_watcher")
        else None
    )
    if gap_watcher is not None:
        logger.info("gap event watcher attached — SSE gap_detected events enabled")
    else:
        logger.debug("gap event watcher not available — gap SSE events disabled")

    running = True

    def _handle_signal(signum: int, _frame: object) -> None:
        nonlocal running
        logger.info("received signal %d, shutting down", signum)
        running = False

    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    bars_written = 0

    last_trades_received = 0
    last_trade_increment_time = time.monotonic()
    watchdog_restarts = 0
    consecutive_timeouts = 0
    last_restart_mono = 0.0
    timeout_heartbeat_interval = 60

    lethe_last_dropped = 0
    lethe_drop_accumulator = 0
    lethe_threshold = config.lethe_alert_threshold

    argus_last_bar_time: dict[str, float] = {}
    argus_last_check = time.monotonic()
    argus_phi = PhiAccrualDetector(
        threshold=8.0,
        first_heartbeat_estimate_ms=60_000,
        min_std_deviation_ms=5_000.0,
    )
    if config.argus_timeout_s > 0:
        # Argus only monitors symbols that stream via WebSocket (Binance).
        # Non-Binance symbols (e.g., Exness forex) are seeded via timer,
        # not streamed — monitoring them would cause perpetual false alerts.
        try:
            from opendeviationbar.symbol_registry import get_symbol_entries

            _ws_exchanges = {"binance"}
            _registry = get_symbol_entries()
            _argus_symbols = {
                sym
                for sym, _ in valid_pairs
                if sym in _registry and _registry[sym].exchange in _ws_exchanges
            }
        except ImportError:
            _argus_symbols = {sym for sym, _ in valid_pairs}

        now_mono = time.monotonic()
        for sym in _argus_symbols:
            argus_last_bar_time.setdefault(sym, now_mono)
            argus_phi.heartbeat(sym, now_mono * 1000)

    charon_last_replay = time.monotonic()

    last_checkpoint_push = time.monotonic()

    bar_batch_buffer = defaultdict(list)
    batch_max_size = 10
    batch_timeout_s = 1.0
    last_batch_flush_time = time.monotonic()

    def _update_data_freshness(
        symbol: str, threshold: int, bars: list[dict[str, Any]]
    ) -> None:
        """Update data_freshness table with latest bar timestamp (Issue #163)."""
        if not bars or ch_cache is None:
            return
        last_bar = bars[-1]
        close_time_us = last_bar.get("close_time_us", 0)
        if not close_time_us:
            return
        with contextlib.suppress(OSError, RuntimeError):
            ch_cache.client.command(
                "INSERT INTO opendeviationbar_cache.data_freshness "
                "(symbol, threshold_decimal_bps, last_close_time_us, last_bar_count) "
                f"VALUES ('{symbol}', {threshold}, {close_time_us}, {bars_written})"
            )

    def _flush_all_batches() -> int:
        nonlocal last_batch_flush_time
        written = 0
        for (symbol, threshold), bars in bar_batch_buffer.items():
            if not bars:
                continue
            # ALLOC-03: Resolve ouroboros_mode from pre-computed symbol_modes dict
            mode = symbol_modes.get(symbol, "aion")
            # Issue #318: Gate Python CH write based on feature flag
            if _should_python_write(rust_ch_writer_mode):
                if _write_batch_with_retry(
                    symbol,
                    threshold,
                    bars,
                    cache=ch_cache,
                    ouroboros_mode=mode,
                ):
                    written += len(bars)
                    _update_data_freshness(symbol, threshold, bars)
                if _should_shadow_compare(rust_ch_writer_mode):
                    _shadow_compare(symbol, threshold, bars, ch_cache)
            else:
                # live mode: Rust already wrote — count bars, update freshness
                written += len(bars)
                _update_data_freshness(symbol, threshold, bars)
        bar_batch_buffer.clear()
        last_batch_flush_time = time.monotonic()
        return written

    def _drain_gap_events() -> None:
        nonlocal gap_watcher
        if gap_watcher is None or not config.sse_enabled:
            return
        while True:
            try:
                gap_event = gap_watcher.next_event(timeout_ms=0)
            except (RuntimeError, AttributeError):
                gap_watcher = None
                return
            if gap_event is None:
                return
            _broadcast_gap_event(gap_event)
            logger.info(
                "gap detected: %s expected=%d actual=%d gap=%d filled=%s",
                gap_event["symbol"],
                gap_event["expected_tid"],
                gap_event["actual_tid"],
                gap_event["gap_size"],
                gap_event["filled_inline"],
            )

    try:
        while running:
            now = time.monotonic()
            if (now - last_batch_flush_time) >= batch_timeout_s and bar_batch_buffer:
                written = _flush_all_batches()
                if config.verbose:
                    logger.debug("batch timeout flush: %d bars written", written)

            bar = engine.next_bar(timeout_ms=config.timeout_ms)

            if bar is None:
                consecutive_timeouts += 1

                if consecutive_timeouts % timeout_heartbeat_interval == 0:
                    has_metrics = hasattr(engine, "get_metrics")
                    metrics = engine.get_metrics() if has_metrics else {}
                    trades_received = (
                        metrics.get("trades_received", 0) if metrics else 0
                    )
                    stale_s = time.monotonic() - last_trade_increment_time
                    logger.info(
                        "watchdog heartbeat: %d consecutive timeouts, "
                        "last_trades_received=%d, stale=%.0fs",
                        consecutive_timeouts,
                        trades_received,
                        stale_s,
                    )

                if config.watchdog_timeout_s > 0:
                    metrics = engine.get_metrics()
                    _update_health_metrics(metrics)

                    _prev_reconnections = _health_state.get("_prev_reconnections", 0)
                    current_reconnections = metrics.get("reconnections", 0)
                    if current_reconnections > _prev_reconnections:
                        new_reconnects = current_reconnections - _prev_reconnections
                        for _ in range(new_reconnects):
                            _update_stream_signal("DISCONNECT")
                            _update_stream_signal("CONNECT")
                        _health_state["_prev_reconnections"] = current_reconnections

                    current_trades = metrics.get("trades_received", 0)

                    if current_trades > last_trades_received:
                        consecutive_timeouts = 0
                        last_trades_received = current_trades
                        last_trade_increment_time = time.monotonic()
                    else:
                        stale_s = time.monotonic() - last_trade_increment_time
                        if stale_s >= config.watchdog_timeout_s:
                            logger.warning(
                                "WATCHDOG: no trades %.0fs, %d timeouts",
                                stale_s,
                                consecutive_timeouts,
                            )
                            _notify_watchdog_trigger(
                                stale_s,
                                current_trades,
                                watchdog_restarts,
                            )

                            if watchdog_restarts >= config.max_watchdog_restarts:
                                logger.error(
                                    "WATCHDOG: max restarts (%d) exceeded, exiting",
                                    config.max_watchdog_restarts,
                                )
                                break

                            since_last = time.monotonic() - last_restart_mono
                            if (
                                last_restart_mono > 0
                                and since_last < _sidecar_cfg().min_restart_interval_s
                            ):
                                logger.warning(
                                    "WATCHDOG: restart cooldown active (%.0fs since last, need %ds), skipping",
                                    since_last,
                                    _sidecar_cfg().min_restart_interval_s,
                                )
                                consecutive_timeouts += 1
                                continue

                            written = _flush_all_batches()
                            if written > 0:
                                logger.info("flushed %d bars before restart", written)

                            new_engine, success, new_min_tid, new_symbol_modes, new_ch_cache, rust_ch_writer_mode = (
                                _restart_engine_with_recovery(
                                    engine,
                                    config,
                                    trace_id,
                                    bars_written,
                                    watchdog_restarts + 1,
                                    valid_pairs=valid_pairs,
                                )
                            )

                            if success:
                                engine = new_engine
                                min_tid_guard = new_min_tid
                                symbol_modes = new_symbol_modes
                                ch_cache = new_ch_cache
                                _health_state["engine"] = engine
                                if forming_bar_stop is not None:
                                    forming_bar_stop.set()
                                forming_bar_watcher = engine.take_forming_bar_watcher()
                                if (
                                    config.sse_enabled
                                    and config.forming_bar_interval_s > 0
                                    and forming_bar_watcher is not None
                                ):
                                    forming_bar_stop = _start_forming_bar_broadcaster(
                                        forming_bar_watcher,
                                        valid_pairs,
                                        interval_s=config.forming_bar_interval_s,
                                        stale_s=config.forming_bar_stale_s,
                                    )
                                gap_watcher = (
                                    engine.take_gap_event_watcher()
                                    if hasattr(engine, "take_gap_event_watcher")
                                    else None
                                )
                                watchdog_restarts += 1
                                last_restart_mono = time.monotonic()
                                consecutive_timeouts = 0
                                last_trades_received = 0
                                last_trade_increment_time = time.monotonic()
                                restart_mono = time.monotonic()
                                argus_last_bar_time = {
                                    sym: restart_mono for sym, _ in valid_pairs
                                }
                                argus_phi = PhiAccrualDetector(
                                    threshold=8.0,
                                    first_heartbeat_estimate_ms=60_000,
                                    min_std_deviation_ms=5_000.0,
                                )
                                for sym, _ in valid_pairs:
                                    argus_phi.heartbeat(sym, restart_mono * 1000)
                                logger.info("watchdog restart succeeded")
                            else:
                                logger.error(
                                    "ESCALATION: restart failed after %d timeouts",
                                    consecutive_timeouts,
                                )
                                _notify_restart_failure(
                                    watchdog_restarts + 1,
                                    consecutive_timeouts,
                                    stale_s,
                                )
                                break

                _drain_gap_events()

                if consecutive_timeouts % timeout_heartbeat_interval == 0:
                    lethe_metrics = (
                        engine.get_metrics() if hasattr(engine, "get_metrics") else {}
                    )
                    current_dropped = lethe_metrics.get("dropped_bars", 0)
                    if current_dropped > lethe_last_dropped:
                        new_drops = current_dropped - lethe_last_dropped
                        lethe_drop_accumulator += new_drops
                        lethe_last_dropped = current_dropped
                        if lethe_drop_accumulator >= lethe_threshold:
                            _notify_lethe_overflow(new_drops, current_dropped)
                            lethe_drop_accumulator = 0
                            lethe_threshold = _sidecar_cfg().lethe_rearm_threshold

                if config.argus_timeout_s > 0:
                    now_argus = time.monotonic()
                    if (
                        now_argus - argus_last_check
                        >= _sidecar_cfg().argus_check_interval_s
                    ):
                        argus_last_check = now_argus

                        per_sym_last_trade_us: dict[str, int] = {}
                        try:
                            _fb_source = (
                                forming_bar_watcher
                                if forming_bar_watcher is not None
                                else engine
                            )
                            forming_bars = _fb_source.get_forming_bars()
                            for fb in forming_bars:
                                sym = fb.get("_symbol")
                                ts = fb.get("_last_trade_timestamp_ms", 0)
                                if sym and ts > per_sym_last_trade_us.get(sym, 0):
                                    per_sym_last_trade_us[sym] = ts
                        except (RuntimeError, AttributeError, KeyError):
                            logger.debug(
                                "Argus: get_forming_bars() unavailable, falling back to bar-only tracking"
                            )

                        now_us = int(time.time() * 1_000_000)
                        now_ms = now_argus * 1000
                        dead_syms = []
                        quiet_syms = []

                        for sym in argus_last_bar_time:
                            phi_val = argus_phi.phi(sym, now_ms)
                            if phi_val < argus_phi.threshold:
                                continue
                            last_trade_us = per_sym_last_trade_us.get(sym, 0)
                            if last_trade_us > 0:
                                trade_age_s = (now_us - last_trade_us) / 1_000_000
                                if trade_age_s < _ARGUS_TRADE_RECENCY_S:
                                    quiet_syms.append(sym)
                                    continue
                            dead_syms.append(sym)

                        if dead_syms:
                            _notify_argus_stale(dead_syms, config.argus_timeout_s)
                        if quiet_syms:
                            logger.debug(
                                "Argus: %d symbols receiving trades but no bar breach in %ds: %s",
                                len(quiet_syms),
                                config.argus_timeout_s,
                                ", ".join(sorted(quiet_syms)),
                            )

                if config.sse_enabled and config.checkpoint_push_interval_s > 0:
                    now_cp = time.monotonic()
                    if (
                        now_cp - last_checkpoint_push
                    ) >= config.checkpoint_push_interval_s:
                        last_checkpoint_push = now_cp
                        with _sse_clients_lock:
                            has_cp_clients = bool(_sse_clients)
                        if has_cp_clients:
                            try:
                                cp_data = {}
                                for sym, thr in valid_pairs:
                                    cp = _load_checkpoint(sym, thr)
                                    if cp:
                                        cp_data[f"{sym}:{thr}"] = cp
                                if cp_data:
                                    _broadcast_checkpoint_event(cp_data)
                            except Exception:
                                logger.warning(
                                    "checkpoint push failed (non-fatal)",
                                    exc_info=True,
                                )

                now_charon = time.monotonic()
                if now_charon - charon_last_replay >= config.charon_interval_s:
                    charon_last_replay = now_charon
                    if _DEAD_LETTER_DIR.exists() and any(
                        _DEAD_LETTER_DIR.glob("*.parquet")
                    ):
                        try:
                            from opendeviationbar.kintsugi import replay_dead_letters

                            replayed = replay_dead_letters()
                            if replayed > 0:
                                _notify_charon_replay(replayed, "periodic")
                        except (ImportError, OSError, RuntimeError, ConnectionError):
                            logger.exception(
                                "Charon: periodic replay failed (non-fatal)"
                            )

                continue

            burst_bars = [bar]
            for _ in range(_BURST_DRAIN_MAX):
                extra = engine.next_bar(timeout_ms=0)
                if extra is None:
                    break
                burst_bars.append(extra)

            if len(burst_bars) > 1:
                logger.debug(
                    "burst-atomic drain: processing %d bars atomically",
                    len(burst_bars),
                )

            metrics = engine.get_metrics()
            last_trades_received = metrics.get("trades_received", 0)
            last_trade_increment_time = time.monotonic()

            for burst_bar in burst_bars:
                symbol = burst_bar.pop("_symbol", "UNKNOWN")
                threshold = burst_bar.pop("_threshold", 0)

                # Record first WS bar TID per symbol for junction telemetry (STREAM-02 gap closure)
                if (
                    symbol in _junction_state
                    and "ws_first_tid" not in _junction_state[symbol]
                ):
                    ws_tid = burst_bar.get("first_agg_trade_id", 0)
                    if ws_tid > 0:
                        _junction_state[symbol]["ws_first_tid"] = ws_tid

                if config.argus_timeout_s > 0:
                    argus_last_bar_time[symbol] = time.monotonic()
                    argus_phi.heartbeat(symbol)

                if (symbol, threshold) not in valid_pairs:
                    continue

                bar_first_tid = burst_bar.get("first_agg_trade_id", 0)
                ch_floor = min_tid_guard.get((symbol, threshold), 0)
                if ch_floor > 0 and bar_first_tid > 0 and bar_first_tid <= ch_floor:
                    logger.debug(
                        "min-TID guard: dropping %s@%d bar first_tid=%d "
                        "(≤ CH floor %d)",
                        symbol,
                        threshold,
                        bar_first_tid,
                        ch_floor,
                    )
                    continue
                if ch_floor > 0:
                    del min_tid_guard[(symbol, threshold)]

                if config.sse_enabled:
                    _broadcast_bar_event(symbol, threshold, burst_bar)

                batch_key = (symbol, threshold)
                bar_batch_buffer[batch_key].append(burst_bar)
                bars_written += 1

                if (
                    bars_written % batch_max_size == 0
                    or len(bar_batch_buffer[batch_key]) >= batch_max_size
                ):
                    _save_checkpoint(symbol, threshold, burst_bar)

                if len(bar_batch_buffer[batch_key]) >= batch_max_size:
                    batch = bar_batch_buffer[batch_key]
                    _batch_mode = symbol_modes.get(symbol, "aion")
                    # Issue #318: Gate Python CH write based on feature flag
                    if _should_python_write(rust_ch_writer_mode):
                        wrote = _write_batch_with_retry(
                            symbol,
                            threshold,
                            batch,
                            cache=ch_cache,
                            ouroboros_mode=_batch_mode,
                        )
                        if wrote:
                            _update_data_freshness(symbol, threshold, batch)
                            if config.verbose or bars_written % 100 == 0:
                                logger.info(
                                    "%s@%d: batch written (%d bars, total=%d, close=%s)",
                                    symbol,
                                    threshold,
                                    len(batch),
                                    bars_written,
                                    burst_bar.get("close"),
                                )
                        if _should_shadow_compare(rust_ch_writer_mode):
                            _shadow_compare(symbol, threshold, batch, ch_cache)
                    else:
                        # live mode: Rust already wrote — update freshness only
                        _update_data_freshness(symbol, threshold, batch)
                    bar_batch_buffer[batch_key] = []

            _drain_gap_events()

    finally:
        exit_reason = "clean shutdown" if not running else "exception"

        if forming_bar_stop is not None:
            forming_bar_stop.set()

        if config.sse_enabled:
            with _sse_clients_lock:
                has_shutdown_clients = bool(_sse_clients)
            if has_shutdown_clients:
                try:
                    cp_data = {}
                    for sym, thr in valid_pairs:
                        cp = _load_checkpoint(sym, thr)
                        if cp:
                            cp_data[f"{sym}:{thr}"] = cp
                    if cp_data:
                        _broadcast_checkpoint_event(cp_data)
                except Exception:
                    logger.warning(
                        "shutdown checkpoint push failed (non-fatal)", exc_info=True
                    )

        written = _flush_all_batches()
        if written > 0:
            logger.info("flushed %d pending bars on shutdown", written)

        engine.stop()

        _extract_checkpoints(engine, trace_id, bars_written)

        engine.shutdown()

        if health_server is not None:
            health_server.shutdown()

        try:
            ch_cache.close()
        except (OSError, RuntimeError):
            logger.debug("failed to close cached OpenDeviationBarCache", exc_info=True)

        metrics = engine.get_metrics()
        logger.info(
            "sidecar stopped: bars_written=%d, engine_metrics=%s",
            bars_written,
            metrics,
        )

        try:
            from opendeviationbar.hooks import HookEvent, emit_hook

            emit_hook(
                HookEvent.SIDECAR_STOPPED,
                symbol="*",
                bars_written=bars_written,
                exit_reason=exit_reason,
                pid=os.getpid(),
            )
        except (ImportError, OSError, RuntimeError, ValueError):
            logger.debug("failed to emit SIDECAR_STOPPED hook", exc_info=True)
        try:
            from opendeviationbar.notify.telegram import (
                _build_context_block,
                send_telegram,
            )

            shutdown_msg = (
                "<b>SIDECAR STOPPED</b>\n"
                f"<b>Bars written:</b> {bars_written:,}\n"
                f"<b>Reason:</b> {exit_reason}\n"
                f"<b>PID:</b> {os.getpid()}\n"
                "<b>Next:</b> systemd should auto-restart within 10s\n"
                "<b>If not:</b> <code>journalctl --user -u opendeviationbar-sidecar -n 20</code>"
                + _build_context_block("sidecar")
            )
            send_telegram(shutdown_msg, disable_notification=False)
        except (ImportError, OSError, RuntimeError):
            logger.debug(
                "failed to send shutdown Telegram alert",
                exc_info=True,
            )
