#!/usr/bin/env python3
"""
IBKR executor for the ASX Momentum Reference Strategy (paper first, live behind a flag).

Reads the target book produced by `asx_momentum.py signals`, reconciles prices against
IBKR historical bars, sizes whole-share parcels, and places LIMIT orders in the continuous
session. Dry-run by default. Live orders require BOTH --live and ASX_LIVE=1 in the environment.

Requires: ib_insync (pip install ib_insync), IB Gateway or TWS running with API enabled.
Paper: port 4002 (Gateway) / 7497 (TWS). Live: 4001 / 7496.

Usage:
  python ibkr_executor.py --signals data/signals_today.json            # dry run, prints the order plan
  python ibkr_executor.py --signals data/signals_today.json --send     # send to PAPER
  ASX_LIVE=1 python ibkr_executor.py --signals ... --send --live       # live (after the go-live gate only)

Brainroom rulings implemented here (session bs-3824e276045e):
  D5  limit orders only, continuous session, never market-on-open / market-on-close
  D10 reconciliation: skip a name for the month if our adjusted close vs IBKR close differs > 0.5%
  D11 min parcel AUD 500; whole shares; cash buffer kept for T+2 settlement
  D12 per-clip cost estimate logged before sending (max(0.08%, AUD 6) + 10 bps); no drift trades
"""
import argparse, json, os, sys, time, math, logging

LOG = logging.getLogger("asx-exec")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s",
                    handlers=[logging.StreamHandler(), logging.FileHandler("executor.log")])

LIMIT_OFFSET = 0.002      # buy 0.2% above / sell 0.2% below last close — passive but fillable
MIN_PARCEL = 500.0        # AUD, ASX marketable parcel convention
CASH_BUFFER = 0.03        # keep 3% cash for fees and settlement drift
RECON_TOL = 0.005         # 0.5% price mismatch => skip name this month (D10)
COMM_PCT, COMM_MIN, SLIP = 0.0008, 6.0, 0.0010

def load_signals(path):
    s = json.load(open(path))
    LOG.info("signals asof=%s regime_on=%s top_n=%s w_each=%s", s["asof"], s["regime_on"], s["top_n"], s["target_weight_each"])
    return s

def plan(signals, equity, positions, ib=None):
    """Return list of orders {code, side, qty, limit, est_cost}. positions: {code: qty}."""
    w = signals["target_weight_each"]
    targets = {r["code"]: w for r in signals["ranked"][: signals["top_n"]]} if w > 0 else {}
    # keep-buffer: an existing holding stays if still ranked within buffer_rank
    buffer_codes = {r["code"] for r in signals["ranked"][: signals["buffer_rank"]]}
    for code in list(positions):
        if positions[code] > 0 and w > 0 and code in buffer_codes and code not in targets:
            targets[code] = w
    # trim to top_n if buffer over-fills
    if len(targets) > signals["top_n"]:
        rank = {r["code"]: r["rank"] for r in signals["ranked"]}
        targets = dict(sorted(targets.items(), key=lambda kv: rank.get(kv[0], 999))[: signals["top_n"]])
    investable = equity * (1 - CASH_BUFFER)
    orders = []
    closes = {r["code"]: r["close"] for r in signals["ranked"]}
    for code in set(list(targets) + list(positions)):
        px = closes.get(code)
        if px is None and ib is not None:
            px = ib_last_close(ib, code)
        if not px:
            LOG.warning("no price for %s — skipped", code); continue
        if ib is not None and code in closes:
            ib_px = ib_last_close(ib, code)
            if ib_px and abs(ib_px / px - 1) > RECON_TOL:
                LOG.error("RECON MISMATCH %s ours=%.3f ibkr=%.3f — skipped this month (D10)", code, px, ib_px); continue
        tgt_val = targets.get(code, 0.0) * investable
        tgt_qty = int(tgt_val // px)
        cur_qty = int(positions.get(code, 0))
        d = tgt_qty - cur_qty
        if d == 0: continue
        # NO DRIFT TRADES (D12): a name that stays in the book is not topped up or trimmed for small drift
        if cur_qty > 0 and tgt_qty > 0 and abs(d) * px < max(MIN_PARCEL, 0.01 * equity):
            continue
        if d > 0 and d * px < MIN_PARCEL and cur_qty == 0:
            LOG.warning("%s parcel AUD %.0f < %.0f — skipped (D11)", code, d * px, MIN_PARCEL); continue
        side = "BUY" if d > 0 else "SELL"
        limit = round(px * (1 + LIMIT_OFFSET) if d > 0 else px * (1 - LIMIT_OFFSET), 3)
        notional = abs(d) * px
        est = max(COMM_PCT * notional, COMM_MIN) + SLIP * notional
        orders.append({"code": code, "side": side, "qty": abs(d), "limit": limit, "notional": round(notional, 2), "est_cost": round(est, 2)})
    return orders

# ---------------------------------------------------------------- IBKR
def connect(live):
    from ib_insync import IB
    ib = IB(); port = 4001 if live else 4002
    ib.connect("127.0.0.1", port, clientId=17, timeout=20)
    LOG.info("connected to IBKR %s on %s", "LIVE" if live else "PAPER", port)
    return ib

def contract(code):
    from ib_insync import Stock
    return Stock(code, "ASX", "AUD")

def ib_last_close(ib, code):
    try:
        bars = ib.reqHistoricalData(contract(code), endDateTime="", durationStr="5 D", barSizeSetting="1 day",
                                    whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1)
        return float(bars[-1].close) if bars else None
    except Exception as e:
        LOG.warning("ibkr hist %s failed: %s", code, e); return None

def account_state(ib):
    equity = 0.0
    for v in ib.accountValues():
        if v.tag == "NetLiquidation" and v.currency == "AUD": equity = float(v.value)
    positions = {p.contract.symbol: int(p.position) for p in ib.positions() if p.contract.exchange in ("ASX", "") and p.contract.currency == "AUD"}
    return equity, positions

def send(ib, orders):
    from ib_insync import LimitOrder
    trades = []
    for o in orders:
        order = LimitOrder(o["side"], o["qty"], o["limit"], tif="DAY", outsideRth=False)
        t = ib.placeOrder(contract(o["code"]), order)
        LOG.info("ORDER_SUBMITTED %s", json.dumps(o)); trades.append(t)
    ib.sleep(5)
    for t in trades:
        LOG.info("ORDER_STATUS %s %s filled=%s avg=%s", t.contract.symbol, t.orderStatus.status, t.orderStatus.filled, t.orderStatus.avgFillPrice)
    return trades

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--signals", default="data/signals_today.json")
    ap.add_argument("--equity", type=float, default=25000.0, help="dry-run equity when not connected")
    ap.add_argument("--send", action="store_true"); ap.add_argument("--live", action="store_true")
    a = ap.parse_args()
    if a.live and os.environ.get("ASX_LIVE") != "1":
        LOG.error("--live requires ASX_LIVE=1 in the environment (two-key rule). Aborting."); sys.exit(2)
    sig = load_signals(a.signals)
    ib = None; equity, positions = a.equity, {}
    if a.send:
        ib = connect(a.live); equity, positions = account_state(ib)
    orders = plan(sig, equity, positions, ib)
    total_cost = sum(o["est_cost"] for o in orders); notional = sum(o["notional"] for o in orders)
    LOG.info("PLAN equity=%.0f orders=%d notional=%.0f est_cost=%.2f (%.2f%% of traded)", equity, len(orders), notional, total_cost, 100 * total_cost / notional if notional else 0)
    for o in orders: print(json.dumps(o))
    if a.send and orders:
        send(ib, orders)
    if ib: ib.disconnect()
