"""
Settle the PAPER market-maker quotes logged by mm_quoter.py, and email results.
Places nothing, cancels nothing, touches no money.

HOW A FILL IS DECIDED (fill_from_quotes): a MANAGED resting order that cancels
and reposts every quoting cycle. Walks a leg's full quote history and, for the
ask we were showing during each interval, checks whether any REAL trade printed
at or above it -- strictly BEFORE kickoff, since we cancel at kickoff (user
decision, 2026-07-26) rather than leave orders resting through the match.

THIS REPLACES A BROKEN EARLIER VERSION, kept here as a warning: the original
test used only the FIRST logged ask with no time bound, comparing timestamp
STRINGS (which mixed +00:00/Z/-04:00 suffixes and silently matched almost
nothing correctly). Combined, those two bugs first UNDER-counted fills to
near-zero, then, once one was fixed without the other, OVER-counted by
including in-play trades -- a stale ask getting lifted after a goal, by someone
watching the match. Reported fill rate swung 13% -> 83% across those bugs
before landing at a real ~19% (fixed-margin ask) to ~61% (ask capped at the
market's own ask) once both were corrected. Verify against
research/mm_decile_by_volume.py's sanity checks before trusting any number this
script prints.

STILL DELIBERATELY OPTIMISTIC, and the number to keep distrusting: this ignores
queue position. In reality several makers may sit at the same price and the
buyer takes whoever is first, so the realised fill rate would be lower and
plausibly skewed toward the fills nobody else wanted. Capture is the ONE
question history cannot answer (see [[project_market_making_early_edge]]) --
this measures a ceiling, and the gap between it and a live test is the thing
worth knowing.

P&L per contract sold = our_ask - settlement (1 if that outcome happened, 0
otherwise). Makers pay no Kalshi fee -- verified on a real maker fill (7.8
contracts @ 0.36, fee_cost 0.0000).

Usage:  python main\\mm_settle.py [--email]
"""
import sys
import csv
import datetime as dt
from collections import defaultdict
from pathlib import Path

sys.path.insert(0, r'E:\soccer\main')
import kalshi_trader as kt

QUOTES = Path(r'E:\soccer\research\mm_paper_quotes.csv')
RESULTS = Path(r'E:\soccer\research\mm_paper_results.csv')
# `volume` added 2026-08-09: traded volume on the leg at POST time, carried
# through from the quotes log. It is the variable the liquidity gate acts on --
# min leg volume >=500 lifted completion 36% -> 60% and turned -1.84c/book into
# +3.01c -- so every settled row needs to say what it was, otherwise the gate
# cannot be audited against its own outcomes. This file is rewritten in full on
# each run, so history backfills automatically.
RFIELDS = ['ts', 'div', 'match', 'leg_ticker', 'side', 'our_ask', 'competitive',
           'filled', 'fill_px', 'settled', 'pnl_cents', 'size', 'regime', 'volume']

# ── REGIME BOUNDARY ──────────────────────────────────────────────────────────
# The quoting rule was rewritten on 2026-07-26. Quotes either side of this are
# from DIFFERENT STRATEGIES and must never be pooled into one P&L -- doing that
# once already produced a misleading "today's maker result" that mixed three
# separate configurations logged within a few hours of each other.
#
# v1 (before): flat `fair + 2c`, UNCAPPED (sat above the market's own ask on 86%
#   of legs, so it essentially could not fill), 3h window, fired `--once` from a
#   5-minute cron.
# v2 (from the cutover): margin STAGED by hours-to-kickoff (6/5/3/2/1c), CAPPED at the
#   market's own ask, FLOORED at fair (skip the leg if the market is already
#   below our fair), 12h window, persistent process polling every 20s.
#
# Cutover = the moment the v2 process started (PID 11108). Anything logged
# before this is v1 and is reported separately, clearly labelled, and excluded
# from the headline.
REGIME_V2_START = dt.datetime(2026, 7, 26, 22, 57, 50, tzinfo=dt.timezone.utc)
# v3 = delayed completion: only cross inside the last hour before kickoff, plus
# the 0-5c price ladder. Before this, complete_book() fired the instant a book
# hit 2-of-3 -- in practice 8.6-10.1 HOURS early -- paying ~2c to cross while the
# third leg still had most of its window to sell for free. That is a different
# strategy, not a tweak, so it does not pool with v2.
# Boundary = the quoter restart that introduced COMPLETE_BEFORE_KO_H. Rows from
# that restart onward also carry a `regime` column written at post time; prefer
# that column when present and fall back to this timestamp for older rows.
REGIME_V3_START = dt.datetime(2026, 7, 28, 17, 11, 0, tzinfo=dt.timezone.utc)
# v4 (2026-08-09) = v3 plus the MIN_LEG_VOLUME liquidity gate. It changes WHICH
# books get quoted, not how they are priced, so it does not pool with v3. It has
# no boundary constant because every v4 row carries the tag written at post time
# by mm_quoter.REGIME -- see regime_of(), which prefers that column.


def regime_of(ts_str, tagged=None):
    """Regime for a row. Prefers the `regime` column written at post time."""
    if tagged:
        return tagged
    t = _pt(ts_str)
    if t is None:
        return 'v1'
    if t >= REGIME_V3_START:
        return 'v3'
    return 'v2' if t >= REGIME_V2_START else 'v1' 


def _pt(s):
    """Parse any of our timestamp flavours to an aware datetime.

    Quote times are UTC with +00:00, kickoffs are ET with -04:00, Kalshi trades
    are UTC with Z. Comparing these as STRINGS is meaningless and silently
    filtered out every trade at one point -- always parse.
    """
    try:
        return dt.datetime.fromisoformat(str(s).replace('Z', '+00:00'))
    except Exception:
        return None


def fill_from_quotes(quote_rows, trades, kickoff):
    """Simulate a MANAGED resting order: cancel-and-repost every cycle.

    Walks the leg's quote history in order. For each interval between
    consecutive quotes (bounded above by kickoff), asks whether any trade
    printed at or above the ask WE WERE SHOWING during that interval. First hit
    is the fill.

    WHY THIS AND NOT "did anything ever trade above our first ask":
      - the old test used only the FIRST ask and no upper time bound, so it
        modelled an order posted once and abandoned, and counted IN-PLAY trades.
        That reported an 83% fill rate; the true pre-kickoff figure was 13%.
      - a real maker re-quotes as fair value drifts. If fair moves 27c -> 25c and
        we repost, an order that was hopeless at 29c becomes live. The static
        model never sees that and under-counts genuine fills.
      - we CANCEL AT KICKOFF (user decision, 2026-07-26). Leaving orders resting
        through a match is a different strategy and was measured at -$23.60:
        prices jump on goals and stale asks get lifted by people watching.

    Returns (filled: bool, fill_px: float|None, fill_ts: datetime|None).
    """
    rows = sorted([r for r in quote_rows if r.get('our_ask')],
                  key=lambda r: str(r['ts']))
    for i, r in enumerate(rows):
        start = _pt(r['ts'])
        if start is None or start >= kickoff:
            break
        end = _pt(rows[i + 1]['ts']) if i + 1 < len(rows) else kickoff
        if end is None or end > kickoff:
            end = kickoff
        try:
            ask = float(r['our_ask'])
        except (TypeError, ValueError):
            continue
        for t, px in trades:
            if start <= t < end and px >= ask - 1e-9:
                return True, ask, t
    return False, None, None


def trades_tuples(ticker, since_dt, cap=40000):
    """Every trade on this leg after `since_dt`, as (datetime, price) tuples.

    No upper time bound is applied here -- fill_from_quotes()'s own interval
    logic (each interval capped at kickoff) already excludes in-play trades,
    so fetching everything after the first quote and letting that function
    filter is simpler and cannot reintroduce the in-play-counted-as-a-fill bug.

    PAGINATE UNTIL WE REACH since_dt, don't stop at a fixed count (fixed
    2026-07-27). The API returns trades NEWEST FIRST, and soccer legs take
    thousands of in-play trades in the minutes around a goal -- Pachuca had
    17,999. The old cap=2000 therefore filled entirely with post-kickoff
    activity and never reached back to the pre-kickoff window, so every
    pre-KO fill on a busy leg was invisible. That made the settler report
    10 of 21 legs filled (and a -$9.77 day built from phantom naked legs)
    when the true count was far higher. Stop when the oldest trade fetched
    predates since_dt; `cap` is now only a runaway guard.
    """
    out, cursor = [], None
    while len(out) < cap:
        p = {'ticker': ticker, 'limit': 200}
        if cursor:
            p['cursor'] = cursor
        try:
            d = kt._get('/markets/trades', p)
        except Exception:
            break
        tr = d.get('trades', [])
        out += tr
        cursor = d.get('cursor')
        if not cursor or not tr:
            break
        # reached far enough back in time -> stop paging
        oldest = min((_pt(x.get('created_time')) for x in tr
                      if _pt(x.get('created_time'))), default=None)
        if since_dt and oldest and oldest <= since_dt:
            break
    keep = []
    for x in out:
        t = _pt(x.get('created_time'))
        if t and since_dt and t > since_dt:
            try:
                keep.append((t, float(x.get('yes_price_dollars') or 0)))
            except (TypeError, ValueError):
                continue
    return keep


def settlement(ticker):
    try:
        m = kt.get_market(ticker).get('market', {})
        r = m.get('result')
        if r == 'yes':
            return 1
        if r == 'no':
            return 0
    except Exception:
        pass
    return None


def main():
    if not QUOTES.exists():
        print('no paper quotes logged yet')
        return
    rows = list(csv.DictReader(open(QUOTES, newline='', encoding='utf-8')))

    # Group ALL quote rows per leg (not just the first) -- fill_from_quotes
    # needs the full re-quote history to simulate a MANAGED order that
    # cancels and reposts each cycle, per the corrected model. A leg may also
    # have been COMPLETED live by mm_quoter (sold into the bid to close an
    # incomplete book); that is a certain fill and must override the resting-
    # order simulation, or the hedge is invisible here and the whole point of
    # completing the book is lost from the P&L.
    by_leg, completion, meta = defaultdict(list), {}, {}
    for r in rows:
        k = r['leg_ticker']
        meta.setdefault(k, r)
        if str(r.get('reason', '')).startswith('COMPLETE_AT_BID'):
            if k not in completion or r['ts'] > completion[k]['ts']:
                completion[k] = r
            continue
        by_leg[k].append(r)
    legs = set(by_leg) | set(completion)
    print(f'{len(rows)} quote rows -> {len(legs)} distinct legs '
          f'({len(completion)} completed at bid)')

    out = []
    for leg in legs:
        s = settlement(leg)
        if s is None:
            continue                       # not settled yet
        comp_row = completion.get(leg)
        if comp_row:
            # taken at the bid: certain fill, pays the taker fee
            px = float(comp_row['our_ask'])
            fee_c = 0.07 * px * (1 - px) * 100
            out.append(dict(ts=comp_row['ts'], div=comp_row['div'],
                            match=f"{comp_row['home']} vs {comp_row['away']}",
                            leg_ticker=leg, side=comp_row['side'], our_ask=px,
                            competitive='hedge', filled=1, fill_px=px, settled=s,
                            pnl_cents=round((px - s) * 100 - fee_c, 2),
                            size=comp_row.get('size', 0),
                            volume=comp_row.get('volume', ''),
                            regime=regime_of(comp_row['ts'], comp_row.get('regime'))))
            continue

        qrows = by_leg.get(leg, [])
        if not qrows:
            continue
        r0 = meta[leg]
        kickoff = _pt(r0.get('kickoff'))
        first_ts = _pt(min(qrows, key=lambda x: x['ts'])['ts'])
        if kickoff is None or first_ts is None:
            continue
        trades = trades_tuples(leg, first_ts)
        filled, px, _fts = fill_from_quotes(qrows, trades, kickoff)
        pnl = (px - s) * 100 if filled else 0.0
        out.append(dict(ts=r0['ts'], div=r0['div'],
                        match=f"{r0['home']} vs {r0['away']}", leg_ticker=leg,
                        side=r0['side'], our_ask=(px if filled else float(qrows[0]['our_ask'])),
                        competitive=qrows[0].get('competitive', ''),
                        filled=int(filled),
                        fill_px=px if filled else '',
                        settled=s, pnl_cents=round(pnl, 2), size=r0.get('size', 0),
                        volume=r0.get('volume', ''),
                        regime=regime_of(r0['ts'], r0.get('regime'))))

    with open(RESULTS, 'w', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=RFIELDS)
        w.writeheader()
        for o in out:
            w.writerow(o)

    hedged = backfill_completion(out, rows)
    body = summarise(out, hedged)
    print(body)
    if '--email' in sys.argv:
        try:
            from daily_signals import send_email
            send_email('Paper market-maker results', f'<pre>{body}</pre>')
            print('emailed')
        except Exception as e:
            print(f'email failed: {e}')


def backfill_completion(out, quote_rows):
    """What the CURRENT completion rule would have produced on legacy books.

    Books settled before mm_quoter learned to complete are left unhedged in the
    recorded results. This applies the rule retroactively so the rule can be
    judged -- and returns a SEPARATE list rather than editing `out`, because
    overwriting recorded P&L with a later rule is how track records become
    unfalsifiable.

    LEGITIMATE because the rule is outcome-independent: "if exactly one of three
    legs is unfilled, sell it into the bid" never looks at who won. It is not
    dropping losing bets; it is closing an implementation gap.

    THE ONE REAL CAVEAT: the bid used is the LAST one we logged before kickoff.
    Hedging live would happen at whatever bid existed at the decision moment,
    plausibly a cent or two worse. So this is optimistic, and the gap between it
    and future live-rule results is the honest measure of that optimism.
    """
    by_leg = defaultdict(list)
    for r in quote_rows:
        by_leg[r['leg_ticker']].append(r)
    by_match = defaultdict(list)
    for o in out:
        by_match[(o['div'], o['match'])].append(o)

    hedged = []
    for (div, match), legs in by_match.items():
        if len(legs) != 3:
            hedged += legs
            continue
        unf = [x for x in legs if not x['filled']]
        if len(unf) != 1 or any(x['competitive'] in ('hedge', 'hedge(backfill)')
                                for x in legs):
            hedged += legs
            continue
        tgt = unf[0]
        rows = sorted(by_leg.get(tgt['leg_ticker'], []), key=lambda y: y['ts'])
        if not rows:
            hedged += legs
            continue
        try:
            bid = float(rows[-1]['k_bid'])
        except (TypeError, ValueError):
            hedged += legs
            continue
        if not (0 < bid < 1):
            hedged += legs
            continue
        fee_c = 0.07 * bid * (1 - bid) * 100
        for x in legs:
            if x is tgt:
                y = dict(x, our_ask=bid, filled=1, fill_px=bid,
                         competitive='hedge(backfill)',
                         pnl_cents=round((bid - x['settled']) * 100 - fee_c, 2))
                hedged.append(y)
            else:
                hedged.append(x)
    return hedged


def _totals(rows):
    f = [o for o in rows if o['filled']]
    cts = sum(float(o['size'] or 0) for o in f)
    pnl = sum(o['pnl_cents'] * float(o['size'] or 0) for o in f)
    return len(f), cts, pnl


def _block(rows, title, lines):
    """Render one regime's stats into `lines`."""
    n = len(rows)
    if not n:
        lines += [title, '  (no settled legs yet)', '']
        return
    fills = [o for o in rows if o['filled']]
    comp = sum(1 for o in rows if str(o['competitive']) == '1')
    lines += [title,
              f'  legs quoted         : {n}',
              f'  competitive         : {comp} ({comp/n*100:.0f}%)',
              f'  would have filled   : {len(fills)} ({len(fills)/n*100:.0f}%)']
    if fills:
        tot = sum(o['pnl_cents'] * float(o['size'] or 0) for o in fills)
        cts = sum(float(o['size'] or 0) for o in fills)
        lines += [f'  contracts sold      : {cts:,.0f}',
                  f'  P&L                 : {tot/100:+,.2f} dollars']
        if cts:
            lines.append(f'  per contract        : {tot/cts:+.2f} cents')
        by = defaultdict(lambda: [0.0, 0.0])
        for o in fills:
            by[o['div']][0] += o['pnl_cents'] * float(o['size'] or 0)
            by[o['div']][1] += float(o['size'] or 0)
        lines.append('  by league:')
        for d, (p, c) in sorted(by.items(), key=lambda kv: -kv[1][0]):
            if c:
                lines.append(f'    {d:6s} {c:8,.0f} contracts  {p/c:+7.2f} c/contract')
    lines.append('')


def summarise(out, hedged=None):
    if not out:
        return ('PAPER MARKET MAKER\n\nNo settled paper quotes yet. Results '
                'appear after the next batch of matches finishes.')

    # SPLIT BY REGIME. v1 and v2 are different strategies (see REGIME_V2_START)
    # and pooling them produced a misleading combined figure once already.
    #
    # Bucket EVERY regime EXPLICITLY. v1 was once `regime not in ('v2','v3')`,
    # a catch-all, so when the quoter started stamping 'v4' on 2026-08-09 every
    # v4 book fell through into the block headed "v1 LEGACY -- DO NOT POOL".
    # v4 was invisible in this report for its whole life, and v1 read -3.50c
    # instead of its true -6.20c. That is the exact failure the regime column
    # exists to prevent, so there is no catch-all here any more: an unrecognised
    # tag gets its own block rather than hiding inside a known one.
    known = ('v4', 'v3', 'v2', 'v1')
    by_reg = {g: [o for o in out if o.get('regime') == g] for g in known}
    v4, v3, v2, v1 = (by_reg[g] for g in known)
    stray = sorted({(o.get('regime') or '?') for o in out} - set(known))

    L = ['PAPER MARKET MAKER — Kalshi soccer', '']
    _block(v4, 'v4 CURRENT RULE (v3 plus the MIN_LEG_VOLUME liquidity gate:'
               ' quote only books whose thinnest leg has traded volume >= gate)', L)
    L.append('-' * 62)
    L.append('v3 (delayed completion, NO liquidity gate)'
             ' — DIFFERENT RULE, DO NOT POOL')
    _block(v3, '', L)
    if v2:
        L.append('')
        L.append('-' * 62)
        L.append('v2 (crossed to complete IMMEDIATELY at 2-of-3, ~9h early)'
                 ' — DIFFERENT RULE, DO NOT POOL')
    _block(v2, '', L)
    if v1:
        L += ['-' * 62,
              'v1 LEGACY — DIFFERENT STRATEGY, DO NOT POOL WITH THE ABOVE',
              '(flat fair+2c, uncapped so it sat above the market on ~86% of legs,',
              ' 3h window, 5-min cron. Kept only for reference.)']
        _block(v1, '', L)
    for g in stray:
        # A regime this report has never heard of. Surface it loudly instead of
        # letting it vanish -- that is how v4 went unreported for its whole life.
        L += ['-' * 62,
              f'{g} — UNRECOGNISED REGIME, not described in mm_settle.py.',
              'Add a labelled block for it; do not pool it with anything above.']
        _block([o for o in out if o.get('regime') == g], '', L)

    n = len(out)
    fills = [o for o in out if o['filled']]
    if hedged:
        na, ca, pa = _totals(out)
        nh, ch, ph = _totals(hedged)
        if abs(ph - pa) > 0.5:
            L += ['', '-' * 58,
                  'WITH THE COMPLETION RULE APPLIED TO LEGACY BOOKS',
                  '(books that settled before mm_quoter could hedge; the rule is',
                  ' outcome-independent, so this is a fair retrospective, but the',
                  ' bid used is the LAST one logged, so it is OPTIMISTIC)',
                  f'  as recorded      : {na:3d} fills  {pa/100:+8.2f} dollars  '
                  f'{pa/ca:+6.2f} c/contract' if ca else '',
                  f'  rule applied     : {nh:3d} fills  {ph/100:+8.2f} dollars  '
                  f'{ph/ch:+6.2f} c/contract' if ch else '',
                  f'  difference       : {(ph-pa)/100:+8.2f} dollars']
    L += ['', 'CAVEAT: fills assume we win every trade that printed at or above',
          'our ask. Real queue position would give fewer and worse fills, so',
          'treat this as the CEILING, not an estimate.']
    return '\n'.join(L)


if __name__ == '__main__':
    main()
