"""
birdseye_feed.py — pulls IBKR snapshots for the Bird's Eye dashboard and serves them locally.
No Claude calls. Requires TWS running with the API enabled (port 7496, read-only is fine).

Setup once:   pip install ib_insync
Run:          python birdseye_feed.py            (refresh every 60s, serve on http://localhost:8765)
              python birdseye_feed.py --every 30 --port 8765
Then open:    http://localhost:8765/birdseye-dashboard.html   (keep this file and the HTML in the same folder)

Writes quotes.json next to this script:  {"asof": ISO time, "quotes": {"SYM": {"last","open","prev","high","low","adr","vol","avgvol"}}}
Writes roster.json next to this script:  the latest Daily Log rows from core_shortlist_log.xlsx (priority, play, coil,
                                         structure, direction) — re-read only when the workbook's modified time changes.
              python birdseye_feed.py --xlsx "C:\\path\\to\\core_shortlist_log.xlsx"   (default: ..\\Bernie_s Shortlist\\)
"""
import argparse, json, os, re, sys, threading, time
from datetime import datetime, timezone, date
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler

import asyncio
asyncio.set_event_loop(asyncio.new_event_loop())   # Python 3.12+: eventkit expects a loop to exist at import time
try:
    from ib_insync import IB, Contract
except ImportError:
    sys.exit("ib_insync not installed — run: pip install ib_insync")

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "quotes.json")
ROSTER_OUT = os.path.join(HERE, "roster.json")
XLSX_DEFAULT = os.path.join(os.path.dirname(HERE), "Bernie_s Shortlist", "core_shortlist_log.xlsx")

# CORE50 (IBKR watchlist 106) + index/sector ETFs — contract ids as pinned in the dashboard
STOCKS = {"AXTI":4726868,"MRNA":344809106,"AEHR":4725941,"AAOI":135423662,"TEM":709237125,"SMTC":3655896,"HUT":669228291,"MXL":73342855,"TWST":339974449,"DOCN":478483393,"CRCL":789044667,"RBRK":699030013,"SEI":275582621,"P":208813725,"TTMI":10311716,"FROG":445419999,"MSTR":272110,"MP":455592408,"TXG":382846993,"INTC":270639,"WING":196892722,"CRWV":771759702,"EIX":11899,"AFRM":465119069,"DKS":16310970,"ASTS":480745767,"PBF":118786560,"VSXY":502415513,"AVAV":42464367,"HOOD":504546674,"HPE":209411798,"HNGE":786034490,"SYRE":652828849,"ANF":2585399,"DAR":6604385,"RKLB":787273575,"VSAT":6746554,"NOW":109911821,"SWKS":4726021,"KGS":639336275,"CAVA":636387343,"CVNA":274144952,"FLUT":680895249,"ESTC":335844146,"MCHP":271568,"SFM":132310192,"FLR":11218575,"CART":655310909,"CTSH":4728759,"FCX":7089}
ETFS = {"SPY":756733,"QQQ":320227571,"IWM":9579970,"DIA":73128548,"XLK":4215230,"XLF":4215220,"XLE":4215217,"XLV":4215205,"XLY":4215215,"XLP":4215210,"XLI":4215227,"XLU":4215235,"XLB":4215200,"XLRE":209048377,"XLC":322317077,"SOXX":12658194,"IGV":12658199,"XME":45540699}
ALL = {**STOCKS, **ETFS}

def num(x):
    try:
        x = float(x)
        return None if x != x or x <= 0 else x   # NaN or -1 sentinel -> None
    except Exception:
        return None

def pull(ib, debug=False, data_type=4):
    contracts = [Contract(conId=cid) for cid in ALL.values()]
    ib.qualifyContracts(*contracts)
    if ADR_DAY != datetime.now().date(): compute_adr(ib, contracts)
    ib.reqMarketDataType(data_type)               # 1 live, 2 frozen, 3 delayed, 4 delayed-frozen (live where subscribed, delayed otherwise)
    tickers = [ib.reqMktData(c, "", False, False) for c in contracts]   # streaming, not snapshot
    for _ in range(20):                           # wait up to ~10s, stop early once nearly all have a price
        ib.sleep(0.5)
        have = sum(1 for t in tickers if num(t.last) or num(t.close))
        if have >= len(tickers) - 2: break
    out = {}
    for sym, c, t in zip(ALL.keys(), contracts, tickers):
        last = num(t.last) or num(t.close)
        out[sym] = {"last": last, "open": num(t.open), "prev": num(t.close),
                    "high": num(t.high), "low": num(t.low), "vol": num(t.volume)}
        if debug and sym in ("SPY", "RKLB"):
            print(f"  {sym}: last={t.last} close={t.close} open={t.open} high={t.high} low={t.low} bid={t.bid} ask={t.ask}")
    for c in contracts: ib.cancelMktData(c)
    # Fallback: fill anything still empty from daily bars (works after hours; also a safety net intraday)
    missing = [(sym, c) for sym, c in zip(ALL.keys(), contracts) if not out[sym]["last"]]
    if missing:
        print(f"  {len(missing)} names empty from ticks — filling from daily bars")
        for sym, c in missing:
            try:
                bars = ib.reqHistoricalData(c, "", "3 D", "1 day", "TRADES", useRTH=True, formatDate=1)
                if bars:
                    b = bars[-1]; pv = bars[-2].close if len(bars) > 1 else None
                    out[sym] = {"last": num(b.close), "open": num(b.open), "prev": num(pv),
                                "high": num(b.high), "low": num(b.low), "vol": num(b.volume), "bars": True}
                    if debug and sym in ("SPY", "RKLB"):
                        print(f"  {sym} bars: {[(x.date.isoformat(), x.close) for x in bars]}")
            except Exception as e:
                if debug: print(f"  {sym} bars failed: {e}")
            ib.sleep(0.15)   # keep under IBKR's historical-data pacing
    for sym in out:
        if sym in ADR: out[sym]["adr"] = ADR[sym]
        if sym in AVGVOL: out[sym]["avgvol"] = AVGVOL[sym]
    return out

ADR = {}          # sym -> 20-session average daily range, % of close
AVGVOL = {}       # sym -> 20-session average daily volume (same unit as the daily bars; RVOL = vol/avgvol)
ADR_DAY = None    # date the ADR set was last computed

def compute_adr(ib, contracts):
    global ADR, AVGVOL, ADR_DAY
    print("  computing 20-session ADR% and average volume for all names (once per day)")
    for sym, c in zip(ALL.keys(), contracts):
        try:
            bars = ib.reqHistoricalData(c, "", "30 D", "1 day", "TRADES", useRTH=True, formatDate=1)
            bars = [b for b in bars if b.close and b.close > 0][-20:]
            if len(bars) >= 5:
                ADR[sym] = round(sum((b.high - b.low) / b.close for b in bars) / len(bars) * 100, 2)
                vols = [b.volume for b in bars if b.volume and b.volume > 0]
                if vols: AVGVOL[sym] = round(sum(vols) / len(vols))
        except Exception as e:
            print(f"  ADR {sym} failed: {e}")
        ib.sleep(0.15)
    ADR_DAY = datetime.now().date()
    print(f"  ADR ready for {len(ADR)}/{len(ALL)} names")

# ---------- Daily Log -> roster.json ----------
LOG_MTIME = None
LANE_KEYS = ["Date", "Time (ET)", "Rank (RVOL)", "Ticker", "% Chg", "RVOL", "Zone Position", "Trade Priority",
             "Play", "Coil", "Structure", "Alignment", "Universe", "MA Spread %"]

def read_log(xlsx):
    """Latest Log date from the workbook -> dict of per-ticker rows. Read-only; never writes the workbook."""
    try:
        import openpyxl
    except ImportError:
        print("  openpyxl not installed — run: pip install openpyxl   (roster.json will not be written)"); return None
    wb = openpyxl.load_workbook(xlsx, read_only=True, data_only=True)
    if "Log" not in wb.sheetnames:
        print("  workbook has no 'Log' sheet"); return None
    rows = wb["Log"].iter_rows(values_only=True)
    header = next(rows)
    col = {h: i for i, h in enumerate(header) if h in LANE_KEYS}
    missing = [k for k in ("Date", "Ticker", "Trade Priority", "Play", "Coil", "Structure") if k not in col]
    if missing:
        print(f"  Log sheet is missing columns {missing} — roster.json not written"); return None
    latest, picked = None, []
    for r in rows:
        d = r[col["Date"]]
        if not isinstance(d, datetime) and not isinstance(d, date): continue
        d = d.date() if isinstance(d, datetime) else d
        if latest is None or d > latest: latest, picked = d, []
        if d == latest: picked.append(r)
    wb.close()
    if not picked: return None
    names = {}
    for r in picked:
        g = lambda k: r[col[k]] if k in col else None
        sym = str(g("Ticker")).strip().upper()
        zone = str(g("Zone Position") or "")
        m = re.search(r"\b(LONG|SHORT)\b", zone)
        bm = re.search(r"\((PRIME|OK)\)|\bINSIDE\b", zone)
        sp = g("MA Spread %")
        pr = g("Trade Priority")
        names[sym] = {"priority": int(pr) if isinstance(pr, (int, float)) else None,
                      "play": g("Play"), "coil": g("Coil"), "structure": g("Structure"),
                      "alignment": g("Alignment"), "dir": m.group(1) if m else None,
                      "rvol_rank": g("Rank (RVOL)"), "zone": zone,
                      "spread": round(float(sp), 3) if isinstance(sp, (int, float)) else None,   # 2-min SMA20–200 spread, % of price
                      "band": (bm.group(1) or "INSIDE") if bm else None}
    ranked = sorted((v["priority"], s) for s, v in names.items() if v["priority"] is not None)
    for i, (_, s) in enumerate(ranked): names[s]["top15"] = i < 15
    t = picked[0][col["Time (ET)"]] if "Time (ET)" in col else None
    return {"log_date": latest.isoformat(), "log_time": str(t) if t else None,
            "universe": picked[0][col["Universe"]] if "Universe" in col else None,
            "count": len(names), "names": names}

def write_roster(xlsx, force=False):
    global LOG_MTIME
    if not os.path.exists(xlsx):
        if LOG_MTIME is None: print(f"  workbook not found: {xlsx}  (roster.json will not be written)"); LOG_MTIME = -1
        return
    mt = os.path.getmtime(xlsx)
    if not force and mt == LOG_MTIME: return
    LOG_MTIME = mt
    try:
        doc = read_log(xlsx)
    except Exception as e:
        print(f"  Log read failed: {e}"); return
    if not doc: return
    doc["asof"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    doc["source"] = os.path.basename(xlsx)
    tmp = ROSTER_OUT + ".tmp"
    with open(tmp, "w") as f: json.dump(doc, f, separators=(",", ":"))
    os.replace(tmp, ROSTER_OUT)
    top = [s for s, v in doc["names"].items() if v.get("top15")]
    print(f"{datetime.now():%H:%M:%S}  wrote roster.json — Log {doc['log_date']} {doc['log_time'] or ''}, {doc['count']} names, top15: {', '.join(sorted(top, key=lambda s: doc['names'][s]['priority']))}")

def write(quotes):
    doc = {"asof": datetime.now(timezone.utc).isoformat(timespec="seconds"), "quotes": quotes}
    tmp = OUT + ".tmp"
    with open(tmp, "w") as f: json.dump(doc, f, separators=(",", ":"))
    os.replace(tmp, OUT)
    got = sum(1 for q in quotes.values() if q["last"])
    print(f"{datetime.now():%H:%M:%S}  wrote {got}/{len(quotes)} quotes -> {OUT}")

class Quiet(SimpleHTTPRequestHandler):
    def log_message(self, *a): pass
    def end_headers(self):
        self.send_header("Cache-Control", "no-store"); super().end_headers()

def serve(port):
    os.chdir(HERE)
    ThreadingHTTPServer(("127.0.0.1", port), Quiet).serve_forever()

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--every", type=int, default=60, help="seconds between pulls")
    ap.add_argument("--port", type=int, default=8765, help="local web port for the dashboard")
    ap.add_argument("--tws", type=int, default=7496, help="TWS API socket port")
    ap.add_argument("--once", action="store_true", help="pull once and exit")
    ap.add_argument("--debug", action="store_true", help="print raw ticker fields for SPY and RKLB")
    ap.add_argument("--data", type=int, default=4, choices=[1,2,3,4], help="market data type: 1 live, 2 frozen, 3 delayed, 4 delayed-frozen (default)")
    ap.add_argument("--xlsx", default=XLSX_DEFAULT, help="path to core_shortlist_log.xlsx (Daily Log source for roster.json)")
    ap.add_argument("--roster-only", action="store_true", help="write roster.json from the workbook and exit (no TWS needed)")
    a = ap.parse_args()

    if a.roster_only:
        write_roster(a.xlsx, force=True); return

    threading.Thread(target=serve, args=(a.port,), daemon=True).start()
    print(f"dashboard: http://localhost:{a.port}/birdseye-dashboard.html")

    ib = IB()
    while True:
        write_roster(a.xlsx)                      # cheap: re-reads only when the workbook's mtime changes
        try:
            if not ib.isConnected():
                ib.connect("127.0.0.1", a.tws, clientId=17, readonly=True, timeout=10)
                print("connected to TWS")
            write(pull(ib, a.debug, a.data))
        except Exception as e:
            print(f"{datetime.now():%H:%M:%S}  error: {e}  (is TWS running with the API enabled?)")
        if a.once: break
        time.sleep(a.every)

if __name__ == "__main__":
    main()
