"""
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            (snapshot all names once a minute at :55, serve on http://localhost:8765)
              python birdseye_feed.py --stream   (stream the 50 stocks instead — holds 50 of TWS's 100 market-data lines)
              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",
                                          "adr14": abs 14-day ADR,
                                          "t2":  live 2-min geometry  {ma20, ma200, spread, state, band, side, align, struct, play, asof, bars},
                                          "t15": live 15-min trend    {ma20, ma200, struct, cross:{dir,time,confirmed}|null, asof, bars}}}}
v1.12.3: quote pass = brief streaming per batch of 20 (true snapshots returned nothing); naive local time, no zoneinfo.
v1.12.2: snapshot mode by default — no market-data lines held between cycles (TWS caps simultaneous lines at 100; v1.12 held 68 permanently).
v1.12.1: fixes — re-subscribe after a TWS reconnect / silent ticks, seed bar times in ET, and the daily-bar fallback
         no longer shows yesterday's session as today's move (pre-market it shows the prior close, flat, flagged stale).
v1.12: market-data subscriptions stay open and the feed builds its own 2-minute and 15-minute RTH bars from the ticks,
seeded from IBKR history at startup (and the 15-minute series re-seeded hourly). Geometry (coil / band position / alignment)
uses the same thresholds as bernie_scan.py — 2-minute bars, regular hours, completed bars only.
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

CONTRACTS = None      # qualified contracts, in ALL order
TICKERS = {}          # sym -> live ticker (subscription kept open for the life of the process)

def resubscribe(ib, why):
    """Drop the streaming subscriptions so the next pull re-requests them. Needed after any (re)connect — a ticker
    object from a previous TWS session never updates again — and used as a self-heal when ticks go silent in RTH."""
    print(f"  re-subscribing market data ({why})")
    for c in CONTRACTS or []:
        try: ib.cancelMktData(c)
        except Exception: pass
    TICKERS.clear()

def ticks_alive():
    """True if at least a handful of tickers carry a live price right now."""
    return sum(1 for t in TICKERS.values() if num(t.last)) >= 5

STREAM = False        # --stream: keep the 50 stocks streaming (50 lines held). Default: snapshots, no lines held between cycles.
SNAP_BATCH = 20       # snapshots in flight at once — well under TWS's 100-line cap even with TWS's own watchlists open

def ensure_subscribed(ib, data_type):
    global CONTRACTS
    if CONTRACTS is None:
        CONTRACTS = [Contract(conId=cid) for cid in ALL.values()]
        ib.qualifyContracts(*CONTRACTS)
        ib.reqMarketDataType(data_type)           # 1 live, 2 frozen, 3 delayed, 4 delayed-frozen (live where subscribed, delayed otherwise)
    if STREAM and not TICKERS:
        for sym, c in zip(ALL.keys(), CONTRACTS):
            if sym in STOCKS:
                TICKERS[sym] = ib.reqMktData(c, "", False, False)   # streaming, stocks only; ETFs are snapshotted each cycle

def snapshot(ib, syms):
    """Quotes for the given symbols, SNAP_BATCH at a time: subscribe, wait until the batch has prices (a few seconds),
    cancel, next batch. Lines are held for seconds per minute, never between cycles (TWS caps simultaneous lines at 100).
    True snapshot requests (snapshot=True) returned nothing on this account's data setup, so this mirrors v1.11's method."""
    got = {}
    pairs = [(sym, c) for sym, c in zip(ALL.keys(), CONTRACTS) if sym in syms]
    for i in range(0, len(pairs), SNAP_BATCH):
        batch = pairs[i:i + SNAP_BATCH]
        ts = {sym: ib.reqMktData(c, "", False, False) for sym, c in batch}
        for _ in range(16):                       # up to ~4s per batch
            ib.sleep(0.25)
            if all(num(t.last) or num(t.close) for t in ts.values()): break
        # copy the fields out, then release the lines
        for sym, t in ts.items():
            snap = type("Snap", (), {})(); 
            for k in ("last", "close", "open", "high", "low", "volume", "bid", "ask"): setattr(snap, k, getattr(t, k, None))
            got[sym] = snap
        for _, c in batch: ib.cancelMktData(c)
    filled = sum(1 for t in got.values() if num(t.last) or num(t.close))
    if filled < len(got) - 5: print(f"  quotes: only {filled}/{len(got)} names returned a price this cycle")
    return got

def pull(ib, debug=False, data_type=4):
    ensure_subscribed(ib, data_type)
    if ADR_DAY != datetime.now().date(): compute_adr(ib, CONTRACTS)
    seed_bars(ib)                                 # no-op once seeded; re-seeds the 15-min series hourly
    if STREAM:
        for _ in range(20):                       # wait up to ~10s, stop early once nearly all have a price
            ib.sleep(0.5)
            sample_ticks()                        # feed the bar builder while we wait
            have = sum(1 for t in TICKERS.values() if num(t.last) or num(t.close))
            if have >= len(TICKERS) - 2: break
        if in_rth(et_now()) and not ticks_alive():   # market open but nothing streaming: subscriptions are dead
            resubscribe(ib, "no live ticks during RTH")
            ensure_subscribed(ib, data_type)
            for _ in range(20):
                ib.sleep(0.5); sample_ticks()
                if ticks_alive(): break
        snaps = snapshot(ib, ETFS.keys())
        TICKERS.update(snaps)                     # ETF snapshots ride along for this cycle's write
    else:
        TICKERS.clear(); TICKERS.update(snapshot(ib, ALL.keys()))
        sample_ticks()                            # this cycle's prices are the bar builder's sample (cycles run at :55, so it is the bar's close)
    out = {}
    for sym, t in TICKERS.items():
        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}")
    # 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]
                    if b.date == et_now().date():   # today's (possibly still-forming) session bar: usable as a quote
                        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}
                    else:                           # no session bar for today yet: show the prior close as a flat reference, never as a move
                        out[sym] = {"last": num(b.close), "open": None, "prev": num(b.close),
                                    "high": None, "low": None, "vol": None, "bars": True, "stale": b.date.isoformat()}
                    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]
        if sym in ADR14: out[sym]["adr14"] = ADR14[sym]
        if sym in STOCKS:
            g = geometry(sym, out[sym]["last"])
            if g: out[sym].update(g)
    return out

ADR = {}          # sym -> 20-session average daily range, % of close
ADR14 = {}        # sym -> mean(high-low) over the last 14 COMPLETED sessions, absolute — bernie_scan's adr14, used for band position
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]
            done = [b for b in bars if b.date < datetime.now().date()]     # completed sessions only, as the scan does
            if len(done) >= 14:
                ADR14[sym] = round(sum(b.high - b.low for b in done[-14:]) / 14, 4)
            bars = bars[-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")


# ---------- live bars and geometry (v1.12) ----------
# Thresholds copied from bernie_scan.py (param_v 4). Change them there first; these mirror it.
FAST, SLOW = 20, 200
COIL_PINCHED, COIL_TIGHT, COIL_COILED = 0.25, 0.50, 1.00     # % of price: spread between the 2-min averages
POS_PRIME, POS_OK, POS_EXTENDED = 0.25, 0.75, 2.00           # distance from the near band edge, in ADR(14D)
# Times are naive LOCAL time throughout. The PC runs on Eastern time, which is what RTH and the bar clock assume;
# zoneinfo is not used because Windows Python has no tz database unless the tzdata package is installed.
ET = None
def to_local(d):
    """IBKR history timestamps arrive tz-aware (UTC) -> naive local."""
    return d.astimezone().replace(tzinfo=None) if getattr(d, "tzinfo", None) else d

CLOSES = {2: {}, 15: {}}     # tf -> sym -> list of (bar_start_et, close) for COMPLETED RTH bars, oldest first
CUR = {2: {}, 15: {}}        # tf -> sym -> [bar_start_et, last_price] for the bar still forming
SEEDED = {2: None, 15: None} # tf -> datetime of last seed
MAX_KEEP = 600               # bars kept per series (>= SLOW plus a day of history for cross detection)

def et_now():
    return datetime.now()

def in_rth(dt):
    m = dt.hour * 60 + dt.minute
    return dt.weekday() < 5 and 570 <= m < 960

def bar_start(dt, tf):
    m = (dt.hour * 60 + dt.minute)
    m = 570 + ((m - 570) // tf) * tf                          # anchor to 09:30 so 15-min bars land on :30/:45/:00/:15
    return dt.replace(hour=m // 60, minute=m % 60, second=0, microsecond=0)

def push_close(tf, sym, start, close):
    ser = CLOSES[tf].setdefault(sym, [])
    if ser and ser[-1][0] == start: ser[-1] = (start, close)
    elif not ser or start > ser[-1][0]: ser.append((start, close))
    if len(ser) > MAX_KEEP: del ser[:len(ser) - MAX_KEEP]

def sample_ticks():
    """Called often (every few seconds). Turns the streaming last price into 2-min and 15-min RTH bars.
    Only the close of each bar is kept — that is all the averages need."""
    now = et_now()
    if not in_rth(now): return
    for sym, t in TICKERS.items():
        if sym not in STOCKS: continue
        px = num(t.last)
        if not px: continue
        for tf in (2, 15):
            start = bar_start(now, tf)
            cur = CUR[tf].get(sym)
            if cur and cur[0] != start:
                push_close(tf, sym, cur[0], cur[1])            # previous bar is complete
                cur = None
            if cur is None: CUR[tf][sym] = [start, px]
            else: cur[1] = px

def seed_bars(ib):
    """Seed from IBKR history so the averages are valid from the first cycle, not 200 bars later.
    2-min: once per process (a session's worth of ticks then keeps it going).  15-min: at start and hourly (self-heals dropped ticks)."""
    now = et_now()
    for tf, dur, size, every in ((15, "12 D", "15 mins", 3600), (2, "3 D", "2 mins", None)):
        last = SEEDED[tf]
        if last and (every is None or (now - last).total_seconds() < every): continue
        print(f"  seeding {size} bars for {len(STOCKS)} names from IBKR history ({dur})")
        ok = 0
        for sym, c in zip(ALL.keys(), CONTRACTS):
            if sym not in STOCKS: continue
            try:
                bars = ib.reqHistoricalData(c, "", dur, size, "TRADES", useRTH=True, formatDate=2)
                ser = []
                for b in bars:
                    d = b.date
                    if hasattr(d, "hour"):
                        if d.tzinfo is None: d = d.replace(tzinfo=timezone.utc)   # formatDate=2 -> UTC
                        d = to_local(d)
                    if hasattr(d, "hour"):
                        ser.append((d.replace(second=0, microsecond=0), float(b.close)))
                # the last bar from history may still be forming — treat it as current, not completed
                if ser and in_rth(now) and ser[-1][0] == bar_start(now, tf):
                    CUR[tf][sym] = [ser[-1][0], ser[-1][1]]; ser = ser[:-1]
                CLOSES[tf][sym] = ser[-MAX_KEEP:]
                ok += 1
            except Exception as e:
                print(f"  seed {size} {sym} failed: {e}")
            ib.sleep(0.2)
        SEEDED[tf] = now
        print(f"  {size}: {ok}/{len(STOCKS)} seeded")

def sma(vals, n):
    return sum(vals[-n:]) / n if len(vals) >= n else None

def geometry(sym, price):
    """Live equivalent of bernie_scan's ma_band / coil / position / alignment / classify on the 2-min series,
    plus the 15-min trend structure and today's confirmed 20/200 cross."""
    out = {}
    c2 = CLOSES[2].get(sym, [])
    if price and len(c2) >= SLOW:
        closes = [x[1] for x in c2]
        fast, slow = sma(closes, FAST), sma(closes, SLOW)
        lo, hi = min(fast, slow), max(fast, slow)
        struct = "BULL" if fast > slow else "BEAR"
        spread = (hi - lo) / price * 100
        state = "PINCHED" if spread <= COIL_PINCHED else "TIGHT" if spread <= COIL_TIGHT else "COILED" if spread <= COIL_COILED else "WIDE"
        adr = ADR14.get(sym)
        if lo <= price <= hi:
            side, band, x = "INSIDE", "INSIDE", 0.0
        else:
            side = "ABOVE" if price > hi else "BELOW"
            dist = (price - hi) if side == "ABOVE" else (lo - price)
            x = dist / adr if adr else None
            band = None if x is None else "PRIME" if x <= POS_PRIME else "OK" if x <= POS_OK else "EXTENDED" if x <= POS_EXTENDED else "RUNAWAY"
        align = "UNCONFIRMED" if side == "INSIDE" else ("ALIGNED" if (struct == "BULL") == (side == "ABOVE") else "CONTESTED")
        play = ("NO-TRADE" if side == "INSIDE" else None if band is None else
                "CONTINUATION" if band in ("PRIME", "OK") else "FADE" if band == "EXTENDED" else "RUNAWAY")
        direction = None if play in (None, "NO-TRADE") else ("LONG" if side == "ABOVE" else "SHORT")
        out["t2"] = {"ma20": round(fast, 4), "ma200": round(slow, 4), "spread": round(spread, 3), "state": state,
                     "band": band, "dist": None if x is None else round(x, 3), "side": side, "align": align,
                     "struct": struct, "play": play, "dir": direction,
                     "asof": c2[-1][0].strftime("%H:%M"), "bars": len(c2)}
    c15 = CLOSES[15].get(sym, [])
    if len(c15) >= SLOW:
        closes = [x[1] for x in c15]
        fast, slow = sma(closes, FAST), sma(closes, SLOW)
        struct = "BULL" if fast > slow else "BEAR"
        # structure at each completed bar today; a cross is confirmed once the NEXT bar closes on the same side
        today = c15[-1][0].date()
        first_today = next((i for i, (d, _) in enumerate(c15) if d.date() == today), None)
        cross = None
        if first_today is not None and first_today >= SLOW:
            prev_struct = None
            for i in range(first_today - 1, len(c15)):
                f, sl = sma(closes[:i + 1], FAST), sma(closes[:i + 1], SLOW)
                st = "BULL" if f > sl else "BEAR"
                if prev_struct and st != prev_struct and i >= first_today:
                    cross = {"dir": st, "time": c15[i][0].strftime("%H:%M"), "confirmed": False, "i": i}
                elif cross and not cross["confirmed"] and i == cross["i"] + 1:
                    if st == cross["dir"]: cross["confirmed"] = True
                    else: cross = None                              # flipped straight back — whipsaw, not a reversal
                prev_struct = st
            if cross: cross.pop("i", None)
        out["t15"] = {"ma20": round(fast, 4), "ma200": round(slow, 4), "struct": struct, "cross": cross,
                      "asof": c15[-1][0].strftime("%H:%M"), "bars": len(c15)}
    return out

# ---------- 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"])
    fb = sum(1 for q in quotes.values() if q.get("bars")); st = sum(1 for q in quotes.values() if q.get("stale"))
    print(f"{datetime.now():%H:%M:%S}  wrote {got}/{len(quotes)} quotes -> {OUT}" + (f"  ({fb} from daily bars, {st} prior-close only)" if fb else ""))

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)")
    ap.add_argument("--stream", action="store_true", help="stream the 50 stocks instead of snapshotting (holds 50 market-data lines)")
    a = ap.parse_args()
    global STREAM; STREAM = a.stream

    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")
                if TICKERS: resubscribe(ib, "reconnected")
            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
        if STREAM:
            end = time.time() + a.every
            while time.time() < end:              # keep the bar builder fed between writes
                try: ib.sleep(3); sample_ticks()
                except Exception: time.sleep(3)
        else:
            # snapshot mode: run on a fixed 60s cadence aligned to :55, so each cycle's price lands just before the
            # 2-min / 15-min bar boundary and serves as that bar's close
            now = time.time(); wait = (55 - now % 60) % 60
            if wait < 5: wait += 60
            try: ib.sleep(wait)
            except Exception: time.sleep(wait)

if __name__ == "__main__":
    main()
