"""Rebuild the maker's BOOK-level economics under the queue-aware fill model.

WHY. Every maker number reported to date -- completion rate, the P&L split by
legs filled, the liquidity-gate sweep -- comes from mm_settle, which counts any
trade printed at or above our ask as ours. That ignores queue position and its
own docstring calls it a CEILING. Meanwhile the live quoter acts on
paper_filled(), a queue-aware LOWER bound. So we have been choosing policy
against the ceiling while the machine ran on the floor, and the two disagree
badly: the settler scores 59 books as 2-of-3 while the quoter took no action on
39 of them.

This does not need new live data. Everything required is already recorded:
  * mm_paper_quotes.csv  -- our ask over time, plus ask_size (the depth we would
    have queued behind) at post time
  * Kalshi trade history -- fetched per leg by queue_aware_fill.trades_sized
So the pessimistic model can be replayed over the same books the optimistic one
scored, and the gate re-tested against BOTH bounds.

THE MODEL (same as the live quoter): posting at P with Q resting at P, we are
filled once cumulative volume of trades at price >= P after we post exceeds Q.
Q = ask_size at post time if we joined the touch, else 0. Q is pessimistic
because makers ahead of us can cancel unobserved -- hence a lower bound.

READ THE OUTPUT AS A RANGE, not a point. Optimistic is the ceiling, queue-aware
the floor, truth between. A gate that only works under the ceiling is not a
finding; one that holds under both is worth acting on.

Usage:  python research\\mm_queue_aware_books.py [--limit N]
"""
import csv
import statistics
import sys
from collections import defaultdict

sys.path.insert(0, r'E:\soccer\main')
sys.path.insert(0, r'E:\soccer\research')

import mm_settle as S
from queue_aware_fill import trades_sized

QUOTES = r'E:\soccer\research\mm_paper_quotes.csv'
RESULTS = QUOTES.replace('quotes', 'results')
GATES = (0, 100, 250, 500, 1000, 2500)


def _f(x):
    try:
        return float(x)
    except (TypeError, ValueError):
        return None


def main():
    limit = None
    if '--limit' in sys.argv:
        try:
            limit = int(sys.argv[sys.argv.index('--limit') + 1])
        except (IndexError, ValueError):
            pass

    # settlement per leg (1 if that outcome happened) -- needed to charge for
    # naked legs rather than scoring partial books as free
    settled = {}
    try:
        with open(RESULTS, encoding='utf-8') as fh:
            for r in csv.DictReader(fh):
                v = _f(r.get('settled'))
                if v is not None:
                    settled[r['leg_ticker']] = v
    except OSError:
        pass
    print(f'settlements known for {len(settled)} legs')

    legs = defaultdict(list)
    with open(QUOTES, encoding='utf-8') as fh:
        for r in csv.DictReader(fh):
            if r.get('our_ask') and r.get('k_ask') and r.get('leg_ticker'):
                legs[r['leg_ticker']].append(r)
    for tk in legs:
        legs[tk].sort(key=lambda x: x['ts'])

    books = defaultdict(list)
    for tk, rs in legs.items():
        books[rs[0].get('event') or tk.rsplit('-', 1)[0]].append(tk)
    books = {e: ls for e, ls in books.items() if len(ls) == 3}
    order = sorted(books)
    if limit:
        order = order[:limit]
    print(f'{len(books)} three-leg books; scoring {len(order)}')

    out = []
    for i, ev in enumerate(order, 1):
        if i % 25 == 0:
            print(f'  ...{i}/{len(order)}')
        rec = {'ev': ev, 'opt': 0, 'q': 0, 'vol': None,
               'opt_pnl': 0.0, 'q_pnl': 0.0}
        vols = []
        for tk in books[ev]:
            rs = legs[tk]
            r0 = rs[0]
            start, ko = S._pt(r0['ts']), S._pt(r0['kickoff'])
            our = _f(r0['our_ask'])
            mkt = _f(r0['k_ask'])
            if None in (start, ko, our, mkt):
                continue
            v = _f(r0.get('volume'))
            if v is not None:
                vols.append(v)
            if _f(r0.get('ask_size')) is None:
                continue                       # predates depth logging

            # WALK THE QUOTE INTERVALS, do not judge the whole window against the
            # FIRST ask. This quoter cancels and reposts every cycle, so an ask
            # that was hopeless at 29c becomes live once fair drifts to 25c.
            # Scoring against r0 only understated fills badly enough to report 0%
            # completion -- mm_settle.fill_from_quotes makes the same point in
            # the opposite direction. Each repost also RESETS queue position
            # (cancel-replace puts us at the back), which is why the queue is
            # re-read per interval rather than carried.
            tr = [(t, p, c) for t, p, c in trades_sized(tk, start) if t < ko]
            opt_hit = q_hit = False
            fill_px = None
            # Queue position resets on a REPRICE (cancel-replace puts us at the
            # back), NOT on every poll. The quotes log writes a row every ~20s
            # whether or not the price moved, so resetting per row modelled a
            # maker who cancels and re-queues 180 times an hour -- which made
            # completion look like 5% when the leg-level study measured ~40%.
            # Accumulate across unchanged quotes; reset only when our_ask moves.
            cum = 0.0
            queue = None
            prev_ask = None
            for j, rq in enumerate(rs):
                a = _f(rq.get('our_ask'))
                if a is None:
                    continue
                t0 = S._pt(rq['ts'])
                t1 = S._pt(rs[j + 1]['ts']) if j + 1 < len(rs) else ko
                if t0 is None or t0 >= ko:
                    break
                if t1 is None or t1 > ko:
                    t1 = ko
                if prev_ask is None or abs(a - prev_ask) > 1e-9:
                    mk_ask = _f(rq.get('k_ask'))
                    asz = _f(rq.get('ask_size')) or 0.0
                    queue = asz if (mk_ask is not None and a <= mk_ask + 1e-9) else 0.0
                    cum = 0.0
                    prev_ask = a
                for t, p, c in tr:
                    if not (t0 <= t < t1) or p < a - 1e-9:
                        continue
                    if not opt_hit:
                        opt_hit = True
                        fill_px = a
                    cum += c
                    if cum > queue:
                        q_hit = True
                        break
                if q_hit:
                    if fill_px is None:
                        fill_px = a
                    break
            sv = settled.get(tk)
            px = (fill_px or our)
            if opt_hit:
                rec['opt'] += 1
                rec['opt_pnl'] += (px - sv) * 100 if sv is not None else 0.0
            if q_hit:
                rec['q'] += 1
                rec['q_pnl'] += (px - sv) * 100 if sv is not None else 0.0
            if sv is None:
                rec['unscored'] = rec.get('unscored', 0) + 1
        rec['vol'] = min(vols) if vols else None
        # QUEUE DEPTH, the constraint the volume gate does not capture. A leg can
        # be busy and still hopeless if we join the touch behind a wall: measured
        # across 579 legs, median queue ahead is 3,350 against median volume 872,
        # so the typical leg makes us wait for ~4x its entire traded history.
        # Live confirmation 2026-08-09: Sarmiento v Huracan passed the volume
        # gate with 43,896 and still filled 0 of 3, sitting behind 10-27k.
        qs = []
        for tk in books[ev]:
            q = _f(legs[tk][0].get('ask_size'))
            our = _f(legs[tk][0].get('our_ask'))
            mk = _f(legs[tk][0].get('k_ask'))
            if q is None or our is None or mk is None:
                continue
            qs.append(q if our <= mk + 1e-9 else 0.0)
        rec['queue'] = max(qs) if qs else None          # worst leg gates the book
        rec['ratio'] = (min(vols) / rec['queue']) if (vols and rec['queue']) else None
        out.append(rec)

    # SCORE EVERY FILLED LEG AGAINST ITS SETTLEMENT.
    #
    # An earlier version credited completed books with sum(asks)-100 and scored
    # partial books as ZERO -- i.e. it counted the profit on the 26% that
    # completed and charged nothing for the 74% left naked. That is what made a
    # 26% completion rate look profitable, which it plainly should not be: a
    # 2-of-3 book is an unhedged short that pays 100c if either sold outcome
    # wins (the -7.62c bucket in the settler's own numbers).
    #
    # Correct treatment, matching mm_settle: for each leg we actually sold,
    # P&L = our_ask - settlement, where settlement is 1 if that outcome happened
    # and 0 otherwise. A complete book collapses to sum(asks) - 100 because
    # exactly one of the three outcomes settles at 1. Partial books carry the
    # real risk instead of being waved through.
    def summarise(sel, key, pnlkey):
        n = len(sel)
        if not n:
            return None
        comp = sum(1 for r in sel if r[key] == 3)
        pnl = sum(r[pnlkey] for r in sel)
        return dict(n=n, comp=comp, rate=100.0 * comp / n, pnl=pnl, per=pnl / n)

    print()
    print(f'{"gate":>7s}  {"books":>5s} | {"OPTIMISTIC (ceiling)":>28s} | '
          f'{"QUEUE-AWARE (floor)":>28s}')
    print(f'{"":>7s}  {"":>5s} | {"3/3":>4s} {"rate":>6s} {"c/book":>9s} | '
          f'{"3/3":>4s} {"rate":>6s} {"c/book":>9s}')
    for g in GATES:
        sel = [r for r in out if r['vol'] is not None and r['vol'] >= g]
        if len(sel) < 8:
            continue
        a = summarise(sel, 'opt', 'opt_pnl')
        b = summarise(sel, 'q', 'q_pnl')
        print(f'{g:7d}  {a["n"]:5d} | {a["comp"]:4d} {a["rate"]:5.0f}% '
              f'{a["per"]:+8.2f}c | {b["comp"]:4d} {b["rate"]:5.0f}% {b["per"]:+8.2f}c')
    # Does queue depth predict completion better than raw volume?
    for label, key, buckets, lo_is_good in (
            ('MAX QUEUE AHEAD (worst leg)', 'queue',
             ((0, 500), (500, 2000), (2000, 8000), (8000, 1e12)), True),
            ('VOLUME / QUEUE ratio', 'ratio',
             ((0, .25), (.25, 1), (1, 4), (4, 1e12)), False)):
        print()
        print(f'{label}')
        print(f'  {"bucket":>18s} {"books":>5s} | {"ceiling 3/3":>12s} {"c/book":>8s} '
              f'| {"floor 3/3":>10s} {"c/book":>8s}')
        for lo, hi in buckets:
            sel = [r for r in out if r.get(key) is not None and lo <= r[key] < hi]
            if len(sel) < 5:
                continue
            a = summarise(sel, 'opt', 'opt_pnl')
            b = summarise(sel, 'q', 'q_pnl')
            name = f'{lo:,.0f}-{hi:,.0f}' if hi < 1e11 else f'{lo:,.0f}+'
            if key == 'ratio':
                name = f'{lo:g}-{hi:g}' if hi < 1e11 else f'{lo:g}+'
            print(f'  {name:>18s} {a["n"]:5d} | {a["rate"]:11.0f}% {a["per"]:+7.2f}c '
                  f'| {b["rate"]:9.0f}% {b["per"]:+7.2f}c')

    print()
    print('Optimistic is the CEILING, queue-aware the FLOOR. A gate that only')
    print('works under the ceiling is not a finding; one that holds under both is.')


if __name__ == '__main__':
    main()
