"""
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"}}}
"""
import argparse, json, os, sys, threading, time
from datetime import datetime, timezone
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")

# 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)}
        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), "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]
    return out

ADR = {}          # sym -> 20-session average daily range, % of close
ADR_DAY = None    # date the ADR set was last computed

def compute_adr(ib, contracts):
    global ADR, ADR_DAY
    print("  computing 20-session ADR% 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)
        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")

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)")
    a = ap.parse_args()

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

    ib = IB()
    while True:
        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()
