"""Does pre-quote volatility predict fills? Clean version.

The first pass (mm_volatility_fill.py) found +5pp for the wildest quartile but
could not be trusted, because both halves of the test came from the same file.
The paper log marks a leg filled when a trade printed at or above our ask, so a
leg whose price moves more mechanically generates more qualifying prints -- the
instrument was biased in exactly the direction of the hypothesis.

This separates the two sides completely:

  MOVEMENT  from live_data -- the snapshot feed the xlow/xhigh strategies run on.
            93 days of it, written by a different process for a different
            purpose, and measured over the window BEFORE the maker ever quoted
            the leg, so no fill can be inside it.

  FILL      from real Kalshi trade prints, queue-aware: filled only once
            cumulative volume at or above our price exceeds the depth that was
            already resting there when we posted. Not "a print happened".

If the effect survives that, it is a fact about the market. If it vanishes, the
first result was an artifact of the fill model and the hypothesis is unproven.
"""
import csv, collections, glob, json, os, statistics, sys, datetime as dt

sys.path.insert(0, r'E:\soccer\main')
sys.path.insert(0, r'E:\soccer\research')
import mm_settle as S

QUOTES = r'E:\soccer\research\mm_paper_quotes.csv'
CACHE = r'E:\soccer\research\.trades_cache.json'
LIVE_DIR = r'E:\soccer\live_data'
SIDE_COL = {'home': 'kalshi_home_mid', 'draw': 'kalshi_draw_mid',
            'away': 'kalshi_away_mid'}


def _f(x):
    try:
        v = float(x)
        return v if 0 < v < 1 else None
    except (TypeError, ValueError):
        return None


def load_legs():
    """First quote per leg: price, queue depth, kickoff, fixture identity."""
    first = {}
    with open(QUOTES, encoding='utf-8', errors='replace') as fh:
        for r in csv.DictReader(fh):
            t = r.get('leg_ticker')
            if not t or t in first:
                continue
            if not (r.get('ask_size') or '').strip():
                continue                       # before depth logging
            a, ts, ko = _f(r.get('our_ask')), r.get('ts'), r.get('kickoff')
            if not (a and ts and ko):
                continue
            first[t] = {'our_ask': a, 'queue': float(r['ask_size']),
                        'ts': S._pt(ts), 'ko': S._pt(ko),
                        'home': r.get('home'), 'away': r.get('away'),
                        'side': (r.get('side') or '').lower(),
                        'gd': (r.get('kickoff') or '')[:10]}
    return {t: v for t, v in first.items()
            if v['ts'] and v['ko'] and v['side'] in SIDE_COL}


def load_movement(legs):
    """{leg: stdev of the leg's mid BEFORE we first quoted it}, from live_data."""
    want = collections.defaultdict(list)      # (gd, home, away) -> [leg tickers]
    for t, v in legs.items():
        want[(v['gd'], v['home'], v['away'])].append(t)

    dates = sorted({k[0] for k in want})
    series = collections.defaultdict(list)
    for d in dates:
        path = os.path.join(LIVE_DIR, f'{d}.csv')
        if not os.path.exists(path):
            continue
        with open(path, encoding='utf-8', errors='replace') as fh:
            for r in csv.DictReader(fh):
                key = (r.get('game_date'), r.get('home'), r.get('away'))
                if key not in want:
                    continue
                ts = S._pt(r.get('timestamp'))
                if not ts:
                    continue
                for t in want[key]:
                    col = SIDE_COL[legs[t]['side']]
                    m = _f(r.get(col))
                    if m is not None:
                        series[t].append((ts, m))
    out = {}
    for t, pts in series.items():
        cut = legs[t]['ts']
        # STRICTLY BEFORE the first quote -- this is what makes it non-circular
        # live_data timestamps are naive local; the quote feed is tz-aware.
        # Comparing them raises, and stripping tzinfo off the quote side is the
        # SAME truncation bug that made me report an 8h collection ceiling
        # earlier today when the real one was 12h. Normalise by converting the
        # aware side to naive LOCAL time rather than by discarding the offset.
        cutn = cut.astimezone().replace(tzinfo=None) if cut.tzinfo else cut
        pre = [m for ts, m in sorted(pts)
               if (ts.replace(tzinfo=None) if ts.tzinfo else ts) < cutn]
        if len(pre) < 8:
            continue
        out[t] = {'sd': statistics.pstdev(pre), 'n': len(pre),
                  'range': max(pre) - min(pre),
                  'changes': sum(1 for a, b in zip(pre, pre[1:])
                                 if abs(b - a) > 1e-9)}
    return out


def main():
    if not os.path.exists(CACHE):
        print('no trades cache -- run mm_ratchet_backtest.py first')
        return
    trades = json.load(open(CACHE, encoding='utf-8'))
    legs = load_legs()
    print(f'{len(legs)} legs with depth logged; {len(trades)} tickers in the '
          f'trade cache')

    mv = load_movement(legs)
    print(f'{len(mv)} of those have pre-quote price history in live_data')

    rows = []
    for t, v in legs.items():
        if t not in mv:
            continue
        key = f"{t}|{v['ts'].isoformat()}"
        if key not in trades:
            continue
        cum, filled = 0.0, False
        for ts_s, p, c in trades[key]:
            ts = S._pt(ts_s)
            if not ts:
                break
            ko = v['ko']
            if (ts.tzinfo is None) != (ko.tzinfo is None):
                ts = ts.astimezone() if ts.tzinfo else ts.replace(
                    tzinfo=ko.tzinfo)
            if ts >= ko:
                break
            if p >= v['our_ask'] - 1e-9:
                cum += c
                if cum > v['queue']:
                    filled = True
                    break
        rows.append(dict(mv[t], filled=int(filled), ticker=t))

    if len(rows) < 40:
        print(f'only {len(rows)} legs join across all three sources -- too few')
        return
    base = 100.0 * sum(r['filled'] for r in rows) / len(rows)
    print(f'\n{len(rows)} legs with movement AND queue-aware fills, '
          f'base fill rate {base:.1f}%\n')

    for metric, label in (('sd', 'pre-quote stdev'), ('changes', 'price changes'),
                          ('range', 'pre-quote range')):
        vals = sorted(r[metric] for r in rows)
        q = [vals[int(p * len(vals))] for p in (0.5, 0.75)]
        buckets = [(-1, q[0], 'calm half'), (q[0], q[1], '3rd quartile'),
                   (q[1], 1e9, 'wildest 25%')]
        print(f'  by {label}:')
        got = []
        for lo, hi, name in buckets:
            sel = [r for r in rows if lo <= r[metric] < hi]
            if len(sel) < 12:
                continue
            f = sum(r['filled'] for r in sel)
            got.append((name, 100.0 * f / len(sel)))
            print(f'    {name:>14} {len(sel):>5} legs  {f:>4} filled  '
                  f'{100.0*f/len(sel):>6.1f}%')
        if len(got) >= 2:
            print(f'    -> {got[-1][0]} vs {got[0][0]}: '
                  f'{got[-1][1]-got[0][1]:+.1f}pp')
        print()


if __name__ == '__main__':
    main()
