"""Can we predict WHICH LEGS FILL, at the moment we post them?

WHY. The maker's whole problem is partial fills: the prize is ~3c a book and
every repair (crossing to complete, flattening a stranded leg) costs about that
much. If fill probability is predictable at post time, the fix is to quote only
books whose three legs are all likely to fill -- and skip the rest, which costs
nothing.

NO LOOK-AHEAD. Every feature is read from the FIRST quote row for that leg, so
it is knowable at the instant we decide to post. Nothing about how the quote
evolved, and nothing about the trade that filled it, is allowed in.

THE BAR IT HAS TO CLEAR. We already know price distance predicts fills -- legs
that walk to the touch fill ~25%, legs that stay 2c above fill 1-6%. A model
that only rediscovers that is worth nothing. So the honest test is whether the
full feature set beats a one-feature baseline on held-out data, not whether it
beats random.

Usage:  python research\\fill_model.py
"""
import csv
import shutil
import tempfile
import datetime as dt
from collections import defaultdict
from pathlib import Path

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

QUOTES = Path(r'E:\soccer\research\mm_paper_quotes.csv')
RESULTS = Path(r'E:\soccer\research\mm_paper_results.csv')


def _f(x):
    try:
        v = float(x)
        return v if v == v else None      # reject NaN
    except (TypeError, ValueError):
        return None


def _pt(s):
    for f in ('%Y-%m-%dT%H:%M:%S.%f%z', '%Y-%m-%dT%H:%M:%S%z',
              '%Y-%m-%d %H:%M:%S'):
        try:
            return dt.datetime.strptime(str(s), f)
        except (ValueError, TypeError):
            pass
    return None


def build(with_events=False):
    """(X, y, names[, events]) -- one row per leg, features from its FIRST quote.

    ONE code path. An earlier version rebuilt the event list with a copy of the
    row filter, which can drift from this one and silently pair a leg's features
    with another leg's book -- the kind of bug that produces a plausible number.
    """
    label, legpnl = {}, {}
    with RESULTS.open(newline='', encoding='utf-8', errors='replace') as fh:
        for r in csv.DictReader(fh):
            t = r.get('leg_ticker')
            if not t:
                continue
            label[t] = 1 if str(r.get('filled')).strip() in ('1', 'True') else 0
            legpnl[t] = (_f(r.get('pnl_cents')) or 0.0) * (_f(r.get('size')) or 0.0)

    tmp = Path(tempfile.gettempdir()) / 'mm_paper_quotes.snap.csv'
    shutil.copyfile(QUOTES, tmp)
    first = {}
    with tmp.open(newline='', encoding='utf-8', errors='replace') as fh:
        for r in csv.DictReader(fh):
            t = r.get('leg_ticker')
            if t and t in label and t not in first and not (r.get('reason') or ''):
                first[t] = r

    # Every column in the quote log that is KNOWABLE AT POST TIME and is not an
    # identifier. Missing optional values are imputed to 0 and flagged, rather
    # than dropping the row -- pc_* only exists from 2026-07-29, and dropping on
    # it would silently throw away the earlier third of the sample.
    NAMES = ['our_ask', 'edge_over_mkt_c', 'spread_c', 'overround_c',
             'over_mid_c', 'min_leg_vol', 'book_vol_sum', 'ask_size',
             'bid_size', 'book_ask_size_min', 'hrs_to_ko', 'is_draw',
             'is_home', 'fair_gap_c', 'arm', 'mkt_age_h', 'open_interest',
             'volume_24h', 'no_bid', 'no_ask', 'hour_sin', 'hour_cos',
             'pc_gap_c', 'has_pc']
    X, y, events = [], [], []
    # BOOK CONTEXT. Whether a leg fills depends on its two siblings -- a leg is
    # only sellable if someone is trading the book at all -- so the book's
    # aggregate liquidity goes in alongside the leg's own.
    bookvol, bookask = defaultdict(list), defaultdict(list)
    for t, r in first.items():
        v = _f(r.get('volume'))
        if v is not None:
            bookvol[r.get('event')].append(v)
        a = _f(r.get('ask_size'))
        if a is not None:
            bookask[r.get('event')].append(a)

    for t, r in first.items():
        ask, kb, ka = _f(r.get('our_ask')), _f(r.get('k_bid')), _f(r.get('k_ask'))
        over, fair = _f(r.get('over_ask')), _f(r.get('pin_fair'))
        ts, ko = _pt(r.get('ts')), _pt(r.get('kickoff'))
        vols = bookvol.get(r.get('event')) or []
        if None in (ask, kb, ka, over) or not vols or ts is None or ko is None:
            continue
        if not (0 < kb < ka < 1):
            continue
        hrs = (ko - ts).total_seconds() / 3600.0
        if not (0 < hrs <= 24):
            continue
        import math
        side = str(r.get('side')).lower()
        omid = _f(r.get('over_mid'))
        pca = _f(r.get('pc_ask'))
        hour = _f(r.get('local_hour'))
        asks = bookask.get(r.get('event')) or [0.0]
        X.append([ask,
                  (ask - ka) * 100.0,            # how far ABOVE the market we sit
                  (ka - kb) * 100.0,             # spread
                  (over - 1.0) * 100.0,          # overround on offer
                  ((omid - 1.0) * 100.0) if omid is not None else 0.0,
                  min(vols),
                  sum(vols),
                  _f(r.get('ask_size')) or 0.0,
                  _f(r.get('bid_size')) or 0.0,
                  min(asks),
                  hrs,
                  1.0 if side == 'draw' else 0.0,
                  1.0 if side == 'home' else 0.0,
                  ((fair - ka) * 100.0) if fair is not None else 0.0,
                  _f(r.get('arm')) or 0.0,
                  _f(r.get('mkt_age_h')) or 0.0,
                  _f(r.get('open_interest')) or 0.0,
                  _f(r.get('volume_24h')) or 0.0,
                  _f(r.get('no_bid')) or 0.0,
                  _f(r.get('no_ask')) or 0.0,
                  math.sin(2 * math.pi * (hour or 0) / 24.0),
                  math.cos(2 * math.pi * (hour or 0) / 24.0),
                  ((pca - ka) * 100.0) if pca is not None else 0.0,
                  1.0 if pca is not None else 0.0])
        y.append(label[t])
        events.append({'event': r.get('event'), 'ko': ko, 'ticker': t,
                       'pnl': legpnl.get(t, 0.0), 'vol': min(vols),
                       'regime': r.get('regime') or '?'})
    Xa, ya = np.array(X, float), np.array(y, int)
    return (Xa, ya, NAMES, events) if with_events else (Xa, ya, NAMES)


def main():
    X, y, names = build()
    n, k = X.shape
    print(f'{n} legs with complete post-time features, {y.sum()} filled '
          f'({100.0*y.mean():.0f}%)\n')
    if n < 60 or y.sum() < 15 or (n - y.sum()) < 15:
        print('too few events to fit anything honest -- stopping.')
        return

    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
    pipe = make_pipeline(StandardScaler(),
                         LogisticRegression(max_iter=2000, C=1.0))

    full = cross_val_score(pipe, X, y, cv=cv, scoring='roc_auc')
    # the bar: price distance alone
    i_edge = names.index('edge_over_mkt_c')
    base = cross_val_score(pipe, X[:, [i_edge]], y, cv=cv, scoring='roc_auc')

    print(f'{"model":28} {"CV AUC":>8} {"sd":>7}')
    print(f'{"price distance only":28} {base.mean():>8.3f} {base.std():>7.3f}')
    print(f'{"all features":28} {full.mean():>8.3f} {full.std():>7.3f}')
    print(f'{"lift from everything else":28} {full.mean()-base.mean():>+8.3f}')

    # leave-one-out on features, to see which actually carry weight
    print('\nper-feature CV AUC on its own:')
    for i, nm in enumerate(names):
        s = cross_val_score(pipe, X[:, [i]], y, cv=cv, scoring='roc_auc')
        print(f'  {nm:20} {s.mean():.3f}')

    pipe.fit(X, y)
    coef = pipe[-1].coef_[0]
    print('\nstandardised coefficients (full fit, sign = direction):')
    for nm, c in sorted(zip(names, coef), key=lambda t: -abs(t[1])):
        print(f'  {nm:20} {c:+.3f}')

    print('\nAUC 0.5 is a coin flip; 0.7+ is a usable signal. Judge the LIFT '
          'line, not\nthe headline -- price distance is already known and '
          'already actionable.')
    book_readout(pipe, cv)


def book_readout(pipe, cv):
    """A leg model is only useful if it separates COMPLETABLE BOOKS.

    A book pays only when all three legs fill, so the decision at post time is
    P(all three) -- the product of the per-leg probabilities. Probabilities are
    OUT-OF-FOLD, so no book is scored by a model that trained on its own legs.
    """
    from sklearn.model_selection import cross_val_predict
    X, y, names, events = build(with_events=True)
    if len(X) < 60:
        return
    p = cross_val_predict(pipe, X, y, cv=cv, method='predict_proba')[:, 1]

    books = defaultdict(lambda: {'p': [], 'y': []})
    for pi, yi, ev in zip(p, y, events):
        books[ev['event']]['p'].append(pi)
        books[ev['event']]['y'].append(yi)
    rows = [(np.prod(d['p']), int(all(d['y'])), len(d['y']))
            for d in books.values() if len(d['y']) == 3]
    if len(rows) < 20:
        print(f'\nonly {len(rows)} complete 3-leg books -- too few to bucket.')
        return
    rows.sort()
    print(f'\nBOOK COMPLETION vs PREDICTED P(all 3), {len(rows)} books, '
          'out-of-fold')
    print(f'  {"bucket":14} {"books":>6} {"pred":>7} {"actual":>8}')
    q = max(1, len(rows) // 4)
    for i, lbl in enumerate(('worst 25%', '2nd', '3rd', 'best 25%')):
        seg = rows[i * q:(i + 1) * q] if i < 3 else rows[3 * q:]
        if not seg:
            continue
        pr = float(np.mean([s[0] for s in seg]))
        ac = float(np.mean([s[1] for s in seg]))
        print(f'  {lbl:14} {len(seg):>6} {pr:>7.1%} {ac:>8.1%}')
    print('  If actual rises with predicted, the model can pick books to quote.')




def backtest(train_frac=0.65):
    """TEMPORAL split, scored in dollars.

    Cross-validation leaks here: legs from the same book, the same day and the
    same regime land in different folds, and a gate is a decision made BEFORE
    the market it is judged on. So train on the earliest books and test on the
    latest, and score the test set the way a bankroll would feel it -- realised
    P&L of the books the gate would have admitted, against the two baselines
    that already exist: quote everything, and the live volume gate.
    """
    X, y, names, meta = build(with_events=True)
    books = defaultdict(lambda: {'idx': [], 'pnl': 0.0, 'ko': None,
                                 'vol': None, 'regime': None})
    for i, m in enumerate(meta):
        b = books[m['event']]
        b['idx'].append(i)
        b['pnl'] += m['pnl']
        b['ko'] = m['ko'] if b['ko'] is None else min(b['ko'], m['ko'])
        b['vol'] = m['vol'] if b['vol'] is None else min(b['vol'], m['vol'])
        b['regime'] = m['regime']
    full = [(b['ko'], ev, b) for ev, b in books.items() if len(b['idx']) == 3]
    full.sort(key=lambda t: t[0])
    if len(full) < 60:
        print(f'\nonly {len(full)} full books -- too few to backtest.')
        return

    cut = int(len(full) * train_frac)
    train, test = full[:cut], full[cut:]
    tr_idx = [i for _, _, b in train for i in b['idx']]
    pipe = make_pipeline(StandardScaler(),
                         LogisticRegression(max_iter=2000, C=1.0))
    pipe.fit(X[tr_idx], y[tr_idx])

    scored = []
    for ko, ev, b in test:
        p = float(np.prod(pipe.predict_proba(X[b['idx']])[:, 1]))
        comp = int(all(y[i] == 1 for i in b['idx']))
        scored.append({'p': p, 'pnl': b['pnl'], 'comp': comp, 'vol': b['vol']})

    print(f'\n{"="*64}\nTEMPORAL BACKTEST  (train {len(train)} books, '
          f'test {len(test)})')
    print(f'  train {train[0][0]:%Y-%m-%d} .. {train[-1][0]:%Y-%m-%d}   '
          f'test {test[0][0]:%Y-%m-%d} .. {test[-1][0]:%Y-%m-%d}')
    tr_reg = collections_counter([b['regime'] for _, _, b in train])
    te_reg = collections_counter([b['regime'] for _, _, b in test])
    print(f'  regimes  train {tr_reg}   test {te_reg}')

    def row(lbl, sel):
        if not sel:
            print(f'  {lbl:26} {"0 books":>9}')
            return
        pnl = sum(s['pnl'] for s in sel) / 100.0
        cr = 100.0 * sum(s['comp'] for s in sel) / len(sel)
        print(f'  {lbl:26} {len(sel):>5} books  {cr:>5.0f}% complete  '
              f'{pnl:>+8.2f} $  {pnl/len(sel):>+7.3f} $/book')

    print(f'\n  {"rule":26} {"books":>5}         {"complete":>8}      '
          f'{"P&L":>8}   per book')
    row('quote everything', scored)
    row('live gate: vol >= 500', [s for s in scored if s['vol'] >= 500])
    for thr in (0.20, 0.30, 0.40, 0.50, 0.60):
        row(f'model: P(all 3) >= {thr:.2f}', [s for s in scored if s['p'] >= thr])
    print('\n  The gate can only ever REMOVE books, so it wins by cutting losers.')
    print('  Judge it against "quote everything" on the same test window.')


def collections_counter(xs):
    c = defaultdict(int)
    for x in xs:
        c[x] += 1
    return dict(sorted(c.items()))


if __name__ == '__main__':
    main()
    backtest()
