r"""
tv-mt5-listener.py  —  TradingView webhook → MetaTrader 5 bridge (the "Python route" from Chapter 10)

Companion download for "From Manual Trader to Automated Trader" (quantnova.site/book/resources).
Runs on the Windows VPS next to MT5. Receives the book strategy's alert message, checks the secret,
enforces the risk-sheet fences, sizes the trade from the live balance, and sends the order to MT5
with stop and target attached. Everything it does is written to bridge.log and (optionally) Telegram.

FIRST-TIME SETUP (Windows VPS, once)
  1. Install Python 3.11+ from python.org (tick "Add python to PATH").
  2. Open Command Prompt and run:   pip install MetaTrader5 flask requests
  3. Put this file in a folder, e.g. C:\bridge\, and edit the SETTINGS block below.
  4. Start MT5, log in to the account, keep it open.
  5. Run:   python C:\bridge\tv-mt5-listener.py
     You should see:  * Running on http://0.0.0.0:8080
  6. Your webhook URL is  http://<VPS public IP>:8080/webhook   (see NOTE ON HTTPS below).
     Paste it into the TradingView alert (Chapter 7, step 6). The alert message stays
     {{strategy.order.alert_message}} — the book script already sends the right JSON.
  7. Test with the fast-firing test strategy (Chapter 11), then run the 22 tests.

NOTE ON HTTPS: TradingView sends webhooks over http or https. Plain http works for testing; for real
money put a free reverse proxy with a certificate in front (Caddy on Windows does this in one line:
`caddy reverse-proxy --from your-domain.com --to localhost:8080`). Also open port 8080 (or 443) in the
Windows firewall and the VPS provider's firewall, and restrict it to TradingView's published IPs if
the provider lets you.

This is education, not advice. Run it on DEMO first. The author accepts no liability for losses.
Licence: MIT — use it, change it, share it.
"""

import json
import logging
import time
from datetime import datetime, timedelta, timezone

import MetaTrader5 as mt5
import requests
from flask import Flask, jsonify, request

# ============================ SETTINGS — EDIT HERE ============================
SECRET = "CHANGE-ME"            # must match the "Webhook secret" input in the TradingView script
PORT = 8080

# Symbol mapping: TradingView ticker -> your broker's MT5 symbol name
SYMBOL_MAP = {
    "EURUSD": "EURUSD",         # e.g. "EURUSD": "EURUSD.m" if your broker adds a suffix
    "BTCUSD": "BTCUSD",
    "XAUUSD": "XAUUSD",
}

# Risk sheet (Chapter 6). Percentages of account balance.
DEFAULT_RISK_PCT = 1.0          # used if the message has no "risk" field
MAX_OPEN_POSITIONS = 1
MAX_TRADES_PER_DAY = 3
MAX_DAILY_LOSS_PCT = 3.0
MAX_WEEKLY_LOSS_PCT = 6.0
MAGIC = 10001                   # one magic number per strategy
MAX_SLIPPAGE_POINTS = 20
MAX_MESSAGE_AGE_SEC = 120       # drop alerts older than this (Chapter 11, test 18: fail safe, not late)

# Optional Telegram notifications (Chapter 13). Leave blank to disable.
TELEGRAM_TOKEN = ""
TELEGRAM_CHAT_ID = ""

# Optional: only accept requests from TradingView's published webhook IPs (check their help page
# for the current list). Leave empty to accept from anywhere (the secret still protects you).
ALLOWED_IPS = set()             # e.g. {"52.89.214.238", "34.212.75.30", "54.218.53.128", "52.32.178.7"}
# ==============================================================================

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[logging.FileHandler("bridge.log", encoding="utf-8"), logging.StreamHandler()],
)
log = logging.getLogger("bridge")
app = Flask(__name__)


# ---------------------------------------------------------------- helpers ----
def notify(text: str) -> None:
    """Send a Telegram message if configured. Never raises."""
    log.info(text)
    if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
        return
    try:
        requests.post(
            f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
            json={"chat_id": TELEGRAM_CHAT_ID, "text": text},
            timeout=10,
        )
    except Exception as exc:  # noqa: BLE001
        log.warning("telegram send failed: %s", exc)


def ensure_mt5() -> bool:
    if mt5.terminal_info() is not None:
        return True
    if not mt5.initialize():
        log.error("MT5 initialize failed: %s", mt5.last_error())
        return False
    return True


def account_balance() -> float:
    info = mt5.account_info()
    return float(info.balance) if info else 0.0


def closed_pnl_since(since: datetime) -> float:
    """Realised profit of this magic number's deals since a given time."""
    deals = mt5.history_deals_get(since, datetime.now(timezone.utc)) or []
    return float(sum(d.profit + d.commission + d.swap for d in deals if d.magic == MAGIC and d.entry == 1))


def trades_opened_since(since: datetime) -> int:
    deals = mt5.history_deals_get(since, datetime.now(timezone.utc)) or []
    return sum(1 for d in deals if d.magic == MAGIC and d.entry == 0)


def open_positions(symbol: str | None = None):
    pos = mt5.positions_get(symbol=symbol) if symbol else mt5.positions_get()
    return [p for p in (pos or []) if p.magic == MAGIC]


def start_of_day() -> datetime:
    now = datetime.now(timezone.utc)
    return now.replace(hour=0, minute=0, second=0, microsecond=0)


def start_of_week() -> datetime:
    sod = start_of_day()
    return sod - timedelta(days=sod.weekday())


def fences_ok(balance: float) -> tuple[bool, str]:
    """Chapter 6 fences. Returns (ok, reason)."""
    if len(open_positions()) >= MAX_OPEN_POSITIONS:
        return False, "max open positions reached"
    if trades_opened_since(start_of_day()) >= MAX_TRADES_PER_DAY:
        return False, "max trades per day reached"
    day_pnl = closed_pnl_since(start_of_day())
    if balance > 0 and -day_pnl >= balance * MAX_DAILY_LOSS_PCT / 100:
        return False, f"daily loss limit hit ({day_pnl:.2f})"
    week_pnl = closed_pnl_since(start_of_week())
    if balance > 0 and -week_pnl >= balance * MAX_WEEKLY_LOSS_PCT / 100:
        return False, f"weekly loss limit hit ({week_pnl:.2f})"
    return True, "ok"


def lot_size(symbol: str, risk_money: float, entry: float, stop: float) -> float:
    """Chapter 6 formula, rounded DOWN to the broker's volume step. 0 means skip."""
    info = mt5.symbol_info(symbol)
    if info is None:
        return 0.0
    stop_dist = abs(entry - stop)
    if stop_dist <= 0:
        return 0.0
    # value of a 1-lot move of stop_dist, in account currency
    tick_value = info.trade_tick_value
    tick_size = info.trade_tick_size
    if tick_size <= 0 or tick_value <= 0:
        return 0.0
    loss_per_lot = stop_dist / tick_size * tick_value
    if loss_per_lot <= 0:
        return 0.0
    lots = risk_money / loss_per_lot
    step = info.volume_step or 0.01
    lots = int(lots / step) * step          # round down
    lots = round(lots, 8)
    if lots < info.volume_min:
        return 0.0                            # skip, never round up (Chapter 6)
    return min(lots, info.volume_max)


def close_positions(symbol: str) -> None:
    for p in open_positions(symbol):
        tick = mt5.symbol_info_tick(symbol)
        price = tick.bid if p.type == mt5.POSITION_TYPE_BUY else tick.ask
        req = {
            "action": mt5.TRADE_ACTION_DEAL,
            "position": p.ticket,
            "symbol": symbol,
            "volume": p.volume,
            "type": mt5.ORDER_TYPE_SELL if p.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY,
            "price": price,
            "deviation": MAX_SLIPPAGE_POINTS,
            "magic": MAGIC,
            "comment": "bridge close",
            "type_filling": mt5.ORDER_FILLING_IOC,
        }
        res = mt5.order_send(req)
        notify(f"CLOSE {symbol} ticket {p.ticket}: retcode {res.retcode} {res.comment}")


def open_position(symbol: str, side: str, lots: float, sl: float, tp: float) -> None:
    tick = mt5.symbol_info_tick(symbol)
    is_buy = side == "buy"
    req = {
        "action": mt5.TRADE_ACTION_DEAL,
        "symbol": symbol,
        "volume": lots,
        "type": mt5.ORDER_TYPE_BUY if is_buy else mt5.ORDER_TYPE_SELL,
        "price": tick.ask if is_buy else tick.bid,
        "sl": sl,
        "tp": tp,
        "deviation": MAX_SLIPPAGE_POINTS,
        "magic": MAGIC,
        "comment": "bridge",
        "type_filling": mt5.ORDER_FILLING_IOC,
    }
    res = mt5.order_send(req)
    if res.retcode == mt5.TRADE_RETCODE_DONE:
        notify(f"OPEN {side.upper()} {symbol} {lots} lots @ {res.price} sl {sl} tp {tp}")
    else:
        notify(f"REJECTED {side.upper()} {symbol} {lots} lots: retcode {res.retcode} {res.comment}")


# ---------------------------------------------------------------- webhook ----
@app.post("/webhook")
def webhook():
    if ALLOWED_IPS and request.remote_addr not in ALLOWED_IPS:
        log.warning("rejected: ip %s not allowed", request.remote_addr)
        return jsonify(ok=False, reason="ip not allowed"), 403

    raw = request.get_data(as_text=True)
    log.info("received: %s", raw[:400])
    try:
        msg = json.loads(raw)
    except json.JSONDecodeError:
        log.warning("rejected: malformed message")
        return jsonify(ok=False, reason="malformed"), 400

    if msg.get("secret") != SECRET:
        log.warning("rejected: invalid secret")
        return jsonify(ok=False, reason="unauthorized"), 401

    ts = msg.get("time")
    if ts and time.time() - float(ts) > MAX_MESSAGE_AGE_SEC:
        notify("DROPPED: message too old (fail safe)")
        return jsonify(ok=False, reason="stale"), 200

    if not ensure_mt5():
        notify("ERROR: MT5 not available")
        return jsonify(ok=False, reason="mt5 down"), 500

    action = str(msg.get("action", "")).lower()
    tv_symbol = str(msg.get("symbol", ""))
    symbol = SYMBOL_MAP.get(tv_symbol)
    if symbol is None or not mt5.symbol_select(symbol, True):
        notify(f"REJECTED: unknown symbol {tv_symbol}")
        return jsonify(ok=False, reason="unknown symbol"), 200

    if action == "close":
        close_positions(symbol)
        return jsonify(ok=True)

    if action not in ("buy", "sell"):
        notify(f"REJECTED: unknown action {action}")
        return jsonify(ok=False, reason="unknown action"), 200

    # reverse: close the opposite position first (rule 7 on the rule sheet)
    for p in open_positions(symbol):
        opposite = (p.type == mt5.POSITION_TYPE_BUY) == (action == "sell")
        if opposite:
            close_positions(symbol)
            break

    balance = account_balance()
    ok, reason = fences_ok(balance)
    if not ok:
        notify(f"FENCE: {reason} — {action} {symbol} not placed")
        return jsonify(ok=False, reason=reason), 200

    try:
        sl = float(msg["sl"])
        tp = float(msg["tp"])
    except (KeyError, TypeError, ValueError):
        notify("REJECTED: sl/tp missing")
        return jsonify(ok=False, reason="sl/tp missing"), 200

    risk_pct = float(msg.get("risk", DEFAULT_RISK_PCT))
    tick = mt5.symbol_info_tick(symbol)
    entry = tick.ask if action == "buy" else tick.bid
    lots = lot_size(symbol, balance * risk_pct / 100, entry, sl)
    if lots <= 0:
        notify(f"SKIPPED: size rounds to zero for {symbol} (stop too wide for this account)")
        return jsonify(ok=False, reason="size zero"), 200

    open_position(symbol, action, lots, sl, tp)
    return jsonify(ok=True)


@app.get("/health")
def health():
    return jsonify(ok=ensure_mt5(), balance=account_balance(), open=len(open_positions()))


if __name__ == "__main__":
    ensure_mt5()
    notify("bridge started")
    app.run(host="0.0.0.0", port=PORT)
