"""
live_signal_monitor.py
=======================
Live execution signal monitor for the validated EURUSD macro lead-lag model.

Runs continuously on VPS. Every hour during the London/NY session it:
  1. Updates rate data from FRED and Bundesbank (auto_rates_loader.py)
  2. Pulls latest 1H EURUSD bars from MT5
  3. Rebuilds the lag z-score signal
  4. If signal threshold breached, looks for 15M Fibonacci entry
  5. If entry found and no trade open, places order with TP/stop preset
  6. Monitors open trades for z-score reversal exit

VALIDATED PARAMETERS (DO NOT CHANGE)
--------------------------------------
  Signal threshold : z-score >= 2.75
  Entry            : 0.786 Fibonacci pullback on 15M within 6h
  Take profit      : 0.20%
  Stop loss        : 0.25%
  Z-exit threshold : ±1.5 reversal
  Hold max         : 52 hours
  Session          : 08:00-17:00 EET (hours 7-16)
  Base risk        : 0.75% ($300k notional on $100k)
  Signal scaling   : 1x / 1.5x / 2x by z-score band

Requirements
------------
  pip install MetaTrader5 --break-system-packages
  pip install schedule --break-system-packages

MT5 Setup
---------
  1. Install MT5 on Windows machine or VPS
  2. Enable "Allow Algo Trading" in MT5 settings
  3. Enable "Allow DLL imports" in MT5 Terminal settings
  4. Log in to your broker account in MT5
  5. Set MT5_LOGIN, MT5_PASSWORD, MT5_SERVER in config below

Place this file in:
  C:\\Users\\paul_\\OneDrive\\fx_macro_intraday\\src\\execution\\live_signal_monitor.py

Run from project root:
  python src/execution/live_signal_monitor.py

Stop with Ctrl+C
"""

import os
import time
import logging
import schedule
from datetime import datetime, timezone, date, timedelta
from pathlib import Path
import sys
import pandas as pd
import numpy as np

BASE_PATH = Path(__file__).resolve().parents[2]
SRC_PATH  = BASE_PATH / "src"
if str(SRC_PATH) not in sys.path:
    sys.path.append(str(SRC_PATH))

# ── Logging setup ─────────────────────────────────────────────────────────────
LOG_DIR = BASE_PATH / "data" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)

logging.basicConfig(
    level   =logging.INFO,
    format  ="%(asctime)s  %(levelname)-8s  %(message)s",
    datefmt ="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.FileHandler(LOG_DIR / "live_monitor.log", encoding="utf-8"),
        logging.StreamHandler(sys.stdout),
    ],
)
log = logging.getLogger("live_monitor")

# ── MT5 Configuration ─────────────────────────────────────────────────────────
# EDIT THESE BEFORE RUNNING
MT5_LOGIN    = 52793562           # Your MT5 account number
MT5_PASSWORD = "xIaJM$A4t2Vc5s"           # Your MT5 password
MT5_SERVER   = "ICMarketsSC-Demo"           # Your broker's server name (e.g. "ICMarkets-Live")
SYMBOL       = "EURUSD"
TIMEFRAME_1H = None         # set after MT5 import
TIMEFRAME_15M= None         # set after MT5 import

# ── Validated model parameters ────────────────────────────────────────────────
THRESHOLD         = 2.75
FIB               = 0.786
HOLD_HOURS        = 52
STOP_PCT          = 0.0025     # 0.25%
TP_PCT            = 0.0020     # 0.20%
ZSCORE_EXIT       = 1.5
SESSION_START_EET = 7          # 08:00 EET = hour 7 in 0-indexed
SESSION_END_EET   = 17         # 18:00 EET = hour 17 — extended for BST (UTC+1 vs UTC+2)

BASE_RISK_PCT     = 0.0075     # 0.75%
ZSCORE_BANDS      = [
    (2.75, 3.50, 1.0),
    (3.50, 4.50, 1.5),
    (4.50, 99.0, 2.0),
]

# Rolling parameters for live signal rebuild
BETA_WINDOW   = 120
SMOOTH_SPAN   = 20
ZSCORE_WINDOW = 60
BARS_1H       = 3500   # number of 1H bars to pull from MT5 (~145 trading days)

# State tracking
STATE = {
    "position_open"  : False,
    "position_ticket": None,
    "position_signal": None,    # 1 = long, -1 = short
    "entry_price"    : None,
    "entry_time"     : None,
    "position_lot"   : None,
    "last_zscore"    : None,
    "last_signal_time": None,
}

# State persistence — single file, always overwritten
STATE_FILE = BASE_PATH / "data" / "state" / "live_state.json"
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)

def save_state():
    """Persist current STATE and ARMED to disk so it survives VPS reboots."""
    try:
        import json
        data = STATE.copy()
        if data.get("entry_time") and hasattr(data["entry_time"], "isoformat"):
            data["entry_time"] = data["entry_time"].isoformat()

        # Also persist ARMED state
        armed_data = ARMED.copy()
        if armed_data.get("armed_time") and hasattr(armed_data["armed_time"], "isoformat"):
            armed_data["armed_time"] = armed_data["armed_time"].isoformat()
        data["_armed"] = armed_data

        STATE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
    except Exception as e:
        log.warning(f"State save failed: {e}")

def load_state():
    """
    Restore STATE and ARMED from disk on startup.
    If a position was open or signal was armed, restores both
    so monitoring and entry detection work correctly after restart.
    """
    if not STATE_FILE.exists():
        return
    try:
        import json
        from datetime import datetime
        data = json.loads(STATE_FILE.read_text(encoding="utf-8"))

        # Restore position state if open
        if data.get("position_open", False):
            STATE["position_open"]   = data.get("position_open", False)
            STATE["position_ticket"] = data.get("position_ticket")
            STATE["position_signal"] = data.get("position_signal")
            STATE["entry_price"]     = data.get("entry_price")
            STATE["position_lot"]    = data.get("position_lot")
            STATE["last_zscore"]     = data.get("last_zscore")
            et = data.get("entry_time")
            if et:
                STATE["entry_time"] = datetime.fromisoformat(et)
            log.info(f"State restored — position open: "
                     f"ticket={STATE['position_ticket']} "
                     f"entry={STATE['entry_price']}")
            send_telegram(
                f"⚠️ <b>STATE RESTORED AFTER REBOOT</b>\n"
                f"Position was open at restart\n"
                f"Ticket: {STATE['position_ticket']}\n"
                f"Entry: {STATE['entry_price']}\n"
                f"Resuming monitoring..."
            )
        else:
            log.info("State file found — no open position, starting fresh")

        # Restore ARMED state if signal was active and within 6h window
        armed_data = data.get("_armed", {})
        if armed_data.get("active", False):
            armed_time_str = armed_data.get("armed_time")
            if armed_time_str:
                armed_time = datetime.fromisoformat(armed_time_str)
                hours_since = (datetime.utcnow() - armed_time).total_seconds() / 3600
                if hours_since < 6.0:
                    # Signal still within 6h window — restore armed state
                    ARMED["active"]       = True
                    ARMED["direction"]    = armed_data.get("direction")
                    ARMED["target_price"] = armed_data.get("target_price")
                    ARMED["armed_time"]   = armed_time
                    ARMED["zscore_abs"]   = armed_data.get("zscore_abs")
                    dir_str = "LONG" if ARMED["direction"] == 1 else "SHORT"
                    remaining = round(6.0 - hours_since, 1)
                    log.info(f"Armed state restored — {dir_str} target="
                             f"{ARMED['target_price']:.5f} | "
                             f"{remaining:.1f}h remaining in window")
                    send_telegram(
                        f"🎯 <b>SIGNAL RESTORED AFTER REBOOT</b>\n"
                        f"Direction: {dir_str}\n"
                        f"Target: {ARMED['target_price']:.5f}\n"
                        f"Time remaining: {remaining:.1f}h\n"
                        f"Fast polling resuming..."
                    )
                else:
                    log.info(f"Armed signal expired during restart "
                             f"({hours_since:.1f}h ago) — not restoring")

    except Exception as e:
        log.warning(f"State restore failed: {e}")

# Track last rate update — prevents duplicate calls within the same calendar day
LAST_RATE_UPDATE = {"date": None}

# ── Telegram notifications ─────────────────────────────────────────────────────
# Credentials hardcoded for reliability on VPS reboot
TELEGRAM_TOKEN   = os.getenv("TELEGRAM_TOKEN",   "8794831706:AAGzqHNhyvfWL2lG_kCQUupacilRf7GfNSI")
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "1031071259")

def send_telegram(message: str):
    """Send a message to Telegram. Fails silently if not configured."""
    if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
        return
    try:
        import urllib.request
        import urllib.parse
        import ssl
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        url  = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage"
        data = f"chat_id={TELEGRAM_CHAT_ID}&text={urllib.parse.quote(message)}&parse_mode=HTML"
        req  = urllib.request.Request(url, data=data.encode(), method="POST")
        urllib.request.urlopen(req, timeout=10, context=ctx)
    except Exception as e:
        log.warning(f"Telegram notification failed: {e}")

# Fast polling state — armed when signal threshold breached, disarmed on entry or expiry
ARMED = {
    "active"        : False,   # True when fast-polling mode is running
    "direction"     : None,    # 1 = long, -1 = short
    "target_price"  : None,    # 0.786 Fibonacci level to watch
    "armed_time"    : None,    # when signal fired (for 6h expiry)
    "zscore_abs"    : None,    # z-score magnitude for position sizing
}


def get_multiplier(z: float) -> float:
    for lo, hi, mult in ZSCORE_BANDS:
        if lo <= z < hi:
            return mult
    return ZSCORE_BANDS[-1][2]


# ── MT5 connection ─────────────────────────────────────────────────────────────
def connect_mt5() -> bool:
    """Initialises and logs in to MT5. Returns True if successful."""
    try:
        import MetaTrader5 as mt5
        global TIMEFRAME_1H, TIMEFRAME_15M

        if not mt5.initialize():
            log.error(f"MT5 initialize failed: {mt5.last_error()}")
            return False

        if MT5_LOGIN and MT5_PASSWORD and MT5_SERVER:
            if not mt5.login(MT5_LOGIN, MT5_PASSWORD, MT5_SERVER):
                log.error(f"MT5 login failed: {mt5.last_error()}")
                return False

        TIMEFRAME_1H  = mt5.TIMEFRAME_H1
        TIMEFRAME_15M = mt5.TIMEFRAME_M15

        info = mt5.account_info()
        if info is None:
            log.error("Could not retrieve account info")
            return False

        log.info(f"MT5 connected | Account: {info.login} | "
                 f"Balance: ${info.balance:,.2f} | "
                 f"Broker: {info.company}")
        return True

    except ImportError:
        log.error("MetaTrader5 package not installed. Run: "
                  "pip install MetaTrader5 --break-system-packages")
        return False


def disconnect_mt5():
    try:
        import MetaTrader5 as mt5
        mt5.shutdown()
        log.info("MT5 disconnected")
    except Exception:
        pass


# ── Data collection ───────────────────────────────────────────────────────────
def update_rate_data():
    """
    Updates US and DE yield data from FRED and Bundesbank.
    Only runs once per calendar day — skips if already updated today.
    """
    today = date.today()
    if LAST_RATE_UPDATE["date"] == today:
        log.info("Rate data already updated today — skipping")
        return True
    try:
        from ingestion.auto_rates_loader import update_local_csv, fetch_fred_series
        import os

        api_key = os.getenv("FRED_API_KEY")
        if not api_key:
            log.warning("FRED_API_KEY not set — skipping rate update")
            return False

        log.info("Updating rate data from FRED/Bundesbank...")
        update_local_csv("us2y.csv",  fetch_fred_series, series_id="DGS2",
                         value_col_name="us2y")
        update_local_csv("us10y.csv", fetch_fred_series, series_id="DGS10",
                         value_col_name="us10y")

        # DE rates via Bundesbank (from auto_rates_loader)
        from ingestion.auto_rates_loader import (
            fetch_bundesbank_series, BUNDESBANK_DE2Y_CSV, BUNDESBANK_DE10Y_CSV
        )
        update_local_csv("de2y.csv", fetch_bundesbank_series,
                         csv_url=BUNDESBANK_DE2Y_CSV, value_col_name="de2y")
        update_local_csv("de10y.csv", fetch_bundesbank_series,
                         csv_url=BUNDESBANK_DE10Y_CSV, value_col_name="de10y")

        LAST_RATE_UPDATE["date"] = today
        log.info("Rate data updated successfully")
        return True

    except Exception as e:
        log.error(f"Rate update failed: {e}")
        return False


def get_mt5_bars_1h(n_bars: int = BARS_1H) -> pd.DataFrame | None:
    """Pulls n_bars of 1H EURUSD data from MT5."""
    try:
        import MetaTrader5 as mt5
        rates = mt5.copy_rates_from_pos(SYMBOL, TIMEFRAME_1H, 0, n_bars)
        if rates is None or len(rates) == 0:
            log.error(f"Failed to get 1H bars: {mt5.last_error()}")
            return None

        df = pd.DataFrame(rates)
        df["datetime"] = pd.to_datetime(df["time"], unit="s")
        df = df.rename(columns={"open": "open", "high": "high",
                                 "low": "low", "close": "close",
                                 "tick_volume": "volume"})
        df = df[["datetime", "open", "high", "low", "close", "volume"]]
        return df.sort_values("datetime").reset_index(drop=True)

    except Exception as e:
        log.error(f"get_mt5_bars_1h error: {e}")
        return None


def get_mt5_bars_15m(n_bars: int = 200) -> pd.DataFrame | None:
    """Pulls recent 15M EURUSD bars from MT5 for entry detection."""
    try:
        import MetaTrader5 as mt5
        rates = mt5.copy_rates_from_pos(SYMBOL, TIMEFRAME_15M, 0, n_bars)
        if rates is None or len(rates) == 0:
            log.error(f"Failed to get 15M bars: {mt5.last_error()}")
            return None

        df = pd.DataFrame(rates)
        df["datetime"] = pd.to_datetime(df["time"], unit="s")
        df = df.rename(columns={"open": "open", "high": "high",
                                 "low": "low", "close": "close"})
        df = df[["datetime", "open", "high", "low", "close"]]
        return df.sort_values("datetime").reset_index(drop=True)

    except Exception as e:
        log.error(f"get_mt5_bars_15m error: {e}")
        return None


# ── Signal computation ────────────────────────────────────────────────────────
def compute_live_zscore(prices_1h: pd.DataFrame) -> pd.DataFrame | None:
    """
    Rebuilds the lag z-score signal purely from live MT5 1H bars and
    the rate CSV files on disk (updated daily by update_rate_data).

    Does NOT load any historical price CSVs — uses only the 500 bars
    pulled from MT5 (covering ~21 trading days which is enough for the
    60-bar z-score window and the 24h return lookback).

    Rolling beta is computed inline from the MT5 price data and
    the current rate spread files.
    """
    try:
        import statsmodels.api as sm
        from pathlib import Path

        RATES_DIR = BASE_PATH / "data" / "raw" / "rates"

        # ── Step 1: Daily prices from MT5 bars ───────────────────────────────
        prices_1h = prices_1h.copy()
        prices_1h["date"] = prices_1h["datetime"].dt.normalize()
        daily_px = (
            prices_1h.groupby("date", as_index=False)
            .agg(close=("close", "last"))
            .sort_values("date")
            .reset_index(drop=True)
        )
        daily_px["eurusd_return_1d"] = daily_px["close"].pct_change()
        daily_px["date"] = pd.to_datetime(daily_px["date"])

        # ── Step 2: Load rate CSVs (updated daily by update_rate_data) ───────
        def load_rate(fname, col):
            path = RATES_DIR / fname
            if not path.exists():
                raise FileNotFoundError(f"Rate file not found: {path}")
            df = pd.read_csv(path, parse_dates=["date"])
            df.columns = [c.lower() for c in df.columns]
            df["date"] = pd.to_datetime(df["date"])
            df[col] = pd.to_numeric(df[col], errors="coerce")
            return df[["date", col]].dropna().sort_values("date")

        us2y  = load_rate("us2y.csv",  "us2y")
        us10y = load_rate("us10y.csv", "us10y")
        de2y  = load_rate("de2y.csv",  "de2y")
        de10y = load_rate("de10y.csv", "de10y")

        # ── Step 3: Build spread features ────────────────────────────────────
        date_min = daily_px["date"].min()
        date_max = daily_px["date"].max()
        date_range = pd.DataFrame({"date": pd.date_range(date_min, date_max, freq="D")})

        def ffill_to_daily(rate_df, col):
            left  = date_range.copy()
            right = rate_df.copy()
            left["date"]  = left["date"].astype("datetime64[us]")
            right["date"] = right["date"].astype("datetime64[us]")
            return pd.merge_asof(left.sort_values("date"),
                                  right.sort_values("date"),
                                  on="date", direction="backward")

        us2y_d  = ffill_to_daily(us2y,  "us2y")
        us10y_d = ffill_to_daily(us10y, "us10y")
        de2y_d  = ffill_to_daily(de2y,  "de2y")
        de10y_d = ffill_to_daily(de10y, "de10y")

        spreads = us2y_d.merge(us10y_d, on="date").merge(de2y_d, on="date").merge(de10y_d, on="date")
        spreads["spread_2y"]  = spreads["us2y"]  - spreads["de2y"]
        spreads["spread_10y"] = spreads["us10y"] - spreads["de10y"]
        spreads["spread_2y_change_1d"]  = spreads["spread_2y"].diff(1)
        spreads["spread_10y_change_1d"] = spreads["spread_10y"].diff(1)
        spreads = spreads[["date", "spread_2y_change_1d", "spread_10y_change_1d"]].dropna()

        # ── Step 4: Merge prices and spreads ─────────────────────────────────
        model_df = daily_px.merge(spreads, on="date", how="left").dropna(
            subset=["eurusd_return_1d", "spread_2y_change_1d"])

        if len(model_df) < BETA_WINDOW + 5:
            log.warning(f"Insufficient data for beta model: {len(model_df)} days, need {BETA_WINDOW+10}")
            # Fall back to simple spread signal without beta
            model_df["predicted_return_1d"] = model_df["spread_2y_change_1d"] * -0.03

        else:
            # ── Step 5: Rolling beta (inline — no CSV loading) ───────────────
            n         = len(model_df)
            b2y_raw   = np.full(n, np.nan)
            b10y_raw  = np.full(n, np.nan)

            for i in range(BETA_WINDOW, n):
                sample = model_df.iloc[i-BETA_WINDOW:i].copy()
                sample = sample[(sample["spread_2y_change_1d"].abs() > 0) |
                                 (sample["spread_10y_change_1d"].abs() > 0)]
                if len(sample) < 30:
                    continue
                X = sm.add_constant(sample[["spread_2y_change_1d", "spread_10y_change_1d"]])
                try:
                    res = sm.OLS(sample["eurusd_return_1d"], X).fit()
                    b2y_raw[i]  = res.params.get("spread_2y_change_1d",  np.nan)
                    b10y_raw[i] = res.params.get("spread_10y_change_1d", np.nan)
                except Exception:
                    pass

            model_df["beta_2y_raw"]  = b2y_raw
            model_df["beta_10y_raw"] = b10y_raw
            model_df["beta_2y"]  = model_df["beta_2y_raw"].ewm(span=SMOOTH_SPAN).mean().shift(1).clip(-0.10, 0.10)
            model_df["beta_10y"] = model_df["beta_10y_raw"].ewm(span=SMOOTH_SPAN).mean().shift(1).clip(-0.08, 0.08)
            model_df["predicted_return_1d"] = (
                model_df["beta_2y"]  * model_df["spread_2y_change_1d"] +
                model_df["beta_10y"] * model_df["spread_10y_change_1d"]
            )

        # ── Step 6: Merge daily model onto hourly bars ────────────────────────
        model_df = model_df[["date", "predicted_return_1d",
                              "spread_2y_change_1d", "spread_10y_change_1d"]].copy()

        df = prices_1h.merge(model_df, on="date", how="left")
        fill_cols = ["predicted_return_1d", "spread_2y_change_1d", "spread_10y_change_1d"]
        df[fill_cols] = df[fill_cols].ffill()

        # ── Step 7: Z-score ───────────────────────────────────────────────────
        df["eurusd_return_24h"] = df["close"].pct_change(24)
        df["lag_gap_24h"]       = df["predicted_return_1d"] - df["eurusd_return_24h"]

        lag_mean = df["lag_gap_24h"].rolling(ZSCORE_WINDOW, min_periods=ZSCORE_WINDOW).mean()
        lag_std  = df["lag_gap_24h"].rolling(ZSCORE_WINDOW, min_periods=ZSCORE_WINDOW).std()
        df["lag_zscore_24h"] = (df["lag_gap_24h"] - lag_mean) / lag_std

        df = df.dropna(subset=["lag_zscore_24h"]).reset_index(drop=True)

        log.info(f"Signal built from {len(model_df)} days MT5 data + live rates (no CSV price dependency)")
        return df

    except Exception as e:
        log.error(f"compute_live_zscore error: {e}")
        import traceback
        traceback.print_exc()
        return None


# ── Position sizing ───────────────────────────────────────────────────────────
def calculate_lot_size(zscore_abs: float) -> float:
    """
    Calculates MT5 lot size for the given z-score and account balance.

    Formula:
      risk_dollar   = balance * BASE_RISK_PCT * multiplier
      stop_dollars  = lot_size * pip_value_per_lot * stop_pips
      lot_size      = risk_dollar / (stop_pips * pip_value_per_lot)

    For EURUSD standard lot:
      pip_value_per_lot = $10 per pip (on $100k notional)
    """
    try:
        import MetaTrader5 as mt5

        account  = mt5.account_info()
        if account is None:
            return 0.01

        balance    = account.balance
        multiplier = get_multiplier(zscore_abs)
        risk_dollar= balance * BASE_RISK_PCT * multiplier

        # EURUSD: 1 standard lot = $100,000 notional
        # Stop distance in pips
        stop_pips  = STOP_PCT / 0.0001   # 0.25% / pip_size = 25 pips

        # Get pip value for EURUSD (varies slightly with exchange rate)
        symbol_info = mt5.symbol_info(SYMBOL)
        if symbol_info is None:
            pip_value_per_lot = 10.0   # standard approximation
        else:
            pip_value_per_lot = symbol_info.trade_tick_value * 10

        lot_size = risk_dollar / (stop_pips * pip_value_per_lot)

        # Respect broker min/max lot constraints
        min_lot  = symbol_info.volume_min  if symbol_info else 0.01
        max_lot  = symbol_info.volume_max  if symbol_info else 100.0
        lot_step = symbol_info.volume_step if symbol_info else 0.01

        lot_size = max(min_lot, min(max_lot, lot_size))
        lot_size = round(lot_size / lot_step) * lot_step

        log.info(f"Position size: {lot_size:.2f} lots | "
                 f"Risk: ${risk_dollar:,.0f} | "
                 f"Multiplier: {multiplier}x | "
                 f"Balance: ${balance:,.2f}")
        return lot_size

    except Exception as e:
        log.error(f"calculate_lot_size error: {e}")
        return 0.01


# ── Order management ──────────────────────────────────────────────────────────
def place_order(
    signal     : int,
    entry_price: float,
    lot_size   : float,
    zscore_abs : float,
) -> int | None:
    """
    Places a market order with TP and stop already set.
    Returns the order ticket number or None if failed.
    """
    try:
        import MetaTrader5 as mt5

        symbol_info = mt5.symbol_info(SYMBOL)
        if symbol_info is None:
            log.error(f"Symbol {SYMBOL} not found")
            return None

        if not symbol_info.visible:
            if not mt5.symbol_select(SYMBOL, True):
                log.error(f"Failed to select {SYMBOL}")
                return None

        tick = mt5.symbol_info_tick(SYMBOL)
        if tick is None:
            log.error("Failed to get tick data")
            return None

        if signal == 1:   # Long
            order_type = mt5.ORDER_TYPE_BUY
            price      = tick.ask
            sl         = round(price - STOP_PCT * price, 5)
            tp         = round(price + TP_PCT   * price, 5)
        else:             # Short
            order_type = mt5.ORDER_TYPE_SELL
            price      = tick.bid
            sl         = round(price + STOP_PCT * price, 5)
            tp         = round(price - TP_PCT   * price, 5)

        request = {
            "action"        : mt5.TRADE_ACTION_DEAL,
            "symbol"        : SYMBOL,
            "volume"        : lot_size,
            "type"          : order_type,
            "price"         : price,
            "sl"            : sl,
            "tp"            : tp,
            "deviation"     : 20,          # max 2 pip slippage allowed
            "magic"         : 20240101,    # unique EA identifier
            "comment"       : f"z={zscore_abs:.2f} macro signal",
            "type_time"     : mt5.ORDER_TIME_GTC,
            "type_filling"  : mt5.ORDER_FILLING_IOC,
        }

        result = mt5.order_send(request)

        if result is None:
            log.error(f"order_send returned None: {mt5.last_error()}")
            return None

        if result.retcode != mt5.TRADE_RETCODE_DONE:
            log.error(f"Order failed: retcode={result.retcode}  "
                      f"comment={result.comment}")
            return None

        log.info(f"ORDER PLACED | "
                 f"{'BUY' if signal==1 else 'SELL'}  "
                 f"{lot_size:.2f} lots  @{price:.5f}  "
                 f"TP={tp:.5f}  SL={sl:.5f}  "
                 f"Ticket={result.order}")
        return result.order

    except Exception as e:
        log.error(f"place_order error: {e}")
        return None


def close_position_market(ticket: int, reason: str) -> bool:
    """Closes an open position at market price."""
    try:
        import MetaTrader5 as mt5

        position = mt5.positions_get(ticket=ticket)
        if not position:
            log.warning(f"Position {ticket} not found — may already be closed")
            return True

        pos = position[0]
        tick = mt5.symbol_info_tick(SYMBOL)
        if tick is None:
            log.error("Failed to get tick for close")
            return False

        if pos.type == 0:   # BUY
            close_type  = mt5.ORDER_TYPE_SELL
            close_price = tick.bid
        else:               # SELL
            close_type  = mt5.ORDER_TYPE_BUY
            close_price = tick.ask

        request = {
            "action"      : mt5.TRADE_ACTION_DEAL,
            "symbol"      : SYMBOL,
            "volume"      : pos.volume,
            "type"        : close_type,
            "position"    : ticket,
            "price"       : close_price,
            "deviation"   : 20,
            "magic"       : 20240101,
            "comment"     : f"close: {reason}",
            "type_time"   : mt5.ORDER_TIME_GTC,
            "type_filling": mt5.ORDER_FILLING_IOC,
        }

        result = mt5.order_send(request)

        if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
            log.error(f"Close failed: {result.retcode if result else 'None'}")
            return False

        pnl = pos.profit
        log.info(f"POSITION CLOSED | Ticket={ticket} | "
                 f"Reason={reason} | P&L=${pnl:.2f}")
        return True

    except Exception as e:
        log.error(f"close_position_market error: {e}")
        return False


def check_position_status() -> bool:
    """Checks if our position is still open in MT5."""
    try:
        import MetaTrader5 as mt5
        positions = mt5.positions_get(symbol=SYMBOL, magic=20240101)
        return positions is not None and len(positions) > 0
    except Exception:
        return False



# ── Fast polling execution engine ─────────────────────────────────────────────
def get_live_price():
    """Returns (bid, ask) from MT5 tick data."""
    try:
        import MetaTrader5 as mt5
        tick = mt5.symbol_info_tick(SYMBOL)
        if tick is None:
            return None
        return (tick.bid, tick.ask)
    except Exception:
        return None


def arm_fast_polling(direction: int, target_price: float, zscore_abs: float):
    """
    Arms the fast-polling execution engine.
    Called when z-score threshold is breached.
    Script polls every 5 seconds for the 0.786 level to be touched.
    """
    ARMED["active"]       = True
    ARMED["direction"]    = direction
    ARMED["target_price"] = target_price
    ARMED["armed_time"]   = datetime.now(timezone.utc)
    ARMED["zscore_abs"]   = zscore_abs

    dir_label = "LONG" if direction == 1 else "SHORT"
    log.info(f"ARMED | {dir_label} | Target={target_price:.5f} | "
             f"z={zscore_abs:.3f} | Polling every 5s for up to 6h")
    save_state()
    send_telegram(
        f"🎯 <b>SIGNAL ARMED</b>\n"
        f"Direction: <b>{dir_label}</b>\n"
        f"Z-score: {zscore_abs:.3f}\n"
        f"Fib 0.786 target: {target_price:.5f}\n"
        f"Watching every 5s for up to 6h"
    )


def disarm_fast_polling(reason: str):
    """Disarms the fast-polling engine."""
    ARMED["active"]       = False
    ARMED["direction"]    = None
    ARMED["target_price"] = None
    ARMED["armed_time"]   = None
    ARMED["zscore_abs"]   = None
    log.info(f"DISARMED | Reason: {reason}")
    save_state()


def run_fast_poll() -> bool:
    """
    Fast execution loop — called every 5 seconds when ARMED.
    Checks live bid/ask against the 0.786 target price.
    Also checks if an open position has closed (TP/stop hit by MT5).
    Returns True if order was placed.
    """
    if not ARMED["active"]:
        return False

    # Also check if open position just closed — reset state immediately
    if STATE["position_open"]:
        still_open = check_position_status()
        if not still_open:
            log.info("Position closed by broker (detected in fast poll) — resetting state")
            STATE["position_open"]   = False
            STATE["position_ticket"] = None
            STATE["position_signal"] = None
            STATE["entry_price"]     = None
            STATE["entry_time"]      = None
        return False  # don't look for new entry while previous position check resolves

    now = datetime.now(timezone.utc)

    # Check 6-hour expiry
    elapsed_hours = (now - ARMED["armed_time"]).total_seconds() / 3600
    if elapsed_hours >= 6.0:
        log.info(f"Entry window expired ({elapsed_hours:.1f}h) — disarming")
        disarm_fast_polling("6h expiry")
        return False

    # Allow entry execution outside session if signal fired within session
    # The 6h entry window takes priority over session end
    # Session end only prevents NEW signals from firing, not existing entries
    pass  # session end handled by 6h expiry only

    # Get live price
    prices = get_live_price()
    if prices is None:
        return False

    bid, ask   = prices
    direction  = ARMED["direction"]
    target     = ARMED["target_price"]

    # Debug log every 30 seconds (every 6th poll) to show bid/ask vs target
    if not hasattr(run_fast_poll, '_poll_count'):
        run_fast_poll._poll_count = 0
    run_fast_poll._poll_count += 1
    if run_fast_poll._poll_count % 6 == 0:
        log.info(f"Fast poll | bid={bid:.5f} ask={ask:.5f} target={target:.5f} "
                 f"dir={'SHORT' if direction==-1 else 'LONG'} "
                 f"hit={'YES' if (direction==1 and ask<=target) or (direction==-1 and bid>=target) else 'NO'}")

    # Check if target touched
    if direction == 1:
        # Long — price pulls back DOWN to target, then buy at ask
        entry_hit  = ask <= target
        exec_price = ask
    else:
        # Short — price pulls back UP to target, then sell at bid
        entry_hit  = bid >= target
        exec_price = bid

    if not entry_hit:
        return False

    # Target hit — place order immediately
    elapsed_min = elapsed_hours * 60
    log.info(f"TARGET HIT | {'LONG' if direction==1 else 'SHORT'} | "
             f"Price={exec_price:.5f} | Target={target:.5f} | "
             f"Elapsed={elapsed_min:.0f}m since signal")
    send_telegram(
        f"✅ <b>ENTRY HIT</b>\n"
        f"Direction: <b>{'LONG' if direction==1 else 'SHORT'}</b>\n"
        f"Price: {exec_price:.5f}\n"
        f"Target was: {target:.5f}\n"
        f"Time since signal: {elapsed_min:.0f} minutes\n"
        f"Placing order now..."
    )

    zscore_abs = ARMED["zscore_abs"]
    lot_size   = calculate_lot_size(zscore_abs)

    if lot_size < 0.01:
        log.error("Lot size too small — skipping")
        disarm_fast_polling("lot size error")
        return False

    ticket = place_order(
        signal     =direction,
        entry_price=exec_price,
        lot_size   =lot_size,
        zscore_abs =zscore_abs,
    )

    if ticket is not None:
        STATE["position_open"]   = True
        STATE["position_ticket"] = ticket
        STATE["position_signal"] = direction
        STATE["entry_price"]     = exec_price
        STATE["entry_time"]      = now.replace(tzinfo=None)
        STATE["position_lot"]    = lot_size
        disarm_fast_polling("order placed")
        save_state()
        dir_str = "LONG" if direction==1 else "SHORT"
        tp_price = exec_price*(1+TP_PCT) if direction==1 else exec_price*(1-TP_PCT)
        sl_price = exec_price*(1-STOP_PCT) if direction==1 else exec_price*(1+STOP_PCT)
        send_telegram(
            f"📈 <b>ORDER PLACED</b>\n"
            f"Direction: <b>{dir_str}</b>\n"
            f"Entry: {exec_price:.5f}\n"
            f"Lots: {lot_size:.2f}\n"
            f"TP: {tp_price:.5f}\n"
            f"SL: {sl_price:.5f}\n"
            f"Ticket: {ticket}"
        )
        return True
    else:
        log.error("Order placement failed in fast poll")
        disarm_fast_polling("order failed")
        return False


# ── Session check ─────────────────────────────────────────────────────────────
def in_trading_session() -> bool:
    """Returns True if current time is within London/NY session (EET)."""
    now_utc = datetime.now(timezone.utc)
    # Convert UTC to EET (UTC+2 winter / UTC+3 summer)
    # Simple approximation: use hour in UTC + 2
    hour_eet = (now_utc.hour + 2) % 24
    return SESSION_START_EET <= hour_eet <= SESSION_END_EET


# ── Main signal check loop ────────────────────────────────────────────────────
def run_signal_check():
    """
    Main function called every hour.
    Checks for new signals and manages open positions.
    """
    now = datetime.now(timezone.utc)
    log.info(f"─── Signal check at {now.strftime('%Y-%m-%d %H:%M UTC')} ───")

    # Check if in trading session — only block NEW signal detection
    # If already armed from an earlier signal, execution continues outside session
    # If position is open, always continue to monitor z-score exit
    if not in_trading_session():
        if STATE["position_open"]:
            log.info("Outside trading session — position open, monitoring z-exit")
        elif not ARMED["active"]:
            log.info("Outside trading session — skipping")
            return
        else:
            log.info("Outside trading session — but armed, continuing to watch for entry")

    # Reconnect MT5 if needed
    import MetaTrader5 as mt5
    if not mt5.initialize():
        log.warning("MT5 not connected — attempting reconnect")
        if not connect_mt5():
            log.error("Failed to reconnect MT5 — skipping check")
            return

    # ── Update position status ────────────────────────────────────────────────
    if STATE["position_open"]:
        still_open = check_position_status()
        if not still_open:
            log.info("Position closed by broker (TP or stop hit)")
            STATE["position_open"]   = False
            STATE["position_ticket"] = None
            STATE["position_signal"] = None
            STATE["entry_price"]     = None
            STATE["entry_time"]      = None

    # Rate data is updated by the daily scheduler — no action needed here

    # ── Get live price data ───────────────────────────────────────────────────
    bars_1h = get_mt5_bars_1h()
    if bars_1h is None or len(bars_1h) < ZSCORE_WINDOW + BETA_WINDOW:
        log.error("Insufficient 1H bars for signal computation")
        return

    # ── Compute z-score ───────────────────────────────────────────────────────
    signal_df = compute_live_zscore(bars_1h)
    if signal_df is None or signal_df.empty:
        log.error("Signal computation failed")
        return

    latest = signal_df.iloc[-1]
    current_z = float(latest["lag_zscore_24h"])
    STATE["last_zscore"] = current_z

    log.info(f"Current z-score: {current_z:.3f}  "
             f"(threshold ±{THRESHOLD})")

    # ── Check z-score exit on open position ──────────────────────────────────
    if STATE["position_open"] and STATE["position_ticket"] is not None:
        signal_dir = STATE["position_signal"]

        # Check hold time limit (52h)
        if STATE["entry_time"] is not None:
            hours_held = (now.replace(tzinfo=None) -
                         STATE["entry_time"]).total_seconds() / 3600
            if hours_held >= 52:
                log.info(f"Hold limit reached ({hours_held:.1f}h) — closing")
                close_position_market(STATE["position_ticket"], "time_limit")
                STATE["position_open"]   = False
                STATE["position_ticket"] = None
                return

        # Check z-score reversal
        if (signal_dir == 1 and current_z <= -ZSCORE_EXIT):
            log.info(f"Z-score reversal detected: {current_z:.3f} <= -{ZSCORE_EXIT} "
                     f"(was long) — closing position")
            send_telegram(
                f"⚠️ <b>Z-EXIT</b>\n"
                f"Z-score reversed to {current_z:.3f}\n"
                f"Closing LONG position"
            )
            close_position_market(STATE["position_ticket"], "zscore_reversal")
            STATE["position_open"]   = False
            STATE["position_ticket"] = None
            return

        elif (signal_dir == -1 and current_z >= ZSCORE_EXIT):
            log.info(f"Z-score reversal detected: {current_z:.3f} >= +{ZSCORE_EXIT} "
                     f"(was short) — closing position")
            close_position_market(STATE["position_ticket"], "zscore_reversal")
            STATE["position_open"]   = False
            STATE["position_ticket"] = None
            return

        log.info(f"Position open | Ticket={STATE['position_ticket']} | "
                 f"Entry={STATE['entry_price']:.5f} | "
                 f"Z-score={current_z:.3f} — holding")
        return   # Don't look for new signals while in a trade

    # ── Check for new signal ──────────────────────────────────────────────────
    if abs(current_z) < THRESHOLD:
        log.info(f"No signal (|z|={abs(current_z):.3f} < {THRESHOLD})")
        # If armed for a previous signal and z-score has reversed, disarm
        if ARMED["active"]:
            armed_dir = ARMED["direction"]
            if (armed_dir == 1  and current_z <= -ZSCORE_EXIT) or                (armed_dir == -1 and current_z >=  ZSCORE_EXIT):
                disarm_fast_polling("z-score reversed before entry")
        return

    signal_direction = 1 if current_z >= THRESHOLD else -1
    dir_label = "LONG" if signal_direction == 1 else "SHORT"

    # ── Calculate Fibonacci target from current 1H bar ────────────────────────
    # Use bars_1h for high/low as signal_df may not carry these columns
    signal_close = float(latest["close"]) if "close" in latest.index else float(bars_1h.iloc[-1]["close"])
    signal_high  = float(bars_1h.iloc[-1]["high"])
    signal_low   = float(bars_1h.iloc[-1]["low"])

    log.info(f"Signal bar: O={bars_1h.iloc[-1]['open']:.5f} H={signal_high:.5f} "
             f"L={signal_low:.5f} C={signal_close:.5f}")

    if signal_direction == 1:
        pullback_range = signal_close - signal_low
        if pullback_range <= 0.00005:
            log.info(f"Pullback range too small for long ({pullback_range:.5f}) — skipping")
            return
        target_price = signal_close - FIB * pullback_range
    else:
        pullback_range = signal_high - signal_close
        if pullback_range <= 0.00005:
            log.info(f"Pullback range too small for short ({pullback_range:.5f}) — skipping")
            return
        target_price = signal_close + FIB * pullback_range

    # ── Arm fast polling if not already armed for this signal ─────────────────
    if not ARMED["active"]:
        log.info(f"SIGNAL DETECTED | {dir_label} | z={current_z:.3f}")
        arm_fast_polling(signal_direction, target_price, abs(current_z))
    elif ARMED["direction"] != signal_direction:
        # Direction flipped — rearm
        log.info(f"Signal direction changed — rearming | {dir_label} | z={current_z:.3f}")
        disarm_fast_polling("direction change")
        arm_fast_polling(signal_direction, target_price, abs(current_z))
    else:
        # Already armed in same direction — update target from new signal bar
        old_target = ARMED["target_price"]
        ARMED["target_price"] = target_price
        ARMED["zscore_abs"]   = abs(current_z)
        log.info(f"Already armed | {dir_label} | Target updated: "
                 f"{old_target:.5f} → {target_price:.5f} | "
                 f"z={current_z:.3f} | Fast polling active")


# ── Entry point ───────────────────────────────────────────────────────────────
def save_trade_history(ticket, entry_price, direction, lot_size, net_pnl):
    """Append closed trade to trade_history.json for dashboard consumption."""
    try:
        import json
        history_path = STATE_FILE.parent / "trade_history.json"
        trades = []
        if history_path.exists():
            trades = json.loads(history_path.read_text(encoding="utf-8"))
        trades.append({
            "ticket"     : ticket,
            "entry_price": entry_price,
            "direction"  : direction,
            "lot_size"   : lot_size,
            "net_pnl"    : round(net_pnl, 2),
            "closed_at"  : datetime.now().isoformat(),
        })
        history_path.write_text(json.dumps(trades, indent=2), encoding="utf-8")
        log.info(f"Trade history saved: {len(trades)} trades, "
                 f"total PnL=${sum(t['net_pnl'] for t in trades):.2f}")
    except Exception as e:
        log.warning(f"Trade history save failed: {e}")


# ── Signal outlook helpers ────────────────────────────────────────────────────
# Import directly from dashboard_server so both always use identical logic

def _get_outlook_for_monitor(current_z=0.0):
    """Delegate to dashboard_server's get_weekly_outlook — single source of truth."""
    try:
        import importlib.util, sys as _sys
        srv_path = BASE_PATH / "src" / "execution" / "dashboard_server.py"
        spec = importlib.util.spec_from_file_location("dashboard_server", srv_path)
        mod  = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        return mod.get_weekly_outlook(current_z)
    except Exception:
        # Fallback: inline minimal version if dashboard_server unavailable
        return _outlook_fallback(current_z)


def _outlook_fallback(current_z=0.0):
    """Minimal fallback if dashboard_server import fails."""
    now    = datetime.now()
    monday = now - timedelta(days=now.weekday())
    days   = ["Mon","Tue","Wed","Thu","Fri"]
    T      = 2.75
    dist   = T - abs(current_z)
    zp     = 30 if dist<=0 else 25 if dist<0.5 else 20 if dist<1.0 else 12 if dist<1.5 else 6 if dist<2.0 else 3

    FOMC_MINUTES = ["2026-02-19","2026-04-09","2026-05-21","2026-06-18",
                    "2026-07-09","2026-10-08","2026-11-19","2026-12-31"]
    ECB_MEETINGS = ["2026-01-30","2026-03-06","2026-04-17","2026-05-06",
                    "2026-06-05","2026-07-16","2026-09-10","2026-10-22","2026-12-03"]
    result = []
    for i in range(5):
        date  = (monday + timedelta(days=i)).strftime("%Y-%m-%d")
        d_obj = datetime.strptime(date, "%Y-%m-%d")
        evs   = []
        if date in FOMC_MINUTES: evs.append({"event":"FOMC Meeting Minutes","country":"US","score":75})
        if date in ECB_MEETINGS: evs.append({"event":"ECB Rate Decision","country":"EU","score":90})
        if 8<=d_obj.day<=21:     evs.append({"event":"CPI (likely this week)","country":"US","score":75})
        if d_obj.weekday()==1 and 8<=d_obj.day<=14:
            evs.append({"event":"German ZEW Sentiment","country":"DE","score":50})
        evs = sorted(evs, key=lambda x: x["score"], reverse=True)
        top  = evs[0]["score"] if evs else 0
        ep   = min(top/100*50, 50)
        prob = min(int(ep + zp), 85) if evs else 3
        result.append({"date":date,"day":days[i],"events":evs[:3],"prob":prob,
                        "top_event":evs[0]["event"] if evs else "No key events"})
    return result


def _prob_bar(p): return "█"*round(p/10)+"░"*(10-round(p/10))
def _prob_emoji(p): return "🟢" if p>=60 else "🟡" if p>=35 else "⚪"

def _build_outlook_section(outlook, is_monday, today_str):
    if not outlook:
        return ""
    if is_monday:
        best  = max(outlook, key=lambda x: x["prob"])
        lines = ["", "📅 <b>WEEK AHEAD — SIGNAL OUTLOOK</b>", "────────────────"]
        for d in outlook:
            star = "⭐" if d["date"] == best["date"] and d["prob"] > 20 else ""
            evs  = ", ".join(e["event"] for e in d["events"][:2]) if d["events"] else "No key events"
            row  = (_prob_emoji(d['prob']) + " <b>" + d['day'] + "</b>  " +
                    str(d['prob']) + "%  " + _prob_bar(d['prob']) + " " + star +
                    "\n    \u2514 " + evs)
            lines.append(row)
        lines += ["────────────────", "\u2b50 Best day: <b>" + best['day'] + "</b> at " + str(best['prob']) + "%"]
        return "\n".join(lines)
    else:
        td = next((d for d in outlook if d["date"] == today_str), None)
        if not td:
            return ""
        if td["events"]:
            evs = "\n".join("    \u2022 " + e['event'] + " (" + e['country'] + ")" for e in td["events"][:3])
        else:
            evs = "    \u2022 No key macro events today"
        return (
            "\n\U0001f4c5 <b>TODAY'S SIGNAL OUTLOOK \u2014 " + td['day'] + "</b>\n"
            "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"
            + _prob_emoji(td['prob']) + " Signal probability: <b>" + str(td['prob']) + "%</b>\n"
            + _prob_bar(td['prob']) + "\n"
            "Key events:\n" + evs + "\n"
            "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
        )


def send_daily_summary():
    """Sends a daily morning summary to Telegram at 06:05 UTC."""
    current_z  = STATE.get("last_zscore")
    pos_open   = STATE.get("position_open", False)

    # Read trade history from file
    trade_count = 0
    pnl_total   = 0.0
    try:
        import json
        history_path = STATE_FILE.parent / "trade_history.json"
        if history_path.exists():
            trades = json.loads(history_path.read_text(encoding="utf-8"))
            trade_count = len(trades)
            pnl_total   = sum(t["net_pnl"] for t in trades)
    except Exception:
        pass

    z_str  = f"{current_z:.3f}" if current_z is not None else "—"
    z_pct  = f"{abs(current_z)/THRESHOLD*100:.0f}% of threshold" if current_z else ""

    if pos_open:
        entry_px  = STATE.get("entry_price")
        entry_dir = STATE.get("position_signal")
        dir_str   = "LONG" if entry_dir == 1 else "SHORT"
        pos_str   = f"🔵 {dir_str} @ {entry_px:.5f}" if entry_px else "🔵 Open"
    else:
        pos_str = "⚪ No position"

    pnl_str = f"+${pnl_total:,.2f}" if pnl_total >= 0 else f"-${abs(pnl_total):,.2f}"

    today_str = datetime.now().strftime("%Y-%m-%d")
    is_monday = datetime.now().weekday() == 0
    try:
        outlook      = _get_outlook_for_monitor(float(current_z or 0))
        outlook_text = _build_outlook_section(outlook, is_monday, today_str)
    except Exception:
        outlook_text = ""

    send_telegram(
        f"📊 <b>DAILY SUMMARY</b>\n"
        f"────────────────\n"
        f"Z-score: <b>{z_str}</b>  ({z_pct})\n"
        f"Position: {pos_str}\n"
        f"Trades: {trade_count}  |  PnL: {pnl_str}\n"
        f"System: ✅ Running\n"
        f"────────────────\n"
        f"Threshold needed: ±{THRESHOLD}"
        f"{outlook_text}"
    )


def main():
    log.info("=" * 60)
    log.info("LIVE SIGNAL MONITOR STARTING")
    log.info(f"Symbol    : {SYMBOL}")
    log.info(f"Threshold : z-score ±{THRESHOLD}")
    log.info(f"TP        : {TP_PCT:.2%}  |  Stop: {STOP_PCT:.2%}")
    log.info(f"Z-exit    : ±{ZSCORE_EXIT}")
    log.info(f"Risk      : {BASE_RISK_PCT:.2%}")
    log.info(f"Session   : {SESSION_START_EET}:00 - {SESSION_END_EET+1}:00 EET")
    log.info("=" * 60)

    # Connect to MT5
    if not connect_mt5():
        log.error("Could not connect to MT5 — check credentials and MT5 is running")
        return

    # Restore state from disk in case of reboot with open position
    load_state()

    # Update rate data immediately on startup
    log.info("Running startup rate data update...")
    update_rate_data()

    # Run signal check immediately on start
    run_signal_check()

    # Schedule hourly signal checks
    schedule.every().hour.at(":01").do(run_signal_check)

    # Schedule daily rate update at 06:00 UTC (08:00 EET) — independent of session
    schedule.every().day.at("06:00").do(update_rate_data)

    # Schedule daily morning Telegram summary at 06:05 UTC
    schedule.every().day.at("06:05").do(send_daily_summary)

    log.info("Scheduler running. Signal checks every hour at :01.")
    log.info("Rate data updates: at startup and daily at 06:00 UTC.")
    log.info("Press Ctrl+C to stop.")

    # Startup message with current z-score
    startup_z = STATE.get("last_zscore")
    z_str = f"{startup_z:.3f}" if startup_z is not None else "computing..."
    send_telegram(
        f"🟢 <b>LIVE MONITOR STARTED</b>\n"
        f"Symbol: EURUSD\n"
        f"Threshold: z ±{THRESHOLD}\n"
        f"Session: {SESSION_START_EET}:00-{SESSION_END_EET+1}:00 EET\n"
        f"Current z-score: {z_str}\n"
        f"Watching for signals..."
    )

    try:
        last_state_save = time.time()
        while True:
            schedule.run_pending()

            # Save state every 60 seconds so reboots don't lose position
            if time.time() - last_state_save >= 60:
                save_state()
                last_state_save = time.time()

            # Always check position status every 5 seconds
            # This ensures we know immediately when TP/stop fires
            # so the next signal is not delayed by up to 1 hour
            if STATE["position_open"]:
                still_open = check_position_status()
                if not still_open:
                    log.info("Position closed by MT5 (TP or stop) — state reset")
                    ticket    = STATE.get("position_ticket")
                    entry_px  = STATE.get("entry_price") or 0
                    entry_dir = STATE.get("position_signal") or 0
                    lot_size  = STATE.get("position_lot") or 0

                    # Get actual P&L from MT5 deal history
                    net_pnl = 0.0
                    try:
                        import MetaTrader5 as mt5
                        from datetime import timezone
                        # Search last 7 days to catch any timing edge cases
                        from_dt = datetime.now(timezone.utc).replace(
                            hour=0, minute=0, second=0) - \
                            __import__('datetime').timedelta(days=7)
                        deals = mt5.history_deals_get(
                            from_dt, datetime.now(timezone.utc))
                        if deals:
                            import pandas as pd
                            df = pd.DataFrame(list(deals),
                                              columns=deals[0]._asdict().keys())
                            # Match by position_id which links entry and exit deals
                            if ticket:
                                match = df[
                                    (df["position_id"] == int(ticket)) &
                                    (df["entry"] == 1)  # 1 = exit deal
                                ]
                            else:
                                match = pd.DataFrame()
                            if match.empty:
                                # Fallback: most recent exit deal with our magic
                                match = df[
                                    (df["magic"] == 20240101) &
                                    (df["entry"] == 1)
                                ].tail(1)
                            if not match.empty:
                                net_pnl = float(match.iloc[-1]["profit"]) + \
                                          float(match.iloc[-1]["swap"])
                    except Exception as e:
                        log.warning(f"P&L lookup failed: {e}")

                    send_telegram(
                        f"🔔 <b>POSITION CLOSED</b>\n"
                        f"Ticket: {ticket}\n"
                        f"Net P&L: {'+'if net_pnl>=0 else ''}${net_pnl:.2f}\n"
                        f"Closed by MT5 (TP or SL hit)"
                    )
                    save_trade_history(ticket, entry_px, entry_dir,
                                       lot_size, net_pnl)
                    STATE["position_open"]   = False
                    STATE["position_ticket"] = None
                    STATE["position_signal"] = None
                    STATE["entry_price"]     = None
                    STATE["entry_time"]      = None
                    save_state()

            # Fast poll every 5 seconds when armed — catches 0.786 touch immediately
            if ARMED["active"]:
                run_fast_poll()
                time.sleep(5)
            else:
                time.sleep(5)  # always 5s so position close is detected quickly

    except KeyboardInterrupt:
        log.info("Shutdown requested by user")
    finally:
        disconnect_mt5()
        log.info("Live signal monitor stopped")


if __name__ == "__main__":
    main()
