"""
PAPER market maker for Kalshi soccer. Places NOTHING. Ever.

WHAT IT DOES. Every cycle it looks at fixtures inside the pre-kickoff window,
works out the quotes a market maker WOULD post, and writes them to a log. A
separate script (mm_settle.py) later checks Kalshi's real trade history to see
which of those quotes would have been hit, and what they settled for.

WHY IT EXISTS. research/mm_feasibility.py measured, on 637 settled events, that
the PASSIVE side of Kalshi soccer trades earns +30% in the first decile of a
market's volume and loses in the last, monotonically. Makers pay no fee; takers
pay 0.07*P*(1-P) (~4.4% of stake). So making is the structurally correct side.
The one thing history CANNOT answer is capture -- what share of those fills you
would actually win against whoever else is quoting. This logs the decisions so
that question can be answered forward without risking money.

THE RULES, all from measurement rather than taste:
  - WINDOW + TIME-STAGED MARGIN (2026-07-26 redesign). research/mm_decile_by_volume.py
    showed the passive-side edge is huge in a market's first ~30% of volume
    (t=+11.6, +7.2, +5.2 across the first three deciles) then fades to
    significantly NEGATIVE by the back half (t=-3.2 to -5.6). Separately, that
    first decile finishes at a MEDIAN of 2.7h before kickoff but a P90 of 4.7h --
    so a fixed 3h window missed the earliest-starting tenth of events entirely.
    Two changes follow directly from that:
      1. WINDOW_H widened to 5.0h to catch that P90 tail. Cost is negligible --
         events see essentially no volume before 4-5h out anyway, so this mostly
         means quoting into empty air a little earlier, not wasted risk.
      2. MARGIN now STAGES DOWN as kickoff approaches, via `margin_for(hrs)`,
         because we cannot observe "% of this event's eventual total volume" in
         real time (the total is unknown until the event ends) but we CAN
         observe hours-to-kickoff, which the historical data ties directly to
         volume decile:
           >= 2.5h  -> 5.0c   (the true "be first" phase; thin, high-edge)
           1.5-2.5h -> 3.0c   (still solidly positive, decile ~10-30%)
           0.5-1.5h -> 2.0c   (fading edge, decile ~30-50%; old flat default)
           <  0.5h  -> 1.0c   (decile 50%+ is where the historical edge turns
                               NEGATIVE for the passive side -- quote thin here
                               deliberately, mainly to allow complete_book() to
                               finish a book that's already 2/3 filled, not to
                               invite fresh exposure)
    These thresholds are read off the DECILE BOUNDARIES, not fitted to this
    project's own paper results (which remain far too few legs to fit anything
    to) -- but they are a first cut, not a proven schedule. Revisit once enough
    paper legs exist to check it directly.
  - REST, DON'T CHASE. Once a leg is posted, `cycle()` never rewrites its price
    in state -- the ask that decides whether a trade counts as a fill is always
    the ORIGINALLY posted one, even though a fresh (and possibly different)
    `our` is computed and logged every cycle for visibility. This was already
    true before today's redesign; it matters more now, because time-priority is
    the entire point of showing up early -- re-quoting down every cycle to
    chase the market would throw away exactly the priority this is meant to buy.
  - FAST, PERSISTENT POLLING. A 5-minute Task-Scheduler cron cannot be "first"
    against other makers who are plausibly resting continuously. Run as a
    persistent process (like live_monitor.py) with POLL_SECS in the tens of
    seconds, guarded by a restart task, rather than fired anew every 5 minutes.
  - NO SEPARATE OVERROUND GATE -- redundant once the ask is capped at the
    market's own ask (see below): our ask only gets hit when the market is
    already paying above our target, which is the condition the gate existed to
    detect. Kept only as logged fields (`over_mid`, `over_ask`) for later study.
  - CAP AT THE MARKET'S OWN ASK, FLOOR AT FAIR. The first version of this
    quoter asked a flat fair+2c with NO ceiling and sat above the market's own
    ask on 86% of legs (verified against the historical log) -- it essentially
    never had a chance to be lifted. `our = min(target, market_ask)`, and if the
    market's own ask is already below our fair estimate, we skip that leg
    entirely (selling below your own fair in isolation is guaranteed-negative
    EV, and a stale/bad Pinnacle read should not be able to force one).
  - LEVEL from PINNACLE, not from our own models. Elo and form features were
    measurably WORSE than the closing line (1.012 vs 0.985 log loss on 32k
    fixtures), and as a maker your fair value IS your protection -- quoting off
    a worse number just donates to anyone using a better one.

SAFETY: there is no live-order code path in this file at all. It imports no
trader module. The only way this places an order is if someone writes one.

Usage:  python main\\mm_quoter.py [--once]
"""
import sys
import os
import csv
import time
import math
import hashlib
import datetime as dt
from pathlib import Path

sys.path.insert(0, r'E:\soccer\main')
import maps
import kalshi_trader as kt              # READ-ONLY use: _get() for book data
from kalshi_live import build_event_ticker, find_event_by_abrs, get_abr
import live_monitor as LM               # for fetch_prematch_odds + watchlist parse

OUT = Path(r'E:\soccer\research\mm_paper_quotes.csv')
STATE = Path(r'E:\soccer\research\mm_paper_state.json')
WINDOW_H = 12.0           # hours before kickoff to quote inside. 3.0 -> 5.0 -> 12.0.
                          # Measured maker ROI and volume share by hours-to-KO across
                          # 637 events (all pre-kickoff, 5c-95c):
                          #     0-0.5h    0.8% of vol   + 7.24%
                          #     0.5-1.5h 52.5%          + 0.56%
                          #     1.5-2.5h 30.4%          + 5.04%
                          #     2.5-5h   11.8%          +10.58%
                          #     5-12h     2.1%          +15.85%  <- RICHEST window
                          #     12-24h    2.0%          + 5.95%
                          #     24-72h    0.3%          - 8.78%  <- loses money
                          #     >72h      0.1%          +27.40%  (only $36k -- noise)
                          # 12h captures ~97.6% of pre-KO volume INCLUDING the richest
                          # slice. Deliberately NOT scanning 24/7: the 24-72h bucket is
                          # genuinely negative, >72h is a rounding error that could flip
                          # on a few matches, and every extra in-window match multiplies
                          # Kalshi + API-Football calls per cycle.
POLL_SECS = 20            # persistent-process interval; was only meaningful via
                          # --once fired every 5min by Task Scheduler before today
SIZE = 10                 # contracts per leg, paper only
# ── PRICE LADDER (2026-07-27 as a 2-arm A/B; widened to 0-5c 2026-07-28) ──
# Every historical reconstruction of "does charging more still fill?" failed.
# The at-kickoff backfill ask produced look-ahead fills; the causal trade-tape
# version predicted 97.5% of books fill all three legs naturally at +0c, when
# live we saw 0 of 7. The gap is QUEUE PRIORITY: a replay fills you whenever any
# trade prints at your price, but a real resting order sits behind the size
# already there and the buyer lifts that instead. Only our own orders in a real
# queue can measure it.
#
# WHY A LADDER RATHER THAN TWO ARMS. The question is no longer "does +1c fill"
# but "what price maximises EV", and that needs a FILL CURVE:
#       EV(price) = P(fill | price) x (price - fair)
# Two arms give a slope; six give a shape, which is the minimum to locate a
# maximum. We cannot estimate this from history because we have only ever rested
# at the touch -- any model would extrapolate into prices we never posted.
#
# PER-LEG, NOT PER-BOOK. Randomising each leg independently means every book
# contains a spread of price levels, so book quality (liquidity, league, time to
# kickoff) is controlled WITHIN book rather than confounded with arm -- the flaw
# that made the first read look decisive when all four unfilled legs happened to
# be in the two youngest books. It also triples the independent observations.
#
# Assignment is md5(event|side): STABLE across restarts. Python's hash() is
# randomised per process and would silently reassign everything on every restart.
#
# NOTE the nominal bump is an UPPER bound: our ask is still min(target, ask+bump),
# so when fair+margin binds first the realised delta is smaller. Analysis must
# use the realised (our_ask - k_ask), not the arm label. Both are logged.
#
# Expect the top of the ladder to fill rarely or never -- that is the point. A
# curve that is already flat at zero by +2c is a real answer ("quote at the
# touch, there is no optimum"), and costs nothing to learn in paper.
AB_ENABLED = True

# ── PRICE DECAY: sell as high as possible, concede only as time runs out ──
# Replaces a FIXED per-leg arm. Previously a leg drawn at +5c sat at +5c for its
# whole life and then jumped straight to the market ask at T-60 -- it never tried
# +4, +3, +2, +1. That is holding one rung, not walking a ladder, and it left
# most legs unfilled while forgoing the prices in between.
#
# Now the premium DECAYS with time to kickoff, so we ask the most we plausibly
# can while there is still time to climb down, and concede progressively.
#
# Why fine-grained steps are cheap: queue_ahead is 0 at any price ABOVE the
# touch (nobody else is quoting there), so a cancel-replace from +5c to +4c
# forfeits no queue position. Only the last step, joining the touch, puts us
# behind resting size -- so exactly one expensive reprice, at the end.
#
# Schedule is anchored on WHEN FILLS ARRIVE (measured 07-28/29, n=32 filled
# legs): 44% land >9h out, 66% by 6h, 75% by 3h, 91% by 1h. So the high rungs
# get the window where most buying happens, and the touch is reached with ~1.5h
# still to run.
DECAY = ((9.0, 5.0), (6.0, 4.0), (4.0, 3.0), (2.5, 2.0), (1.5, 1.0))


def arm_for(event, side='', hrs=99.0):
    """Premium in cents over the market ask, decaying toward the touch.

    A per-leg offset of -1/0/+1 (md5-stable) is kept so that at any given time
    different legs sit at different rungs. Without it, price and time-to-kickoff
    would be perfectly confounded and the fill curve could never be separated
    from a time-of-day effect.
    """
    base = 0.0
    for cut, val in DECAY:
        if hrs >= cut:
            base = val
            break
    off = (int(hashlib.md5(f'{event}|{side}'.encode()).hexdigest(), 16) % 3) - 1
    return max(0.0, min(5.0, base + off))



# Only cross to complete a book inside this many hours of kickoff. See the long
# note in complete_book() -- firing immediately cost ~2c per book for nothing.
COMPLETE_BEFORE_KO_H = 1.0

# GROSS-MARGIN GATE: minimum cents of overround (sum of market asks - 100c) a
# book must show before we quote ANY leg of it. See the long note at the gate's
# call site in cycle(). Backtested in research/gross_gate_backtest.py.
GROSS_GATE_C = 2.5

# LIQUIDITY CHECK: max total 3-leg spread (sum of ask-bid). Exists to stop the
# gross-margin gate being fooled by an unquoted/postponed book, whose enormous
# spread fakes a large "overround" (a live case summed to 233c of spread).
# Real books here run 3-6c; 15c is deliberately generous so no genuine book is
# excluded -- the target is the 200c+ pathology, not tight-vs-wide.
MAX_SPREAD_C = 15.0

# ── LIQUIDITY GATE (added 2026-08-09) ─────────────────────────────────────
# The maker's losses were never the spread -- they were PARTIAL FILLS. Measured
# over 242 paper books:
#     3 of 3 legs filled : 88 books  +1.38c/book   <- the thesis works
#     2 of 3             : 59 books  -7.62c/book   <- max exposure, no hedge
#     1 of 3             : 52 books  -2.27c/book
# A complete book earns the overround and is outcome-independent; a partial one
# is a naked position on whichever side the market is moving toward. So the
# problem is not "how wide is the margin" but "will this book COMPLETE".
#
# Completion turns out to be predictable from liquidity at post time, and the
# relationship is monotonic in both completion rate and P&L:
#     min leg volume    books  3/3 rate   c/book
#         (no gate)      242      36%      -1.84
#         >= 100         121      45%      +1.64
#         >= 500          60      60%      +3.01
#         >= 1000         46      59%      +2.86
#         >= 2500         25      72%      -1.53   <- reverses; see below
# Open interest gives the same shape, so this is not an artifact of one field.
#
# WHY NOT A HIGHER THRESHOLD. Above ~1500 the P&L turns negative even as the
# completion rate keeps climbing -- plausibly adverse selection: the busiest
# markets are where an informed counterparty is most likely to be the one
# lifting us. Cranking the gate up is NOT free.
#
# HONEST PROVENANCE: 500 was chosen by sweeping this data, so it is in-sample.
# It is frozen here to be judged on fixtures AFTER 2026-08-09, the same
# discipline as research/forward_test_unique_book.fire_xhigh.
MIN_LEG_VOLUME = 500

# Stop quoting when API-Football is nearly exhausted. live_monitor already
# degrades gracefully below its own low-water mark; the quoters previously had
# NO quota check at all, so on a heavy day the polite process would back off
# while the two with no brakes kept spending. Checked once a cycle from the
# shared cache -- /status itself is free and does not count against quota.
AF_QUOTA_FLOOR = 400
_quota_checked_at = 0.0
_quota_ok = True


def quota_ok():
    """False when API-Football is nearly spent. Cached 5 min; fails OPEN so a
    status-endpoint hiccup never silently halts quoting."""
    global _quota_checked_at, _quota_ok
    import time as _t
    if _t.time() - _quota_checked_at < 300:
        return _quota_ok
    try:
        remaining = LM.get_api_remaining()
        _quota_ok = remaining >= AF_QUOTA_FLOOR
        if not _quota_ok:
            print(f'  [quota] API-Football down to {remaining} -- pausing quoting')
    except Exception:
        _quota_ok = True
    _quota_checked_at = _t.time()
    return _quota_ok

# RULE REGIME, stamped at post time. v1/v2 were split by a TIMESTAMP in
# mm_settle, which works but means every later analysis has to know the boundary
# and get it right. Writing the regime onto the row instead makes each book
# self-describing, so a mixed day cannot silently average two strategies -- which
# is exactly what happened on 2026-07-28, when three books completed under the
# old immediate-crossing rule and two under the delayed one and the daily total
# blended them.
# v4 (2026-08-09) = v3 plus the MIN_LEG_VOLUME liquidity gate. This is a regime
# bump, not a tweak, by this file's own standard: it changes WHICH BOOKS GET
# QUOTED, not just how they are priced. On the 2026-08-09 board it admitted 4 of
# 30 otherwise-playable books, so v4 P&L is drawn from a different and much
# smaller population than v3's -- pooling them would average two strategies, the
# exact failure the regime column exists to prevent.
REGIME = 'v4'    # v3 = delayed completion + ladder; v4 adds MIN_LEG_VOLUME

FAIR_TOLERANCE_C = 2.0    # cents below fair we will still sell at, to keep a book
                          # completable. A hard never-below-fair floor stranded
                          # books at 1-of-2 filled over gaps as small as 0.1c --
                          # see the long note in cycle(). Beyond ~2c the market
                          # disagreeing with us usually means OUR number is stale,
                          # so refuse there.


def _num(x):
    try:
        return round(float(x), 1)
    except (TypeError, ValueError):
        return ''


# LEAGUE-LOCAL CLOCK (added 2026-07-28). Whether a resting offer gets hit
# depends on whether the people who trade that league are awake: an Argentine
# book quoted into Buenos Aires overnight is dead regardless of how thin its
# queue is. Hours-to-kickoff cannot express this -- two legs 5h from kickoff sit
# at completely different local times depending on the competition. This is the
# one candidate feature that CANNOT be reconstructed after the fact, because it
# needs the quote timestamp joined to the league's zone, so it is logged live.
# Multi-timezone competitions (MLS, USL, NWSL, CAN) are approximated by their
# most common zone -- good enough to separate "local evening" from "local 4am".
LEAGUE_TZ = {
    'AR1': 'America/Argentina/Buenos_Aires', 'BSA': 'America/Sao_Paulo',
    'BR2': 'America/Sao_Paulo', 'CL1': 'America/Santiago',
    'CO1': 'America/Bogota', 'PE1': 'America/Lima',
    'MX1': 'America/Mexico_City', 'CAN': 'America/Toronto',
    'MLS': 'America/New_York', 'USL': 'America/New_York',
    'NWSL': 'America/New_York', 'KO1': 'Asia/Seoul', 'CN1': 'Asia/Shanghai',
    'JP1': 'Asia/Tokyo', 'NO1': 'Europe/Oslo', 'SE1': 'Europe/Stockholm',
    'DK1': 'Europe/Copenhagen', 'PL1': 'Europe/Warsaw', 'CZ1': 'Europe/Prague',
    'RO1': 'Europe/Bucharest', 'CH1': 'Europe/Zurich', 'HR1': 'Europe/Zagreb', 'T1': 'Europe/Istanbul',
    'G1': 'Europe/Athens', 'E0': 'Europe/London', 'E1': 'Europe/London',
    'E2': 'Europe/London', 'E3': 'Europe/London', 'EC': 'Europe/London',
    'SC0': 'Europe/London', 'SC1': 'Europe/London', 'SC2': 'Europe/London',
    'SC3': 'Europe/London', 'SP1': 'Europe/Madrid', 'SP2': 'Europe/Madrid',
    'I1': 'Europe/Rome', 'I2': 'Europe/Rome', 'D1': 'Europe/Berlin',
    'D2': 'Europe/Berlin', 'F1': 'Europe/Paris', 'F2': 'Europe/Paris',
    'N1': 'Europe/Amsterdam', 'B1': 'Europe/Brussels', 'P1': 'Europe/Lisbon',
}


def _local_hour(div, now):
    """Hour-of-day (float) in the league's own timezone, or '' if unmapped."""
    z = LEAGUE_TZ.get((div or '').upper())
    if not z:
        return ''
    try:
        from zoneinfo import ZoneInfo
        t = now.astimezone(ZoneInfo(z))
    except Exception:
        return ''
    return round(t.hour + t.minute / 60.0, 2)


def _age_h(open_time, now):
    t = _pt(open_time)
    return round((now - t).total_seconds() / 3600.0, 2) if t else ''



# ── Polymarket.COM reference prices (added 2026-07-29) ───────────────────
# Logged for CROSS-VENUE COMPARISON only; we do not and cannot quote there
# (geo-restricted -- see the Kalshi-only execution research). Capturing all
# three venues at the SAME timestamp is the only way to compare their maker
# economics honestly: overround, spread and depth all move, so prices pulled
# minutes apart are not comparable. Cached because the quoters poll every 20s
# and this would otherwise add an API call per match per cycle.
_PC_CACHE = {}
_PC_TTL = 60


def poly_com_prices(div, home, away, game_date):
    """{'home_bid':..,'home_ask':..,...} from Polymarket.com, or {} if absent."""
    key = (div, home, away, str(game_date))
    hit = _PC_CACHE.get(key)
    if hit and time.time() - hit[0] < _PC_TTL:
        return hit[1]
    try:
        import polymarket_live as _pl
        r = _pl.get_all_prices(str(game_date), home, away, league=str(div).upper()) or {}
    except Exception:
        r = {}
    _PC_CACHE[key] = (time.time(), r)
    return r


def margin_for(hrs_to_ko):
    """Cents above fair to target, staged by hours-to-kickoff.

    Derived from research/mm_decile_by_volume.py's per-event decile results:
    decile 0-10% t=+11.58, 10-20% t=+7.16, 20-30% t=+5.20 (all strongly
    positive for the passive/maker side), fading through 30-50% (t=+1.49,
    +2.40), flat at 50-60% (t=+0.08), then SIGNIFICANTLY NEGATIVE from 60%
    onward (t=-1.54 to -5.57). First-decile timing: median 2.7h before
    kickoff, p90 4.7h. This maps volume deciles (unobservable live -- the
    event's eventual total volume isn't known until it's over) onto
    hours-to-kickoff (observable live) via that median timing.

    The >=5h stage was added with WINDOW_H 5->12: the 5-12h bucket is the
    RICHEST in the whole dataset at +15.85% maker ROI, so ask wider there --
    though it is only 2.1% of volume, so expect few fills, and treat the wide
    ask as "priced for how rarely anyone shows up", not as free money.

    FIRST CUT, not fitted to this project's own paper results -- there are
    nowhere near enough legs yet to fit anything to. Revisit once there are.
    """
    if hrs_to_ko >= 5.0:
        return 6.0   # 5-12h: +15.85% historical, thinnest flow -> ask widest
    if hrs_to_ko >= 2.5:
        return 5.0
    if hrs_to_ko >= 1.5:
        return 3.0
    if hrs_to_ko >= 0.5:
        return 2.0
    return 1.0   # decile 50%+ territory: the historical edge is negative here.
                 # Quote thin deliberately -- this is for complete_book() to be
                 # able to finish an already-2/3-filled book, not to invite
                 # fresh naked exposure this close to kickoff.

# BANKROLL. On Kalshi a resting SELL locks collateral of (1 - price) per
# contract until it fills-and-settles or is cancelled -- so capital, not
# appetite, caps how many matches can be quoted at once. Modelling this is the
# difference between "the paper test made $X" and "$500 would have made $X":
# without it the log implies a scale that cannot actually be funded.
BANKROLL = 500.00

FIELDS = ['ts', 'div', 'home', 'away', 'kickoff', 'event', 'leg_ticker', 'side',
          'pin_fair', 'k_bid', 'k_ask', 'over_mid', 'over_ask', 'our_ask',
          'competitive', 'size', 'collateral', 'reason', 'arm',
          'bid_size', 'ask_size', 'volume',
          'open_time', 'mkt_age_h', 'open_interest', 'volume_24h', 'no_bid', 'no_ask',
          'local_hour', 'regime', 'pc_bid', 'pc_ask']


def load_state():
    import json
    try:
        return json.loads(STATE.read_text())
    except Exception:
        return {}


def save_state(st):
    import json
    STATE.write_text(json.dumps(st, indent=1))


# ── LIQUIDITY-GATE TELEMETRY ──────────────────────────────────────────────
# MIN_LEG_VOLUME rejects books with a bare `continue`, so the gate's cost was
# invisible: you could see what it admitted (books that got quoted) but never
# what it turned away or by how much. "It admitted 4 of 30" was a one-off number
# somebody counted by hand on 2026-08-09 and could not be re-checked afterwards.
#
# This writes a SNAPSHOT (overwritten every cycle), not an append-log -- at
# POLL_SECS=20 an append-log would be ~4,300 rows a day of near-duplicates. What
# is worth keeping across cycles is the DISTINCT set of books seen per day, so
# the daily tallies are kept as key sets and trimmed to GATE_KEEP_DAYS.
GATE_STATE     = Path(r'E:\soccer\research\mm_gate_state.json')
GATE_KEEP_DAYS = 14

# TELEMETRY BEYOND THE QUOTING WINDOW (2026-08-13). The panel used to show only
# books inside WINDOW_H, so on a quiet evening it was a single row and there was
# no way to see what was queued up behind it. Books further out are probed too
# now, but on a SLOW cadence: each probe costs 1-3 Kalshi calls (resolve_event
# tries both ticker orders before falling back to a search) and the quoting loop
# runs every POLL_SECS, so probing the whole watchlist every cycle would
# multiply Kalshi calls by the fixture count to watch a number that moves over
# hours. Misses are cached too -- without that, the ~55% of watchlist fixtures
# with no Kalshi book would be re-probed every single cycle, which is precisely
# the cost this throttle exists to avoid.
#
# Volume is cached for GATE_SWEEP_SECS; hours-to-kickoff is recomputed every
# cycle, so the countdown stays live even when the volume behind it is not.
GATE_SWEEP_SECS = 300
_ahead_cache    = {}       # 'DIV|Home vs Away' -> (probed_at, (minvol, vols) | None)
_ahead_swept_at = 0.0


def _write_gate_state(rows, now, ahead=()):
    """Snapshot of what the liquidity gate is doing to the books in window.

    `ahead` is DISPLAY ONLY and is deliberately kept out of the daily tallies:
    those have to keep meaning "books the gate actually ruled on that day", or
    the admitted-share stat would start counting books hours before they were
    ever quotable, and a book that was thin at 30h but fat by 10h would be
    tallied on the strength of a reading the gate never acted on.
    """
    import json
    try:
        try:
            prev = json.loads(GATE_STATE.read_text())
        except Exception:
            prev = {}
        daily = prev.get('daily') or {}
        today = now.date().isoformat()
        d = daily.setdefault(today, {'admitted': [], 'rejected': []})
        adm, rej = set(d.get('admitted') or []), set(d.get('rejected') or [])
        for r in rows:
            key = f"{r['div']}|{r['match']}"
            (adm if r['pass'] else rej).add(key)
        # a book that later clears the gate should not stay counted as rejected
        rej -= adm
        d['admitted'], d['rejected'] = sorted(adm), sorted(rej)
        for k in sorted(daily)[:-GATE_KEEP_DAYS]:
            daily.pop(k, None)
        GATE_STATE.write_text(json.dumps({
            'ts': now.isoformat(),
            'gate': MIN_LEG_VOLUME,
            'regime': REGIME,
            'window_h': WINDOW_H,
            'sweep_secs': GATE_SWEEP_SECS,
            'now': sorted(rows, key=lambda r: r['hrs']),
            'ahead': sorted(ahead, key=lambda r: r['hrs']),
            'daily': daily,
        }, indent=1))
    except Exception:
        pass          # telemetry must never break a quoting cycle


LIVE_FILLS = Path(r'E:\soccer\research\mm_live_fills.csv')
LIVE_FILL_FIELDS = ['released_at', 'event', 'leg_ticker', 'side', 'kickoff',
                    'our_ask', 'filled', 'fill_kind', 'fill_px', 'completed',
                    'flattened', 'book_closed', 'queue_ahead', 'vol_cum',
                    'volume_at_post', 'regime']


def _archive_state(leg_key, v):
    """Record what the LIVE quoter believed about a leg, before its state is
    dropped.

    WHY THIS EXISTS. There are two incompatible definitions of "filled" in this
    system and only one of them was ever persisted:
      * the LIVE quoter uses paper_filled() -- queue-aware, a lower bound, and
        it is what actually drives complete_book()/flatten_stalled();
      * mm_settle uses fill_from_quotes() -- optimistic, its own docstring calls
        it a CEILING.
    Every number we report (completion rate, the P&L split by legs filled, the
    liquidity-gate sweep) comes from the settler, so we have been analysing the
    ceiling while the quoter acted on the lower bound. That gap is why the
    settler scores 59 books as 2-of-3 while the quoter took no action on 39 of
    them -- it never believed two legs had sold.
    Persisting the live view is the only way to measure the gap instead of
    guessing at it. Append-only; failures are swallowed so bookkeeping can never
    break quoting.
    """
    try:
        new = not LIVE_FILLS.exists()
        with open(LIVE_FILLS, 'a', newline='', encoding='utf-8') as fh:
            w = csv.DictWriter(fh, fieldnames=LIVE_FILL_FIELDS)
            if new:
                w.writeheader()
            w.writerow({
                'released_at': dt.datetime.now(dt.timezone.utc).isoformat(),
                'event': v.get('event', ''), 'leg_ticker': leg_key,
                'side': v.get('side', ''), 'kickoff': v.get('kickoff', ''),
                'our_ask': v.get('our_ask', ''), 'filled': int(bool(v.get('filled'))),
                'fill_kind': v.get('fill_kind', ''), 'fill_px': v.get('fill_px', ''),
                'completed': int(bool(v.get('completed'))),
                'flattened': int(bool(v.get('flattened'))),
                'book_closed': int(bool(v.get('book_closed'))),
                'queue_ahead': v.get('queue_ahead', ''),
                'vol_cum': v.get('vol_cum', ''),
                'volume_at_post': v.get('volume_at_post', ''),
                'regime': v.get('regime', REGIME),
            })
    except Exception:
        pass


def release_expired(st, now):
    """Free collateral once kickoff is >4h past: the match has finished and the
    paper position has settled. Archives the live view first -- see
    _archive_state; without that the quoter's own belief is lost forever and
    only the optimistic settler survives to be analysed."""
    for k in list(st):
        try:
            ko = dt.datetime.fromisoformat(st[k]['kickoff'])
        except Exception:
            st.pop(k, None)
            continue
        if (now - ko).total_seconds() > 4 * 3600:
            _archive_state(k, st[k])
            st.pop(k, None)
    return st


def committed(st):
    return sum(v.get('collateral', 0.0) for v in st.values())


def devig(o):
    imp = [1.0 / v for v in o]
    s = sum(imp)
    return [v / s for v in imp]


_TRADEABLE = 'active'
_seen_odd_status = set()


def kalshi_playable(legs, event_ticker=''):
    """Kalshi's own view: are all three legs tradeable right now?

    Requiring `active` is not merely a guard, it is the precondition for what
    we are about to do -- a resting order cannot be placed on a market that is
    not active. Observed values are `active` (open), `inactive` (halted at
    kickoff) and `finalized` (settled); anything else is logged ONCE so a new
    status shows up in the output instead of silently stopping the quoter.
    """
    ok = True
    for m in legs:
        st = (m.get('status') or '').lower()
        if st != _TRADEABLE:
            ok = False
            if st not in ('inactive', 'finalized', '') and st not in _seen_odd_status:
                _seen_odd_status.add(st)
                print(f"  [status] unrecognised Kalshi market status {st!r} on {event_ticker}")
    return ok


def legs_for(event_ticker):
    try:
        r = kt._get('/markets', {'event_ticker': event_ticker, 'limit': 10})
        return r.get('markets', [])
    except Exception:
        return []


def resolve_event(div, game_date, home, away):
    series = maps.league_kalshi_series((div or '').upper())
    if not series:
        return None
    ha, aa = get_abr(home), get_abr(away)
    if not ha or not aa:
        return None
    for ev in (build_event_ticker(series, game_date, ha, aa),
               build_event_ticker(series, game_date, aa, ha)):
        if legs_for(ev):
            return ev
    try:
        e = find_event_by_abrs(series, ha, aa)
        return e if e else None
    except Exception:
        return None


def leg_side(leg_ticker, event_ticker, home, away):
    """home / draw / away for a leg, from the ticker suffix.

    Team codes are 2-4 chars (KC, SJ, LAFC), so a fixed [:3] slice mis-assigns
    ~2% of legs -- strip the 7-char date prefix and use startswith instead.
    """
    suffix = leg_ticker.split('-')[-1]
    if suffix.upper() in ('TIE', 'DRAW'):
        return 'draw'
    mid = event_ticker.split('-')[1][7:]
    return 'home' if mid.startswith(suffix) else 'away'


def taker_fee(price):
    """Kalshi taker fee per contract, verified against real fills: 0.07*P*(1-P)."""
    return 0.07 * price * (1.0 - price)


def _pt(s):
    """Parse to an aware datetime. NEVER compare timestamp strings directly --
    quote times carry +00:00, kickoffs carry -04:00, Kalshi trades carry Z, and
    lexicographic comparison of those three formats is not meaningful (this
    silently filtered out 100% of trades in an earlier version of this
    function, which is how the paper quoter reported a 0-completion day while
    genuinely believing every book was still open)."""
    try:
        return dt.datetime.fromisoformat(str(s).replace('Z', '+00:00'))
    except (ValueError, TypeError):
        return None


def paper_filled(state_leg, leg_ticker, our_ask, since_dt, kickoff_dt, now):
    """QUEUE-AWARE fill test. Are we actually at the front of the queue yet?

    A print at our price is NOT our fill. In a FIFO book the buyer takes the size
    already resting at that price first, and at the touch that is 1,749-44,427
    contracts (measured on Kalshi). The old test ("did anyone print at or above
    our ask") therefore counted other makers' fills as ours, and did so in a
    DIRECTIONAL way: at +0c we join the touch behind a huge queue, while at
    +3c/+5c we are alone at our price. So it flattered exactly the cheap arms and
    left the expensive ones honest, which biased the whole price-ladder result.

    Measured on 2026-07-29 (research/queue_aware_fill.py): optimistic 48% of legs
    filled vs 40% queue-aware; at +0c specifically 100% vs 67%.

    THE MODEL: we are filled once cumulative volume traded at or above our ask,
    since we posted, EXCEEDS the depth that was resting at our price when we
    posted. Trades above our price count too -- the book walked up through us.

    `queue_ahead` is set at post time: the touch depth if we joined the touch,
    else 0 (nobody else is showing our price). It is deliberately PESSIMISTIC --
    makers ahead of us can cancel, which we cannot observe -- so this is a lower
    bound where the old test was an upper bound. Truth is between; logging both
    (`vol_cum`) lets that gap be measured later rather than guessed.

    Volume is accumulated against a WATERMARK so each cycle only counts trades it
    has not already seen. One API call per unfilled leg per cycle, same as before.
    """
    if since_dt is None or kickoff_dt is None:
        return False

    mark = _pt(state_leg.get('vol_watermark')) or since_dt

    # PAGINATE BACK TO THE WATERMARK (fixed 2026-08-09). This used to take a
    # single limit=200 page. The API returns trades NEWEST FIRST, so any gap
    # longer than 200 trades was skipped -- and skipped PERMANENTLY, because the
    # watermark then jumped to the newest trade seen. 92% of legs take more than
    # 200 trades in total, and this quoter restarts often (guard loop, code
    # changes, reboots), so every restart silently discarded the volume traded
    # while it was down.
    #
    # Consequence: vol_cum under-counts -> never exceeds queue_ahead -> the leg
    # never registers as filled -> complete_book() never sees 2-of-3 and the
    # book walks into kickoff naked. That is the most likely explanation for the
    # 39 books the settler scores as 2-of-3 on which the quoter took NO action.
    #
    # mm_settle.trades_tuples was fixed for exactly this on 2026-07-27 (Pachuca,
    # 17,999 trades); the quoter was left on the broken pattern.
    trades, cursor, pages = [], None, 0
    while pages < 25:
        p = {'ticker': leg_ticker, 'limit': 200}
        if cursor:
            p['cursor'] = cursor
        try:
            d = kt._get('/markets/trades', p)
        except Exception:
            break
        tr = d.get('trades', [])
        trades += tr
        pages += 1
        cursor = d.get('cursor')
        if not cursor or not tr:
            break
        # stop once the oldest trade on this page predates the watermark
        oldest = _pt(tr[-1].get('created_time'))
        if oldest is not None and oldest <= mark:
            break

    newest = mark
    added = 0.0
    for x in trades:
        t = _pt(x.get('created_time'))
        if t is None or not (mark < t < kickoff_dt):
            continue
        try:
            px = float(x.get('yes_price_dollars') or 0)
            # Kalshi calls trade size `count_fp`, not `count`. Reading the wrong
            # key yields 0 for every trade and nothing ever clears the queue.
            cnt = float(x.get('count_fp') or x.get('count') or 0)
        except (TypeError, ValueError):
            continue
        if px >= our_ask - 1e-9:
            added += cnt
        if t > newest:
            newest = t

    if added:
        state_leg['vol_cum'] = round(state_leg.get('vol_cum', 0.0) + added, 2)
    if newest > mark:
        state_leg['vol_watermark'] = newest.isoformat()

    return state_leg.get('vol_cum', 0.0) > state_leg.get('queue_ahead', 0.0)

def flatten_stalled(st, event, legs, book, writer, now, div, home, away, ko):
    """A book stuck at 1-of-3 near kickoff: BUY BACK the one sold leg, go flat.

    WHY THIS EXISTS SEPARATELY FROM complete_book(). complete_book fires only at
    EXACTLY 2-of-3 -- with one leg sold and two unsold it does nothing, so the
    book walked into kickoff as a naked directional short. On 2026-07-29 that
    happened to EIGHT legs across both venues.

    WHY BUY BACK RATHER THAN COMPLETE. Completing a 1-of-3 book needs TWO
    crossings (~4.6c); buying back needs ONE (~2.3c) and leaves zero exposure.

    WHY AT ALL, given naked legs are roughly break-even. Measured (Monte Carlo on
    the 2026-07-29 legs, n=200k): mean -0.04, MEDIAN +5.30, 1st pct -34.70. Mean
    ~zero because we sell at fair, but a long left tail with a positive median --
    the shape that reads as edge for weeks then gives it back. Worse, it is
    unmeasurable: detecting even a $0.50/day naked edge against $12.34 daily
    noise needs ~2,400 days. And it BURIES the signal we actually care about --
    on 2026-07-29 complete books made -$0.22 while naked legs swung +$7.50, 34x
    the noise. So this trades a small certain fee for the ability to measure
    whether market making works at all.

    Honest cost: buy-back is slightly NEGATIVE EV if we truly sell above fair.
    That is accepted deliberately, not overlooked.
    """
    mine = [t for t in book if t in st and st[t].get('event') == event]
    if len(mine) != 3:
        return 0
    filled = [t for t in mine if st[t].get('filled')]
    if len(filled) != 1:
        return 0
    tgt = filled[0]
    if st[tgt].get('flattened') or st[tgt].get('completed'):
        return 0
    if (ko - now).total_seconds() / 3600.0 > COMPLETE_BEFORE_KO_H:
        return 0
    ask = book[tgt][1]
    if not (0 < ask < 1):
        return 0
    sold = st[tgt]['our_ask']
    cost_c = (ask - sold) * 100 + taker_fee(ask) * 100
    # BOOK IS NOW CLOSED. Buying the leg back means we no longer hold it, but
    # `filled` stays True as a record that it once traded -- so without this flag
    # complete_book() would later count the flattened leg as a live position and
    # sell a third leg against a position we had already exited. Observed live on
    # 2026-07-30: Corinthians flattened at 21:34 then "completed" at 21:49.
    for _t in mine:
        st[_t]['book_closed'] = True
    st[tgt]['flattened'] = True
    st[tgt]['flatten_px'] = round(ask, 2)
    st[tgt]['flatten_cost_c'] = round(cost_c, 2)
    st[tgt]['flattened_at'] = now.isoformat()
    writer.writerow({
        'ts': now.isoformat(), 'div': div, 'home': home, 'away': away,
        'kickoff': ko.isoformat(), 'event': event, 'leg_ticker': tgt,
        'side': st[tgt].get('side'), 'our_ask': round(ask, 2),
        'competitive': 1, 'size': SIZE, 'arm': st[tgt].get('arm'),
        'regime': REGIME,
        'reason': f'FLATTEN_1OF3: bought back {st[tgt].get("side")} sold @'
                  f'{sold:.2f} at ask {ask:.2f}, fee {taker_fee(ask)*100:.2f}c '
                  f'=> cost {cost_c:+.1f}c/contract'})
    print(f'    [FLATTEN] {div} {home} vs {away}: bought back '
          f'{st[tgt].get("side")} @{ask:.2f} (sold {sold:.2f}) -> '
          f'{cost_c:+.1f}c/contract')
    return 1


def complete_book(st, event, legs, book, writer, now, div, home, away, ko):
    """If exactly one leg of a 3-leg book is still unfilled, SELL it into the bid.

    WHY. A complete book is riskless: three asks each ~2c above fair sum to
    ~1.06, so you collect ~106c against a fixed 100c payout and the result does
    not matter. An INCOMPLETE book is a naked directional short. Measured on the
    first six paper matches: four complete books all landed near +7c/contract,
    while the two incomplete ones lost 48-50c. Completing the Korean books at the
    bid would have turned -$6.90 into +$4.26.

    Cost of completing is one taker fee (~1.7c at 57c) plus giving up the
    bid-ask. That is cheap against a 50c tail -- the opposite conclusion to
    "hedging costs 1.5c against a 2.5c edge", which measured the fee against the
    per-leg edge instead of against the risk being removed.
    """
    mine = [t for t in book if t in st and st[t].get('event') == event]
    if len(mine) != 3:
        return 0
    if any(st[t].get('book_closed') for t in mine):
        return 0                      # already exited via flatten_stalled
    unfilled = [t for t in mine if not st[t].get('filled')]
    if len(unfilled) != 1:
        return 0
    tgt = unfilled[0]
    if st[tgt].get('completed'):
        return 0

    # WAIT BEFORE CROSSING (added 2026-07-28). This used to fire the instant a
    # book hit 2-of-3, which in practice meant 8.6-10.1 HOURS before kickoff --
    # paying ~2c to cross while the third leg still had most of a twelve-hour
    # window to sell at our resting ask for FREE. It also manufactured the
    # "9 of 9 books needed completing" statistic: no book was ever given the
    # chance to finish on its own.
    #
    # Counterfactual on the 14 completions to date
    # (research/mm_delayed_completion_backtest.py): the third leg saw trades at
    # or above our original ask before kickoff in 13 of 14 books, and several
    # saw 80-223 of them. Waiting turns -$0.54 into +$2.75 (an upper bound --
    # a print at our price is not proof we would have been the one filled).
    #
    # So: only cross inside the last COMPLETE_BEFORE_KO_H hours, as a backstop
    # for legs that genuinely did not sell. The cost is real exposure -- a
    # 2-of-3 book is a naked leg until then -- which is the trade being made
    # deliberately: hours of open risk against ~2c of certain loss per book.
    hrs_left = (ko - now).total_seconds() / 3600.0
    if hrs_left > COMPLETE_BEFORE_KO_H:
        return 0
    bid = book[tgt][0]
    if not (0 < bid < 1):
        return 0
    collected = sum(st[t]['our_ask'] for t in mine if st[t].get('filled')) + bid
    net_c = (collected - 1.0) * 100 - taker_fee(bid) * 100
    st[tgt]['completed'] = True
    st[tgt]['filled'] = True
    _p = _pt(st[tgt].get('posted_at'))
    st[tgt]['filled_at'] = now.isoformat()
    st[tgt]['fill_secs'] = round((now - _p).total_seconds(), 1) if _p else ''
    st[tgt]['fill_kind'] = 'completed'
    st[tgt]['fill_px'] = bid
    st[tgt]['action'] = 'completed_at_bid'
    writer.writerow({
        'ts': now.isoformat(), 'div': div, 'home': home, 'away': away,
        'kickoff': ko.isoformat(), 'event': event, 'leg_ticker': tgt,
        'side': st[tgt].get('side', ''), 'pin_fair': st[tgt].get('pin_fair', ''),
        'k_bid': bid, 'k_ask': book[tgt][1],
        'over_mid': '', 'over_ask': '', 'our_ask': round(bid, 2),
        'competitive': 1, 'size': SIZE,
        'collateral': round((1.0 - bid) * SIZE, 2),
        'reason': f'COMPLETE_AT_BID: book collected {collected*100:.0f}c, '
                  f'fee {taker_fee(bid)*100:.1f}c => {net_c:+.1f}c/contract locked'})
    print(f'    [COMPLETE] {div} {home} vs {away}: sold {st[tgt].get("side")} '
          f'at bid {bid:.2f} -> {net_c:+.1f}c/contract locked')
    return 1


def _ko_of(m):
    """(kickoff_dt, game_date) for a watchlist row, or (None, None).

    Watchlist kickoff is HH:MM ET on the game date. Factored out of cycle() so
    the in-window quoting pass and the out-of-window telemetry sweep cannot
    drift apart on how they read a fixture's time.
    """
    try:
        gd = m.get('game_date') or dt.date.today()
        if isinstance(gd, str):
            gd = dt.date.fromisoformat(gd)
        hh, mm = str(m['kickoff']).split(':')[:2]
        return dt.datetime(gd.year, gd.month, gd.day, int(hh), int(mm),
                           tzinfo=dt.timezone(dt.timedelta(hours=-4))), gd
    except Exception:
        return None, None


def _gate_probe(div, gd, m):
    """(minvol, sorted vols) for one fixture on Kalshi, or None if it is not a
    playable 3-leg book there. Exactly the reads the in-window pass makes."""
    ev = resolve_event(div, gd, m.get('home'), m.get('away'))
    if not ev:
        return None
    legs = legs_for(ev)
    if len(legs) != 3 or not kalshi_playable(legs, ev):
        return None
    vols = [float(L.get('volume_fp') or 0) for L in legs]
    return (min(vols) if vols else 0.0), sorted(round(v, 1) for v in vols)


def _ahead_rows(matches, now):
    """Gate telemetry for watchlist books still OUTSIDE the quoting window.

    Volumes refresh at most every GATE_SWEEP_SECS and hours-to-kickoff is
    recomputed every call, so a row ages honestly rather than freezing. Returns
    display rows only -- see _write_gate_state on why these stay out of the
    daily tallies.
    """
    global _ahead_swept_at
    due = (time.time() - _ahead_swept_at) >= GATE_SWEEP_SECS
    out, seen = [], set()
    for m in matches:
        ko, gd = _ko_of(m)
        if ko is None:
            continue
        hrs = (ko - now).total_seconds() / 3600.0
        if hrs <= WINDOW_H:        # in window -> the quoting pass already ruled on it
            continue
        div = (m.get('lc') or '').upper()
        key = f"{div}|{m.get('home','?')} vs {m.get('away','?')}"
        seen.add(key)
        if due or key not in _ahead_cache:
            try:
                probe = _gate_probe(div, gd, m)
            except Exception:
                probe = None       # telemetry must never break a quoting cycle
            _ahead_cache[key] = (time.time(), probe)
        probed_at, probe = _ahead_cache[key]
        if probe is None:          # no Kalshi book -- nothing for the gate to say
            continue
        minvol, vols = probe
        out.append({'div': div, 'match': key.split('|', 1)[1],
                    'hrs': round(hrs, 2), 'minvol': round(minvol, 1),
                    'vols': vols, 'pass': minvol >= MIN_LEG_VOLUME,
                    'stage': 'ahead',
                    'vol_age_m': round((time.time() - probed_at) / 60.0, 1)})
    for k in set(_ahead_cache) - seen:      # fixture dropped off the watchlist
        _ahead_cache.pop(k, None)
    if due:
        _ahead_swept_at = time.time()
    return out


def cycle(writer, fh):
    if not quota_ok():
        return 0, 0
    _, matches = LM.load_watchlist_file()
    now = dt.datetime.now(dt.timezone.utc)
    st = release_expired(load_state(), now)
    quoted = 0
    completed = 0
    gate_rows = []
    for m in matches:
        ko, gd = _ko_of(m)
        if ko is None:
            continue
        hrs = (ko - now).total_seconds() / 3600.0
        if not (0 < hrs <= WINDOW_H):
            continue

        # ORDER MATTERS (2026-08-07). The postponement guard used to run FIRST,
        # as an API-Football call, immediately before legs_for() fetched the
        # Kalshi markets -- whose objects already carry `status`. We were paying
        # API-Football ~2,200 calls/day for an answer the very next call hands
        # over free. Kalshi is also the stronger signal: it DELISTS postponed
        # fixtures outright (verified 2026-08-07 against the two PST Ekstraklasa
        # games, neither of which appeared, while all 8 listed games were
        # `active`), so resolve_event/legs_for already fail closed on them.
        div = (m.get('lc') or '').upper()
        ev = resolve_event(div, gd, m['home'], m['away'])
        if not ev:
            continue
        legs = legs_for(ev)
        if len(legs) != 3:
            continue
        if not kalshi_playable(legs, ev):
            continue
        # Liquidity gate -- see MIN_LEG_VOLUME. Uses the THINNEST leg, because a
        # book completes only if all three fill; one illiquid leg is what leaves
        # us stranded at 2/3, which is the worst state to be in.
        vols = [float(m.get('volume_fp') or 0) for m in legs]
        minvol = min(vols) if vols else 0.0
        grow = {'div': div, 'match': f"{m.get('home','?')} vs {m.get('away','?')}",
                'hrs': round(hrs, 2), 'minvol': round(minvol, 1),
                'vols': sorted(round(v, 1) for v in vols),
                'pass': minvol >= MIN_LEG_VOLUME, 'stage': 'gate'}
        gate_rows.append(grow)
        if minvol < MIN_LEG_VOLUME:
            continue
        # API-Football only as a BACKSTOP now, at a long TTL -- it still catches
        # the case Kalshi misses: an event left listed and active after the
        # match was called off (the 2026-07-29 BSA relist).
        if not LM.fixture_playable(m.get('fid'), max_age=LM.PLAYABLE_BACKSTOP_TTL):
            continue          # postponed/cancelled -- see live_monitor.NOT_PLAYED

        book = {}
        depth = {}
        extra = {}
        for L in legs:
            try:
                b = float(L.get('yes_bid_dollars') or 0)
                a = float(L.get('yes_ask_dollars') or 0)
            except (TypeError, ValueError):
                b = a = 0.0
            if not (0 < b < a < 1):
                book = {}
                break
            book[L['ticker']] = (b, a)
            # QUEUE DEPTH (added 2026-07-28). yes_ask_size_fp is the size already
            # resting AT the ask -- i.e. the orders in front of us when we join
            # that price. This is the variable every unresolved fill question in
            # this project turns on (why the bump arm does not fill, why the draw
            # lags, whether a replay's "a trade printed at our price" would
            # actually have been OURS). It has been in the API response all along
            # and simply was not logged. Logging only; nothing gates on it yet.
            depth[L['ticker']] = (_num(L.get('yes_bid_size_fp')),
                                  _num(L.get('yes_ask_size_fp')),
                                  _num(L.get('volume_fp')))
            # open_time -> MARKET AGE. The whole strategy rests on the maker edge
            # living in a market's first volume decile; we have been proxying that
            # with hours-to-kickoff, which is a different quantity entirely. This
            # is the direct measure. open_interest/volume_24h separate "deep queue
            # in an active market" from "deep queue in a dead one" -- the exact
            # distinction whose absence made the historical backfill report a fake
            # +20c/contract. NO-side quotes because selling YES and buying NO are
            # the same trade, so a deeper NO book would be a free queue upgrade.
            extra[L['ticker']] = {
                'open_time': L.get('open_time') or '',
                'open_interest': _num(L.get('open_interest_fp')),
                'volume_24h': _num(L.get('volume_24h_fp')),
                'no_bid': L.get('no_bid_dollars') or '',
                'no_ask': L.get('no_ask_dollars') or ''}
        if not book:
            continue
        over_mid = sum((b + a) / 2 for b, a in book.values())
        over_ask = sum(a for b, a in book.values())
        over_bid = sum(b for b, a in book.values())

        # LIQUIDITY CHECK (added 2026-07-31; corrected same day). Reject books
        # whose 3-leg spread is absurdly wide, which is the signature of a book
        # NOBODY HAS QUOTED -- either not yet opened for trading, or a stale
        # postponed listing. Live example: "Botafogo" showed bids summing to
        # 10c against asks summing to 243c, i.e. a fake "+143c overround" that
        # is pure illiquidity. Without this the gross-margin gate below would
        # rank such a book as the most attractive on the board. Same class of
        # bug as the historical backfill's fake +20c/contract
        # (research/mm_gross_gate_history.py), now guarded live.
        #
        # DO NOT also require sum(bids) < 100c. That was the first version of
        # this check and it was backwards: sum(bids) >= 100c means all three
        # legs could be SOLD INTO THE BIDS for more than the 100c payout -- a
        # genuine arbitrage, and empirically the best books we see (four such
        # books on 07-28/29/30, all with 3-4c of gross). Filtering them out
        # silently removed every book that cleared a 2.5c gate.
        if (over_ask - over_bid) * 100 > MAX_SPREAD_C:
            continue

        # GROSS-MARGIN GATE (added 2026-07-31). Do not post a book at all unless
        # the market's OWN three asks already sum to enough to survive a
        # completion. Every completion pays ~1.3-1.7c in taker fee alone
        # (measured on the last 34 live completions/flattens: fee ranged
        # 0.85-1.70c), and a book that only has to cross ONE leg into the bid
        # gives up the spread on top of that. So "the asks sum above 100c" is
        # NOT sufficient -- it has to clear the fee with room for the bid/ask
        # spread too. On 2026-07-30/31, every completion that collected under
        # ~101c LOST money; every one that cleared it WON. GROSS_GATE_C=2.5
        # requires over_ask >= 102.5c, i.e. the book must show 2.5c of
        # overround before we quote a single leg of it.
        #
        # This is a book-level decision made ONCE, from data available before
        # any leg is priced -- so it costs nothing extra to check, and it is
        # re-evaluated every cycle: a book whose asks widen out later becomes
        # eligible even if it was skipped earlier.
        grow['gross_c'] = round((over_ask - 1.0) * 100, 2)
        if (over_ask - 1.0) * 100 < GROSS_GATE_C:
            grow['stage'] = 'gross_reject'
            continue
        grow['stage'] = 'quoted'

        b365, pin = LM.fetch_prematch_odds(m.get('fid'))
        if not pin or not all(pin.get(k) for k in ('h', 'd', 'a')):
            continue
        fair = devig([pin['h'], pin['d'], pin['a']])
        fmap = dict(home=fair[0], draw=fair[1], away=fair[2])

        pc = poly_com_prices(div, m['home'], m['away'], gd)
        for L in legs:
            side = leg_side(L['ticker'], ev, m['home'], m['away'])
            arm = arm_for(ev, side, hrs)
            bb, aa = book[L['ticker']]
            # CAP AT THE MARKET'S OWN ASK so the order can actually compete.
            # The old rule was a fixed fair+2c with no ceiling, which meant
            # "competitive" (our <= market ask) was true on only ~14% of legs
            # -- verified on the historical log, our ask sat above the market
            # 86% of the time and simply never had a chance to fill. Joining
            # the market's own ask instead of insisting on our own markup
            # raised the (corrected, pre-kickoff) fill rate from 13% to 61% in
            # the same replay. Floor at fair: never sell for less than our own
            # fair-value estimate, even if the market is pricing below it --
            # that would be a guaranteed-negative-EV leg in isolation, and a
            # stale/bad Pinnacle read should not be able to force one.
            fair_c = fmap[side] * 100
            target_c = fair_c + margin_for(hrs)
            # A/B: 'bump' books may sit up to AB_BUMP_C above the market ask.
            # Still capped by target_c, so the bump can never push us above our
            # own fair+margin -- this tests queue position, not greed.
            # round() before ceil(): 0.28*100 is 28.000000000000004 in binary
            # float, so math.ceil turned a 28c cap into a 29c ask -- silently
            # posting ABOVE the market on exactly the prices we meant to join,
            # which is the opposite of the cap's purpose and would have quietly
            # corrupted the arm comparison below. Found 2026-07-27 when a 'cap'
            # book showed +1.0c vs market. Affects 28c and every other price
            # whose float representation rounds up.
            ceiling_c = round(aa * 100, 6) + (arm if AB_ENABLED else 0.0)
            our_c = round(min(target_c, ceiling_c), 6)
            # FLOOR: allow selling slightly BELOW fair, up to FAIR_TOLERANCE_C.
            #
            # The original floor was a hard "never below fair", and it backfired:
            # on 2026-07-27 it skipped Recife's away leg at 22c vs a 22.6c fair
            # (0.6c) and Rosenborg's at 17c vs 17.1c (0.1c -- a rounding artifact
            # in Pinnacle's devig). Skipping a leg means it can NEVER be sold,
            # so the book can never be completed, and complete_book() won't even
            # evaluate a book with fewer than 3 legs tracked. Those two books
            # were left stranded at 1-of-2 filled: exactly the unhedgeable naked
            # position that has caused every large loss in this project.
            #
            # The arithmetic: selling Recife away at 22c against a 22.6c fair
            # costs 0.6c in expectation, but COMPLETES the book -- 49+30+22 =
            # 101c collected against a guaranteed 100c payout, locking a certain
            # penny instead of holding a one-legged bet that historically swings
            # +/-50c. Trading 0.6c of theoretical edge for the removal of a 50c
            # coin flip is obviously right. (Same error as first calling the
            # completion hedge "too expensive" -- measuring a small cost against
            # the wrong denominator.)
            #
            # We still refuse when the market is FAR below our fair, because that
            # signals our own number is stale/wrong rather than the market being
            # generous -- selling into that is how a bad Pinnacle read turns into
            # a real loss.
            skip_below_fair = our_c < fair_c - FAIR_TOLERANCE_C
            our = math.ceil(our_c) / 100.0 if not skip_below_fair else None
            comp = (not skip_below_fair) and our is not None and our <= aa + 1e-9
            if skip_below_fair:
                our = math.ceil(fair_c) / 100.0   # for logging/collateral only; never quoted
            need = (1.0 - our) * SIZE          # collateral a resting sell locks
            already = L['ticker'] in st
            avail = BANKROLL - committed(st)
            funded = already or (need <= avail)
            reason = ''
            if skip_below_fair:
                reason = (f'market ask {aa:.2f} is {fair_c - aa*100:.1f}c below our fair '
                          f'{fmap[side]:.3f} (> {FAIR_TOLERANCE_C:.1f}c tolerance) '
                          f'-- our number is likely stale, skipped')
                comp = False
            elif not funded:
                reason = f'no collateral: need ${need:.2f}, ${avail:.2f} free'
                comp = False
            # Record EVERY leg we quote, not only the competitive ones. A real
            # maker posts all three and the non-competitive ones simply rest
            # unfilled -- they are not "not posted". Tracking only competitive
            # legs meant complete_book() never saw a 3-leg book (competitive rate
            # is ~14%, so 3-of-3 essentially never happened) and the completion
            # rule fired ZERO times in its first day live despite being correct.
            # Collateral is reserved for all three, which is what Kalshi actually
            # locks on resting sells.
            # A skipped leg (market below our fair) is never actually posted --
            # it must not enter state as a resting order, or it corrupts both
            # the collateral count and complete_book()'s 3-legs-tracked check.
            # WALK THE PRICE DOWN, AND CONCEDE NEAR KICKOFF (2026-07-29).
            #
            # This replaces a strict "post once, never reprice" rule. That rule
            # meant the staged margin in margin_for() was computed every cycle
            # but only ever APPLIED at first post -- a leg posted at T-12h with
            # a 6c margin sat at that price until kickoff, so it never staged
            # down at all despite the docstring saying it did.
            #
            # Measured on 2026-07-29's 45 Kalshi legs / 15 books:
            #     static (post once)          21/45 legs,  1 complete book, 11 naked
            #     walk down w/ staged margin  29/45 legs,  5 complete books, 9 naked
            #     walk down + concede at T-60 39/45 legs, 12 complete books, 2 naked
            # Completing a book is what makes money (three asks summing >100c
            # against a fixed 100c payout); a naked leg is a directional bet with
            # ~zero EV and a huge tail. Trading queue priority for completion is
            # therefore clearly right, which is the opposite of what the old
            # comment assumed.
            #
            # Rules: only ever reprice DOWN (repricing up is chasing, and gives
            # up queue position for nothing), and inside COMPLETE_BEFORE_KO_H
            # concede all the way to the market's own ask so the book closes.
            if already and not st[L['ticker']].get('filled') and our is not None:
                cur = st[L['ticker']]['our_ask']
                tgt = aa if hrs <= COMPLETE_BEFORE_KO_H else our
                tgt = math.ceil(round(tgt * 100, 6)) / 100.0
                if tgt < cur - 1e-9:
                    st[L['ticker']]['our_ask'] = tgt
                    # Repricing is a CANCEL-REPLACE: we lose our place and join
                    # the back of the queue at the new price. So the queue ahead
                    # of us is re-measured and the volume counted so far is void
                    # -- it cleared a queue we are no longer standing in.
                    st[L['ticker']]['queue_ahead'] = (
                        float(depth.get(L['ticker'], ('', 0, ''))[1] or 0)
                        if tgt <= aa + 1e-9 else 0.0)
                    st[L['ticker']]['vol_cum'] = 0.0
                    st[L['ticker']]['vol_watermark'] = now.isoformat()
                    st[L['ticker']]['repriced_at'] = now.isoformat()
                    st[L['ticker']]['reprice_count'] = 1 + st[L['ticker']].get(
                        'reprice_count', 0)
            if funded and not already and not skip_below_fair:
                st[L['ticker']] = {'collateral': round(need, 2),
                                   'kickoff': ko.isoformat(), 'our_ask': our,
                                   'side': side, 'event': ev,
                                   'pin_fair': round(fmap[side], 4),
                                   'filled': False, 'completed': False,
                                   'competitive': bool(comp),
                                   'arm': arm,
                                   'regime': REGIME,
                                   # depth resting at our price when we posted:
                                   # the touch size if we joined the touch, else
                                   # 0 (we are the only order at this price)
                                   'queue_ahead': (float(depth.get(L['ticker'],
                                       ('', 0, ''))[1] or 0)
                                       if our <= aa + 1e-9 else 0.0),
                                   'vol_cum': 0.0,
                                   'posted_at': now.isoformat()}
            writer.writerow({
                'ts': now.isoformat(), 'div': div, 'home': m['home'], 'away': m['away'],
                'kickoff': ko.isoformat(), 'event': ev, 'leg_ticker': L['ticker'],
                'side': side, 'pin_fair': round(fmap[side], 4),
                'k_bid': bb, 'k_ask': aa,
                'over_mid': round(over_mid, 4), 'over_ask': round(over_ask, 4),
                'our_ask': round(our, 2), 'competitive': int(comp),
                'size': SIZE, 'collateral': round((1.0 - our) * SIZE, 2),
                'reason': reason, 'arm': arm,
                'bid_size': depth.get(L['ticker'], ('', '', ''))[0],
                'ask_size': depth.get(L['ticker'], ('', '', ''))[1],
                'volume':   depth.get(L['ticker'], ('', '', ''))[2],
                **{k: extra.get(L['ticker'], {}).get(k, '')
                   for k in ('open_time', 'open_interest', 'volume_24h', 'no_bid', 'no_ask')},
                'mkt_age_h': _age_h(extra.get(L['ticker'], {}).get('open_time'), now),
                'local_hour': _local_hour(div, now), 'regime': REGIME,
                'pc_bid': pc.get(f'{side}_bid', ''),
                'pc_ask': pc.get(f'{side}_ask', '')})
            quoted += 1 if comp else 0

        # ── fill detection, then complete the book if one leg is lagging ──
        # Checked live (not at settlement) so the completion happens before
        # kickoff, which is the only way the cost of completing is realistic.
        for t in list(book):
            s = st.get(t)
            if not s or s.get('filled') or s.get('book_closed'):
                continue
            posted = _pt(s.get('posted_at'))
            leg_ko = _pt(s.get('kickoff'))
            if posted and leg_ko and paper_filled(s, t, s['our_ask'], posted,
                                                  leg_ko, now):
                s['filled'] = True
                s['fill_px'] = s['our_ask']
                # WHEN it filled, not just whether (added 2026-07-28). Time-to-fill
                # is the dependent variable every queue question actually needs,
                # and a duration carries far more power than a yes/no -- the same
                # argument that made edge_mine target net cents instead of win
                # rate. Detection granularity is POLL_SECS, so treat this as
                # "first cycle we saw it", an upper bound on the true fill time.
                s['filled_at'] = now.isoformat()
                s['fill_secs'] = round((now - posted).total_seconds(), 1)
                s['fill_kind'] = 'natural'
        _tk = legs and [L['ticker'] for L in legs]
        completed += complete_book(st, ev, _tk, book, writer, now, div,
                                   m['home'], m['away'], ko)
        completed += flatten_stalled(st, ev, _tk, book, writer, now, div,
                                     m['home'], m['away'], ko)
        fh.flush()
    _write_gate_state(gate_rows, now, _ahead_rows(matches, now))
    save_state(st)
    return quoted, completed


def main():
    once = '--once' in sys.argv
    new = not OUT.exists()
    fh = open(OUT, 'a', newline='', encoding='utf-8')
    w = csv.DictWriter(fh, fieldnames=FIELDS)
    if new:
        w.writeheader()
    print(f'PAPER quoter running (window {WINDOW_H}h, staged margin '
          f'6/5/3/2/1c by hrs-to-KO, size {SIZE}, bankroll ${BANKROLL:.0f}, '
          f'${committed(load_state()):.2f} committed). Places nothing.')
    while True:
        try:
            n, c = cycle(w, fh)
            print(f'{dt.datetime.now():%H:%M:%S}  logged quotes on {n} legs'
                  + (f', completed {c} book(s)' if c else ''))
        except Exception as e:
            print(f'{dt.datetime.now():%H:%M:%S}  cycle error: {e}')
        if once:
            break
        time.sleep(POLL_SECS)
    fh.close()


if __name__ == '__main__':
    main()
