#!/usr/bin/env python3
"""
live_ibkr_trader.py
===================

V1 daily runner for the 4-regime VRP strategy on Interactive Brokers (``ib_insync``).

**Schedule:** Designed for cron ~**3:45 PM America/New_York** (adjust to your process).

**Mirrors** ``RenTech/strategy_stack/vrp_backtester.py``:
  - **Cash/Wait** if SPY **<** 200-day SMA (no new risk).
  - **R1–R4** by VIX when SPY **>=** SMA (R1 = weekly long strangle; R2 = R2a diagonal + R2b PCS; sleeve % from ``sleeve_risk_fractions.json``, same as ``vrp_backtest_theta.py`` / ``vrp_backtester``).

**Exits**
  - **Take profit:** GTC limit on the **closing** combo right after entry fill.
  - **Stop / time:** This script runs ``manage_open_positions()`` **before** entries; sends
    **Market** close on breach. Cancels the sibling GTC id when possible.

**State:** ``live_vrp_state.json`` in this directory.

**Audit:** ``--audit`` compares SPY option legs implied by state vs IB open positions (conId + signed qty)
and reports missing GTC order ids; optional ``--state-path``.

**Deps:** ``pip install ib_insync``

**Connect:** TWS / IB Gateway **paper** port **7497**, ``clientId=1``.

**Parity:** optional ``--strategy-config`` (default ``RenTech/strategy_stack/sleeve_risk_fractions.json``)
loads sleeve risk fractions + ``strategy_params`` into ``vrp_backtester`` module constants and re-syncs
this file’s mirrored constants + per-sleeve sizing (same JSON as ``vrp_backtest_theta.py``).

DISCLAIMER: Prototype / educational. Validate in paper. Not financial advice.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import math
import sys
from collections import defaultdict
import uuid
from dataclasses import asdict, dataclass
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Literal
from zoneinfo import ZoneInfo

_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

try:
    from ib_insync import IB, ComboLeg, Contract, Index, LimitOrder, MarketOrder, Option, Stock, util
except ImportError as e:
    raise ImportError("Install ib_insync: pip install ib_insync") from e

from RenTech.strategy_stack.vrp_strategy_config import (
    DEFAULT_STRATEGY_CONFIG_PATH,
    apply_strategy_params_to_vrp_backtester_module,
    load_strategy_config_file,
    sync_live_constants_from_vrp_backtester_module,
)

# --- Align with vrp_backtester.py -------------------------------------------------
CONTRACT_MULTIPLIER = 100.0
RISK_FRACTION_PER_TRADE = 0.02
SIZING_MIN_MAX_RISK_USD = 25.0

VIX_R1_MAX = 12.0
VIX_R2_MAX = 20.0
VIX_R3_MAX = 30.0

R1_TIME_STOP_DAYS = 7
R1_TP_FRAC = 0.50
R1_SL_FRAC = -0.50
R1_STRANGLE_DTE = 7
R1_CALL_DELTA = 0.45
R1_PUT_DELTA = -0.18

# R2a: put diagonal
R2_SHORT_DTE = 21
R2_LONG_DTE = 45
R2_SHORT_DELTA = -0.30
R2_LONG_DELTA = -0.15
R2_TP_PER_CONTRACT = 50.0
R2_SL_PER_CONTRACT = -150.0
R2_TIME_STOP_DAYS = 14
# R2b: put credit spread — short ~−22Δ @ ~30D; long same expiry at short − round(spot × width_frac)
R2B_SHORT_DTE = 30
R2B_SHORT_DELTA = -0.22
R2B_WIDTH_FRAC_OF_SPOT = 0.01
R2B_TP_FRAC = 0.50
R2B_TIME_STOP_DAYS = 21

R3_TARGET_DTE = 45
R3_TARGET_DELTA = -0.15
R3_TP_FRAC = 0.50
R3_SL_FRAC = -4.0
R3_TIME_STOP_DAYS = 24

R4_TP_FRAC = 0.50
R4_TIME_STOP_DAYS = 24
# R4 short/long deltas (synced from ``vrp_backtester.R4_CREDIT_LEG_SPECS`` when parity JSON loads)
R4_SHORT_DTE = 45
R4_SHORT_DELTA = -0.15
R4_LONG_DELTA = -0.05

RegimeName = Literal["pmcc", "diagonal", "r2_spread", "naked", "credit_spread"]

_PARITY_CFG: Any = None


def _risk_budget_usd(net_liq: float, sleeve: str) -> float:
    """Per-sleeve risk budget (USD) from parity JSON or legacy single fraction."""
    if _PARITY_CFG is not None:
        fr = float(_PARITY_CFG.sleeve_risk_fractions.get(sleeve, RISK_FRACTION_PER_TRADE))
        return float(net_liq) * fr
    return float(net_liq) * float(RISK_FRACTION_PER_TRADE)

STATE_PATH = Path(__file__).resolve().parent / "live_vrp_state.json"
IB_HOST = "127.0.0.1"
IB_PORT = 7497
CLIENT_ID = 1
ENTRY_AFTER_ET = "15:40"
NY_TZ = ZoneInfo("America/New_York")
RECOMMEND_OUT_DEFAULT = _REPO_ROOT / "RenTech" / "data" / "logs" / "ibkr_trade_recommendation.json"
LIVE_SIGNAL_STATE_OUT = _REPO_ROOT / "RenTech" / "data" / "logs" / "live_signal_state.json"
MIN_BUDGET_TO_RISK_RATIO_FOR_1LOT = 0.75
MIN_LEG_BID_USD = 0.01
MAX_LEG_SPREAD_PCT = 0.80
MAX_LEG_SPREAD_ABS_USD = 1.50
DELTA_FALLBACK_CANDIDATES = 120


def _norm_cdf(x: float) -> float:
    return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def bs_call_delta(spot: float, strike: float, t_years: float, rate: float, iv: float) -> float:
    if t_years <= 1e-8:
        return 1.0 if strike < spot else 0.0
    iv = max(iv, 1e-4)
    srt = iv * math.sqrt(t_years)
    d1 = (math.log(spot / strike) + (rate + 0.5 * iv * iv) * t_years) / srt
    return _norm_cdf(d1)


def bs_put_delta(spot: float, strike: float, t_years: float, rate: float, iv: float) -> float:
    if t_years <= 1e-8:
        return -1.0 if strike > spot else 0.0
    iv = max(iv, 1e-4)
    srt = iv * math.sqrt(t_years)
    d1 = (math.log(spot / strike) + (rate + 0.5 * iv * iv) * t_years) / srt
    return _norm_cdf(d1) - 1.0


@dataclass
class LegStateRich:
    conId: int
    action: str  # as opened: SELL / BUY
    ratio: int
    symbol: str
    expiry: str
    strike: float
    right: str
    entry_price_per_share: float  # per-share premium at entry for this leg


def _load_state() -> dict[str, Any]:
    return _load_state_path(STATE_PATH)


def _load_state_path(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {"strategies": []}
    try:
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) and "strategies" in data else {"strategies": []}
    except (json.JSONDecodeError, OSError) as e:
        print(f"[WARN] State read failed ({e}); using empty strategies.")
        return {"strategies": []}


def _save_state(data: dict[str, Any]) -> None:
    tmp = STATE_PATH.with_suffix(".json.tmp")
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    tmp.replace(STATE_PATH)


def _write_live_signal_state(payload: dict[str, Any], out_path: Path = LIVE_SIGNAL_STATE_OUT) -> None:
    """Persist a lightweight runtime monitor snapshot for the standalone live website."""
    try:
        out = out_path.expanduser().resolve()
        out.parent.mkdir(parents=True, exist_ok=True)
        tmp = out.with_suffix('.json.tmp')
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(payload, f, indent=2)
        tmp.replace(out)
    except OSError as e:
        print(f"[WARN] Could not write live signal state ({e})")


async def get_net_liquidation(ib: IB) -> float:
    """
    Read NetLiq using async-only IB calls.

    Calling ``ib.accountSummary()`` inside our running event loop triggers
    nested ``util.run(...)`` in ib_insync and raises:
    ``RuntimeError: This event loop is already running``.
    """
    vals = await ib.accountSummaryAsync()
    for av in vals:
        if av.tag == "NetLiquidation" and av.currency in ("USD", "BASE"):
            return float(av.value)
    raise RuntimeError("NetLiquidation (USD) not found.")


async def get_spy_sma200(ib: IB, spy: Stock) -> tuple[float, float]:
    """Return (sma200, last_close)."""
    # IBKR currently rejects durations >365 days when specified in days;
    # use year-based duration to ensure we can compute a 200-day SMA.
    bars = await ib.reqHistoricalDataAsync(
        spy,
        endDateTime="",
        durationStr="2 Y",
        barSizeSetting="1 day",
        whatToShow="TRADES",
        useRTH=True,
        formatDate=1,
    )
    if len(bars) < 200:
        bars = await ib.reqHistoricalDataAsync(
            spy,
            endDateTime="",
            durationStr="1 Y",
            barSizeSetting="1 day",
            whatToShow="TRADES",
            useRTH=True,
            formatDate=1,
        )
    if len(bars) < 200:
        raise RuntimeError(f"Insufficient SPY history: {len(bars)} bars")
    closes = [float(b.close) for b in bars]
    sma = sum(closes[-200:]) / 200.0
    last = closes[-1]
    return sma, last


async def snapshot_price(
    ib: IB,
    c: Contract,
    label: str,
    timeout: float = 2.0,
    fallback: float | None = None,
) -> float:
    t = ib.reqMktData(c, "", False, False)
    await asyncio.sleep(timeout)
    ib.cancelMktData(c)
    # Prefer direct trade/close marks, then computed marketPrice()/midpoint from bid/ask.
    candidates: list[float] = []
    for attr in ("last", "close", "bid", "ask"):
        v = getattr(t, attr, None)
        try:
            fv = float(v)
            if fv > 0 and not math.isnan(fv):
                candidates.append(fv)
        except (TypeError, ValueError):
            pass
    try:
        mp = float(t.marketPrice())
        if mp > 0 and not math.isnan(mp):
            candidates.append(mp)
    except Exception:  # noqa: BLE001
        pass
    if len(candidates) >= 2:
        # If only bid/ask are available (delayed/frozen), midpoint is safer than one side.
        b = getattr(t, "bid", None)
        a = getattr(t, "ask", None)
        try:
            bf, af = float(b), float(a)
            if bf > 0 and af > 0 and not math.isnan(bf) and not math.isnan(af):
                return 0.5 * (bf + af)
        except (TypeError, ValueError):
            pass
    for fv in candidates:
        if fv > 0:
            return fv
    if fallback is not None:
        try:
            fb = float(fallback)
            if fb > 0 and math.isfinite(fb):
                print(f"[WARN] No live snapshot for {label}; using fallback={fb:.4f}")
                return fb
        except (TypeError, ValueError):
            pass
    raise RuntimeError(f"No price for {label}")


async def get_target_option(
    ib: IB,
    spy: Stock,
    spot: float,
    target_dte: int,
    target_delta: float,
    right: str,
    avoid_expiries: frozenset[str] | None = None,
    recommend_only: bool = False,
    min_strike: float | None = None,
    max_strike: float | None = None,
) -> Option:
    """
    Pick qualified ``Option`` with delta closest to ``target_delta`` (puts: negative).
    Uses ``modelGreeks`` when populated; else Black–Scholes with IV from ticker or 0.25.
    """
    dets = await ib.reqContractDetailsAsync(spy)
    if not dets:
        raise RuntimeError("SPY contract details missing")
    uid = dets[0].contract.conId
    chains = await ib.reqSecDefOptParamsAsync("SPY", "", "STK", uid)
    if not chains:
        raise RuntimeError("No option chain params")

    chain = _pick_spy_chain(chains)
    today = date.today()
    expiries: list[tuple[str, int]] = []
    for exp in chain.expirations:
        try:
            y, m, d = int(exp[:4]), int(exp[4:6]), int(exp[6:8])
            dte = (date(y, m, d) - today).days
            if dte > 0:
                expiries.append((exp, dte))
        except (ValueError, IndexError):
            continue
    if not expiries:
        raise RuntimeError("No future expiries")
    if avoid_expiries:
        expiries = [(e, d) for e, d in expiries if e not in avoid_expiries]
    if not expiries:
        raise RuntimeError("No future expiries after avoid_expiries filter")
    best_exp, _ = min(expiries, key=lambda x: abs(x[1] - target_dte))

    exp_date = date(int(best_exp[:4]), int(best_exp[4:6]), int(best_exp[6:8]))
    t_years = max(1 / 365.0, (exp_date - today).days / 365.0)
    cand_k = _strike_candidates_for_chain(chain, spot)
    if min_strike is not None:
        cand_k = [k for k in cand_k if float(k) >= float(min_strike)]
    if max_strike is not None:
        cand_k = [k for k in cand_k if float(k) <= float(max_strike)]
    if not cand_k:
        # Fallback: re-expand to the full chain under bounds, then keep nearest strikes.
        full = sorted({float(s) for s in chain.strikes})
        if min_strike is not None:
            full = [k for k in full if float(k) >= float(min_strike)]
        if max_strike is not None:
            full = [k for k in full if float(k) <= float(max_strike)]
        if full:
            full = sorted(full, key=lambda x: abs(x - spot))
            cand_k = sorted(full[:121])
        else:
            raise RuntimeError("No strike candidates left after strike bounds filter")

    tc = str(getattr(chain, "tradingClass", "") or "").strip()
    mult = str(getattr(chain, "multiplier", "") or "").strip()
    opt_kwargs: dict[str, Any] = {"currency": "USD"}
    if tc:
        opt_kwargs["tradingClass"] = tc
    if mult:
        opt_kwargs["multiplier"] = mult
    rate = 0.05
    ranked = []
    for k in cand_k:
        if str(right).upper().startswith("C"):
            d = bs_call_delta(spot, float(k), t_years, rate, 0.25)
        else:
            d = bs_put_delta(spot, float(k), t_years, rate, 0.25)
        ranked.append((abs(d - target_delta), float(k)))
    ranked.sort(key=lambda x: x[0])

    if recommend_only:
        # Recommendation mode: require leg quote quality and search nearby strikes when top candidates are thin.
        checked: list[str] = []
        for _, k in ranked[:DELTA_FALLBACK_CANDIDATES]:
            o = Option("SPY", best_exp, k, right, "SMART", **opt_kwargs)
            q = await ib.qualifyContractsAsync(o)
            if not q:
                checked.append(f"K={k}:unqualified")
                continue
            oq = q[0]
            tks = await ib.reqTickersAsync(oq)
            await asyncio.sleep(0.15)
            tk = tks[0] if tks else None
            if tk is None:
                checked.append(f"K={k}:no_ticker")
                continue
            ok, reason, bid, ask, mid = _leg_quote_quality(tk)
            if ok:
                return oq
            checked.append(f"K={k}:{reason}:b={bid:.2f}:a={ask:.2f}:m={(mid if mid is not None else float('nan')):.2f}")
        raise RuntimeError(
            "No qualifying liquid option candidate in recommendation mode; checked=" + "; ".join(checked[:25])
        )

    opts = [Option("SPY", best_exp, k, right, "SMART", **opt_kwargs) for k in cand_k]
    qualified = await ib.qualifyContractsAsync(*opts)
    if not qualified:
        raise RuntimeError("qualifyContracts failed for candidates")

    tickers = await ib.reqTickersAsync(*qualified)
    await asyncio.sleep(1.8)

    best: tuple[float, Option] | None = None
    for opt, ticker in zip(qualified, tickers):
        delta_m = None
        if ticker.modelGreeks and ticker.modelGreeks.delta is not None:
            delta_m = float(ticker.modelGreeks.delta)
        iv = 0.25
        if ticker.modelGreeks and ticker.modelGreeks.impliedVol:
            iv = max(float(ticker.modelGreeks.impliedVol), 1e-4)
        elif ticker.impliedVolatility and ticker.impliedVolatility > 0:
            iv = float(ticker.impliedVolatility)
        if delta_m is None:
            if str(right).upper().startswith("C"):
                delta_m = bs_call_delta(spot, float(opt.strike), t_years, rate, iv)
            else:
                delta_m = bs_put_delta(spot, float(opt.strike), t_years, rate, iv)
        err = abs(delta_m - target_delta)
        if best is None or err < best[0]:
            best = (err, opt)
    assert best is not None
    return best[1]


async def get_r2b_long_put_on_expiry(
    ib: IB,
    spy: Stock,
    spot: float,
    expiry_yyyymmdd: str,
    ideal_long_strike: float,
    short_strike: float,
    recommend_only: bool,
) -> Option:
    """
    R2b long leg: **same expiry** as short; strike strictly below short; closest to ``ideal_long_strike``.
    Mirrors backtest ``_r2b_select_long_put_same_expiry``.
    """
    dets = await ib.reqContractDetailsAsync(spy)
    if not dets:
        raise RuntimeError("SPY contract details missing")
    uid = dets[0].contract.conId
    chains = await ib.reqSecDefOptParamsAsync("SPY", "", "STK", uid)
    if not chains:
        raise RuntimeError("No option chain params")
    chain = _pick_spy_chain(chains)
    cand_k = _strike_candidates_for_chain(chain, spot)
    below = [float(k) for k in cand_k if float(k) < float(short_strike)]
    if not below:
        raise RuntimeError("No put strikes below short strike for R2b long leg")
    ranked = sorted(below, key=lambda k: abs(k - float(ideal_long_strike)))

    tc = str(getattr(chain, "tradingClass", "") or "").strip()
    mult = str(getattr(chain, "multiplier", "") or "").strip()
    opt_kwargs: dict[str, Any] = {"currency": "USD"}
    if tc:
        opt_kwargs["tradingClass"] = tc
    if mult:
        opt_kwargs["multiplier"] = mult

    if recommend_only:
        for k in ranked[: min(60, len(ranked))]:
            o = Option("SPY", expiry_yyyymmdd, k, "P", "SMART", **opt_kwargs)
            q = await ib.qualifyContractsAsync(o)
            if not q:
                continue
            oq = q[0]
            tks = await ib.reqTickersAsync(oq)
            await asyncio.sleep(0.15)
            tk = tks[0] if tks else None
            if tk is None:
                continue
            ok, reason, bid, ask, mid = _leg_quote_quality(tk)
            if ok:
                return oq
        raise RuntimeError("No qualifying R2b long put in recommendation mode")

    best_o: Option | None = None
    for k in ranked[: min(80, len(ranked))]:
        o = Option("SPY", expiry_yyyymmdd, k, "P", "SMART", **opt_kwargs)
        q = await ib.qualifyContractsAsync(o)
        if q:
            best_o = q[0]
            break
    if best_o is None:
        raise RuntimeError("Could not qualify R2b long put")
    return best_o


async def get_weekly_strangle_options(
    ib: IB,
    spy: Stock,
    spot: float,
    target_dte: int,
    call_target_delta: float,
    put_target_delta: float,
    recommend_only: bool = False,
) -> tuple[Option, Option]:
    """
    Same listed expiry closest to ``target_dte``: long call (higher Δ) + long put (further OTM).
    """
    dets = await ib.reqContractDetailsAsync(spy)
    if not dets:
        raise RuntimeError("SPY contract details missing")
    uid = dets[0].contract.conId
    chains = await ib.reqSecDefOptParamsAsync("SPY", "", "STK", uid)
    if not chains:
        raise RuntimeError("No option chain params")

    chain = _pick_spy_chain(chains)
    today = date.today()
    expiries: list[tuple[str, int]] = []
    for exp in chain.expirations:
        try:
            y, m, d = int(exp[:4]), int(exp[4:6]), int(exp[6:8])
            dte = (date(y, m, d) - today).days
            if dte > 0:
                expiries.append((exp, dte))
        except (ValueError, IndexError):
            continue
    if not expiries:
        raise RuntimeError("No future expiries")

    best_exp, _ = min(expiries, key=lambda x: abs(x[1] - target_dte))
    exp_date = date(int(best_exp[:4]), int(best_exp[4:6]), int(best_exp[6:8]))
    t_years = max(1 / 365.0, (exp_date - today).days / 365.0)
    cand_k = _strike_candidates_for_chain(chain, spot)

    tc = str(getattr(chain, "tradingClass", "") or "").strip()
    mult = str(getattr(chain, "multiplier", "") or "").strip()
    opt_kwargs: dict[str, Any] = {"currency": "USD"}
    if tc:
        opt_kwargs["tradingClass"] = tc
    if mult:
        opt_kwargs["multiplier"] = mult
    rate = 0.05
    if recommend_only:
        ranked_c = sorted(
            ((abs(bs_call_delta(spot, float(k), t_years, rate, 0.25) - call_target_delta), float(k)) for k in cand_k),
            key=lambda x: x[0],
        )
        ranked_p = sorted(
            ((abs(bs_put_delta(spot, float(k), t_years, rate, 0.25) - put_target_delta), float(k)) for k in cand_k),
            key=lambda x: x[0],
        )
        c_opt = p_opt = None
        for _, k in ranked_c[:40]:
            q = await ib.qualifyContractsAsync(Option("SPY", best_exp, k, "C", "SMART", **opt_kwargs))
            if q:
                c_opt = q[0]
                break
        for _, k in ranked_p[:40]:
            q = await ib.qualifyContractsAsync(Option("SPY", best_exp, k, "P", "SMART", **opt_kwargs))
            if q:
                p_opt = q[0]
                break
        if c_opt is None or p_opt is None:
            raise RuntimeError("Could not resolve strangle legs in recommendation mode")
    else:
        opts_c = [Option("SPY", best_exp, k, "C", "SMART", **opt_kwargs) for k in cand_k]
        opts_p = [Option("SPY", best_exp, k, "P", "SMART", **opt_kwargs) for k in cand_k]
        qualified = await ib.qualifyContractsAsync(*(opts_c + opts_p))
        if not qualified:
            raise RuntimeError("qualifyContracts failed for strangle candidates")
        tickers = await ib.reqTickersAsync(*qualified)
        await asyncio.sleep(1.8)
        best_c: tuple[float, Option] | None = None
        best_p: tuple[float, Option] | None = None
        for opt, ticker in zip(qualified, tickers):
            r0 = str(opt.right).upper()
            is_call = r0.startswith("C")
            delta_m = None
            if ticker.modelGreeks and ticker.modelGreeks.delta is not None:
                delta_m = float(ticker.modelGreeks.delta)
            iv = 0.25
            if ticker.modelGreeks and ticker.modelGreeks.impliedVol:
                iv = max(float(ticker.modelGreeks.impliedVol), 1e-4)
            elif ticker.impliedVolatility and ticker.impliedVolatility > 0:
                iv = float(ticker.impliedVolatility)
            if delta_m is None:
                if is_call:
                    delta_m = bs_call_delta(spot, float(opt.strike), t_years, rate, iv)
                else:
                    delta_m = bs_put_delta(spot, float(opt.strike), t_years, rate, iv)
            if is_call:
                err = abs(delta_m - call_target_delta)
                if best_c is None or err < best_c[0]:
                    best_c = (err, opt)
            else:
                err = abs(delta_m - put_target_delta)
                if best_p is None or err < best_p[0]:
                    best_p = (err, opt)
        if best_c is None or best_p is None:
            raise RuntimeError("Could not resolve strangle legs")
        c_opt, p_opt = best_c[1], best_p[1]
    if float(p_opt.strike) >= float(c_opt.strike):
        raise RuntimeError("Strangle requires put strike below call strike")
    return (c_opt, p_opt)


def build_bag(legs: list[tuple[Option, str, int]]) -> Contract:
    cls = [
        ComboLeg(conId=int(o.conId), ratio=r, action=a.upper(), exchange="SMART")
        for o, a, r in legs
    ]
    return Contract(symbol="SPY", secType="BAG", exchange="SMART", currency="USD", comboLegs=cls)


def _pick_spy_chain(chains: list[Any]) -> Any:
    """
    Prefer standard SPY option classes; avoid odd adjusted classes (e.g. 2SPY)
    that often fail qualification for generic strike grids.
    """
    if not chains:
        raise RuntimeError("No option chain params")
    smart = [c for c in chains if str(getattr(c, "exchange", "")).upper() == "SMART"] or list(chains)

    def mult_ok(c: Any) -> bool:
        m = str(getattr(c, "multiplier", "") or "").strip()
        return m in {"", "100"}

    preferred_tc = {"SPY", "SPYW"}
    for c in smart:
        tc = str(getattr(c, "tradingClass", "") or "").strip().upper()
        if tc in preferred_tc and mult_ok(c):
            return c

    # Any standard-ish class with $100 multiplier (exclude mini / prefixed numerics like 2SPY).
    for c in smart:
        if not mult_ok(c):
            continue
        tc = str(getattr(c, "tradingClass", "") or "").strip().upper()
        if not tc or tc.startswith(("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")):
            continue
        if tc in {"2SPY"}:
            continue
        return c

    for c in smart:
        if mult_ok(c):
            return c
    return smart[0]


def _strike_candidates_for_chain(chain: Any, spot: float) -> list[float]:
    """Strike list near spot; drop half-dollar strikes when the chain is on a $1 grid (reduces IB error 200)."""
    strikes = sorted({float(s) for s in chain.strikes})
    lo, hi = spot * 0.90, spot * 1.10
    cand = [k for k in strikes if lo <= k <= hi] or strikes
    if len(cand) < 2:
        return cand
    gaps = sorted({round(b - a, 4) for a, b in zip(cand, cand[1:]) if (b - a) > 1e-6})
    if not gaps:
        return cand
    step = gaps[0]
    if 0.99 <= step <= 1.01:
        cand = [k for k in cand if abs(k - round(k)) < 0.02]
    elif 0.49 <= step <= 0.51:
        # Prefer integer strikes for SPY; half-dollar lines can be missing for some expiries.
        ints = [k for k in cand if abs(k - round(k)) < 0.02]
        cand = ints or [k for k in cand if abs(round(2 * k) - 2 * k) < 0.02]
    # Keep the candidate set compact to reduce IB data requests.
    cand = sorted(cand, key=lambda x: abs(x - spot))
    return sorted(cand[:61])


def _opt_mid_from_ticker(tk: Any) -> float | None:
    b, a = float(tk.bid or 0), float(tk.ask or 0)
    if b > 0 and a > 0 and math.isfinite(b) and math.isfinite(a):
        return 0.5 * (b + a)
    for attr in ("last", "close"):
        v = getattr(tk, attr, None)
        try:
            fv = float(v)
            if fv > 0 and math.isfinite(fv):
                return fv
        except (TypeError, ValueError):
            pass
    try:
        mp = float(tk.marketPrice())
        if mp > 0 and math.isfinite(mp):
            return mp
    except Exception:  # noqa: BLE001
        pass
    return None


def _leg_quote_quality(tk: Any) -> tuple[bool, str, float | None, float | None, float | None]:
    """Return (ok, reason, bid, ask, mid)."""
    try:
        bid = float(tk.bid or 0.0)
    except Exception:
        bid = 0.0
    try:
        ask = float(tk.ask or 0.0)
    except Exception:
        ask = 0.0
    mid = _opt_mid_from_ticker(tk)
    if not (math.isfinite(bid) and math.isfinite(ask) and bid > 0 and ask > 0 and ask > bid):
        return (False, "missing_bid_ask", bid, ask, mid)
    if bid < MIN_LEG_BID_USD:
        return (False, f"bid_too_small<{MIN_LEG_BID_USD}", bid, ask, mid)
    spr = ask - bid
    spr_pct = spr / max(abs(mid or 0.0), 1e-6)
    if spr > MAX_LEG_SPREAD_ABS_USD:
        return (False, f"spread_abs>{MAX_LEG_SPREAD_ABS_USD}", bid, ask, mid)
    if spr_pct > MAX_LEG_SPREAD_PCT:
        return (False, f"spread_pct>{MAX_LEG_SPREAD_PCT:.2f}", bid, ask, mid)
    return (True, "ok", bid, ask, mid)


def _diag_risk_per_contract_usd(short_put: Option, long_put: Option, combo_mid_per_share: float) -> float:
    """
    Conservative 1-lot diagonal risk proxy aligned to backtest ``TwoLegSpreadPosition.max_margin``:
      width*100 + max(0, debit_paid)
    where debit_paid ≈ combo_mid_per_share*100 when entry action is BUY.
    """
    width_usd = abs(float(short_put.strike) - float(long_put.strike)) * CONTRACT_MULTIPLIER
    debit_usd = max(float(combo_mid_per_share), 0.0) * CONTRACT_MULTIPLIER
    return max(float(width_usd + debit_usd), SIZING_MIN_MAX_RISK_USD)


async def _synthetic_combo_mid(
    ib: IB, opened: list[tuple[Option, str, int]]
) -> tuple[float, float, float, bool, list[str]]:
    """
    Signed combo mid from leg mids (matches typical IB BAG sign: credit → negative mid when entry is SELL).
    Returns (mid, 0.0, 0.0, liquid_ok, leg_notes).
    """
    opts = [o for o, _, _ in opened]
    tickers = await ib.reqTickersAsync(*opts)
    await asyncio.sleep(1.2)
    net = 0.0
    liquid_ok = True
    notes: list[str] = []
    for (o, act, ratio), tk in zip(opened, tickers):
        ok, reason, bid, ask, m = _leg_quote_quality(tk)
        if m is None:
            raise RuntimeError(f"Leg mid unavailable for {o.localSymbol or o.symbol} {o.lastTradeDateOrContractMonth} {o.strike}{o.right}")
        leg_lbl = f"{o.lastTradeDateOrContractMonth} {o.right}{float(o.strike):.1f}"
        notes.append(f"{leg_lbl}:{reason}:b={bid:.2f}:a={ask:.2f}:m={m:.2f}")
        if not ok:
            liquid_ok = False
        r = int(ratio)
        if act.upper() == "SELL":
            net += m * r
        else:
            net -= m * r
    mid = -net
    return mid, 0.0, 0.0, liquid_ok, notes


async def combo_quote_mid(
    ib: IB, bag: Contract, opened: list[tuple[Option, str, int]] | None = None
) -> tuple[float, float, float, str]:
    """Return (mid, bid, ask, quality): quality in {\"live_bag_mid\", \"synthetic_fallback\", \"stale_last_close\"}."""
    t = ib.reqMktData(bag, "", False, False)
    await asyncio.sleep(1.5)
    bid_raw, ask_raw = getattr(t, "bid", None), getattr(t, "ask", None)
    try:
        bid = float(bid_raw) if bid_raw is not None else float("nan")
    except Exception:
        bid = float("nan")
    try:
        ask = float(ask_raw) if ask_raw is not None else float("nan")
    except Exception:
        ask = float("nan")

    bag_has_two_sided = (
        math.isfinite(bid)
        and math.isfinite(ask)
        and abs(bid) > 1e-8
        and abs(ask) > 1e-8
        and ask > bid
    )
    if bag_has_two_sided:
        mid = 0.5 * (bid + ask)
    else:
        mid = float(t.last or t.close or 0.0)
    ib.cancelMktData(bag)
    if bag_has_two_sided and math.isfinite(mid) and abs(mid) > 1e-8:
        return mid, bid, ask, "live_bag_mid", [f"bag_two_sided:b={bid:.2f}:a={ask:.2f}:m={mid:.2f}"]
    if opened:
        try:
            sm, sb, sa, liquid_ok, notes = await _synthetic_combo_mid(ib, opened)
            if math.isfinite(sm) and abs(sm) > 1e-8:
                q = "synthetic_liquid" if liquid_ok else "synthetic_fallback"
                print(f"[WARN] BAG quote missing/invalid (mid={mid}); using synthetic mid={sm:.4f} quality={q}")
                return sm, sb, sa, q, notes
        except Exception as e:  # noqa: BLE001
            print(f"[WARN] Synthetic combo mid failed: {e}")
    if not math.isfinite(mid) or abs(mid) <= 1e-8:
        if not (t.last or t.close):
            raise RuntimeError("Combo mid unavailable")
    return mid, bid, ask, "stale_last_close", [f"bag_last_close_only:m={mid:.2f}"]


def unrealized_pnl_dollars(legs: list[LegStateRich], mids: dict[int, float], qty: int) -> float:
    total = 0.0
    for leg in legs:
        mid = mids.get(leg.conId, 0.0)
        ent = leg.entry_price_per_share
        if leg.action.upper() == "SELL":
            total += (ent - mid) * CONTRACT_MULTIPLIER * leg.ratio * qty
        else:
            total += (mid - ent) * CONTRACT_MULTIPLIER * leg.ratio * qty
    return total


async def leg_mids(ib: IB, legs: list[LegStateRich]) -> dict[int, float]:
    opts: list[Option] = []
    for leg in legs:
        o = Option(
            leg.symbol,
            leg.expiry,
            leg.strike,
            leg.right,
            "SMART",
            currency="USD",
        )
        o.conId = leg.conId
        opts.append(o)
    tickers = await ib.reqTickersAsync(*opts)
    await asyncio.sleep(1.2)
    out: dict[int, float] = {}
    for leg, tk in zip(legs, tickers):
        b, a = float(tk.bid or 0), float(tk.ask or 0)
        if b > 0 and a > 0:
            out[leg.conId] = 0.5 * (b + a)
        elif tk.last and tk.last > 0:
            out[leg.conId] = float(tk.last)
        elif tk.close and tk.close > 0:
            out[leg.conId] = float(tk.close)
        else:
            out[leg.conId] = 0.0
    return out


def _cancel_order_by_id(ib: IB, order_id: int) -> None:
    try:
        for tr in ib.openTrades():
            if tr.order.orderId == order_id:
                ib.cancelOrder(tr.order)
                print(f"[ORDER] Cancelled GTC / working order id={order_id}")
                return
        print(f"[WARN] No open order id={order_id} to cancel")
    except Exception as e:  # noqa: BLE001
        print(f"[WARN] cancel order {order_id}: {e}")


async def close_strategy_market(ib: IB, raw: dict[str, Any], legs: list[LegStateRich], qty: int) -> None:
    """Flip each leg action; BUY to close credit-opened strategies, SELL to close debit-opened."""
    closing: list[tuple[Option, str, int]] = []
    for leg in legs:
        o = Option(
            leg.symbol,
            leg.expiry,
            leg.strike,
            leg.right,
            "SMART",
            currency="USD",
        )
        o.conId = leg.conId
        await ib.qualifyContractsAsync(o)
        ca = "BUY" if leg.action.upper() == "SELL" else "SELL"
        closing.append((o, ca, leg.ratio))
    bag = build_bag(closing)
    entry_debit = bool(raw.get("entry_was_debit"))
    # Debit opening -> close with SELL; credit opening -> close with BUY
    act = "SELL" if entry_debit else "BUY"
    _ = ib.placeOrder(bag, MarketOrder(act, qty))
    print(f"[ORDER] Emergency close {act} x{qty} on BAG")


async def manage_open_positions(ib: IB, data: dict[str, Any], today: date) -> None:
    """Stops & time stops only (GTC handles TP)."""
    kept: list[dict[str, Any]] = []
    for raw in data.get("strategies", []):
        try:
            legs = [
                LegStateRich(
                    conId=int(x["conId"]),
                    action=str(x["action"]),
                    ratio=int(x["ratio"]),
                    symbol=str(x["symbol"]),
                    expiry=str(x["expiry"]),
                    strike=float(x["strike"]),
                    right=str(x["right"]),
                    entry_price_per_share=float(x["entry_price_per_share"]),
                )
                for x in raw["legs"]
            ]
            qty = int(raw["qty"])
            ts_end = date.fromisoformat(raw["time_stop_date"])
            sl_thr = float(raw["sl_threshold_unrealized"])

            mids = await leg_mids(ib, legs)
            pnl = unrealized_pnl_dollars(legs, mids, qty)
            time_hit = today >= ts_end
            stop_hit = pnl <= sl_thr

            if not time_hit and not stop_hit:
                kept.append(raw)
                continue

            tag = "time_stop" if time_hit else "stop_loss"
            print(
                f"[EXIT] id={raw['id']} regime={raw['regime']} {tag} "
                f"pnl=${pnl:,.0f} thr=${sl_thr:,.0f}"
            )

            await close_strategy_market(ib, raw, legs, qty)

            gtc = raw.get("gtc_order_id")
            if gtc is not None:
                _cancel_order_by_id(ib, int(gtc))
            await asyncio.sleep(1.0)
        except Exception as e:  # noqa: BLE001
            print(f"[ERROR] Dropping corrupt state row: {e}")
            # Do not re-append broken JSON rows
            continue

    data["strategies"] = kept
    _save_state(data)


def _expected_spy_option_positions_from_state(
    data: dict[str, Any],
) -> tuple[dict[int, float], dict[int, list[str]]]:
    """
    From ``live_vrp_state.json`` strategies, compute net IB-style position per conId
    (long +qty contracts, short -qty) for each leg.
    """
    expected: dict[int, float] = {}
    notes: dict[int, list[str]] = defaultdict(list)
    for raw in data.get("strategies", []):
        sid = str(raw.get("id", "?"))
        reg = str(raw.get("regime", "?"))
        qty = int(raw.get("qty", 1))
        for x in raw.get("legs", []):
            cid = int(x["conId"])
            ratio = int(x.get("ratio", 1))
            act = str(x.get("action", "")).upper()
            inc = float(qty * ratio) if act == "BUY" else -float(qty * ratio)
            expected[cid] = expected.get(cid, 0.0) + inc
            leg_label = (
                f"{x.get('symbol', '')} {x.get('expiry', '')} {x.get('strike', '')}{x.get('right', '')} "
                f"{act}×{ratio}"
            )
            notes[cid].append(f"{sid}/{reg}: {leg_label}")
    return expected, dict(notes)


def _ib_spy_option_position_maps(ib: IB) -> tuple[dict[int, float], dict[int, float], dict[int, Option]]:
    """SPY option conId -> position, other symbols' options -> position, conId -> contract for labels."""
    spy: dict[int, float] = {}
    other: dict[int, float] = {}
    contracts: dict[int, Option] = {}
    for p in ib.positions():
        c = p.contract
        if not isinstance(c, Option):
            continue
        cid = int(c.conId or 0)
        if cid <= 0:
            continue
        pos = float(p.position)
        if abs(pos) < 1e-9:
            continue
        contracts[cid] = c
        sym = str(getattr(c, "symbol", "") or "")
        if sym == "SPY":
            spy[cid] = spy.get(cid, 0.0) + pos
        else:
            other[cid] = pos
    return spy, other, contracts


def _open_order_ids(ib: IB) -> set[int]:
    return {int(tr.order.orderId) for tr in ib.openTrades()}


async def run_ib_state_audit(ib: IB, *, state_path: Path) -> int:
    """
    Print comparison of state-file strategies vs IB SPY option positions (by conId).
    Returns 0 if SPY legs align within tolerance, else 1.
    """
    data = _load_state_path(state_path)
    expected, exp_notes = _expected_spy_option_positions_from_state(data)
    ib_spy, ib_other, ib_contracts = _ib_spy_option_position_maps(ib)
    open_ids = _open_order_ids(ib)

    tol = 0.05  # contracts; allow fractional rounding / partials
    issues: list[str] = []
    gtc_warnings: list[str] = []

    print("=" * 72)
    print(f" IB vs state audit  |  state file: {state_path.resolve()}")
    print(f" State strategies: {len(data.get('strategies', []))}")
    print(f" IB SPY option positions (non-zero conIds): {len(ib_spy)}")
    print("=" * 72)

    all_cids = sorted(set(expected) | set(ib_spy))
    for cid in all_cids:
        e = float(expected.get(cid, 0.0))
        g = float(ib_spy.get(cid, 0.0))
        if abs(e - g) <= tol:
            tag = "OK"
        else:
            tag = "MISMATCH"
            issues.append(f"conId={cid} state={e:.4f} ib={g:.4f}")
        c = ib_contracts.get(cid)
        c_lab = ""
        if c is not None:
            c_lab = f" {c.localSymbol or c.symbol} {c.lastTradeDateOrContractMonth} {c.strike}{c.right}"
        note = "; ".join(exp_notes.get(cid, [])) if cid in exp_notes else "(not in state)"
        print(f"  [{tag}] conId={cid}{c_lab}")
        print(f"         state_net={e:+.4f}  ib_net={g:+.4f}  |  {note}")

    if ib_other:
        print("-" * 72)
        print(f" Non-SPY option positions at IB (n={len(ib_other)}); not compared to VRP state:")
        for cid, pos in sorted(ib_other.items(), key=lambda kv: abs(kv[1]), reverse=True):
            c = ib_contracts.get(cid)
            lab = f"{c.symbol} {c.lastTradeDateOrContractMonth} {c.strike}{c.right}" if c else str(cid)
            print(f"   conId={cid} pos={pos:+.2f}  {lab}")

    print("-" * 72)
    print(" GTC take-profit orders referenced in state vs IB openTrades:")
    any_gtc = False
    for raw in data.get("strategies", []):
        oid = raw.get("gtc_order_id")
        if oid is None:
            continue
        any_gtc = True
        oid_i = int(oid)
        ok = oid_i in open_ids
        st = "OPEN" if ok else "MISSING"
        if not ok:
            gtc_warnings.append(
                f"gtc_order_id={oid_i} strategy={raw.get('id')} regime={raw.get('regime')} "
                "(not in openTrades — filled/cancelled or stale state?)"
            )
        print(f"  [{st}] strategy id={raw.get('id')} regime={raw.get('regime')} gtc_order_id={oid_i}")
    if not any_gtc:
        print("  (no gtc_order_id fields in state strategies)")

    if not data.get("strategies") and not ib_spy:
        print("  (no state strategies and no SPY option inventory)")

    print("=" * 72)
    if gtc_warnings:
        print(f"WARNINGS ({len(gtc_warnings)}):")
        for line in gtc_warnings:
            print(f"  - {line}")
    if issues:
        print(f"AUDIT FAILED — position drift ({len(issues)} issue(s)):")
        for line in issues:
            print(f"  - {line}")
        return 1
    print("AUDIT OK: SPY option legs match state within tolerance.")
    return 0


def regime_from_vix(vix: float) -> RegimeName:
    if vix < VIX_R1_MAX:
        return "pmcc"
    if vix <= VIX_R2_MAX:
        return "diagonal"
    if vix <= VIX_R3_MAX:
        return "naked"
    return "credit_spread"


async def place_entry_with_gtc(
    ib: IB,
    data: dict[str, Any],
    regime: RegimeName,
    net_liq: float,
    spy: Stock,
    spy_px: float,
    max_entry_qty: int | None = None,
    recommend_only: bool = False,
    recommendation_out: Path | None = None,
    ignore_state_check: bool = False,
) -> None:
    """
    Build the regime structure, send limit @ mid (slight price improvement), wait for fill,
    persist JSON state, and rest a GTC take-profit on the **closing** combo.

    IB combo sign convention (typical):
      * **Debit** (you pay): positive ``avgFillPrice`` on a **BUY** of the BAG.
      * **Credit** (you collect): negative ``avgFillPrice`` on a **SELL** of the BAG.
    """
    if data.get("strategies") and not recommend_only and not ignore_state_check:
        print("[SKIP] Strategy slot(s) already tracked in state; avoid stacking risk same day.")
        return

    today = date.today()
    opened: list[tuple[Option, str, int]] = []
    time_stop_days = R1_TIME_STOP_DAYS

    if regime == "pmcc":
        target_risk = _risk_budget_usd(net_liq, "pmcc")
        call_o, put_o = await get_weekly_strangle_options(
            ib, spy, spy_px, R1_STRANGLE_DTE, R1_CALL_DELTA, R1_PUT_DELTA, recommend_only=recommend_only
        )
        opened = [(call_o, "BUY", 1), (put_o, "BUY", 1)]
        time_stop_days = R1_TIME_STOP_DAYS
    elif regime == "diagonal":
        target_risk = _risk_budget_usd(net_liq, "diagonal")
        shortp = await get_target_option(
            ib, spy, spy_px, R2_SHORT_DTE, R2_SHORT_DELTA, "P", recommend_only=recommend_only
        )
        avoid = frozenset({str(shortp.lastTradeDateOrContractMonth)})
        longp = await get_target_option(
            ib, spy, spy_px, R2_LONG_DTE, R2_LONG_DELTA, "P", avoid_expiries=avoid, recommend_only=recommend_only
        )
        if int(shortp.conId or 0) == int(longp.conId or 0):
            print("[SKIP] Diagonal legs resolved to same contract; skipping entry.")
            return
        if str(shortp.lastTradeDateOrContractMonth) == str(longp.lastTradeDateOrContractMonth):
            print("[SKIP] Diagonal legs share same expiry; skipping entry.")
            return
        opened = [(shortp, "SELL", 1), (longp, "BUY", 1)]
        time_stop_days = R2_TIME_STOP_DAYS
    elif regime == "r2_spread":
        target_risk = _risk_budget_usd(net_liq, "r2_spread")
        shortp = await get_target_option(
            ib, spy, spy_px, R2B_SHORT_DTE, R2B_SHORT_DELTA, "P", recommend_only=recommend_only
        )
        width_pts = max(1.0, round(float(spy_px) * float(R2B_WIDTH_FRAC_OF_SPOT)))
        ideal_long = float(shortp.strike) - width_pts
        longp = await get_r2b_long_put_on_expiry(
            ib,
            spy,
            spy_px,
            str(shortp.lastTradeDateOrContractMonth),
            ideal_long,
            float(shortp.strike),
            recommend_only=recommend_only,
        )
        if str(shortp.lastTradeDateOrContractMonth) != str(longp.lastTradeDateOrContractMonth):
            print("[SKIP] R2b spread legs did not resolve to same expiry.")
            return
        if float(longp.strike) >= float(shortp.strike):
            print("[SKIP] R2b requires long put strike below short put strike.")
            return
        opened = [(shortp, "SELL", 1), (longp, "BUY", 1)]
        time_stop_days = R2B_TIME_STOP_DAYS
    elif regime == "naked":
        target_risk = _risk_budget_usd(net_liq, "naked")
        p = await get_target_option(
            ib, spy, spy_px, R3_TARGET_DTE, R3_TARGET_DELTA, "P", recommend_only=recommend_only
        )
        opened = [(p, "SELL", 1)]
        time_stop_days = R3_TIME_STOP_DAYS
    else:
        target_risk = _risk_budget_usd(net_liq, "credit_spread")
        shortp = await get_target_option(
            ib, spy, spy_px, R4_SHORT_DTE, R4_SHORT_DELTA, "P", recommend_only=recommend_only
        )
        longp = await get_target_option(
            ib, spy, spy_px, R4_SHORT_DTE, R4_LONG_DELTA, "P", recommend_only=recommend_only
        )
        opened = [(shortp, "SELL", 1), (longp, "BUY", 1)]
        time_stop_days = R4_TIME_STOP_DAYS

    for o, _, _ in opened:
        await ib.qualifyContractsAsync(o)

    bag = build_bag(opened)
    try:
        mid, _bid, _ask, quote_quality, quote_notes = await combo_quote_mid(ib, bag, opened)
    except Exception as e:
        if recommend_only and regime == "diagonal":
            # Recommendation fallback when option quote subscriptions are unavailable.
            mid = -0.01
            quote_quality = "synthetic_fallback"
            quote_notes = [f"exception:{e}"]
            print(f"[WARN] Combo quote unavailable in recommend-only mode ({e}); using fallback mid={mid:.2f} for sizing ticket.")
        else:
            raise
    if not (math.isfinite(mid) and abs(mid) > 1e-6):
        if recommend_only and regime == "diagonal":
            mid = -0.01
            quote_quality = "synthetic_fallback"
            quote_notes = ["invalid_combo_mid_fallback"]
            print(f"[WARN] Invalid combo mid; using fallback mid={mid:.2f} for diagonal recommendation.")
        else:
            print(f"[SKIP] Combo quote unavailable/invalid (mid={mid}); no entry order sent.")
            return

    # --- Sizing (per vrp_backtester) ---
    qty = 1
    sl_thr = 0.0
    risk_per_contract_usd = 0.0
    if regime == "pmcc":
        debit_per_unit = abs(mid) * CONTRACT_MULTIPLIER
        if debit_per_unit < 1e-3:
            print("[SKIP] R1 strangle debit ~0")
            return
        risk_per_contract_usd = debit_per_unit
        qty = max(1, int(math.floor(target_risk / debit_per_unit)))
        sl_thr = R1_SL_FRAC * debit_per_unit * qty
    elif regime == "diagonal":
        shortp, longp = opened[0][0], opened[1][0]
        diag_risk_per_contract = _diag_risk_per_contract_usd(shortp, longp, mid)
        risk_per_contract_usd = diag_risk_per_contract
        qty = max(1, int(math.floor(target_risk / diag_risk_per_contract)))
        sl_thr = R2_SL_PER_CONTRACT * qty
    elif regime == "r2_spread":
        shortp, longp = opened[0][0], opened[1][0]
        width = abs(float(shortp.strike) - float(longp.strike))
        credit_per_unit = max(-mid, 0.0) * CONTRACT_MULTIPLIER if mid <= 0 else max(mid, 0.0) * CONTRACT_MULTIPLIER
        max_loss = max(width * CONTRACT_MULTIPLIER - credit_per_unit, SIZING_MIN_MAX_RISK_USD)
        risk_per_contract_usd = max_loss
        qty = max(1, int(math.floor(target_risk / max_loss)))
        sl_thr = -1.0 * max_loss * qty
    elif regime == "naked":
        credit_per_unit = max(-mid, 0.0) * CONTRACT_MULTIPLIER if mid <= 0 else max(mid, 0.0) * CONTRACT_MULTIPLIER
        if credit_per_unit < 1e-3:
            credit_per_unit = max(0.01 * CONTRACT_MULTIPLIER, 1.0)
        rpc = max(4.0 * credit_per_unit, 1e-6)
        risk_per_contract_usd = rpc
        qty = max(1, int(math.floor(target_risk / rpc)))
        sl_thr = R3_SL_FRAC * credit_per_unit * qty
    else:
        shortp, longp = opened[0][0], opened[1][0]
        width = abs(float(shortp.strike) - float(longp.strike))
        credit_per_unit = max(-mid, 0.0) * CONTRACT_MULTIPLIER if mid <= 0 else max(mid, 0.0) * CONTRACT_MULTIPLIER
        max_loss = max(width * CONTRACT_MULTIPLIER - credit_per_unit, SIZING_MIN_MAX_RISK_USD)
        risk_per_contract_usd = max_loss
        qty = max(1, int(math.floor(target_risk / max_loss)))
        sl_thr = -1.0 * max_loss * qty

    if max_entry_qty is not None and int(max_entry_qty) > 0:
        qty = min(qty, int(max_entry_qty))

    budget_to_risk_ratio = (
        float(target_risk) / float(risk_per_contract_usd)
        if risk_per_contract_usd and risk_per_contract_usd > 0
        else float("inf")
    )
    budget_too_small = qty == 1 and budget_to_risk_ratio < float(MIN_BUDGET_TO_RISK_RATIO_FOR_1LOT)

    # Entry: BUY if paying debit (mid > 0), SELL if collecting credit (mid < 0)
    entry_action = "BUY" if mid > 0 else "SELL"
    tick = 0.02
    preview_limit = round(mid + tick if entry_action == "BUY" else mid - tick, 2)

    if recommend_only:
        legs_preview: list[dict[str, Any]] = []
        for o, act, ratio in opened:
            legs_preview.append(
                {
                    "symbol": o.symbol,
                    "expiry": o.lastTradeDateOrContractMonth,
                    "right": o.right,
                    "strike": float(o.strike),
                    "open_action": act,
                    "ratio": int(ratio),
                    "conId": int(o.conId or 0),
                }
            )
        rec = {
            "mode": "recommend_only",
            "decision": ("NO_QUOTE" if quote_quality not in ("live_bag_mid", "synthetic_liquid") else ("SKIP_BUDGET_TOO_SMALL" if budget_too_small else "ENTER")),
            "created_at": datetime.now().astimezone().isoformat(),
            "regime": regime,
            "net_liq_usd": float(net_liq),
            "spy_price": float(spy_px),
            "risk_budget_usd": float(target_risk),
            "risk_per_contract_usd": (
                float(risk_per_contract_usd)
                if math.isfinite(float(risk_per_contract_usd)) and float(risk_per_contract_usd) > 0
                else None
            ),
            "min_budget_to_risk_ratio_for_1lot": float(MIN_BUDGET_TO_RISK_RATIO_FOR_1LOT),
            "budget_to_risk_ratio": float(budget_to_risk_ratio) if math.isfinite(float(budget_to_risk_ratio)) else None,
            "quote_quality": quote_quality,
            "combo_bid_per_share": float(_bid) if math.isfinite(float(_bid)) else None,
            "combo_ask_per_share": float(_ask) if math.isfinite(float(_ask)) else None,
            "quote_notes": quote_notes,
            "entry_action": entry_action,
            "entry_limit_per_share": float(preview_limit),
            "combo_mid_per_share": float(mid),
            "proposed_qty": 0 if (budget_too_small or quote_quality not in ("live_bag_mid", "synthetic_liquid")) else int(qty),
            "max_entry_qty": int(max_entry_qty) if max_entry_qty is not None else None,
            "stop_threshold_unrealized_usd": 0.0 if (budget_too_small or quote_quality not in ("live_bag_mid", "synthetic_liquid")) else float(sl_thr),
            "time_stop_days": int(time_stop_days),
            "quote_gate_reason": ("requires_live_or_synthetic_liquid" if quote_quality not in ("live_bag_mid", "synthetic_liquid") else "ok"),
            "sizing_formula": (
                f"qty=floor(risk_budget_usd/risk_per_contract_usd)={target_risk:.2f}/{risk_per_contract_usd:.2f}"
            ),
            "legs": legs_preview,
        }
        txt = json.dumps(rec, indent=2)
        print("[RECOMMENDATION]")
        print(txt)
        if recommendation_out is not None:
            p = recommendation_out.expanduser().resolve()
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(txt + "\n", encoding="utf-8")
            print(f"[RECOMMENDATION] wrote -> {p}")
        return

    if budget_too_small:
        print(
            f"[SKIP] Budget too small for 1 lot: budget=${target_risk:,.2f}, "
            f"risk_per_contract=${risk_per_contract_usd:,.2f}, ratio={budget_to_risk_ratio:.3f} "
            f"< {MIN_BUDGET_TO_RISK_RATIO_FOR_1LOT:.2f}"
        )
        return

    trade = None
    attempt_qty = qty
    for margin_try in range(5):
        lim = round(mid + tick if entry_action == "BUY" else mid - tick, 2)
        if not math.isfinite(lim):
            print(f"[SKIP] Entry limit non-finite (lim={lim}); no entry order sent.")
            return
        entry = LimitOrder(entry_action, attempt_qty, lim, tif="DAY", outsideRth=False)
        print(f"[ENTRY] {regime} BAG {entry_action} x{attempt_qty} @ {lim} (mid {mid:.2f})")
        trade = ib.placeOrder(bag, entry)

        for _ in range(90):
            await asyncio.sleep(1.0)
            if trade.orderStatus.status in ("Filled", "Cancelled", "Inactive"):
                break
        if trade.orderStatus.status == "Filled":
            qty = attempt_qty
            break

        log_text = " ".join(
            str(le.message or "")
            for le in getattr(trade, "log", []) or []
        )
        margin_reject = (
            "Error 201" in log_text
            or "Initial Margin" in log_text
            or "margin requirements" in log_text.lower()
        )
        if margin_reject and attempt_qty > 1:
            nq = max(1, attempt_qty // 2)
            if nq < attempt_qty:
                attempt_qty = nq
                print(f"[WARN] Margin rejection (qty); retrying entry with qty={attempt_qty}")
                continue
        print(f"[WARN] Entry not filled: {trade.orderStatus.status}")
        return

    assert trade is not None
    avg = float(trade.orderStatus.avgFillPrice)
    print(f"[FILL] avgFillPrice={avg:.4f} (signed: +debit / -credit per IB)")

    leg_states: list[LegStateRich] = []
    for o, act, ratio in opened:
        tk = (await ib.reqTickersAsync(o))[0]
        await asyncio.sleep(0.4)
        b, a = float(tk.bid or 0), float(tk.ask or 0)
        lm = 0.5 * (b + a) if b and a else float(tk.last or tk.close or 0)
        leg_states.append(
            LegStateRich(
                conId=int(o.conId),
                action=act,
                ratio=ratio,
                symbol=o.symbol,
                expiry=o.lastTradeDateOrContractMonth,
                strike=float(o.strike),
                right=o.right,
                entry_price_per_share=lm,
            )
        )

    # --- Final SL thresholds from fill ---
    entry_was_debit = entry_action == "BUY"
    if regime == "pmcc":
        debit_total = abs(avg) * CONTRACT_MULTIPLIER * qty
        sl_thr = R1_SL_FRAC * debit_total
        # TP: SELL when proceeds >= entry * (1 + 50% of debit ratio) → +50% profit on debit
        tp_limit = round(abs(avg) * (1.0 + R1_TP_FRAC), 2)
        close_act = "SELL"
    elif regime == "diagonal":
        # R2: fixed dollar TP/SL per contract (matches vrp_backtester).
        sl_thr = float(R2_SL_PER_CONTRACT) * qty
        entry_was_debit = entry_action == "BUY"
        if entry_was_debit:
            tp_limit = round(abs(avg) * (1.0 + R1_TP_FRAC), 2)
            close_act = "SELL"
        else:
            ec = max(-avg, 0.0)
            target_pay = ec - R2_TP_PER_CONTRACT / CONTRACT_MULTIPLIER
            tp_limit = round(max(target_pay, 0.01), 2)
            close_act = "BUY"
    elif regime == "r2_spread":
        credit_per_unit = max(-avg, 0.0) * CONTRACT_MULTIPLIER
        width = abs(float(opened[0][0].strike) - float(opened[1][0].strike))
        max_loss_per = max(width * CONTRACT_MULTIPLIER - credit_per_unit, SIZING_MIN_MAX_RISK_USD)
        sl_thr = -1.0 * max_loss_per * qty
        tp_limit = round(max(-avg * (1.0 - R2B_TP_FRAC), 0.01), 2)
        close_act = "BUY"
        entry_was_debit = False
    elif regime == "naked":
        cr = max(-avg, 0.0)
        prem = cr * CONTRACT_MULTIPLIER
        sl_thr = R3_SL_FRAC * prem * qty
        tp_limit = round(max(cr * (1.0 - R3_TP_FRAC), 0.01), 2)
        close_act = "BUY"
        entry_was_debit = False
    else:
        credit_per_unit = max(-avg, 0.0) * CONTRACT_MULTIPLIER
        width = abs(float(opened[0][0].strike) - float(opened[1][0].strike))
        max_loss_per = max(width * CONTRACT_MULTIPLIER - credit_per_unit, SIZING_MIN_MAX_RISK_USD)
        sl_thr = -1.0 * max_loss_per * qty
        tp_limit = round(max(-avg * (1.0 - R4_TP_FRAC), 0.01), 2)
        close_act = "BUY"
        entry_was_debit = False

    close_legs = [(_o, "BUY" if _a == "SELL" else "SELL", _r) for _o, _a, _r in opened]
    bag_c = build_bag(close_legs)
    gtc = LimitOrder(close_act, qty, tp_limit, tif="GTC", outsideRth=False)
    gtc_trade = ib.placeOrder(bag_c, gtc)
    await asyncio.sleep(0.5)
    gtc_id = gtc_trade.order.orderId

    sid = str(uuid.uuid4())[:8]
    rec = {
        "id": sid,
        "regime": regime,
        "qty": qty,
        "legs": [asdict(x) for x in leg_states],
        "opened_date": today.isoformat(),
        "time_stop_date": (today + timedelta(days=time_stop_days)).isoformat(),
        "entry_net_per_share": float(avg),
        "entry_was_debit": entry_was_debit,
        "sl_threshold_unrealized": float(sl_thr),
        "gtc_order_id": int(gtc_id),
        "tp_limit_price_per_share": float(tp_limit),
        "gtc_close_action": close_act,
    }
    data.setdefault("strategies", []).append(rec)
    _save_state(data)
    print(f"[GTC] TP {close_act} x{qty} @ {tp_limit} orderId={gtc_id} strategy={sid}")


async def async_main() -> None:
    print("=" * 72)
    print(" VRP live_ibkr_trader — start", datetime.now().astimezone().isoformat())
    print("=" * 72)

    ap = argparse.ArgumentParser(description="Run live VRP trader via Interactive Brokers.")
    ap.add_argument("--host", type=str, default=IB_HOST, help=f"IB host (default: {IB_HOST})")
    ap.add_argument("--port", type=int, default=IB_PORT, help=f"IB socket port (paper=7497, live=7496). Default: {IB_PORT}")
    ap.add_argument("--client-id", type=int, default=CLIENT_ID, help=f"IB clientId (default: {CLIENT_ID})")
    ap.add_argument(
        "--entry-after-et",
        type=str,
        default=ENTRY_AFTER_ET,
        help=f"Only allow NEW entries after this ET time HH:MM (default: {ENTRY_AFTER_ET}).",
    )
    ap.add_argument(
        "--force-entry-now",
        action="store_true",
        help="Bypass end-of-day time gate and allow immediate new entry.",
    )
    ap.add_argument(
        "--audit",
        action="store_true",
        help="Compare live_vrp_state.json SPY option legs to IB open positions; then exit (no entries).",
    )
    ap.add_argument(
        "--state-path",
        type=Path,
        default=None,
        help=f"State JSON for --audit (default: {STATE_PATH}).",
    )
    ap.add_argument(
        "--watch",
        action="store_true",
        help="Keep process alive; poll until entry gate opens (then attempt once).",
    )
    ap.add_argument(
        "--poll-seconds",
        type=int,
        default=60,
        help="Polling interval in watch mode (default: 60).",
    )
    ap.add_argument(
        "--max-entry-qty",
        type=int,
        default=None,
        help="Hard cap on contracts per new BAG entry (useful when IB initial margin blocks larger size).",
    )
    ap.add_argument(
        "--strategy-config",
        type=Path,
        default=None,
        help="Parity JSON (default: RenTech/strategy_stack/sleeve_risk_fractions.json if that file exists).",
    )
    ap.add_argument(
        "--recommend-only",
        action="store_true",
        help="Compute and print/write recommended entry ticket only; never place/cancel IB orders.",
    )
    ap.add_argument(
        "--recommend-out",
        type=Path,
        default=RECOMMEND_OUT_DEFAULT,
        help=f"Output JSON for --recommend-only (default: {RECOMMEND_OUT_DEFAULT}).",
    )
    args = ap.parse_args()

    global _PARITY_CFG
    cfg_path = (
        args.strategy_config.expanduser()
        if args.strategy_config is not None
        else DEFAULT_STRATEGY_CONFIG_PATH
    )
    if cfg_path.is_file():
        _PARITY_CFG = load_strategy_config_file(cfg_path)
        apply_strategy_params_to_vrp_backtester_module(_PARITY_CFG.strategy_params)
        sync_live_constants_from_vrp_backtester_module(sys.modules[__name__])
        print(f"[PARITY] Loaded {cfg_path.resolve()}")
    else:
        _PARITY_CFG = None
        print(f"[PARITY] No config at {cfg_path.resolve()} — using built-in constants")

    ib = IB()
    try:
        await ib.connectAsync(args.host, int(args.port), clientId=int(args.client_id), timeout=15)
        print(f"[IB] Connected {args.host}:{int(args.port)} clientId={int(args.client_id)}")
        # Request delayed-frozen data fallback so script can run without live market data subscriptions.
        # 1=live, 2=frozen, 3=delayed, 4=delayed-frozen
        ib.reqMarketDataType(3)

        if bool(args.audit):
            ib.reqPositions()
            await asyncio.sleep(1.5)
            sp = args.state_path.expanduser().resolve() if args.state_path is not None else STATE_PATH
            code = await run_ib_state_audit(ib, state_path=sp)
            raise SystemExit(code)

        spy = Stock("SPY", "SMART", "USD")
        await ib.qualifyContractsAsync(spy)

        vix = Index("VIX", "CBOE", "USD")
        try:
            await ib.qualifyContractsAsync(vix)
        except Exception:  # noqa: BLE001
            vix = Index("VIX", exchange="CBOE", currency="USD")
            await ib.qualifyContractsAsync(vix)

        while True:
            net = await get_net_liquidation(ib)
            sma200, spy_hist_close = await get_spy_sma200(ib, spy)
            spy_live = await snapshot_price(ib, spy, "SPY", fallback=spy_hist_close)
            vix_live = await snapshot_price(ib, vix, "VIX")

            print(
                f"[DATA] NetLiq=${net:,.2f}  SPY(live)={spy_live:.2f}  SPY(last bar)={spy_hist_close:.2f}  "
                f"SMA200={sma200:.2f}  VIX={vix_live:.2f}"
            )

            state = _load_state()
            today = date.today()
            if not bool(args.recommend_only):
                await manage_open_positions(ib, state, today)
            else:
                print("[RECOMMEND] recommend-only mode: skipping stop/time management (no live orders touched).")

            now_et = datetime.now(NY_TZ)
            hh, mm = [int(x) for x in str(args.entry_after_et).split(":", 1)]
            gate = now_et.replace(hour=hh, minute=mm, second=0, microsecond=0)
            spy_gt_sma200 = bool(spy_live >= sma200)
            entry_time_gate_pass = bool(args.force_entry_now) or bool(now_et >= gate)
            has_state_positions = bool(state.get("strategies"))
            reg = regime_from_vix(vix_live)
            _write_live_signal_state(
                {
                    "created_at": now_et.isoformat(),
                    "recommend_only": bool(args.recommend_only),
                    "watch_mode": bool(args.watch),
                    "force_entry_now": bool(args.force_entry_now),
                    "entry_after_et": str(args.entry_after_et),
                    "entry_time_gate_pass": bool(entry_time_gate_pass),
                    "net_liq_usd": float(net),
                    "spy_live": float(spy_live),
                    "spy_last_bar": float(spy_hist_close),
                    "sma200": float(sma200),
                    "spy_gt_sma200": bool(spy_gt_sma200),
                    "vix_live": float(vix_live),
                    "regime_now": str(reg),
                    "has_open_state_positions": bool(has_state_positions),
                    "open_state_position_count": int(len(state.get("strategies", []))),
                    "state_path": str(STATE_PATH.resolve()),
                    "recommendation_paths": [
                        str(args.recommend_out.expanduser().resolve()),
                        str(args.recommend_out.expanduser().resolve().with_name(args.recommend_out.expanduser().resolve().stem + "_r2a_diagonal" + args.recommend_out.expanduser().resolve().suffix)),
                        str(args.recommend_out.expanduser().resolve().with_name(args.recommend_out.expanduser().resolve().stem + "_r2b_spread" + args.recommend_out.expanduser().resolve().suffix)),
                    ],
                    "decision_hint": (
                        "WAIT_BEAR" if not spy_gt_sma200 else
                        ("WAIT_TIME_GATE" if not entry_time_gate_pass else
                         ("SKIP_HAS_STATE" if has_state_positions and not bool(args.recommend_only) else "EVALUATE_ENTRY"))
                    ),
                }
            )

            if spy_live < sma200:
                print(
                    "Bear Market Detected (SPY < 200 SMA). Regime: Cash/Wait. "
                    "No new positions will be opened."
                )
                if not bool(args.watch):
                    return
                await asyncio.sleep(max(5, int(args.poll_seconds)))
                continue

            reg = regime_from_vix(vix_live)
            print(f"[REGIME] {reg.upper()} (VIX={vix_live:.2f})")

            if state.get("strategies"):
                print(f"[INFO] {len(state['strategies'])} strategy(ies) in state (GTC TP + SL/time checks).")

            if (not bool(args.force_entry_now)) and now_et < gate:
                print(
                    f"[TIME] Entry gate active: now={now_et.strftime('%H:%M:%S %Z')} "
                    f"< {gate.strftime('%H:%M %Z')} ; skipping NEW entries."
                )
                if not bool(args.watch):
                    return
                await asyncio.sleep(max(5, int(args.poll_seconds)))
                continue

            if reg == "diagonal":
                rec_a = args.recommend_out
                rec_b = args.recommend_out
                base = args.recommend_out.expanduser().resolve()
                if bool(args.recommend_only):
                    rec_a = base.with_name(base.stem + "_r2a_diagonal" + base.suffix)
                    rec_b = base.with_name(base.stem + "_r2b_spread" + base.suffix)
                await place_entry_with_gtc(
                    ib,
                    state,
                    "diagonal",
                    net,
                    spy,
                    spy_live,
                    max_entry_qty=args.max_entry_qty,
                    recommend_only=bool(args.recommend_only),
                    recommendation_out=rec_a,
                )
                await place_entry_with_gtc(
                    ib,
                    state,
                    "r2_spread",
                    net,
                    spy,
                    spy_live,
                    max_entry_qty=args.max_entry_qty,
                    recommend_only=bool(args.recommend_only),
                    recommendation_out=rec_b,
                    ignore_state_check=True,
                )
                if bool(args.recommend_only):
                    # Keep the base recommendation path current so downstream tools don't need
                    # to know split R2 filenames.
                    try:
                        import json as _json

                        ra = _json.loads(rec_a.read_text(encoding="utf-8")) if rec_a.is_file() else None
                        rb = _json.loads(rec_b.read_text(encoding="utf-8")) if rec_b.is_file() else None
                        agg = {
                            "mode": "recommend_only",
                            "created_at": datetime.now().astimezone().isoformat(),
                            "regime": "diagonal",
                            "composite": "R2_PAIR",
                            "decision": (
                                "ENTER"
                                if (isinstance(ra, dict) and ra.get("decision") == "ENTER")
                                and (isinstance(rb, dict) and rb.get("decision") == "ENTER")
                                else "NO_QUOTE"
                            ),
                            "r2a_file": str(rec_a),
                            "r2b_file": str(rec_b),
                            "r2a": ra,
                            "r2b": rb,
                        }
                        base.parent.mkdir(parents=True, exist_ok=True)
                        base.write_text(_json.dumps(agg, indent=2) + "\n", encoding="utf-8")
                        print(f"[RECOMMENDATION] wrote aggregate R2 -> {base}")
                    except OSError as e:
                        print(f"[WARN] Could not write aggregate R2 recommendation ({e})")
            else:
                await place_entry_with_gtc(
                    ib,
                    state,
                    reg,
                    net,
                    spy,
                    spy_live,
                    max_entry_qty=args.max_entry_qty,
                    recommend_only=bool(args.recommend_only),
                    recommendation_out=args.recommend_out,
                )
            if not bool(args.watch):
                return
            print("[WATCH] Entry attempt complete; continuing to monitor open positions.")
            await asyncio.sleep(max(5, int(args.poll_seconds)))

    except Exception as e:  # noqa: BLE001
        print(f"[FATAL] {type(e).__name__}: {e}")
        raise
    finally:
        if ib.isConnected():
            ib.disconnect()
            print("[IB] Disconnected.")


if __name__ == "__main__":
    util.run(async_main())