"""Does gating on a book's GROSS ask-margin help, historically?

Today's 7 paper books suggested: only post a 3-leg book if sum(asks) - 100 covers
the ~2.4c it costs to complete the last leg (1c crossing + taker fee). That
threshold was fitted to 7 points, so test the mechanism on the 641 settled
events in kalshi_backfill.csv.

THE TAUTOLOGY TRAP, stated up front: if all three legs fill, book P&L IS
sum(asks) - 100. Gating on gross would then "work" by definition and prove
nothing. The only real question is whether high-gross books still FILL -- a wide
book is wide because it is illiquid, and gating toward wide books may select
books nobody trades, leaving 2-of-3 partial books, which is the dangerous state.
So the test here is fill behaviour vs gross, not P&L vs gross.

KNOWN LOOK-AHEAD (do not paper over it): kalshi_backfill snaps the LAST candle
at or before kickoff, but we would post hours earlier. Using that near-kickoff
ask as our posting price and then counting trades over the whole pre-KO window
counts fills at prices the book only reached later -- it inflates fill rates.
Mitigated by reporting a NEAR window (last 3h) alongside the full window; the
near window is the honest one. This is the same class of error that produced the
83%-vs-19% fill bug in the paper maker.

MAKER-SELL SEMANTICS (verified against our own fills): taker_book_side='bid'
means the taker BOUGHT, i.e. a maker SOLD. Those are our fills.
"""
import csv
import datetime as dt
import statistics
from collections import defaultdict

BACKFILL = r'E:\soccer\research\kalshi_backfill.csv'
TRADES = r'E:\soccer\research\mm_trades.csv'
NEAR_H = 3.0          # honest window: trades within 3h of the quote snapshot
FEE = lambda p: 0.07 * p * (1 - p) * 100      # cents per contract

# LIQUIDITY FILTER (added after first run, which was garbage without it).
# 192 of 641 backfill books have gross >= 5c, and they are not opportunities --
# they are EMPTY books. Their mean 3-leg spread is 20.1c vs 4.2c for the rest;
# the worst quotes asks 0.80/0.81/0.80 against bids 0.20/0.20/0.20 (gross
# "+141c"). A resting sell at an ask nobody will ever lift does not fill, so
# counting those as tradeable inflates everything. Our live quoter only ever
# competes on books already at/near the 1c tick, so cap the total 3-leg spread.
MAX_SPREAD_C = 6.0


def _pt(s):
    if not s:
        return None
    try:
        d = dt.datetime.fromisoformat(s.replace('Z', '+00:00'))
    except ValueError:
        return None
    return d if d.tzinfo else d.replace(tzinfo=dt.timezone.utc)


def load_books():
    books = {}
    with open(BACKFILL, encoding='utf-8') as fh:
        for r in csv.DictReader(fh):
            try:
                asks = {s: float(r[f'k_{s}_ask']) for s in ('home', 'draw', 'away')}
                bids = {s: float(r[f'k_{s}_bid']) for s in ('home', 'draw', 'away')}
            except (ValueError, KeyError):
                continue
            ko = _pt(r['kickoff'])
            if ko is None or not all(0 < a < 1 for a in asks.values()):
                continue
            spread = sum(asks[s] - bids[s] for s in asks) * 100
            if spread > MAX_SPREAD_C or spread < 0:
                continue
            # COHERENCE FILTER (added after the spread filter still left a
            # 20c-gross tail). A real 1X2 book must satisfy sum(bids) < 100 <
            # sum(asks): buying all three costs less than the payout, selling
            # all three collects more. 66 of the 69 remaining gross>=5c books
            # had sum(bids) up to 155c, with one leg at 0.96 -- those are
            # IN-PLAY prices that landed in a "pre-kickoff" dataset because the
            # recorded kickoff is wrong, so the last-candle-before-kickoff rule
            # picked a mid-match candle. Not tradeable, and not pre-match.
            if sum(bids.values()) >= 1.0:
                continue
            books[r['event']] = {'div': r['div'], 'ko': ko, 'asks': asks,
                                 'bids': bids, 'ftr': r['ftr'], 'spread': spread,
                                 'gross': sum(asks.values()) * 100 - 100}
    return books


def load_fills(books):
    """Per (event, leg-suffix): maker-sell trades, bounded pre-kickoff."""
    want = {e: b['ko'] for e, b in books.items()}
    out = defaultdict(list)
    with open(TRADES, encoding='utf-8') as fh:
        for r in csv.DictReader(fh):
            tk = r['ticker']
            i = tk.rfind('-')
            if i < 0:
                continue
            ev = tk[:i]
            ko = want.get(ev)
            if ko is None:
                continue
            if r['taker_book_side'] != 'bid':      # taker bought => maker sold
                continue
            t = _pt(r['ts'])
            if t is None or t >= ko:
                continue
            try:
                out[(ev, tk[i + 1:])].append((t, float(r['price']), float(r['count'])))
            except ValueError:
                continue
    return out


def leg_suffixes(books, fills):
    """Map each event's 3 sides to their ticker suffixes by matching ask prices.

    Suffixes are team codes of varying length, so string-slicing them is the bug
    that mis-assigned 24/1060 legs before. Instead: the draw leg's suffix is the
    one Kalshi tags TIE/DRAW; home/away are resolved by which observed trade
    prices sit closest to that side's quoted ask.
    """
    res = {}
    for ev, b in books.items():
        sufs = sorted({s for (e, s) in fills if e == ev})
        if len(sufs) != 3:
            continue
        draw = [s for s in sufs if s.upper() in ('TIE', 'DRAW')]
        if len(draw) != 1:
            continue
        rest = [s for s in sufs if s != draw[0]]
        med = {}
        for s in rest:
            px = [p for (_, p, _) in fills[(ev, s)]]
            med[s] = statistics.median(px) if px else None
        if any(v is None for v in med.values()):
            continue
        # assign so total distance to the two quoted asks is minimised
        a, c = rest
        d1 = abs(med[a] - b['asks']['home']) + abs(med[c] - b['asks']['away'])
        d2 = abs(med[c] - b['asks']['home']) + abs(med[a] - b['asks']['away'])
        home, away = (a, c) if d1 <= d2 else (c, a)
        res[ev] = {'home': home, 'draw': draw[0], 'away': away}
    return res


def filled(trades, our_ask, ko, near_only):
    """Would a resting sell at our_ask have been hit? Trade price >= our ask."""
    lo = ko - dt.timedelta(hours=NEAR_H)
    for (t, p, _) in trades:
        if near_only and t < lo:
            continue
        if p >= our_ask - 1e-9:
            return True
    return False


def main():
    books = load_books()
    fills = load_fills(books)
    smap = leg_suffixes(books, fills)
    print(f'{len(books)} settled books; {len(smap)} with all 3 legs resolvable in trade data\n')

    for near in (True, False):
        lbl = f'NEAR window (last {NEAR_H:g}h, honest)' if near else 'FULL pre-KO window (look-ahead inflated)'
        rows = []
        for ev, sfx in smap.items():
            b = books[ev]
            nf = sum(filled(fills[(ev, sfx[s])], b['asks'][s], b['ko'], near)
                     for s in ('home', 'draw', 'away'))
            rows.append({'gross': b['gross'], 'nf': nf, 'ev': ev, 'b': b, 'sfx': sfx})
        print(f'--- {lbl}')
        print(f"  {'gross bucket':>16s} {'books':>6s} {'3/3':>6s} {'2/3':>6s} {'<=1':>6s} "
              f"{'mean net c/contract':>21s}")
        buckets = [(-99, 1.0), (1.0, 2.0), (2.0, 3.0), (3.0, 5.0), (5.0, 999)]
        for lo, hi in buckets:
            g = [x for x in rows if lo <= x['gross'] < hi]
            if not g:
                continue
            n3 = sum(1 for x in g if x['nf'] == 3)
            n2 = sum(1 for x in g if x['nf'] == 2)
            n1 = sum(1 for x in g if x['nf'] <= 1)
            nets = []
            for x in g:
                if x['nf'] == 3:
                    nets.append(x['gross'])                      # all natural, no fee
                elif x['nf'] == 2:
                    # complete the last leg by selling into the bid, pay taker fee
                    b = x['b']
                    worst = min(b['bids'].values())
                    nets.append(x['gross'] - 1.0 - FEE(worst))
                # <=1 filled: book never formed; no position taken, contributes 0
            mn = statistics.mean(nets) if nets else float('nan')
            name = f'{lo:g} to {hi:g}c' if lo > -99 else f'< {hi:g}c'
            print(f'  {name:>16s} {len(g):6d} {n3:6d} {n2:6d} {n1:6d} {mn:20.2f}c')
        print()

    # The decision the gate actually makes, on the honest window.
    print('GATE SIMULATION (near window): post only books with gross >= T')
    print(f"  {'T':>6s} {'books posted':>13s} {'mean net':>10s} {'total':>10s} {'2/3 stranded':>13s}")
    rows = []
    for ev, sfx in smap.items():
        b = books[ev]
        nf = sum(filled(fills[(ev, sfx[s])], b['asks'][s], b['ko'], True)
                 for s in ('home', 'draw', 'away'))
        if nf == 3:
            net = b['gross']
        elif nf == 2:
            net = b['gross'] - 1.0 - FEE(min(b['bids'].values()))
        else:
            net = None
        rows.append({'gross': b['gross'], 'nf': nf, 'net': net})
    for T in (-99, 0, 1, 2, 2.5, 3, 4):
        g = [x for x in rows if x['gross'] >= T]
        nets = [x['net'] for x in g if x['net'] is not None]
        if not nets:
            continue
        strand = sum(1 for x in g if x['nf'] == 2) / len(g) * 100
        print(f'  {T:6.1f} {len(g):13d} {statistics.mean(nets):9.2f}c '
              f'{sum(nets):9.1f}c {strand:12.1f}%')


if __name__ == '__main__':
    main()
