"""LIVE market maker: rest one contract per leg at the touch, and record fills.

WHAT THIS IS FOR. Every fill number in this project is an inference. The
optimistic model ("a print landed at our price") says 89% of legs fill at the
touch; the queue-aware model ("enough traded to clear the size ahead of us")
says 37%; accounting for the two thirds of the queue that cancels rather than
trades puts it near 53%. Those bounds cannot be narrowed from the log, because
queue position is not observable from outside the book. Only our own resting
orders can measure it. That measurement -- not profit -- is the deliverable.

SEPARATE PROCESS FROM mm_quoter.py ON PURPOSE. That one is the paper quoter and
it feeds mm_settle, the dashboard, the forward test and the fill research. A bug
in live order handling must not be able to take the research pipeline with it.
This module imports mm_quoter for book SELECTION only and never mutates it.

THE STRATEGY, and why it is this and not the paper one:
  * ONE rung, at the touch. The tape says a premium of +1c drops book completion
    from 71% to 6% and +2c to zero. The paper quoter's 0-5c decay ladder is
    walking down a curve whose only live rung is the bottom.
  * All three legs at the SAME price rung. The paper quoter's md5 per-leg offset
    puts legs of one book on different rungs, which is the mechanism that
    strands a book at 1-of-3.
  * NO repair. Crossing to complete costs ~1.5c of a ~3c book; flattening a
    stranded leg costs ~2.9c, and the measured alternative -- simply riding it
    to settlement -- lost less (-0.165 vs -0.291 $/book on Poly-US). At size 1
    the exposure is at most $1 a leg, so repair cannot pay for itself here.

SAFETY. Orders self-expire (kalshi_maker.MAKER_TTL_S) so a crash or a power cut
cannot leave anything resting for more than 90s -- the failure that actually
happened on 2026-08-10, when this machine was off for two and a half days
unnoticed. On top of that: cancel-all at startup and at shutdown, a kill file
checked every cycle, a hard book cap, and every cap in kalshi_maker.

Usage:
    python main\\mm_maker_live.py                 dry run, places nothing
    python main\\mm_maker_live.py --once          one cycle, dry
    python main\\mm_maker_live.py --live          ARMED
    touch E:\\soccer\\STOP_MAKER                   stops it and cancels everything
"""
import csv
import datetime as dt
import json
import signal
import sys
import time
from pathlib import Path

sys.path.insert(0, r'E:\soccer\main')
import kalshi_trader as kt
import kalshi_maker as km
import mm_quoter as Q                      # book selection only, never mutated

# ── configuration ───────────────────────────────────────────────────────
POLL_SECS = 20
WINDOW_H = 72.0            # 12 -> 24 -> 48 -> 72 on 2026-08-17, testing coverage.
                           # Measured fill rate by hours-to-kickoff on our own
                           # legs (2026-08-16, 126 legs):
                           #     0-0.5h  26.1%
                           #     0.5-1.5h 18.2%
                           #     1.5-2.5h 10.0%
                           #     2.5-5h    4.8%
                           #     5-8h      9.1%
                           # so legs posted far out mostly sit. They are not
                           # free either: they hold order slots against
                           # MAX_CONCURRENT_ORDERS, which bound 5 times on
                           # 2026-08-16. The trade is coverage against slot
                           # pressure, and the point of widening is to measure
                           # it rather than argue about it.
MAX_BOOKS = 3              # 2 -> 3 on 2026-09-23 (user). Three size-10
                           # strands at once is ~-$20 worst case, i.e. the
                           # stop loss -- reachable, not likely.
                           # concurrency cap. 20 -> 2 on 2026-09-22 for the
                           # size-10 test below -- see that note for the
                           # collateral math this bounds. Restore to 20 (or
                           # whatever the size-1 baseline was running) when the
                           # test ends; this number has no meaning on its own,
                           # it is sized to SIZE.
SIZE = 10                  # contracts per leg
                           # 1 -> 10 on 2026-09-22 (user): international break,
                           # domestic fixtures thin, tried for a bit. THIS RESETS
                           # THE FORWARD-TEST BASELINE -- same category as the
                           # 09-13 changes (arms retired, refresh cadence,
                           # per-order TTL). Books opened before today do not
                           # count toward the OI-gate experiment's n>=100; see
                           # memory/maker-final-verdict-experiment.md.
                           #
                           # READ THE 08-22..09-06 SIZE-3 TRIAL BELOW BEFORE
                           # EXPECTING THIS TO SPEED UP THE VERDICT: it settled
                           # worse per book than size 1 (though confounded with
                           # the calendar), and the clean finding was that
                           # scaling size buys DOLLARS, not statistical
                           # confidence -- multiplying every P&L by k scales mean
                           # and SD together and leaves t unchanged. Breadth
                           # (more books) is the only axis that buys proof; size
                           # changes what a book pays, not how fast the test
                           # concludes.
                           #
                           # RISK: strand cost scales with size. At size 1,
                           # measured strand -$0.696/book; at size 10, roughly
                           # -$6.96. STOP_LOSS_USD is a flat $20 shared with
                           # every book size, so MAX_BOOKS is cut to 2 above --
                           # tighter concurrency, not a lower stop, is the
                           # guard chosen for this window (user). Two books at
                           # ~$19.8 collateral each (see book_budget: (3 -
                           # ~1.02)*SIZE) is ~$39.6 against ~$89 cash, comfortable
                           # headroom, and caps how many can go wrong at once.
                           #
                           # CORRECTNESS: fill state persists across restarts
                           # keyed by ticker, and "done" means fill_qty>=SIZE.
                           # A leg already sold to completion under SIZE=1 must
                           # not be read as "10% filled" and topped up by 9 more
                           # contracts once SIZE=10 is live -- see opened_size
                           # below, which freezes each leg to the size it was
                           # first posted under specifically to prevent that.
                           #
                           # PRIOR: BACK TO 1 on 2026-09-07 after this same
                           # size-3 trial. Size 3 ran 08-22..09-06 and settled 77
                           # books at -0.0917/book against size 1's -0.0009 over
                           # 106. Read that with care: size is very nearly
                           # collinear with the calendar (2 overlapping days), so
                           # this was NOT a clean measurement that 3 is worse --
                           # and the queue story does not support it either,
                           # since size-3 books sat in SHALLOWER queues (median
                           # 1,902 ahead vs 3,352) and were exposed for half as
                           # long. What was clean is that the only cell with
                           # positive settled EV was size 1 on liquid books.

# ── FROZEN RULE, 2026-09-07 ──────────────────────────────────────────────
# Books whose three legs carry less than this much open interest at selection
# are not opened. Measured on 182 settled kalshi maker books:
#
#                       n    strand%   EV/book
#   size 1, OI >= 3825  42     4.8%    +0.0293
#   size 1, OI <  3825  34    17.6%    -0.0440
#   size 3, OI >= 3825  53    15.1%    -0.0230
#   size 3, OI <  3825  24    25.0%    -0.2436
#
# The split replicates in BOTH size regimes and in both calendar periods, which
# is why it is a gate and not a ranking term: it is the one selection criterion
# here that survived an out-of-sample test (threshold fitted on the first half
# of the sample, scored on the second: 12.5% strand kept vs 22.2% skipped).
#
# READ THE REST OF THE NUMBERS SCEPTICALLY. They come out of ~60 comparisons on
# one month of data, and against the correct null -- random 42-book subsets of
# size-1 books -- this rule lands at 5.16%, so roughly 0.36 hits expected by
# chance across the 7 liquidity measures tried. That is suggestive, not proven.
# Dropping a single book moves the EV estimate ~30%. The forward test is the
# measurement; everything above is the hypothesis.
#
# KILL CRITERION. Break-even strand rate for this cell is 11.0%. Stop if the
# running strand rate clears it with 95% confidence: >=17 strands in the first
# 100 books, >=30 in 200. Disproof takes ~1 week at ~97 qualifying books/week;
# proof needs ~724 books. Do not adjust the rule mid-flight -- a threshold
# tuned against the forward sample turns the test back into the fit.
MIN_BOOK_OI = 3825.0
# RAISED 1 -> 3 on 2026-08-23 (user): partial scale-up on a SMALLER set of
# books, not more books. Books cut 10 -> 5 in mm_maker_live_guard.ps1 at the
# same time, deliberately, because of the collateral ceiling:
#
#   avg leg price 0.361 (last 800 posted legs)
#   size 1, 10 books = 30 legs -> $10.82 resting
#   size 3, 10 books = 30 legs -> $32.45   BREACHES MAX_RESTING_COLLATERAL ($30)
#   size 3,  5 books = 15 legs -> $16.25   fits, ~1.5x current typical exposure
#
# Why the breach matters more than it looks: kalshi_maker raises on the
# collateral check per-order, which mm_maker_live catches as a [skip]. So it
# fails safe, but it fails SELECTIVELY -- cheap legs keep fitting under the
# ceiling while expensive ones get rejected, quietly biasing which books get
# quoted. A silent price-correlated filter on the sample is worse than a
# smaller sample.
#
# EXPECT NO FILL-RATE CHANGE. Median queue ahead is 4,836 contracts, so 3
# contracts is 0.06% of it; you fill when the queue clears past you and it
# clears past 3 as readily as past 1. Partial fills need a sweep to stop inside
# a 3-contract window at the back of a ~5,000 queue -- 182 of 187 observed
# fills were exactly 1.00. Fill rate should be flat; what changes is P&L per
# fill and the book count feeding completion rate.
#
# NOTE the fill-rate series (323 fills, bounds 37/53/89%) now has a size-1
# segment and a size-3 segment. Split on this date before pooling them.
# REFRESH ONLY ON A PRICE CHANGE, OR WHEN THE ORDER IS ABOUT TO LAPSE
# (2026-09-13, user). Re-posting is not free: cancel+repost drops to the BACK
# of the queue at that price under price-time priority. Measured on 79,851
# consecutive re-posts across 149 legs, 99.8% (79,711) were at an UNCHANGED
# price -- queue position surrendered for nothing, once per ~67s cycle per leg.
#
# The price-change clause in `need` does the real work; this threshold is only
# the lapse backstop. Derived from the TTL so the two cannot drift apart, with
# two full cycles of margin so an order is always renewed before it expires.
# At TTL 300 a leg re-posts about every 3rd cycle instead of every cycle.
#
# WATCH: this changes fill rate, and fill rate drives completion rate -- the
# one number the frozen forward test measures. Books opened before 2026-09-13
# were quoted under the old cadence and are not comparable.
#     an order is replaced at the first check with age > REFRESH_AT_S, so it
#     is replaced somewhere in (R, R+C]. For it to NEVER lapse unquoted, the
#     TTL must still have a full cycle left at the latest replacement:
#         TTL - (R + C) >= C   ->   R <= TTL - 2C
#     At TTL 300 and C ~= 67 that caps R at 166. 170 fails it by 4 seconds.
_CYCLE_MARGIN_S = 140      # 2x the observed ~67s cycle, rounded up
REFRESH_AT_S = max(30, km.MAKER_TTL_S - _CYCLE_MARGIN_S)

# Ceiling on the per-order TTL granted to a HELD book's open leg (see the
# place_maker_ask call). Sized to cover kickoff + IN_PLAY_H so an in-play leg
# never renews on age; capped so a book quoted 50h out cannot mint a 50h order.
HELD_TTL_CAP_S = 4 * 3600


def refresh_at_for(state):
    """Age at which THIS leg's order must be re-posted, from its own TTL.

    A held book's order is issued with a much longer TTL than the default, so a
    single global threshold would cancel it at 58 minutes and hand back the
    queue position the long TTL exists to protect. Derive the threshold from
    the ttl actually used, keeping the same two-cycle margin:
        refresh_at <= ttl - 2*cycle   (or the order can lapse unquoted)
    Falls back to the module default when a leg predates this field.
    """
    try:
        ttl = float(state.get('ttl_s') or km.MAKER_TTL_S)
    except (TypeError, ValueError):
        ttl = float(km.MAKER_TTL_S)
    return max(30.0, ttl - _CYCLE_MARGIN_S)

# ── BUDGET RULE ─────────────────────────────────────────────────────────
# The Alavés book on 2026-08-15 filled ALA 0.40 (17:06), TIE 0.34 (17:26),
# GET 0.25 (17:27) -- collected 0.99 against a 1.00 payout, a locked loss, even
# though its three asks summed to ~1.02 when it was quoted. Legs filled 21
# minutes apart and the market re-priced in between.
#
# Pricing each leg at "the touch, whatever that is now" is what allows that. So
# price the BOOK's remaining budget instead: once part of a book has filled,
# the rest may only be sold at prices that still clear
#
#     1.00 + fees + margin
#
# If the market will not pay that, DO NOT SELL. Sitting stranded at 2-of-3
# measured -0.165 $/book; completing below budget is a certain loss. Prefer the
# coin flip to the guaranteed loss.
MAKER_FEE_RATE = 0.0175    # measured: fee 0.0042 on a 1-contract fill at 0.40
                           # -> 0.0042 / (0.40 x 0.60) = 0.0175
TARGET_MARGIN = 0.005      # 0.5c of cushion over break-even
# FAVOURITE PREMIUM. Favourites fill at 49.7% against 17.7% for draws, so the
# favourite runs ahead and the book strands short the leg most likely to win --
# which is where the -78c of 2026-08-16 came from, not from the completions.
#
# Measured on 711 legs, queue-aware, repricing the FAVOURITE ONLY:
#     +0c  completion 3.61%  pays 2c  = 7.221c/book   stranded favourite 46.1%
#     +1c  completion 2.41%  pays 3c  = 7.221c/book   stranded favourite 30.7%
#     +2c  completion 1.76%  pays 4c  = 7.036c/book   stranded favourite 22.4%
#
# +1c is EXACTLY value-neutral -- the extra cent offsets the lost completions to
# three decimals -- and removes a third of the stranding. That is a pure
# variance reduction, which is this strategy's actual problem: completions pay
# 2-4c and a stranded favourite runs 20-42c.
# +1c -> +2c on 2026-08-16 after the full grid. EV/book by favourite premium:
#     +0c  7.213c  stranding 46.1%
#     +1c  7.206c  stranding 30.7%
#     +2c  7.025c  stranding 22.4%
#     +3c  6.459c  stranding 16.5%
# Going +1c -> +2c costs 0.182c of expected book value and removes 8.3pp of
# stranding, so it pays whenever a stranded favourite costs more than 2.2c.
# Observed today: partials averaged -9.7c (-58c over 6 books). +3c would need a
# stranding to cost 9.6c, which is right on that number, so it is not taken.
#
# The grid also says NEVER premium the mid or draw legs: +1c on the mid costs
# 1.06c immediately, because completion is the product of three fill rates and
# the draw is already the binding one at 17.7%.
FAVOURITE_PREMIUM = 0.02   # added to the dearest leg only
FAVOURITE_MIN_PX = 0.45    # what counts as the favourite
MAX_ABOVE_TOUCH = 0.03     # asking more than 3c over the touch never fills
EXIT_BAND = 0.01           # hysteresis: a book already RESTING is only gated
                           # (and only loses its slot) once its touches fall
                           # this far below target. Opening still needs the
                           # full target. 2026-09-22, see book_budget's gate.
LATCH_CAP = 0.03           # how far a part-filled leg may hold above its budget
                           # (measured: +2c already drops completion to zero),
                           # so past that, abandon rather than pretend


def maker_fee(p):
    return MAKER_FEE_RATE * p * (1.0 - p)


def book_budget(asks, st, size=None, latch_cap=None, alloc='even',
                incumbent=False):
    """Decide what a three-leg book still has to fetch, and whether to bother.

    Pure: `asks` is {ticker: yes_ask}, `st` the per-leg state. No I/O, no
    side effects -- this is the arithmetic that decides whether real money
    completes a book, so it is kept separately testable from the loop that
    acts on it.

    Everything is PER CONTRACT, because Kalshi fills fractionally (a live fill
    came back count_fp 0.50). A leg half-filled at 0.47 banked 0.235, not 0.47,
    and is still half open; counting it as closed at 0.47 would overstate the
    budget and underprice the rest of the book -- exactly how a book locks a
    loss.

    Returns a dict with `decision` in {'quote', 'gate', 'abandon'}:
      gate    -- nothing filled yet and the touches do not clear target, so
                 walking away is still free. Take it.
      abandon -- something HAS filled and finishing now needs more over the
                 touch than the market will pay. Hold the partial.
      quote   -- post the open legs at `floor_for(ticker)`.
    """
    size = float(SIZE if size is None else size)
    cap = LATCH_CAP if latch_cap is None else float(latch_cap)
    collected, open_legs, open_qty = 0.0, [], {}
    for t, a in asks.items():
        s = st.get(t) or {}
        qty = float(s.get('fill_qty') or 0.0)
        fp = s.get('fill_px')
        if fp is None and s.get('fill_logged'):
            # filled but price unknown (state was cleared): assume the ask we
            # last posted at, and if even that is unknown, the touch
            fp = float(s.get('price_cents') or a * 100) / 100.0
            if qty <= 0:
                qty = size
        if fp is not None and qty > 0:
            # PREFER THE CASH (2026-09-21). fill_cash is money actually
            # received, accumulated per deduped fill_id; fill_px is derived
            # from it and can go stale when the count is healed against the
            # exchange position. Reading the derived number let a healed count
            # leave a half price behind and under-report `collected` -- see the
            # fill_px heal in the reconcile block. With a coherent fill_px the
            # two are identical (fill_px*qty == fill_cash), so this only ever
            # differs when something upstream has already gone wrong.
            _cash = s.get('fill_cash')
            if _cash is not None and float(_cash) > 0:
                collected += float(_cash) / size
            else:
                collected += float(fp) * qty / size
        rem = size - qty
        if rem > 1e-9:
            open_legs.append(t)
            open_qty[t] = rem

    fees = sum(maker_fee(a) for a in asks.values())
    target = 1.0 + fees + TARGET_MARGIN
    need = target - collected
    open_ask_sum = sum(asks[t] * open_qty[t] / size for t in open_legs)
    scale = (need / open_ask_sum) if open_ask_sum > 0 else 1.0
    worst = (max(asks[t] * (scale - 1.0) for t in open_legs)
             if open_legs and scale > 1.0 else 0.0)

    # TIE-LOADED ALLOCATION. Everything the book still owes beyond what the team
    # legs fetch at their own touch is asked of the tie alone. `tie_px` is None
    # unless this arm asked for it AND there is something to concentrate.
    tie_px = None
    if alloc == 'tie' and scale > 1.0 and len(open_legs) > 1:
        _ties = [t for t in open_legs if t.rsplit('-', 1)[-1] == 'TIE']
        _team = [t for t in open_legs if t not in _ties]
        if _ties and _team:
            _team_sum = sum(asks[t] * open_qty[t] / size for t in _team)
            _tie_qty = sum(open_qty[t] / size for t in _ties)
            if _tie_qty > 0:
                _px = (need - _team_sum) / _tie_qty
                # never below the tie's own touch: the point is to load it, not
                # to discount it, and the budget floor still has to hold
                tie_px = max(_px, asks[_ties[0]])
                worst = max(0.0, tie_px - asks[_ties[0]])

    if not open_legs:
        decision = 'quote'                      # book is done; nothing to post
    elif collected <= 0 and open_ask_sum < need - (EXIT_BAND if incumbent
                                                     else 0.0):
        # HYSTERESIS (2026-09-22). An INCUMBENT book -- one already resting
        # orders -- is only gated once its touches fall EXIT_BAND below
        # target, not the instant they dip under it. SFECAL cleared the gate
        # by 0.36c, so a one-tick move on any leg flipped it out and back
        # every ~1.5 min: cancel, re-post at the back of the queue, and wipe
        # the ratchet with the popped state. Opening is unchanged -- a new
        # book still needs the full target at the touch (incumbent=False).
        # A kept book is NOT accepted at a loss: scale > 1 makes floor_for()
        # quote its legs up so they still sum to target.
        #
        # (Was `scale > 1.0`, which is the same test with no band:
        # scale = need / open_ask_sum when nothing has filled.)
        #
        # ENTRY GATE. A book nothing has filled on yet costs nothing to walk
        # away from, so the bar is simply: do the three touches clear target?
        # If not, the only way it pays is the market coming to us above the
        # touch -- and the observed way that plays out is one leg crossing and
        # an abandoned partial. Never open a book on that hope. Once anything
        # has filled the gate stops applying, because leaving is no longer free.
        decision = 'gate'
    elif worst > MAX_ABOVE_TOUCH:
        decision = 'abandon'
    else:
        decision = 'quote'

    # RATCHET. The floor for a leg never falls once the book is open.
    #
    # This is the lesson of ALAGET on 2026-08-15. It was quoted ALA 40 / TIE 34
    # / GET 26 -- exactly 100c against a 1.00 payout, no edge at all -- and then
    # GET was re-quoted DOWN to 25c after half an hour because the loop re-read
    # the touch every cycle and followed it. It filled at 25. Collected 0.99,
    # owed 1.00. The entry gate now refuses a 100c book outright, but the chase
    # is a separate defect: any book, however good at entry, can be walked down
    # to a loss one cent at a time while it waits for its last leg.
    #
    # So each leg remembers the price it was first posted at, and is never
    # offered below it again. If the touch rises we follow it UP (a seller
    # asking more is strictly better); if it falls we hold and accept a lower
    # fill probability. The trade is deliberate: a fill that loses money is
    # worth less than no fill, and holding the line is what makes "if this book
    # completes, it profits" true by construction rather than by hope.
    dearest = max(asks, key=lambda k: asks[k]) if asks else None

    def floor_for(t):
        f = asks[t]
        # the favourite is offered dearer than the touch: it fills too easily
        # otherwise, and an easy fill on the likely winner is the bad half of
        # the book
        if (t is dearest and asks[t] >= FAVOURITE_MIN_PX
                and t in open_qty and collected <= 0):
            f = asks[t] + FAVOURITE_PREMIUM
        if t in open_qty and scale > 1.0:
            if tie_px is not None:
                # concentrated: the tie carries the shortfall, the team legs
                # stay at their own touch where they fill best
                f = tie_px if t.rsplit('-', 1)[-1] == 'TIE' else asks[t]
            else:
                f = asks[t] * scale              # budget demands more than touch
        # THE LATCH EXPIRES WHEN ANOTHER LEG FILLS. Fixed 2026-08-29.
        #
        # The ratchet above stops a book being walked DOWN to a loss one cent
        # at a time. It must not also stop the price coming down when the book
        # legitimately needs LESS -- which is what happens whenever a leg fills
        # above the share the budget had assumed for it.
        #
        # SOUMIL on 2026-08-29 is the case. With SOU and TIE both open the
        # budget wanted 74.6c from the pair, so TIE was latched at 0.39. Then SOU
        # filled at 0.52 -- far above its assumed ~0.28 -- and the TIE
        # requirement fell to 0.223. The touch reached 0.23/0.24 at 15:10 with
        # scale 0.97, meaning the market alone now cleared the budget and the
        # book would have COMPLETED AT A PROFIT. The stale 0.39 latch sat 16c
        # above it and the draw decayed to 0.02. Across the leg log this had
        # blocked 21 of 35 stranded books at some point.
        #
        # Re-deriving a lower budget is not chasing the touch down.
        #
        # WHY collected <= 0 RATHER THAN "reset the latch on each fill": tried
        # that first and it lasts exactly one cycle, because re-posting writes
        # floor_px again and the reset condition immediately stops holding.
        # The honest rule is that the latch was only ever needed for an
        # UNTOUCHED book. Once anything is banked, `asks[t] * scale` already
        # prices the open legs to fetch exactly `need` between them, so the
        # budget cannot be undercut and the latch adds nothing but staleness.
        # ALAGET -- the case it was written for -- has collected == 0, so that
        # protection is untouched. This also needs no migration: books already
        # stuck behind a stale floor (RMAMCF had been for 2,551 cycles) come
        # unstuck on the next cycle.
        # so the latch is BOUNDED once something has filled, not discarded.
        #
        # LATCH_CAP, 2026-08-30. Removing the latch outright (earlier today) let
        # every completing leg price at the budget, and books started paying a
        # flat $0.06 -- 2c of gross on 3 contracts -- against $0.18 in the week
        # before. At $0.06 a completion and -$0.83 a strand the book needs 93%
        # completion to break even; at $0.18 it needs 82%. The observed rate is
        # 87%, which loses under the first and wins under the second.
        #
        # Measured against WHAT THE BOOK STILL NEEDED over 08-24..08-28, the old
        # latch sat +0c 34% of the time, +1c 26%, +2c 18%, +3c 16% -- 92.7%
        # within three cents -- and beyond that a 1.1% tail reaching +54c. That
        # tail is the whole pathology: Pardubice held 10-11c over a market that
        # would have completed it, SOUMIL 16c. The median was never the problem.
        #
        # So keep the ratchet and bound it. An untouched book keeps the full
        # ratchet, which is the ALAGET case it was written for. A part-filled one
        # may hold above its budget, but never by more than LATCH_CAP.
        latched = (st.get(t) or {}).get('floor_px')
        if latched is not None:
            if collected <= 0:
                f = max(f, float(latched))
            else:
                f = max(f, min(float(latched), f + cap))
        return f

    return {'decision': decision, 'collected': collected, 'fees': fees,
            'target': target, 'need': need, 'scale': scale,
            'open_legs': open_legs, 'open_qty': open_qty,
            'worst_over_touch': worst, 'floor_for': floor_for}


KILL_FILE = Path(r'E:\soccer\STOP_MAKER')
_RESTART_MODE = False      # set when the kill file reads 'restart' -- shutdown
                           # then keeps held-book legs resting (2026-09-29)
STATE_F = Path(r'E:\soccer\research\mm_live_state.json')
FILL_LOG = Path(r'E:\soccer\research\mm_live_realfills.csv')
FILL_FIELDS = ['ts', 'ticker', 'event', 'side', 'price_cents', 'count',
               'queue_ahead_at_post', 'posted_at', 'seconds_to_fill']

# THE BOOK AS IT STOOD THE INSTANT A LEG FILLED (2026-09-07, user).
#
# What it is for: once one leg sells, the maker either keeps quoting the two
# survivors -- risking a strand at about -$0.76 -- or buys the filled leg back
# and walks. Costing that second option needs the price we could actually buy
# at, at that moment, and nothing recorded it.
#
# Why it cannot be reconstructed. mm_live_legs.csv holds ask_at_post, which is
# ALWAYS touch_ask (verified: 100% of 507,225 rows), and our own order IS the
# touch 52.3% of the time. Self-trade prevention means we cannot buy our own
# offer, so that column is a lower bound, not a price. Reconstructing 127 books
# from it returned a flatten cost of exactly +0.0000 for every single one --
# the number was measuring our own resting order. The same warning already
# stands over log_leg: "the book has moved by the time any later job looks, so
# if they are not captured here they are gone."
#
# survivor_* are the two legs still open, which is what the book still has to
# fetch. legs_filled_now lets analysis isolate FIRST fills (== 1) from later
# ones without joining back to the book dataset.
FLATTEN_LOG = Path(r'E:\soccer\research\mm_flatten_snap.csv')
FLATTEN_FIELDS = ['ts', 'event', 'league', 'ticker', 'side', 'hrs_to_ko',
                  'legs_filled_now', 'sold_px', 'fill_qty', 'collected',
                  'filled_bid', 'filled_ask', 'filled_spread',
                  'survivor_tickers', 'survivor_bids', 'survivor_asks',
                  'survivor_ask_sum']

LOCK_FILE = Path(r'E:\soccer\research\.mm_maker_live.lock')
LOCK_STALE_S = 120        # ~6 poll cycles; a live instance refreshes every cycle


def _pid_alive(pid):
    """Is this pid a running process? Windows, no psutil.

    ACCESS DENIED MEANS ALIVE. OpenProcess returns NULL both for a pid that
    is gone and for one owned by another user, and this treated both as dead.
    The maker normally runs as SYSTEM (the guard starts it), so a restart from
    an interactive shell saw the live holder as dead, took the lock, and ran a
    SECOND maker against the account -- the exact 2026-08-16 failure this lock
    exists to prevent. Observed again 2026-08-29 doing precisely that.

    GetLastError separates the two cases: ERROR_ACCESS_DENIED (5) means the
    process is there and is not ours; ERROR_INVALID_PARAMETER (87) means there
    is no such pid. Anything else is unknown, and unknown fails safe to alive.
    """
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.OpenProcess(0x1000, False, int(pid))
        if h:
            k.CloseHandle(h)
            return True
        return k.GetLastError() != 87      # 87 = no such pid; else it exists
    except Exception:
        return True          # cannot tell -> assume held, fail safe


def lock_acquire():
    """Refuse to start if another maker is alive. Returns True if we own it.

    On 2026-08-16 a relaunch happened before the previous process had exited,
    so two makers ran against the same account. Each posted its own orders,
    every leg went to two resting orders, and the duplicate guard could not win:
    it cancels BOTH orders on a leg, and the other process re-posted immediately.
    Resting orders climbed 54 -> 81. Every other safety in this file assumes a
    single writer -- the duplicate guard, the resting-over-cap abort, the post
    budget, the ratchet floors in state -- so that assumption has to be enforced
    rather than hoped for.

    Heartbeat rather than a plain PID file: a crashed process leaves a stale
    lock, and a lock that needs manual clearing after a crash is one that gets
    deleted reflexively. The heartbeat expires on its own.
    """
    import os
    now = time.time()
    try:
        prev = json.loads(LOCK_FILE.read_text(encoding='utf-8'))
        age = now - float(prev.get('heartbeat') or 0)
        # A fresh heartbeat from a DEAD pid must not block. A force-killed maker
        # never runs its release, so its lock looks live for LOCK_STALE_S -- and
        # on 2026-08-17 that stopped the replacement from starting at all after
        # an emergency stop. Heartbeat answers "did it stop updating"; the pid
        # check answers "is it still there". Both are needed.
        holder = prev.get('pid')
        alive = _pid_alive(holder) if holder else False
        if age < LOCK_STALE_S and holder != os.getpid() and alive:
            print(f'refusing to start: another maker holds the lock '
                  f'(pid {prev.get("pid")}, heartbeat {age:.0f}s ago). '
                  f'Stop it first, or wait {LOCK_STALE_S - age:.0f}s if it died.')
            return False
    except (OSError, ValueError, TypeError):
        pass
    lock_beat()
    return True


def lock_beat():
    import os
    try:
        LOCK_FILE.write_text(json.dumps({'pid': os.getpid(),
                                         'heartbeat': time.time()}),
                             encoding='utf-8')
    except OSError:
        pass


def lock_release():
    import os
    try:
        prev = json.loads(LOCK_FILE.read_text(encoding='utf-8'))
        if prev.get('pid') == os.getpid():
            LOCK_FILE.unlink()
    except (OSError, ValueError, TypeError):
        pass


_stop = False
# Post budget, in NEW LEGS (see --max-posts in main). 0 = unlimited. A leg
# counts once, the first time it is ever offered: identified by having no
# latched floor yet, which survives TTL expiry and process restarts in a way
# that "is it currently in the listing" does not.
_POST_BUDGET = 0
_POSTS_MADE = 0
FINISH_ONLY = False

# STOP LOSS, marked to market. Cash alone will not catch this: an unhedged short
# leg shows up as posted collateral, not as a loss, so the account read $101.27
# while the open book was worth -78c. Equity here is cash plus what each short
# would fetch if bought back now -- (1 - current yes ask) per contract.
# 101.44 -> 51.44 on 2026-09-01. $50 was moved out of the primary into
# subaccount 1 to fund xlow, and account_equity() reads /portfolio/balance,
# which is the PRIMARY subaccount only. So the maker saw $50 vanish, scored it
# as a loss, and tripped this stop within one cycle of the transfer:
#     [STOP LOSS] equity $44.45 vs $101.44 funded -- down $56.99, limit $20.00
# Nothing was actually lost; the money had simply moved to a subaccount this
# number cannot see.
#
# Rebased rather than taught to sum both subaccounts, because the maker's
# capital IS subaccount 0 now. xlow trades its own $50 in subaccount 1, and if
# xlow loses that money it must not stop the maker -- separating the strategies
# is the whole point of the split. This stop now means exactly what it says:
# stop when the MAKER is $20 down.
#
# IF CASH MOVES BETWEEN SUBACCOUNTS AGAIN, this constant moves with it. There
# is no automatic link, and getting it wrong halts trading rather than risking
# money, which is the safe direction but not a free one.
# 51.44 -> 88.63 on 2026-09-15. kalshi_transfer swept ALL of subaccount 1 back
# into 0 at 20:25 on 09-14, so equity rose to $88.6 while this constant stayed
# behind -- the stop would have allowed ~$57 of loss instead of $20.
FUNDED_USD = 88.63
STOP_LOSS_USD = 20.0
_EQUITY_EVERY = 5          # cycles between marks; each one costs a market read
_equity_tick = 0
_equity_last = None


CASH_BUFFER_USD = 3.0     # leave this much unspent when sizing a new book


def cash_headroom():
    """Free cash in the MAKER's subaccount, minus what resting orders will
    consume, minus a small buffer. None if unreadable.

    Replaces the fixed MAX_RESTING_COLLATERAL (removed 2026-09-01, user: "the
    book cap should be enough to cover it"). MAX_BOOKS bounds normal operation;
    this exists only so a book is never posted half-in because the money ran
    out mid-post -- the SFEMIL failure, 1 leg of 3.

    Reads the exchange rather than a constant, so it cannot go stale as
    positions settle and cash comes back. Subaccount 0 explicitly: xlow's $50
    lives in subaccount 1 and is not the maker's to spend.
    """
    try:
        b = kt._get('/portfolio/balance', params={'subaccount': 0}) or {}
        cash = float(b.get('balance', 0) or 0) / 100.0
    except Exception:
        return None
    try:
        rest = km.resting_collateral()
    except Exception:
        return None
    if rest == float('inf'):
        return None
    return cash - rest - CASH_BUFFER_USD


def account_equity():
    """Kalshi's own account value: cash + portfolio_value. None if unreadable.

    This used to mark each short at (1 - yes_ask) and add that to cash. That
    number is defensible as a liquidation estimate, but it is NOT what Kalshi
    says the account is worth, and the gap is large: on 2026-08-16 it read
    $114.14 while the exchange and the app both said $101.26. Reporting a
    twelve-dollar gain the venue does not recognise is worse than useless on a
    stop loss, which is the one place this number has to be right.

    /portfolio/balance returns portfolio_value in cents alongside the cash
    balance, and it agrees with the app to the penny. Use the venue's number.
    """
    try:
        b = kt.get_balance() or {}
        cash = float(b.get('balance', 0)) / 100.0
        pv = float(b.get('portfolio_value', 0)) / 100.0
        return cash + pv
    except Exception:
        return None
# Legs this PROCESS has offered. Budget is counted per session, not per leg
# lifetime: a leg carrying a latched floor from an earlier run is resumed at
# that floor (the ratchet is deliberately persistent), but it is still a leg
# this session put on the exchange, so it still spends budget. Counting only
# never-before-seen legs would let a restart quietly offer far more than asked.
_OFFERED = set()


def _sigint(_sig, _frm):
    global _stop
    _stop = True
    print('\n[signal] stopping -- cancelling everything')


signal.signal(signal.SIGINT, _sigint)


def _now():
    return dt.datetime.now(dt.timezone.utc)


def leg_size(s):
    """The target size for THIS leg -- SIZE at the moment it was first posted,
    not necessarily the SIZE running now.

    Added 2026-09-22 when SIZE went 1 -> 10. Fill state (fill_qty, fill_cash)
    persists across restarts keyed by ticker, and "done" means fill_qty >=
    target. Without this, a leg sold to completion under SIZE=1 -- fill_qty
    1.0, already closed -- would be read as "10% filled" the moment SIZE=10
    goes live, and the maker would try to sell 9 MORE contracts of a leg it
    already finished: real orders, deepening a real short position well past
    what that book's collateral and budget were priced for.

    `opened_size` is stamped once, on the FIRST touch of a leg's state (see the
    posting block), and never overwritten after -- so it survives every
    refresh and every TTL renewal for that leg's whole life. Falls back to the
    live SIZE for a leg that has never been posted, which is correct: a book
    opened AFTER a SIZE change should use the new one.
    """
    v = s.get('opened_size')
    return float(v) if v else float(SIZE)


def backfill_opened_size(st, bootstrap_size=1.0):
    """On a SIZE change, freeze every PRE-EXISTING leg to the size it predates.

    Compares the SIZE this state file was last saved under (st['_meta']) to
    the live SIZE. If they differ, every ticker already in `st` that has a
    footprint (a fill or a post -- i.e. everything except a bare placeholder)
    and no `opened_size` yet is stamped with the OLD size, so leg_size() keeps
    reading it correctly right through the transition. Brand-new legs posted
    from here on stamp themselves with the new SIZE at first touch, same as
    always. Runs once at startup; a no-op when SIZE has not changed.

    COLD START. The very first time this runs, `_meta` does not exist -- this
    tracking mechanism has never written it, not "SIZE has never changed". A
    naive `last is None -> skip` here would silently leave every pre-existing
    leg (1,667 of them, verified 2026-09-22) unstamped, which is exactly the
    bug this function exists to prevent: they would fall through to leg_size()'s
    live-SIZE default and get topped up to the NEW size. So an entirely absent
    `_meta` is treated as "everything here predates this code" and backfilled
    against `bootstrap_size` -- the constant's actual value immediately before
    this mechanism was added (1, confirmed from the source at the time). This
    branch fires at most once, ever, on whichever machine boots this code
    first; every run after that reads a real `last_size` from `_meta`.
    """
    had_meta = '_meta' in st
    meta = st.setdefault('_meta', {})
    last = meta.get('last_size')
    if last is None and not had_meta and float(bootstrap_size) != float(SIZE):
        last = bootstrap_size
    if last is not None and float(last) != float(SIZE):
        # Only books that have BANKED something are committed to a size. A
        # book with no leg filled owes nothing, so it can simply move to the
        # new SIZE -- see release_unfilled_books().
        filled_evs = _filled_events(st)
        n = 0
        for tkr, s in st.items():
            if tkr == '_meta' or not isinstance(s, dict):
                continue
            if (s.get('opened_size') is None
                    and _event_of(tkr, s) in filled_evs):
                s['opened_size'] = float(last)
                n += 1
        if n:
            print(f'  [SIZE-CHANGE] {last:g} -> {SIZE:g}: froze {n} '
                  f'leg(s) of books that already hold a fill')
    meta['last_size'] = SIZE
    release_unfilled_books(st)


def _event_of(tkr, s):
    return s.get('event') or tkr.rsplit('-', 1)[0]


def _filled_events(st):
    return {_event_of(t, s) for t, s in st.items()
            if t != '_meta' and isinstance(s, dict)
            and float(s.get('fill_qty') or 0) > 1e-9}


def release_unfilled_books(st):
    """Un-freeze every book with NO leg filled, so it adopts the live SIZE.

    The freeze exists to stop a leg that has already SOLD being topped up to a
    bigger size than its book was committed at. A book where nothing has
    filled has no commitment: its resting orders can be cancelled and
    re-posted at any size at zero cost. Freezing it anyway -- which the first
    version of the 2026-09-22 backfill did, stamping everything with a post --
    left SEARSL quoting 1 contract a leg after the switch to size 10, which is
    the opposite of what the switch was for.

    Per BOOK, not per leg: the three legs are priced against one payout, so
    they must share a size. One filled leg keeps the whole book at its
    original size. Runs every startup, and is idempotent.
    """
    filled_evs = _filled_events(st)
    n = 0
    for tkr, s in st.items():
        if tkr == '_meta' or not isinstance(s, dict):
            continue
        if (s.get('opened_size') is not None
                and float(s['opened_size']) != float(SIZE)
                and _event_of(tkr, s) not in filled_evs):
            s.pop('opened_size', None)
            n += 1
    if n:
        print(f'  [SIZE-RELEASE] {n} leg(s) on books with no fill moved to '
              f'size {SIZE:g}')


def load_state():
    try:
        return json.loads(STATE_F.read_text())
    except Exception:
        return {}


def save_state(st):
    try:
        STATE_F.write_text(json.dumps(st, indent=1))
    except Exception:
        pass                     # state is a convenience; never break a cycle


def _vanished_order_filled(order_id):
    """How much a no-longer-resting order filled before it left the book.

    AN ORDER THAT VANISHED EITHER EXPIRED OR EXECUTED, and only the exchange
    knows which. The maker reads positions once per ~56s cycle, so a fill is
    invisible until the next read -- and the refresh in between sees an empty
    order list, concludes the order expired, and re-posts the FULL size on a
    leg that is already sold.

    Observed on GETDEP-GET, 2026-09-13: executed 16:41:04, a second sell order
    posted immediately after and cancelled at 16:42:00 once positions caught
    up. It rested ~56s; had it filled, the leg would have gone 2/1. Neither
    existing guard can see this -- [DUP] needs two orders resting at once and
    only one ever is, and the cancel SUCCEEDS so [CANCEL-FAIL] is silent.

    Fails OPEN (0.0) on any error: that restores the old behaviour of
    re-posting, which is the risk we already carry, whereas a false positive
    would silently stop quoting a leg we do not actually hold.
    """
    if not order_id:
        return 0.0
    try:
        resp = kt._get(f'/portfolio/orders/{order_id}')
    except Exception:
        return 0.0
    try:
        o = resp.get('order') if isinstance(resp.get('order'), dict) else resp
        if str(o.get('status', '')).lower() in ('canceled', 'cancelled', 'expired'):
            # Cancelled/expired orders can still carry a partial fill; fall
            # through to the quantity fields rather than assuming zero.
            pass
        return _cancel_filled_qty(resp)
    except (AttributeError, TypeError):
        return 0.0


def _cancel_filled_qty(resp):
    """How much of a just-cancelled order had already filled. 0.0 if unknown.

    Kalshi's DELETE returns the cancelled order, but the field carrying the
    filled amount is not guaranteed to keep one name, and this runs in the
    order path of a live maker -- so it reads every plausible spelling and
    NEVER raises. Unknown must read as 0.0: over-counting here would suppress
    a legitimate re-post and silently stop quoting the leg, which is a worse
    failure than the duplicate it prevents.

    Returns a float. See the [LATE-FILL] branch for why this exists.
    """
    try:
        if not isinstance(resp, dict):
            return 0.0
        o = resp.get('order') if isinstance(resp.get('order'), dict) else resp
        # _fp FIRST. Kalshi serves the fixed-point variants and NOT the bare
        # names -- verified against a real executed order 2026-09-13, which
        # carried fill_count_fp="1.00" and no `fill_count` at all. Reading the
        # bare names alone returns 0.0 for every order, which would have made
        # this whole guard silently inert. Exactly the failure that made
        # volume/open_interest log nothing for 31,613 rows.
        for k in ('fill_count_fp', 'filled_count_fp', 'filled_count',
                  'fill_count', 'filled_quantity', 'filled', 'count_filled'):
            v = o.get(k)
            if v is not None:
                return max(0.0, float(v))
        # Fallback: original size minus what is left, _fp variants first.
        orig = o.get('initial_count_fp', o.get('initial_count', o.get('count')))
        left = o.get('remaining_count_fp',
                     o.get('remaining_count', o.get('remaining')))
        if orig is not None and left is not None:
            return max(0.0, float(orig) - float(left))
    except (TypeError, ValueError, AttributeError):
        pass
    return 0.0


def log_fill(row):
    new = not FILL_LOG.exists()
    with FILL_LOG.open('a', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=FILL_FIELDS)
        if new:
            w.writeheader()
        w.writerow(row)


def log_flatten_snap(row):
    """Book state at the instant of a fill. See FLATTEN_LOG.

    Wrapped in its own try: this is a research capture, and a malformed row or
    a locked file must never take down a live trading cycle. Everything else in
    this function is already best-effort for the same reason.
    """
    try:
        new = not FLATTEN_LOG.exists()
        with FLATTEN_LOG.open('a', newline='', encoding='utf-8') as fh:
            w = csv.DictWriter(fh, fieldnames=FLATTEN_FIELDS, extrasaction='ignore')
            if new:
                w.writeheader()
            w.writerow(row)
    except Exception:
        pass


# EVERY leg offered, filled or not. Without this there is no denominator: the
# fill log records queue depth only for legs that FILLED, so any fill-rate
# model built from it is pure survivorship -- it can show that a 17,885-deep
# queue does not prevent a fill, but never what share of such legs fill.
# The paper quoter cannot supply the missing rows either: it tracks a different
# book selection, and covered only 6 of 60 live legs on 2026-08-16.
LEG_LOG = Path(r'E:\soccer\research\mm_live_legs.csv')
LEG_FIELDS = ['ts', 'ticker', 'event', 'league', 'side', 'price_cents',
              # the market AT PLACEMENT -- these are what the decision saw, and
              # they cannot be reconstructed afterwards from any other source
              'ask_at_post', 'bid_at_post', 'mid_at_post',
              'pin_fair', 'volume', 'volume_24h', 'book_volume',
              'touch_ask', 'floor_px', 'above_touch_c', 'queue_ahead',
              'bid_size', 'open_interest', 'hrs_to_ko', 'collected',
              'target', 'scale', 'fresh',
              # dry rows must never be mistaken for orders that really
              # rested on the exchange -- a fill-rate model trained on
              # legs nobody could have lifted is worse than no model
              'live']


_LEG_MIGRATED = False


def _migrate_leg_log():
    """Rewrite the leg log under the current header when columns are added.

    csv.DictWriter appends in LEG_FIELDS order regardless of what header is
    already on disk. Add a column and every new row silently lands under the
    wrong old heading -- the file still parses, so nothing complains, and the
    corruption is only visible when the numbers stop making sense. Old rows
    keep blanks in the new columns, which is honest: we did not record them.
    """
    if not LEG_LOG.exists():
        return
    with LEG_LOG.open('r', newline='', encoding='utf-8') as fh:
        rd = csv.DictReader(fh)
        if rd.fieldnames == LEG_FIELDS:
            return
        rows = list(rd)
    bak = LEG_LOG.with_suffix('.csv.bak')
    LEG_LOG.replace(bak)
    with LEG_LOG.open('w', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=LEG_FIELDS, extrasaction='ignore')
        w.writeheader()
        for r in rows:
            w.writerow({k: r.get(k, '') for k in LEG_FIELDS})
    print(f'  leg log migrated to {len(LEG_FIELDS)} columns '
          f'({len(rows)} rows kept; backup {bak.name})')


def log_leg(row):
    global _LEG_MIGRATED
    if not _LEG_MIGRATED:
        _migrate_leg_log()
        _LEG_MIGRATED = True
    new = not LEG_LOG.exists()
    with LEG_LOG.open('a', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=LEG_FIELDS, extrasaction='ignore')
        if new:
            w.writeheader()
        w.writerow(row)


IN_PLAY_H = 3.0      # keep working a book this long after kickoff

MIN_OPEN_H = 6.0     # do not OPEN a book inside this many hours of kickoff

# RE-ADDED 2026-09-01 AT 6h, AFTER BEING REVERTED AT 1h ON 2026-08-29.
#
# The 08-29 reversal argued: of 91 COMPLETED books, 90 got their final leg after
# kickoff, so "the runway is the match, not the pre-match window" and open time
# cannot matter. That argument conditions on completion. Asking completed books
# when they completed cannot measure the completion RATE, and every book that
# opened late and stranded is missing from the 91 by construction.
#
# mm_book_dataset answers it directly. Among books that took AT LEAST ONE leg --
# so strand risk, not fill rate -- holding the entry gate fixed at gross 2-3c:
#
#     opened <2h    strand 14.3%   (n=21)
#     opened 2-6h   strand 20.8%   (n=24)
#     opened 6-24h  strand  8.8%   (n=34)
#     opened >24h   strand  9.4%   (n=32)
#
# Under 6h is ~17%, over 6h is ~9%. Not a quality confound: median gross_c_open
# is 2.00 in every bucket and median volume is HIGHER for the late books (9684
# vs 2291), which biases the comparison toward late, not away from it.
#
# On the 173 settled maker-only books this is the difference between -$0.041 and
# +$0.026 each; at size 3, -$0.096 and +$0.091.
#
# BOTH THINGS ARE TRUE. The 08-29 note is right that the last leg usually lands
# in play, and IN_PLAY_H stays 3.0 so we keep working a held book straight
# through the match. What it got wrong is the inference that late opening is
# therefore free. Open early, then work it late. The floor below applies ONLY to
# opening -- held_events exempts anything we already have legs in, because
# abandoning a partial is the most expensive state in the strategy.


# --- parallel arms -----------------------------------------------------------
#
# THE ARMS NOW TEST PRICING, NOT SELECTION (2026-08-30, user). The vol/ko
# selection split ran for one day and produced nothing; the open question worth
# six slots is what a part-filled leg should hold out for, because that is worth
# ~12c a book and selection is unproven.
#
#   'budget' -- a part-filled leg prices at exactly what the book still owes.
#               What shipped this morning. Books complete at a flat $0.06.
#   'latch3' -- the ratchet survives the fill but may sit at most 3c above that
#               budget. Reproduces 92.7% of 08-24..08-28 pricing and clips the
#               1.1% tail (+54c max) that stranded Pardubice and SOUMIL.
#
# Both arms share ONE selection ranking so pricing is the only variable.
#   'tieload' -- the squeeze is CONCENTRATED instead of spread. When one leg
#                sells and the budget wants more than the two survivors' touches
#                cover, `ask * scale` marks up BOTH, which halves the chance of
#                either filling and is how a 1-of-3 becomes permanent. DUNHIB on
#                2026-08-30: sold HIB at 0.41, scale drifted 0.98 -> 1.08 -> 20x
#                after two goals, and TIE and DUN sat at 40c and 20c against
#                touches of 2c and 1c. Neither could ever fill.
#
#                So sell the TEAM leg at its touch and load the whole remaining
#                requirement onto the TIE. Which leg takes the load is not
#                arbitrary: measured over 1,351 legs, a team leg gains nothing
#                from a premium (36% fill at touch, 32% at 1-2c over) while a
#                tie gains a lot (28% -> 56%), because in play a tie drifts UP
#                38% of the time against 12% for a team leg, and decays far
#                slower (mean -1.4c vs -4.2c). A tie is the leg that tolerates
#                being marked up.
#
#                Caveat kept in view: that 56% is 61 legs and the premium was
#                never randomly assigned -- a leg gets marked up when the budget
#                demands it, which happens on books already live. This arm is
#                what turns that correlation into a measurement.
# ARMS RETIRED 2026-09-07. The experiment ran and both novel arms lost money:
#
#     arm       settled n   strand%   EV/book
#     tieload      10        50.0%    -0.4049
#     budget       18        22.2%    -0.1258
#     latch3        5         0.0%    +0.0578
#     (no arm)    151        11.3%    -0.0116
#
# 28 of 182 settled books -- 15% -- carried -$6.31 of the -$7.98 total loss.
# tieload stranded HALF its books: concentrating the squeeze onto the tie makes
# that leg the one that cannot fill, which is the failure it was designed to
# prevent. The 1,351-leg measurement it rested on ("a tie gains a lot from a
# premium, 28% -> 56%") carried its own caveat -- 61 legs, never randomly
# assigned -- and the randomised version reversed it.
#
# What remains is latch3, which is not a fourth arm but the ORIGINAL default:
# latch_cap 0.03 / alloc 'even' is exactly what book_budget() does when called
# with latch_cap=None, so it reproduces the pricing of the 151 "no arm" books
# that this file ran before 2026-08-30. Keeping it named means arm_of() in
# mm_book_dataset.py keeps resolving, and the forward test is labelled.
ARM_SPEC = {
    'latch3':  {'latch_cap': 0.03, 'alloc': 'even'},
}
ARM_CAP = {k: v['latch_cap'] for k, v in ARM_SPEC.items()}
ARM_BOOKS = MAX_BOOKS      # single arm -- it may take every slot. The real
                           # limit is `picked[:cap]` below, which honours the
                           # --books override that MAX_BOOKS does not see here.
ARM_FILE = Path('E:/soccer/research/.mm_arm.json')
ARM_LOG = Path('E:/soccer/research/mm_arm_log.csv')
_ARM_OF = {}               # event -> arm name, for the leg log
_THIN_GATED = 0            # books refused by MIN_BOOK_OI last selection pass


def load_arms():
    """Which arm owns each book. Kept OUT of the maker's state file.

    state is keyed by ticker and every loop over it assumes that shape, so a
    per-EVENT mapping does not belong there. A book must also keep its arm
    across restarts, or a restart would re-attribute part-filled books and the
    comparison would quietly measure nothing.
    """
    try:
        a = json.loads(ARM_FILE.read_text(encoding='utf-8'))
    except (OSError, ValueError):
        return {}
    # labels from the retired vol/ko selection experiment are not arms any more
    return {k: v for k, v in a.items() if v in ARM_CAP}


def save_arms(arms, new_events=()):
    """Persist the mapping, and append-only log each book's FIRST assignment.

    ARM_FILE is pruned to live books every cycle, so it cannot answer 'which arm
    owned this book' once the book settles -- which is the only question the test
    exists to answer. The log is a separate small file on purpose: the obvious
    alternative, a column on mm_live_legs.csv, triggers a migration that reads
    all 374k rows of an 81MB file into memory at startup.
    """
    try:
        ARM_FILE.write_text(json.dumps(arms), encoding='utf-8')
    except OSError:
        pass
    if not new_events:
        return
    try:
        fresh = not ARM_LOG.exists()
        with ARM_LOG.open('a', newline='', encoding='utf-8') as fh:
            w = csv.writer(fh)
            if fresh:
                w.writerow(['ts', 'event', 'arm'])
            for ev, arm in new_events:
                w.writerow([_now().isoformat(), ev, arm])
    except OSError:
        pass

# ── MAKER-ONLY SERIES (2026-09-22, user: "add UEFA Nations League just for
# the maker") ───────────────────────────────────────────────────────────────
#
# Discovered straight from Kalshi, NOT through the watchlist. The watchlist is
# shared: live_monitor tracks it, xlow fires signals off it, and the frozen
# forward test and the dashboard score what the monitor writes. Putting these
# fixtures there would make them xlow candidates and forward-test rows, which
# is not what was asked for. So they never touch data/watchlist.txt and only
# books_in_window() ever sees them.
#
# KICKOFF = occurrence_datetime - 3h. Kalshi does not publish kickoff; its
# `occurrence_datetime` is the expected FINISH. Measured 2026-09-22 against
# every watchlist fixture with a trusted API-Football kickoff: 22 of 22, across
# 9 leagues, exactly +3.00h. MIN_OPEN_H and IN_PLAY_H depend on this being
# right, so if Kalshi ever changes the convention these books will open or
# close at the wrong times -- re-run that check before trusting it again.
#
# One /events call with nested markets returns every open game in the series
# (104 for the Nations League at the time, 0.2s), so this costs one request per
# cycle, not one per event. There is no Pinnacle fixture id, so `fid` is None
# and the fair-value columns stay blank -- the maker does not need them to
# quote. Every gate still applies after this: OI, entry, arms, MAX_BOOKS.
MAKER_ONLY_SERIES = {'KXUEFANLGAME': 'UNL'}   # series ticker -> league label
_KO_FROM_OCCURRENCE_H = 3.0


def _maker_only_books(now, held_events, already=frozenset()):
    out = []
    for series, label in MAKER_ONLY_SERIES.items():
        events, cur = [], ''
        try:
            for _ in range(10):
                p = {'series_ticker': series, 'status': 'open', 'limit': 200,
                     'with_nested_markets': 'true'}
                if cur:
                    p['cursor'] = cur
                r = kt._get('/events', p) or {}
                events += r.get('events') or []
                cur = r.get('cursor') or ''
                if not cur:
                    break
        except Exception as e:
            # A failed read is NOT "no games": say so, and add nothing this
            # cycle. Held books still in `resting` are protected by the
            # rotation carve-out for held events, so a blip cannot strand one.
            print(f'  [skip] {label} discovery: {type(e).__name__}: {e}')
            continue
        for e in events:
            ev = e.get('event_ticker')
            legs = e.get('markets') or []
            if not ev or ev in already or len(legs) != 3:
                continue
            occ = next((L.get('occurrence_datetime') for L in legs
                        if L.get('occurrence_datetime')), None)
            if not occ:
                continue
            try:
                ko = (dt.datetime.fromisoformat(occ.replace('Z', '+00:00'))
                      - dt.timedelta(hours=_KO_FROM_OCCURRENCE_H))
            except ValueError:
                continue
            hrs = (ko - now).total_seconds() / 3600.0
            pre = 0 < hrs <= WINDOW_H
            live = -IN_PLAY_H <= hrs <= 0
            if not (pre or live):
                continue
            if live and ev not in held_events:
                continue          # in play: finish ours, start nothing
            if pre and hrs < MIN_OPEN_H and ev not in held_events:
                continue          # too close to kickoff to open something new
            if not Q.kalshi_playable(legs, ev):
                continue
            out.append((ev, legs, e.get('title') or ev, hrs, label, None))
    return out


def books_in_window(now, held_events=frozenset(), resting_events=frozenset()):
    """[(event, [legs], match, hrs)] for playable books inside the window.

    Reuses mm_quoter's resolution so the live maker and the paper maker are
    always looking at the same universe -- a divergence there would make the
    measured fill rate uncomparable to everything already logged.

    IN-PLAY, BUT ONLY TO FINISH WHAT WE STARTED. The window used to be
    `0 < hrs`, so the maker went silent at kickoff and every resting order
    expired 90s later. Any book still short of three legs at kickoff was
    stranded permanently -- five of today's six open legs are sitting in
    markets that are still active and tradeable, abandoned by a cutoff rather
    than by the market.

    Opening a NEW book in play is a bad idea and stays forbidden: the three
    legs sum to one, so a goal that lifts one leg pushes the other two away,
    and the fill arrives exactly when the book can no longer be completed.
    FINISHING an existing partial is the opposite -- a completed book pays the
    same whoever scores, so a fill that completes it cannot lose. `held_events`
    is what separates the two, and the entry gate refuses anything with nothing
    banked, so an unheld book cannot be opened here even by accident.
    """
    _, matches = Q.LM.load_watchlist_file()
    out = []
    for m in matches:
        ko, gd = Q._ko_of(m)
        if ko is None:
            continue
        hrs = (ko - now).total_seconds() / 3600.0
        pre = 0 < hrs <= WINDOW_H
        live = -IN_PLAY_H <= hrs <= 0
        if not (pre or live):
            continue
        div = (m.get('lc') or '').upper()
        try:
            ev = Q.resolve_event(div, gd, m.get('home'), m.get('away'))
            if not ev:
                continue
            if live and ev not in held_events:
                continue          # in play: finish ours, start nothing
            if pre and hrs < MIN_OPEN_H and ev not in held_events:
                continue          # too close to kickoff to open something new
            legs = Q.legs_for(ev)
            if len(legs) != 3 or not Q.kalshi_playable(legs, ev):
                continue
        except Exception as e:
            print(f'  [skip] {div} {m.get("home")}: {type(e).__name__}: {e}')
            continue
        out.append((ev, legs, f"{m.get('home')} vs {m.get('away')}", hrs,
                    (m.get('lc') or '').upper(), m.get('fid')))
    out.extend(_maker_only_books(now, held_events,
                                 already={t[0] for t in out}))
    # RANK BY THE THINNEST LEG'S VOLUME -- the one measured driver of the only
    # number that matters.
    #
    # A book pays iff it COMPLETES, and completion tracks volume hard
    # (project_mm_completion_arithmetic, 154 books): no gate 46.8%, >=250
    # 62.3%, >=500 69.0%, >=2500 82.6%. Break-even is 61.4%. Payout per
    # COMPLETED book is flat across those buckets (+1.56/+2.75/+2.14/+0.98/
    # +1.97c, no trend), so there is nothing to trade off -- more volume is
    # strictly better, and picking on volume is picking on completion.
    #
    # Volume was already computed here and thrown away: `_bookvol` existed only
    # to be logged, and selection ran on kickoff time alone. That was tolerable
    # at MAX_BOOKS=40, where the cap rarely bound and most eligible books got
    # quoted anyway. At 10 the cap binds every cycle and selection IS the
    # strategy, so a book is now chosen for its thinnest leg -- "thinnest"
    # because a book completes only if its WORST leg sells, which is exactly
    # what the sweep above measured.
    # WHOLE-BOOK volume, not the thinnest leg.
    #
    # Corrected 2026-08-22 the same day it was written. The first version used
    # MIN leg volume, following project_mm_completion_arithmetic's gate sweep.
    # That sweep was run on PAPER books; on 193 live books it does not hold:
    #
    #     metric            Q1 -> Q4 completion   spearman   p
    #     min leg volume    20.8% -> 25.0%          +0.095   0.189   (null)
    #     whole-book volume 10.2% -> 35.4%          +0.215   0.003
    #
    # Min-leg volume is non-monotonic and not significant; total book volume
    # is monotone-ish and significant, and it carried the highest permutation
    # importance of any feature in the completion model (+0.091 AUC, vs -0.001
    # for min-leg). So rank on the book, not on its thinnest leg.
    def _book_vol(legs):
        tot = 0.0
        for L in legs:
            try:
                tot += float(L.get('volume_fp') or L.get('volume') or 0)
            except (TypeError, ValueError):
                pass
        return tot

    def _book_oi(legs):
        """Summed open interest across the three legs. See MIN_BOOK_OI.

        Mirrors _book_vol's field fallback: the *_fp names are what the fill
        pipeline writes and the bare names are what 31,613 older rows carry.
        Returns None when no leg reports it at all, which is NOT the same as
        zero -- a book we cannot measure must not be silently treated as thin
        (that would gate out every book on any feed hiccup).
        """
        tot, seen_any = 0.0, False
        for L in legs:
            v = L.get('open_interest_fp')
            if v is None:
                v = L.get('open_interest')
            if v in (None, ''):
                continue
            try:
                tot += float(v)
                seen_any = True
            except (TypeError, ValueError):
                pass
        return tot if seen_any else None

    # COMMITTED BOOKS SORT FIRST, then nearest kickoff, then volume.
    #
    # KICKOFF BEAT VOLUME 2026-08-29. Volume ranked above kickoff until now,
    # on the claim that 'completion tracks volume hard'. That claim came from
    # the completion figures that the (ticker, price_cents) join under-counted,
    # the same bug that produced two other conclusions since retracted. On the
    # exchange-derived numbers completion is nearly FLAT in volume -- 82.6% under
    # 5k vs 86.7% at 25-100k -- so volume was buying 4 points of completion.
    #
    # What it cost: truncating a 72h window to 5 selects on the MAXIMUM volume in
    # that window, and the biggest book in any 72h span is the marquee weekend
    # fixture, never tonight's game. Measured the day of this change: 130 books
    # cleared the gate, 15 of them inside 6h, and all five slots sat on fixtures
    # 25-56h out. Slot-hours are the scarce resource, not books, and completions
    # per 100 slot-hours run 9.16 under 6h, 2.98 at 6-24h, 0.98 beyond 24h -- a
    # far book holds a slot a median 41h against 2.6h for a near one.
    #
    # Nearest-first also defuses the squatter: a book opened at 5h can only pin
    # its slot for 5h once a leg fills, where one opened at 60h pins it for 60h.
    # The window stays at 72h deliberately -- far books are now a RESERVOIR that
    # fills the cap when the near cupboard is bare, rather than the default pick.
    #
    # This used to sort on kickoff alone and truncate, which is fine while
    # MAX_BOOKS is larger than the number of books in the window -- nothing is
    # ever dropped. It stops being fine the moment the cap binds: a book we
    # already hold legs in can fall outside the cut, stop being quoted, and
    # strand at 1-of-3, which project_mm_completion_arithmetic prices as the
    # single most expensive state in the strategy.
    #
    # Surfaced 2026-08-22 when the cap was cut 40 -> 10 with 22 books in window
    # and 6 of them part-filled. The post-budget already exempts held books
    # ("those are already committed and must stay quoted to complete"); this
    # makes the concurrency cap agree with it instead of overriding it.
    # Kickoff stays as the tie-break, not the primary key: fill rate does climb
    # steeply into kickoff (26.1% inside 30min vs 6.8% beyond 24h), but a book
    # that fills two legs and strands is worse than one that never opens, so
    # completion outranks fill rate.
    # GATE BEFORE CAP (2026-08-23). The cap used to be applied to a list sorted
    # on volume alone, so the gate only ever saw the top MAX_BOOKS -- and the
    # two criteria pull in OPPOSITE directions. Volume ranks a book up because
    # heavy trading predicts completion; heavy trading is also what competes the
    # overround down to the 1c tick floor, which is exactly what the entry gate
    # rejects. So the sort was handing the gate its worst candidates.
    #
    # Measured at the moment this was written: 45 playable books in the window,
    # 20 of them clearing the gate, and only 2 reachable inside --books 5.
    #
    # The fix is a SELECTION pre-filter, not a second gate: rank books that
    # would clear the entry gate ahead of those that would not, then cap. The
    # real decision still runs per-cycle in book_state() against fresh prices,
    # so a book whose touch moves between selection and quoting is still gated
    # there. This only stops slots being spent on books that cannot be quoted.
    #
    # Committed books keep absolute priority -- they must stay quoted to
    # complete, and a stranded 1-of-3 is the most expensive state in the
    # strategy. Gated books are not dropped either, only ranked last, so a thin
    # window still fills the cap rather than quoting nothing.
    def _entry_ok(legs, ev=None):
        # A book already RESTING keeps its rank until its touches fall
        # EXIT_BAND below target -- same hysteresis as book_budget's gate.
        # Without it, a 1c tick drops the incumbent to the back of the
        # ranking and the 2-slot cap hands its slot to the next book: that is
        # the SFECAL/CANMIL swap that re-posted both every ~1.5 min on
        # 2026-09-22.
        band = EXIT_BAND if ev in resting_events else 0.0
        asks = []
        for L in legs:
            a = L.get('yes_ask_dollars')
            if a is None:
                return False
            try:
                a = float(a)
            except (TypeError, ValueError):
                return False
            if not (0.0 < a < 1.0):
                return False
            asks.append(a)
        if len(asks) != 3:
            return False
        return (sum(asks) >= 1.0 + sum(maker_fee(a) for a in asks)
                + TARGET_MARGIN - band)

    # TWO ARMS, ONE PROCESS (2026-08-29). Both rankings run and each gets its
    # own slots, so the comparison sees identical fixtures, prices and
    # collateral rather than two different weeks.
    #
    #   'vol' -- volume first, kickoff last. What ran until this morning. It
    #            reaches the marquee weekend fixture, which is WHY the books it
    #            picked sat a median 23-49h from kickoff.
    #   'ko'  -- kickoff first, volume last. This morning's change, chosen on
    #            completions per slot-hour.
    #
    # Why this is worth measuring rather than assuming: slot-hour throughput
    # said 'ko' by 9x, but dollars per book RISKED say the opposite, because a
    # strand costs ~10x what a completion makes and far books carry ~2c more
    # overround (median gross 1.060 beyond 48h vs 1.040 inside it). Those two
    # metrics disagree and only fills settle it.
    prefix = (lambda t: (0 if t[0] in held_events else 1,
                         0 if _entry_ok(t[1], t[0]) else 1))
    # ONE ranking for both arms. Volume-first is the regime that produced
    # 08-24..08-28, and holding selection constant is what makes the pricing
    # comparison mean anything.
    ranked = sorted(out, key=lambda t: prefix(t) + (-_book_vol(t[1]), t[3]))

    arms = load_arms()
    picked, seen, newly = [], set(), []

    # Committed books keep the arm that opened them and are never dropped: a
    # book carrying legs must stay quoted, and reassigning it would charge one
    # arm for the other's strand.
    for t in ranked:
        if t[0] in held_events and t[0] not in seen:
            seen.add(t[0]); picked.append(t)
            if t[0] not in arms:
                # 'budget' until 2026-09-07; that arm is retired (see ARM_SPEC).
                # A held book whose arm was pruned from ARM_FILE lands here, and
                # load_arms() already drops labels not in ARM_CAP, so naming a
                # retired arm would leave it unmapped and silently re-priced at
                # the module default every cycle.
                newly.append((t[0], 'latch3'))
                arms[t[0]] = 'latch3'

    counts = {a: sum(1 for t in picked if arms.get(t[0]) == a) for a in ARM_CAP}
    _gated_thin = 0
    for t in ranked:
        ev = t[0]
        if ev in seen:
            continue
        # OPEN-INTEREST GATE (see MIN_BOOK_OI). Held books never reach here --
        # they were added to `seen` in the committed-books pass above and stay
        # quoted regardless, because a stranded 1-of-3 is the most expensive
        # state in the strategy and this gate must never create one.
        #
        # A hard gate, not a ranking term, unlike the entry-gate pre-filter
        # above. That one ranks rejects last so a thin window still fills the
        # cap; this one must not, because a forward test that quietly admits
        # low-OI books on quiet nights measures a different rule than the one
        # being tested. Posting nothing is the correct action here.
        _oi = _book_oi(t[1])
        if _oi is not None and _oi < MIN_BOOK_OI:
            _gated_thin += 1
            continue
        # a book already owned by an arm keeps it; a new one goes to whichever
        # arm is furthest from its quota, which walks the ranking alternately
        # so neither arm systematically draws the better books
        arm = arms.get(ev) or min(ARM_CAP, key=lambda a: counts[a])
        if arm not in ARM_CAP or counts[arm] >= ARM_BOOKS:
            continue
        if ev not in arms:
            newly.append((ev, arm))
        arms[ev] = arm
        counts[arm] += 1
        seen.add(ev); picked.append(t)
        if all(counts[a] >= ARM_BOOKS for a in ARM_CAP):
            break

    save_arms({e: a for e, a in arms.items() if e in seen}, newly)
    global _ARM_OF, _THIN_GATED
    _ARM_OF = dict(arms)
    _THIN_GATED = _gated_thin
    cap = max(MAX_BOOKS, sum(1 for t in picked if t[0] in held_events))
    return picked[:cap]


def cycle(st, live):
    global _POSTS_MADE
    now = _now()
    lock_beat()
    global _equity_tick, _equity_last
    _equity_tick += 1
    if live and _equity_tick % _EQUITY_EVERY == 1:
        eq = account_equity()
        if eq is not None:
            _equity_last = eq
            down = FUNDED_USD - eq
            if down >= STOP_LOSS_USD:
                print(f'[STOP LOSS] equity ${eq:.2f} vs ${FUNDED_USD:.2f} '
                      f'funded -- down ${down:.2f}, limit ${STOP_LOSS_USD:.2f}. '
                      f'Cancelling and stopping.')
                km.cancel_all('stop loss')
                return False
    if KILL_FILE.exists():
        # 'restart' in the file (written by restart_maker.py) means a RESTART:
        # keep the completing legs of held books resting so they survive the
        # gap -- see kalshi_maker.cancel_all(keep_held). An empty or any other
        # kill file is a deliberate STOP and still cancels everything.
        global _RESTART_MODE
        try:
            _RESTART_MODE = KILL_FILE.read_text(encoding='utf-8').strip() == 'restart'
        except Exception:
            _RESTART_MODE = False
        print(f'[KILL] {KILL_FILE} present -- '
              + ('restart: keeping held-book legs, cancelling the rest'
                 if _RESTART_MODE else 'cancelling and stopping'))
        km.cancel_all('kill file', keep_held=_RESTART_MODE)
        return False

    # what the exchange says we hold and have resting, read fresh each cycle.
    #
    # FILLS COME FROM /portfolio/fills, NOT FROM POSITIONS. The first armed run
    # sold KXLALIGAGAME-26AUG15ALAGET-ALA at 0.40 and get_positions() reported
    # nothing at all -- a short from a sell did not surface there the way this
    # code originally assumed, so the fill was invisible and the fill log stayed
    # empty. Since fill rate is the entire deliverable of this experiment, the
    # detector cannot depend on an endpoint that silently omits the event.
    try:
        # ticker -> LIST. Keying this by ticker as a single order is what let
        # duplicates run away on 2026-08-15: the listing lags a placement by a
        # few seconds, so the next cycle saw no resting order, posted a second,
        # and from then on the dict only ever showed one of the pair -- each
        # cycle cancelled one and posted one, and the count climbed to 81.
        resting = {}
        for o in (kt.get_open_orders() or {}).get('orders', []) or []:
            resting.setdefault(o.get('ticker'), []).append(o)
        fills_by_tkr = {}
        for fl in (kt.get_fills() or {}).get('fills', []) or []:
            t = fl.get('ticker')
            if t:
                fills_by_tkr.setdefault(t, []).append(fl)
        # position_fp -- see kalshi_maker.current_position on why the bare
        # 'position' key silently reads as flat
        positions = {p.get('ticker'): float(p.get('position_fp') or 0)
                     for p in (kt.get_positions() or {}).get('market_positions', [])}
    except Exception as e:
        print(f'  [warn] portfolio read failed, skipping cycle: '
              f'{type(e).__name__}: {e}')
        return True

    # books we already hold a leg in -- the only ones allowed in play
    held = {'-'.join(t.split('-')[:2]) for t, q in positions.items()
            if q <= -1e-9}

    # A COMPLETE BOOK MUST NOT HOLD A SLOT.
    # Held books sort to the front so a committed one is never dropped, but a
    # book with all three legs sold has nothing left to post -- book_budget
    # returns 'quote' with no open legs and the cycle does nothing with it.
    # It still consumed one of MAX_BOOKS and was re-priced every cycle.
    # Harmless at 40 books; at 10 it was FIVE of the ten slots (measured
    # 2026-08-22: 5 complete, 5 part-filled), so half the cap was doing no
    # work and new books could not be opened. Counting legs from POSITIONS
    # rather than state, because positions are what the exchange actually says
    # we hold.
    # A LEG COUNTS AS DONE ONLY WHEN IT IS SOLD TO **SIZE**, not when it holds
    # any short at all. `q <= -1e-9` treated a PARTIAL fill as a finished leg,
    # so a book with a sliver sold on its last leg looked complete, landed in
    # `done`, and was dropped from `books` entirely -- never re-quoted, with the
    # remainder left unsold and unoffered. The fill detection lives inside the
    # book loop, so the partial was not even logged.
    #
    # Como vs Parma, 2026-09-14: TIE filled, the book needed 0.79 from COM, a
    # sweep took 0.09 of the 1.00 at 0.83, and that 0.09 was enough to mark the
    # book done. The remaining 0.91 sat unoffered for 13 minutes and had to be
    # completed by hand. Worse than idle: the book stops tracking the market, so
    # a quote that should have RISEN with the goal stayed parked at 0.83.
    # PER-TICKER, not one global _full (2026-09-22): a leg sold to completion
    # under an earlier SIZE reads a smaller short than the live SIZE demands,
    # and testing it against a single bigger threshold would un-mark that book
    # as done -- the same class of bug leg_size() exists to prevent, reached
    # through book-completion detection instead of the posting path. A leg
    # this maker has never touched (nothing in `st`) has no frozen size to
    # honour, so it falls through to the live SIZE, same as before.
    _legs_held = {}
    for t, q in positions.items():
        _thr = leg_size(st.get(t) or {}) - 1e-9
        if q <= -_thr:
            _legs_held['-'.join(t.split('-')[:2])] = \
                _legs_held.get('-'.join(t.split('-')[:2]), 0) + 1
    done = {ev for ev, n in _legs_held.items() if n >= 3}
    if done:
        held = held - done
    # events with an order resting right now -- the incumbents the entry
    # hysteresis protects (EXIT_BAND)
    _resting_evs = {'-'.join(t.split('-')[:2]) for t, os_ in resting.items()
                    if t and os_}
    books = [b for b in books_in_window(now, held, _resting_evs)
             if b[0] not in done]
    # --finish-only: work ONLY books we already hold a leg in. Stopping
    # the experiment is not the same as flattening it -- the open legs
    # are directional until their book completes, and a completed book
    # pays +2 to +4c instead. --max-posts alone does not do this: legs
    # offered in an earlier session resume for free, so a 'stopped'
    # maker was still quoting 12 books we had no position in.
    if FINISH_ONLY:
        books = [b for b in books if b[0] in held]

    # SNAPSHOT THE HELD-EVENT SET UNDER ITS OWN NAME. `held` is REBOUND to a
    # float inside the fill accumulator below (`held = -_pos if _pos < 0 ...`),
    # so any later use of it as a set raises "argument of type 'float' is not
    # iterable" -- which took the whole cycle down on 2026-09-13 and left the
    # maker quoting nothing. Anything after the leg loop must use this name.
    _held_events = set(held)

    # ROTATION CLEANUP -- CANCEL OFFERS ON BOOKS WE NO LONGER WORK.
    #
    # Selection re-runs every cycle and may swap a book out for a better one.
    # Books that drop out of `picked` are simply not iterated, so nothing here
    # ever cancelled their resting orders: the ORDER TTL did it. That was
    # invisible at 90s and is a real hole at 3600s -- a rotated-away book would
    # keep live offers for up to an hour, and a fill on one creates a partial
    # book on an event the maker is not working, i.e. the stranded state that
    # costs -$0.69. Rotating properly means the old offers go.
    #
    # HELD BOOKS ARE EXEMPT and must stay quoted: they are already committed
    # and only complete if their remaining legs stay on the book. Same carve-out
    # the budget rule makes above. `done` books are excluded from `books` and
    # have nothing left to sell, so cancelling theirs is correct too.
    #
    # AN EMPTY `books` IS A DATA FAILURE, NOT A ROTATION. A zero-fixture
    # watchlist is a real observed state (live_monitor sat on one for eight
    # hours on 2026-09-02), and treating it as "we no longer work any book"
    # would cancel every offer we have on a transient read. Rotation means
    # swapping one book for another, so require that something was selected.
    _active = {b[0] for b in books}
    _rotated = 0
    for _tkr, _orders in (list(resting.items()) if _active else []):
        if not _tkr:
            continue
        _ev = '-'.join(_tkr.split('-')[:2])
        if _ev in _active or _ev in _held_events:
            continue
        for _o in _orders:
            try:
                km.cancel_order_v2(_o['order_id'])
                _rotated += 1
            except Exception:
                pass            # already gone; the state drop below still applies
        st.pop(_tkr, None)
    if _rotated:
        print(f'  [ROTATE] cancelled {_rotated} offer(s) on books no longer '
              f'selected')

    posted = refreshed = filled = abandoned = gated = 0
    # Free cash remaining THIS CYCLE, read once and drawn down per book.
    _cycle_head = None
    for ev, legs, match, hrs, league, fid in books:
        # Pinnacle de-vigged fair, per side. Fetched once per book, cached by
        # live_monitor, and tolerated as blank -- a missing Pinnacle line must
        # not stop us quoting, it just leaves the column empty.
        fair = {}
        try:
            _b365, _pin = Q.LM.fetch_prematch_odds(fid) if fid else (None, None)
            if _pin and all(_pin.get(k) for k in ('h', 'd', 'a')):
                _f = Q.devig([_pin['h'], _pin['d'], _pin['a']])
                fair = dict(home=_f[0], draw=_f[1], away=_f[2])
        except Exception:
            pass
        # ── BOOK BUDGET, recomputed every cycle ───────────────────────────
        # What has already been banked on this book, and therefore what the
        # legs still open have to fetch between them.
        asks = {}
        for L in legs:
            try:
                a = float(L.get('yes_ask_dollars') or 0)
            except (TypeError, ValueError):
                a = 0.0
            if 0 < a < 1:
                asks[L.get('ticker')] = a
        if len(asks) != 3:
            continue
        _spec = ARM_SPEC.get(_ARM_OF.get(ev)) or {}
        # Book-level size, frozen to whatever its legs were opened under (see
        # leg_size()) -- NOT the live SIZE, or a book that predates a SIZE
        # change would suddenly be priced for a different payout than it was
        # ever collateralised for. A brand-new book has no legs in `st` yet,
        # so this falls through to the live SIZE, same as before.
        _book_size = max((leg_size(st.get(t) or {}) for t in asks),
                         default=float(SIZE))
        b = book_budget(asks, st, size=_book_size,
                        latch_cap=_spec.get('latch_cap', LATCH_CAP),
                        alloc=_spec.get('alloc', 'even'),
                        incumbent=ev in _resting_evs)
        collected, open_legs, scale = b['collected'], b['open_legs'], b['scale']

        # Total traded volume across all three legs -- the book's liquidity as
        # one number, which is what fill rate should actually be regressed on.
        # Per-leg volume splits the same book three ways and understates it.
        _bookvol = 0.0
        for L in legs:
            try:
                _bookvol += float(L.get('volume_fp') or 0)
            except (TypeError, ValueError):
                pass

        # BOOK-ATOMIC BUDGET. A book pays only when all three legs sell, so one
        # leg of a three-leg book is the single allocation that can never
        # complete -- and if it fills we are stranded by construction.
        #
        # The budget counts legs, and books are worked nearest-kickoff first, so
        # it ran out mid-book and left NWSL NCCHDA showing one resting order for
        # hours. That book was fine: 102c against a 101.5c target with ~2,000 of
        # depth on every leg. Only the allocation was broken.
        #
        # So spend budget in units of a whole book, and if a book we hold
        # NOTHING in cannot get all its legs, cancel the orphans rather than
        # leave an offer that can only hurt. Books we DO hold a leg in are
        # exempt: those are already committed and must stay quoted to complete.
        fresh = [t for t in open_legs if t not in _OFFERED]
        if fresh and collected <= 0 and _POST_BUDGET:
            if _POSTS_MADE + len(fresh) > _POST_BUDGET:
                for t3 in open_legs:
                    for o in (resting.get(t3) or []):
                        try:
                            km.cancel_order_v2(o['order_id'])
                        except Exception:
                            pass
                    st.pop(t3, None)
                gated += 1
                continue

        if b['decision'] == 'gate':
            for t3 in open_legs:
                for o in (resting.get(t3) or []):
                    try:
                        km.cancel_order_v2(o['order_id'])
                    except Exception:
                        pass
                st.pop(t3, None)
            gated += 1
            continue

        if b['decision'] == 'abandon':
            # DO NOT CANCEL. This used to cancel every resting leg and drop the
            # book, which conflated two different things: "never sell below
            # budget" (right) and "stop offering" (wrong).
            #
            # The resting order is priced AT the budget, so any fill completes
            # the book at a profit -- 82c banked plus 19.5c against a 1.00
            # payout. A completed book pays the same whoever wins, so it cannot
            # matter why the price came back or how likely the outcome then is.
            # Cancelling throws away a free option and keeps the unhedged
            # partial, which is the worse half of both worlds.
            #
            # AJAHEE on 2026-08-16 is the case: two legs filled at 68c and 14c,
            # the draw was resting at 20c, Ajax scored and the draw collapsed to
            # 11c, and the rule cancelled. Had it been left, a Heerenveen
            # equaliser would have carried the draw back through 20c and paid.
            # The only cost of leaving it is collateral, not risk.
            abandoned += 1
            print(f'  [HOLD] {match[:38]}: collected {collected:.2f}, '
                  f'needs {b["need"]:.2f} from {len(open_legs)} leg(s), '
                  f'{b["worst_over_touch"]*100:.1f}c over touch -- '
                  f'holding the offer')
            # NO `continue` here. Falling through to the posting loop is the
            # whole point: orders carry a 90s TTL, so "leave it resting" only
            # means anything if something keeps re-posting it. Skipping the
            # loop would let the offer quietly expire and never return, which
            # is cancelling by omission.
            #
            # MAX_ABOVE_TOUCH therefore no longer gates whether we quote, only
            # what gets logged. That is correct once a book is part-filled:
            # floor_for() prices the leg at the budget, every fill there
            # completes the book at a profit, and being far above the touch
            # costs nothing but collateral and a low fill probability. The
            # entry gate is what stops us opening hopeless books; this branch
            # only ever sees books we are already committed to.

        # A BOOK IS ALL-OR-NOTHING AGAINST THE COLLATERAL CAP (2026-09-01).
        #
        # The cap is enforced per ORDER in kalshi_maker, so it could cut a book
        # in half: SFEMIL posted SFE at $21.87 of a $22 cap, then MIL and TIE
        # were both rejected, leaving one leg resting on a three-leg book. That
        # is the strand state -- the most expensive one in the strategy -- and
        # the cap was manufacturing it rather than preventing anything.
        #
        # So for a book we do NOT already hold legs in, check the WHOLE book
        # fits before posting any of it. Collateral for a leg is (1-ask)*SIZE,
        # so a three-leg book is (3 - sum(asks)) * SIZE, about $6 at SIZE 3.
        # Books we already hold are exempt: those legs are committed and must
        # stay quoted to complete, which is the same reasoning the entry gate
        # and the in-play branch already use.
        # `collected` is the premium already banked on this book, so it is >0
        # exactly when a leg has filled. That is the "we are committed" test in
        # this scope -- held_events lives in books_in_window, not here.
        # ONLY CHARGE HEADROOM FOR A BOOK THAT IS NOT ALREADY UP.
        #
        # A book already resting has its collateral inside resting_collateral(),
        # which cash_headroom() already subtracts. Charging it again every cycle
        # double-counts: with 3 books up, headroom read $17.96 and then had
        # 3 x $5.94 taken off it again, leaving "$0.17 free" and gating books it
        # could plainly afford. It shrank the maker from 5 books to 3 within two
        # cycles of the cap being removed.
        _already_up = any(resting.get(L.get('ticker')) for L in legs)
        if collected <= 0 and not _already_up:
            _fit = sum((1.0 - asks[L.get('ticker')]) * SIZE
                       for L in legs if asks.get(L.get('ticker')) is not None)
            # Headroom is read ONCE per cycle and then drawn down locally: the
            # exchange lags what we posted moments ago in the same pass, and a
            # stale read is what let Real Betis through half-posted.
            if _cycle_head is None:
                _cycle_head = cash_headroom()
                if _cycle_head is None:
                    _cycle_head = -1.0        # unreadable -> post nothing new
            if _fit > _cycle_head:
                print(f'  [fit] {match[:38]}: book needs ${_fit:.2f}, only '
                      f'${_cycle_head:.2f} free -- skipping whole book rather '
                      f'than posting part of it')
                gated += 1
                continue
            # Committed. Spend it now, before posting, so the next book in this
            # same cycle sees it whether or not the exchange has caught up.
            _cycle_head -= _fit

        for L in legs:
            tkr = L.get('ticker')
            ask = asks.get(tkr)
            if ask is None:
                continue
            # A LEG XLOW IS LONG STILL GETS QUOTED (2026-09-01, user: "the
            # maker shouldn't stop working if a book has bought xlow YES
            # shares"). This REVERSES the 2026-08-31 skip.
            #
            # Why the skip existed: Kalshi nets both sides into one position
            # per ticker, so the maker selling a leg xlow is long sells xlow's
            # own contracts back. On 2026-08-24 that sold 3 of xlow's 5 FIO
            # contracts at the 18c it had just paid.
            #
            # Why it is worse than the disease: the skip does not cost "one
            # third of a book", it costs the whole book. The other two legs
            # keep quoting and fill, and the third never can, so the book is a
            # GUARANTEED strand -- and the entry gate does not in fact refuse
            # these, whatever the old comment claimed. Observed 2026-09-01:
            # xlow bought BCSOU-BC at 09:01, the maker opened the same book at
            # 12:07 and sat at 1 of 3. A strand is -$0.69 against +$0.09 for a
            # clean book, so the skip converts the maker's only profitable
            # outcome into its worst one. Netting against xlow is a partial
            # offset on ONE leg; stranding forfeits the entire book.
            #
            # The accounting half of the 08-24 incident is separately fixed and
            # stays fixed: fills are attributed by order_id (_our_ids below), so
            # the maker books only its own sales no matter what the net position
            # reads, and the phantom self-heal below only fires when there are
            # no recorded fill_ids at all.
            # AT the touch normally; ABOVE it when the budget demands, because
            # selling below budget locks a loss the instant it fills.
            floor = b['floor_for'](tkr)
            price_c = int(round(floor * 100 + (0.4999 if floor > ask else 0)))
            if not (1 <= price_c <= 99):
                continue

            # Filled? `action` alone does not identify our fills. A fill from
            # our post_only ask reads action=sell, side=no, is_taker=false; a
            # manual taker unwind of that same position ALSO reads action=sell
            # (side=yes, is_taker=true), so matching on action would book an
            # exit as a new sale and corrupt `collected`. Our orders are
            # post_only and therefore always the maker side -- require that.
            s = st.get(tkr) or {}
            posted_iso = s.get('posted_at') or ''
            # SELF-HEAL A PHANTOM FILL.
            # This maker only ever SELLS, so its OWN fills are always short. A
            # booked fill on a leg the exchange shows us LONG therefore cannot
            # be ours -- it is xlow's position, misread by the position-based
            # fallback that lived here until 2026-08-23.
            #
            # SINCE 2026-09-01 the net position alone no longer proves that:
            # the maker now quotes legs xlow is long, so if xlow's long exceeds
            # our short the ticker nets LONG while we do hold real fills. The
            # `not fill_ids` term is what keeps this correct -- a genuine maker
            # fill always records an id, so only a booked quantity with NO id
            # behind it can be healed away. Do not relax that term to a bare
            # position check.
            #
            # The code fix stops new ones, but state written before it persists
            # and is silently fatal: ROMFIO-FIO carried fill_qty 5.0 with NO
            # price, so `collected` counted 5 contracts sold for $0, the budget
            # rule demanded the whole dollar from the rest, and the leg was
            # never quoted again. The book stays broken until someone edits the
            # state file by hand -- which is not a thing that should be needed.
            #
            # floor_px goes too: the ratchet latched off the phantom's arithmetic
            # and only ever rises, so leaving it keeps the bad price forever.
            if (positions.get(tkr, 0) > 1e-9
                    and float(s.get('fill_qty') or 0) > 0
                    and not (s.get('fill_ids') or [])):
                print(f'  [heal] {tkr}: booked fill {s.get("fill_qty")} but '
                      f'exchange shows LONG {positions.get(tkr)} -- not ours '
                      f'(xlow trades these markets), clearing phantom')
                for _k in ('fill_qty', 'fill_cash', 'fill_px', 'logged_qty',
                           'fill_logged', 'floor_px'):
                    s.pop(_k, None)
                st[tkr] = s
            # order_id is the airtight discriminator; the action/is_taker
            # heuristic below it is kept as the fallback for legs posted before
            # our_ids existed. Requiring BOTH costs nothing and closes the gap
            # where another strategy's fill on this ticker looks like ours.
            _our_ids = set(s.get('our_ids') or [])
            ours = [f for f in fills_by_tkr.get(tkr, [])
                    if str(f.get('action', '')).lower() == 'sell'
                    and not f.get('is_taker')
                    and str(f.get('side', '')).lower() != 'yes'
                    and str(f.get('created_time', '')) >= posted_iso
                    and posted_iso
                    and (not _our_ids or f.get('order_id') in _our_ids)]
            # Accumulate quantity and cash across every distinct fill, deduped
            # by fill_id: one order can come back in several pieces, and
            # posted_at moves forward on each refresh, so recomputing from the
            # current window alone would drop earlier pieces.
            seen = set(s.get('fill_ids') or [])
            new = [f for f in ours if f.get('fill_id') not in seen]
            if new:
                for f in new:
                    try:
                        q = float(f.get('count_fp') or 0)
                        px = float(f.get('yes_price_dollars') or 0)
                    except (TypeError, ValueError):
                        continue
                    if q <= 0:
                        continue
                    s['fill_qty'] = float(s.get('fill_qty') or 0) + q
                    s['fill_cash'] = float(s.get('fill_cash') or 0) + px * q
                    seen.add(f.get('fill_id'))
                s['fill_ids'] = sorted(x for x in seen if x)
                if s.get('fill_qty'):
                    s['fill_px'] = s['fill_cash'] / s['fill_qty']
                st[tkr] = s
            elif not s.get('fill_qty') and positions.get(tkr, 0) <= -1e-9:
                # A short exists that we could not attribute above. The usual
                # cause is that this leg's state was popped -- the gate or the
                # abandon rule clears it -- and the resting order then filled
                # anyway, racing the cancel. `ours` is empty in that case
                # because the filter requires posted_at, which went with the
                # state.
                #
                # Take the price from the EXCHANGE'S OWN fill record, not from
                # our missing bookkeeping. This previously read
                # `s.get('price_cents') or 0` and so recorded 0.0 for exactly
                # the case it was written for: SKBHAK-SKB really sold at 0.63
                # and went into state as fill_px 0.0, which tells the budget
                # rule the book has banked NOTHING and makes it demand the full
                # dollar from the remaining two legs. It also writes a false 0c
                # into the fill log that the fill-rate model would later read.
                mk = [f for f in fills_by_tkr.get(tkr, [])
                      if str(f.get('action', '')).lower() == 'sell'
                      and not f.get('is_taker')
                      and str(f.get('side', '')).lower() != 'yes']
                qty = cash = 0.0
                for f in mk:
                    try:
                        q = float(f.get('count_fp') or 0)
                        px = float(f.get('yes_price_dollars') or 0)
                    except (TypeError, ValueError):
                        continue
                    if q > 0:
                        qty += q
                        cash += px * q
                if qty > 0:
                    s['fill_qty'] = qty
                    s['fill_px'] = cash / qty
                    s['fill_ids'] = sorted(x for x in
                                           (f.get('fill_id') for f in mk) if x)
                else:
                    # NO MAKER FILL RECORD. Until 2026-08-23 this trusted the
                    # exchange POSITION and invented a fill from it. That is the
                    # bug that broke RACBOC: xlow bought 2 BOC on a market this
                    # was quoting, the position moved, and the leg was booked as
                    # sold -- with no price to attach, so `collected` counted it
                    # at 0c. The budget rule then demanded the whole dollar from
                    # the remaining legs and the ratchet latched 60c/94c floors,
                    # which never come down. The book was dead and had to be
                    # cancelled by hand.
                    #
                    # A position is NOT evidence of our fill. This account runs
                    # xlow against the same tickers, so the only safe attribution
                    # is our own order ids (recorded at post time) or, for legacy
                    # legs, a maker-side fill record. If neither exists, book
                    # NOTHING and say so -- an unattributed short is a real
                    # position and worth seeing, but inventing a price for it
                    # corrupts every book it touches.
                    foreign = [f for f in fills_by_tkr.get(tkr, [])
                               if not _our_ids or f.get('order_id') not in _our_ids]
                    print(f'  [!] {tkr}: short '
                          f'{positions.get(tkr, 0)} with no attributable maker '
                          f'fill'
                          + (f' ({len(foreign)} fill(s) on this ticker are not '
                             f'ours -- xlow trades these markets)' if foreign
                             else '')
                          + ' -- NOT booking a fill')
                st[tkr] = s

            got = float(s.get('fill_qty') or 0)
            # TRUST THE EXCHANGE ON QUANTITY. A 0.46 fill on CASHUR-TIE left our
            # books saying 0.46 while the exchange reported position -1.00, so
            # the budget thought 0.54 was still open and would have kept
            # offering -- taking us to 1.54 short on a leg capped at 1. The
            # position is the obligation; our fill sum is only how much premium
            # we can prove we collected. Where they disagree, the larger
            # quantity is the one we owe.
            # ONLY A SHORT IS OURS. This was abs(), which treated xlow's
            # LONG position as quantity we had sold -- re-creating, in the very
            # same cycle, the phantom the heal above had just cleared. The
            # exchange nets both strategies into ONE position per ticker, so the
            # sign is the only thing separating "we sold 5" from "xlow bought 5".
            #
            # And once another strategy has traded this ticker at all, the netted
            # number cannot be decomposed -- our -3 alongside xlow's +5 reads +2,
            # which is neither. So in that case abandon position-based
            # reconciliation entirely and let the order_id-matched fill sum stand
            # as the obligation. That keeps the CASHUR-TIE repair (quantity drift
            # on a partial) working for every book xlow has never touched.
            _pos = positions.get(tkr, 0.0)
            # ONLY A FOREIGN *BUY* CONTAMINATES THE POSITION (fixed 2026-09-03).
            #
            # This guard exists because xlow BUYING the same ticker nets against
            # our short, so the position stops being our obligation and the
            # order_id-matched sum has to stand instead.
            #
            # But it matched ANY foreign fill, and a manual unwind is a foreign
            # SELL. So flattening a leg by hand -- the exact thing the "closed
            # outside the maker" heal below exists for -- set held = got, fell
            # into `elif held > 1e-9`, and popped flat_seen every cycle. The
            # heal could never reach two consecutive reads, so it never fired.
            #
            # 2026-09-03: the user sold ATHATM-ATM by hand while the maker was
            # down in a network outage. The maker kept believing it held the leg,
            # never re-quoted it, and the book sat at 2 of 3 -- which is the
            # -$0.69 strand state. Clearing the state by hand was the only way
            # out, and it would have recurred on the next manual close.
            #
            # A foreign SELL means someone closed our short; the exchange
            # position is then exactly right and must be trusted. A foreign BUY
            # is the xlow case and still wins.
            _foreign_here = [f for f in fills_by_tkr.get(tkr, [])
                             if _our_ids and f.get('order_id') not in _our_ids
                             and str(f.get('action', '')).lower() == 'buy']
            if _foreign_here:
                held = got
            else:
                held = -_pos if _pos < 0 else 0.0
            if held > 1e-9 and abs(held - got) > 1e-9:
                # SYMMETRIC. Raising got when the exchange shows more was the
                # first version, and it left the opposite error uncorrected: a
                # state repair that set fill_qty without fill_ids let the
                # accumulator count the same fill twice, so MOQ read 2.0 against
                # a real position of 1.0 and printed "2/1 @ 18c" -- the cash was
                # right, the quantity was not. The exchange position is the
                # obligation either way, so track it in both directions and let
                # a corrupted count heal itself.
                #
                # AND HEAL fill_px WITH IT (2026-09-21). Healing the count
                # alone left fill_px -- which is fill_cash/fill_qty, computed
                # when the count was still wrong -- stale, and book_budget
                # reads fill_px, not the cash. LANELP-LAN sold 1.00 @ 0.47,
                # the count briefly read 2.0, fill_px froze at 0.235, and the
                # repair below fixed the count and left it there. The budget
                # then saw 0.485 collected where 0.72 was banked, demanded
                # 0.5316 from the last leg and quoted the tie at 54c into a
                # 33c market -- a completed book turned into a near-strand.
                # 12 legs in the live state carried this, 7 of them exactly 2x.
                _prev_q, _cash = got, float(s.get('fill_cash') or 0.0)
                got = held
                s['fill_qty'] = got
                if _cash > 0 and got > 1e-9:
                    if got > _prev_q + 1e-9:
                        # HEALED UP: a fill we never attributed, so the cash is
                        # short by that piece and its price is unknown. Value it
                        # at what we posted at -- the same fallback book_budget
                        # already uses for a filled leg whose price was lost.
                        # Leaving the cash short would understate `collected`
                        # and overprice what remains; carrying the old per-unit
                        # price over the larger count would overstate it and
                        # underprice the book into a locked loss.
                        _est = float(s.get('price_cents') or 0) / 100.0
                        if _est > 0:
                            _cash += _est * (got - _prev_q)
                            s['fill_cash'] = _cash
                    # HEALED DOWN: the cash is right and the count was
                    # inflated, so the unit price simply re-derives.
                    s['fill_px'] = _cash / got
                s.pop('flat_seen', None)
                st[tkr] = s
            elif held > 1e-9:
                s.pop('flat_seen', None)
                st[tkr] = s
            elif got > 1e-9 and tkr in positions:
                # CLOSED OUTSIDE THE MAKER -- the case the "symmetric" repair
                # above still cannot reach, because it lives inside
                # `held > 1e-9` and so can only ever heal a count UP.
                #
                # 2026-08-17: the machine crashed mid-session and six legs were
                # flattened by hand in the app. State kept fill_qty=1 and
                # fill_logged=True, so the `continue` below ran forever -- the
                # leg was neither resting nor held and its book could never
                # complete. Worse, book_budget still counted the phantom 32c as
                # collected, so the other two legs were priced to fetch only the
                # remainder of the dollar against a payout we owed in full.
                #
                # Kalshi returns a row at position 0.0 for something traded and
                # then closed, and omits tickers never traded, so `tkr in
                # positions` separates "flattened" from "untouched". Settled
                # markets cannot reach here: books_in_window only yields
                # pre-kickoff books, so a settled leg is never quoted at all.
                #
                # TWO CONSECUTIVE FLAT READS before acting. A fill we booked
                # from the fills feed can lead the position feed by a moment,
                # and resetting on that race would re-offer a leg we really do
                # hold -- overselling it to 2.0 on a leg capped at 1.
                seen_flat = int(s.get('flat_seen') or 0) + 1
                s['flat_seen'] = seen_flat
                st[tkr] = s
                if seen_flat >= 2:
                    # fill_ids is deliberately KEPT: it is the dedupe set for the
                    # accumulator above, and clearing it would let the original
                    # maker fill be re-detected and re-banked next cycle.
                    print(f'  [RESET] {tkr} state says {got:g} sold, exchange '
                          f'flat -- closed outside the maker; re-offering')
                    s['fill_qty'] = 0.0
                    s['fill_cash'] = 0.0
                    s['fill_px'] = None
                    s['logged_qty'] = 0.0
                    s['fill_logged'] = False
                    s.pop('flat_seen', None)
                    st[tkr] = s
                    got = 0.0
            if got > 1e-9:
                # log once per QUANTITY, not once per cycle. fill_logged was set
                # False again on every partial, so a partially filled leg
                # re-logged itself every 20s -- one leg appeared 8 times in
                # mm_live_realfills.csv, which is the dataset the fill model is
                # meant to be fitted on.
                if float(s.get('logged_qty') or 0) < got - 1e-9:
                    log_fill({'ts': now.isoformat(), 'ticker': tkr, 'event': ev,
                              'side': tkr.rsplit('-', 1)[-1],
                              'price_cents': s.get('price_cents', ''),
                              'count': got,
                              'queue_ahead_at_post': s.get('queue_ahead', ''),
                              'posted_at': posted_iso,
                              'seconds_to_fill': (
                                  round(time.time() - s['posted_ts'], 1)
                                  if s.get('posted_ts') else '')})
                    s['logged_qty'] = got
                    st[tkr] = s
                    filled += 1
                    print(f'  [FILL] {tkr} {got:g}/{leg_size(s):g} @ '
                          f'{s.get("fill_px", 0)*100:.0f}c '
                          f'after {s.get("queue_ahead")} ahead')

                    # Capture the book before it moves (see FLATTEN_LOG).
                    def _f(v):
                        try:
                            return float(v)
                        except (TypeError, ValueError):
                            return None
                    _fb, _fa = _f(L.get('yes_bid_dollars')), _f(L.get('yes_ask_dollars'))
                    _sv = [X for X in legs if X.get('ticker') != tkr]
                    _svb = [_f(X.get('yes_bid_dollars')) for X in _sv]
                    _sva = [_f(X.get('yes_ask_dollars')) for X in _sv]
                    _nfill = sum(1 for X in legs
                                 if float((st.get(X.get('ticker')) or {}).get('fill_qty')
                                          or 0) > 1e-9)
                    _j = lambda xs: '|'.join('' if x is None else f'{x:.4f}' for x in xs)
                    log_flatten_snap({
                        'ts': now.isoformat(), 'event': ev, 'league': league,
                        'ticker': tkr, 'side': tkr.rsplit('-', 1)[-1],
                        'hrs_to_ko': f'{hrs:.2f}' if hrs is not None else '',
                        'legs_filled_now': _nfill,
                        'sold_px': (f'{s["fill_px"]:.4f}'
                                    if s.get('fill_px') is not None else ''),
                        'fill_qty': got,
                        'collected': f'{collected:.4f}',
                        'filled_bid': '' if _fb is None else f'{_fb:.4f}',
                        'filled_ask': '' if _fa is None else f'{_fa:.4f}',
                        'filled_spread': ('' if None in (_fb, _fa)
                                          else f'{_fa - _fb:.4f}'),
                        'survivor_tickers': '|'.join(X.get('ticker') or '' for X in _sv),
                        'survivor_bids': _j(_svb),
                        'survivor_asks': _j(_sva),
                        'survivor_ask_sum': ('' if any(x is None for x in _sva)
                                             else f'{sum(_sva):.4f}'),
                    })
                # only stop working this leg once the whole size is sold; a
                # partial still has quantity to place and must not be treated
                # as a closed leg. leg_size(s), not bare SIZE -- third spot
                # with the same hazard as leg_size()'s docstring describes.
                if got >= leg_size(s) - 1e-9:
                    s['fill_logged'] = True
                    st[tkr] = s
                    continue
                s['fill_logged'] = False
                st[tkr] = s

            mine = resting.get(tkr) or []
            age = time.time() - s.get('posted_ts', 0) if s.get('posted_ts') else 1e9

            # INVARIANT: at most one resting order per leg. If the exchange
            # shows more, cancel every one and post nothing this cycle -- let
            # the next cycle start from a known-clean slate rather than trying
            # to reason about which of them is "ours".
            if len(mine) > 1:
                print(f'  [DUP] {tkr} has {len(mine)} resting -- cancelling all')
                for o in mine:
                    try:
                        km.cancel_order_v2(o['order_id'])
                    except Exception:
                        pass
                st.pop(tkr, None)
                continue

            # Nothing listed, but we posted seconds ago? That is the listing
            # lagging, not a missing order. Waiting one cycle is free; posting
            # a duplicate is not.
            if not mine and s.get('order_id') and age < 25:
                continue

            # Past the listing-lag window, an absent order is either expired or
            # EXECUTED, and the old code assumed expired -- which is what posts
            # a second sell onto an already-sold leg. Ask the exchange before
            # re-offering. See _vanished_order_filled for the GETDEP-GET case.
            if not mine and s.get('order_id'):
                _done = _vanished_order_filled(s.get('order_id'))
                if _done > 1e-9:
                    s['fill_qty'] = max(float(s.get('fill_qty') or 0), _done)
                    st[tkr] = s
                    print(f'  [VANISHED-FILLED] {tkr} order executed {_done:g} '
                          f'-- not re-posting (position read had not caught up)')

            # Per-leg, not global: a held book's order carries a much longer
            # TTL, and the global threshold would cancel it at 58 minutes --
            # surrendering exactly the queue position that TTL was granted to
            # protect. See refresh_at_for().
            # SIZE MISMATCH is a reason to re-post too (2026-09-22). Without
            # it a resting order only changes on a price move or TTL age, so a
            # book released to a new SIZE kept quoting the old quantity for up
            # to an hour. A partial fill the position read has not caught yet
            # also shows up here (remaining < want); that path cancels, and the
            # LATE-FILL check below books the fill before re-posting, which is
            # the existing safe route.
            _rest_q = sum(float(o.get('remaining_count_fp')
                                or o.get('remaining_count') or 0) for o in mine)
            _want_now = leg_size(s) - float(s.get('fill_qty') or 0)
            need = (not mine
                    or s.get('price_cents') != price_c
                    or age > refresh_at_for(s)
                    or abs(_rest_q - _want_now) >= 0.5)
            if not need:
                continue
            # A FAILED CANCEL LEAVES THE OLD ORDER LIVE, and posting over it is
            # how a leg ends up with two resting orders and fills 2/1. The
            # Chicago book on 2026-09-09 lost its hedge exactly that way:
            # collected 1.52 against a 2.00 obligation once CHI won. The [DUP]
            # invariant above only cleans up AFTER the exchange already lists
            # two, which is one fill too late -- it fired 6 times in a day.
            #
            # A 404 means the order is already gone, so posting over it is
            # safe. Anything else (429, 5xx, a DNS blip) leaves exchange state
            # UNKNOWN, so post nothing this cycle: the stale order expires by
            # itself at MAKER_TTL_S and the next cycle starts from a clean
            # read. Skipping costs one cycle of quoting; guessing costs the
            # hedge, which is ~50x more expensive.
            cancel_failed = None
            cancelled_filled = 0.0
            for o in mine:
                try:
                    _resp = km.cancel_order_v2(o['order_id'])
                except Exception as e:      # 404 = expired or already gone
                    if '404' not in str(e):
                        cancel_failed = e
                else:
                    cancelled_filled += _cancel_filled_qty(_resp)
            if cancel_failed is not None:
                print(f'  [CANCEL-FAIL] {tkr}: {cancel_failed} '
                      f'-- not re-posting this cycle')
                continue
            # THE CANCEL RESPONSE IS THE ONLY FRESH TRUTH ABOUT THIS LEG.
            #
            # `fill_qty` is refreshed from positions ONCE per cycle, and a cycle
            # is ~65s. An order that fills at t+50s is invisible until the next
            # read, so the refresh at t+65s cancels it and -- believing nothing
            # is held -- posts a REPLACEMENT for the full SIZE. The replacement
            # fills too, and the leg ends up at 2 on a leg capped at 1.
            #
            # That is how CRAAME-AME and MUNMCI-MCI went 2/1 on 2026-09-12/13
            # (filled 61.6s and 53.9s after posting, both reported as a single
            # count=2 event). It leaves NO trace in the other guards: the cancel
            # SUCCEEDS so [CANCEL-FAIL] is silent, and the two orders never rest
            # at the same time so [DUP] is silent too. Those two guards went
            # 44 -> 0 after the 09-12 fix while this kept happening, which is
            # what isolated it.
            #
            # Kalshi returns the cancelled order, including how much of it
            # filled before the cancel landed. Counting that against `want` is
            # the only check that sees a fill younger than the position read.
            if cancelled_filled > 1e-9:
                s['fill_qty'] = float(s.get('fill_qty') or 0) + cancelled_filled
                st[tkr] = s
                print(f'  [LATE-FILL] {tkr} cancel returned {cancelled_filled:g} '
                      f'filled -- counting it before re-posting')
            # post only what is still unsold on this leg, so a leg that filled
            # 0.50 of 1 goes back for the other 0.50 rather than a fresh whole.
            # leg_size(), not bare SIZE: a leg opened under an earlier SIZE
            # must keep chasing ITS target, never the live constant -- see
            # leg_size()'s docstring for why.
            want = round(leg_size(s) - float(s.get('fill_qty') or 0), 2)
            if want < km.MIN_COUNT:
                continue
            # A leg we have never offered spends post budget; refreshing one we
            # already own does not. Without that split the budget would be gone
            # in two minutes on TTL refreshes of the same few books.
            fresh_leg = tkr not in _OFFERED
            if fresh_leg and _POST_BUDGET and _POSTS_MADE >= _POST_BUDGET:
                continue
            # PER-ORDER TTL: A HELD BOOK'S LEG SHOULD NEVER NEED RENEWING.
            #
            # Renewal is cancel+repost, which drops to the BACK of the queue --
            # and queue position is the whole game on the one leg that decides
            # whether a partial book completes. So size a held book's order to
            # cover the rest of the window we would work it in (kickoff plus
            # IN_PLAY_H), and it never renews on age at all: only a genuine
            # price change re-posts it.
            #
            # WHY THIS IS SAFE FOR HELD BOOKS AND NOT OTHERS. The abandon rule
            # above already establishes it: an open leg on a held book is
            # priced AT the budget, so a fill COMPLETES the book at a profit
            # and a completed book pays the same whoever wins. Leaving it
            # resting through an outage is the outcome we want, not a risk.
            #
            # An UNHELD book is the opposite -- a first-leg fill with nobody
            # home leaves a partial nobody can complete, which is the -$0.69
            # stranded state. Those keep the plain MAKER_TTL_S.
            _ttl = km.MAKER_TTL_S
            if ev in _held_events or collected > 1e-9:
                _stop_in_s = (hrs + IN_PLAY_H) * 3600.0 if hrs is not None else 0.0
                _ttl = int(max(km.MAKER_TTL_S,
                               min(HELD_TTL_CAP_S, _stop_in_s + 300)))
            try:
                r = km.place_maker_ask(tkr, want, price_c, ttl_s=_ttl,
                                       client_order_id=f'mk{int(time.time()*1000)%10**9}')
            except Exception as e:
                print(f'  [skip] {tkr}: {type(e).__name__}: {e}')
                continue
            oid = (((r or {}).get('order') or {}).get('order_id')
                   or (r or {}).get('order_id'))
            # MERGE, never replace: fill_qty/fill_cash/fill_ids are the record
            # of what this leg has already banked, and a partially filled leg
            # gets re-posted for its remainder. Overwriting the dict here would
            # reset `collected` to zero on the next cycle and the budget rule
            # would happily underprice the rest of the book.
            s = st.get(tkr) or {}
            # EVERY order id we have ever placed on this leg. Fill attribution
            # matches on this, so a trade made by any OTHER strategy on the same
            # ticker can never be mistaken for ours. xlow buys the same markets
            # this quotes -- on 2026-08-23 an xlow buy of 2 BOC was read as a
            # maker fill at 0c, which made the budget rule demand the whole
            # dollar from the remaining legs and latched the ratchet at 60c/94c.
            # Capped so a long-lived leg cannot grow state without bound.
            _oids = [x for x in (s.get('our_ids') or []) if x]
            if oid and oid not in _oids:
                _oids.append(oid)
            s['our_ids'] = _oids[-40:]
            s.update({'price_cents': price_c, 'posted_ts': time.time(),
                      # the TTL this particular order was issued with --
                      # refresh_at_for() derives its renewal age from it
                      'ttl_s': _ttl,
                      'posted_at': now.isoformat(), 'order_id': oid,
                      'event': ev,
                      # depth ahead of us at post: the number the whole
                      # experiment exists to calibrate against
                      'queue_ahead': L.get('yes_ask_size_fp')
                                     or L.get('ask_size') or '',
                      'fill_logged': False})
            # Latch the ratchet the first time this leg is offered, and let it
            # rise but never fall. Stored in cents-as-dollars so it compares
            # directly with an ask.
            s['floor_px'] = max(float(s.get('floor_px') or 0), price_c / 100.0)
            # Stamp the size this leg was opened under, ONCE -- setdefault, so
            # a re-post or TTL refresh of an existing leg never overwrites it.
            # A leg that predates today's SIZE change was already stamped by
            # backfill_opened_size() at startup; this only fires for a leg
            # genuinely new to `st`. See leg_size()'s docstring.
            s.setdefault('opened_size', SIZE)
            st[tkr] = s
            if fresh_leg:
                _OFFERED.add(tkr)
                _POSTS_MADE += 1
            # the denominator: one row per leg offered, whatever happens to it
            # Bid/ask/mid as the DECISION saw them. Nothing reconstructs these
            # after the fact -- the book has moved by the time any later job
            # looks, so if they are not captured here they are gone.
            _bid = L.get('yes_bid_dollars')
            _bid = float(_bid) if _bid is not None else None
            _mid = ((_bid + ask) / 2.0) if _bid is not None else None
            # pin_fair: Pinnacle de-vigged, for THIS leg's outcome.
            _sd = Q.leg_side(tkr, ev, None, None)
            _pf = fair.get(_sd)
            log_leg({'ts': now.isoformat(), 'ticker': tkr, 'event': ev,
                     'league': league,
                     'side': tkr.rsplit('-', 1)[-1],
                     'price_cents': price_c,
                     'ask_at_post': f'{ask:.4f}',
                     'bid_at_post': f'{_bid:.4f}' if _bid is not None else '',
                     'mid_at_post': f'{_mid:.4f}' if _mid is not None else '',
                     'pin_fair': f'{_pf:.4f}' if _pf is not None else '',
                     # volume/open_interest read the BARE names for 31,613 rows
                     # and logged nothing every time -- Kalshi only serves the
                     # _fp variants. Keep the fallback chain, _fp first.
                     'volume': L.get('volume_fp') or L.get('volume') or '',
                     'volume_24h': L.get('volume_24h_fp') or '',
                     'book_volume': f'{_bookvol:.0f}' if _bookvol else '',
                     'touch_ask': f'{ask:.4f}',
                     'floor_px': s.get('floor_px'),
                     'above_touch_c': f'{(price_c / 100.0 - ask) * 100:.2f}',
                     'queue_ahead': s.get('queue_ahead'),
                     'bid_size': L.get('yes_bid_size_fp') or L.get('bid_size') or '',
                     'open_interest': (L.get('open_interest_fp')
                                       or L.get('open_interest') or ''),
                     'hrs_to_ko': f'{hrs:.2f}' if hrs is not None else '',
                     'collected': f'{b["collected"]:.4f}',
                     'target': f'{b["target"]:.4f}',
                     'scale': f'{b["scale"]:.4f}',
                     'fresh': int(fresh_leg), 'live': int(live)})
            if mine:
                refreshed += 1
            else:
                posted += 1

    save_state(st)
    tag = 'LIVE' if live else 'dry'
    # A hard ceiling on total resting orders, checked against the EXCHANGE.
    # Three legs per book, so a large excess means duplicates are accumulating
    # again -- stop rather than discover it at 81 orders.
    #
    # HEADROOM ADDED 2026-08-22. The bound was exactly MAX_BOOKS*3, which has
    # no slack for normal churn: when selection reshuffles, a book that drops
    # out still has legs resting on their 90s TTL while the book replacing it
    # posts three more, so the count briefly exceeds 3x. Invisible at
    # MAX_BOOKS=40 (the cap never bound); at 10 it tripped within two cycles
    # and shut the maker down -- 33 > 30 -- with nothing actually wrong.
    #
    # 1.5x still catches what this guards against: a second maker doubles the
    # count (60 > 45) and a runaway loop far exceeds it. It only stops the
    # tripwire firing on one cycle's worth of legitimate overlap.
    ABORT_SLACK = 1.5
    total = sum(len(v) for v in resting.values())
    _abort_at = int(MAX_BOOKS * 3 * ABORT_SLACK)
    over = total > _abort_at
    budget = (f'  legs {_POSTS_MADE}/{_POST_BUDGET}' if _POST_BUDGET else '')
    if _equity_last is not None:
        budget += f'  equity ${_equity_last:.2f}'
    # `thin` is the frozen-rule OI gate (MIN_BOOK_OI), reported separately from
    # `gated` -- that one is the per-cycle entry gate on live prices, and
    # collapsing the two would hide which rule is turning books away.
    thin = f'  thin {_THIN_GATED}' if _THIN_GATED else ''
    print(f'{dt.datetime.now():%H:%M:%S} [{tag}] {len(books)} books  '
          f'posted {posted}  refreshed {refreshed}  fills {filled}  '
          f'gated {gated}  abandoned {abandoned}{thin}  resting {total}{budget}')
    if over:
        print(f'  [ABORT] {total} resting > cap {_abort_at} '
              f'({MAX_BOOKS} books x3 x{ABORT_SLACK}) -- '
              f'cancelling everything and stopping')
        km.cancel_all('resting over cap')
        return False
    return True


def _arg_int(flag, default):
    """--flag N off the command line. Used for --books so the FIRST armed run
    can be one book (three orders) rather than sixty, without editing and then
    un-editing a constant."""
    if flag in sys.argv:
        i = sys.argv.index(flag)
        if i + 1 < len(sys.argv):
            try:
                return max(1, int(sys.argv[i + 1]))
            except ValueError:
                pass
    return default


def main():
    global MAX_BOOKS, _POST_BUDGET, FINISH_ONLY
    live = '--live' in sys.argv
    once = '--once' in sys.argv
    # --books may now RAISE the cap as well as lower it. It could only lower
    # before, which made sense when every book got quoted; with the entry gate
    # refusing ~80% of them, a cap of 20 books is a cap of ~4 quoted, and a
    # timed test would collect almost nothing. The real exposure limits are
    # elsewhere and unchanged: MAX_CONCURRENT_ORDERS and MAX_RESTING_COLLATERAL
    # are checked against the exchange before every single order.
    MAX_BOOKS = _arg_int('--books', MAX_BOOKS)
    # HARD DEADLINE. A timed run must stop by itself, not because someone
    # remembers to stop it: the kill file needs a person and the 90s order TTL
    # only expires ORDERS, so a forgotten process keeps re-posting them forever.
    # The deadline runs the normal shutdown path -- cancel everything, verify
    # nothing is resting -- so it ends the session the same way a manual stop
    # would, just without needing anyone to be awake.
    minutes = _arg_int('--minutes', 0)
    deadline = (time.time() + minutes * 60) if minutes else None
    # POST BUDGET: stop opening NEW legs after this many.
    #
    # Counts new legs only, never refreshes, and the distinction is the whole
    # point. Every resting order is re-posted about every 45s as its 90s TTL
    # runs down, so with 27 legs live the exchange sees ~27 orders a minute --
    # a budget of 72 "orders" would be spent in two minutes on three books
    # being re-offered, which measures nothing. 72 NEW legs is 24 books.
    #
    # When the budget runs out the loop keeps running: existing legs still get
    # refreshed, still fill, and the budget and abandon rules still act on
    # them. Cutting off there instead would cancel the whole experiment at the
    # moment it started producing data.
    _POST_BUDGET = _arg_int('--max-posts', 0)
    FINISH_ONLY = '--finish-only' in sys.argv
    if KILL_FILE.exists():
        print(f'refusing to start: {KILL_FILE} exists')
        return 1
    if not lock_acquire():
        return 1
    km.set_live(live)
    print(f'maker starting  LIVE={live}  size={SIZE}  books<={MAX_BOOKS}  '
          f'ttl={km.MAKER_TTL_S}s  poll={POLL_SECS}s'
          + (f'  max {_POST_BUDGET} new legs' if _POST_BUDGET else '')
          + (f'  backstop {dt.datetime.fromtimestamp(deadline):%H:%M:%S}'
             if deadline else ''))
    if not live:
        print('DRY RUN -- no orders will be sent. Pass --live to arm.')

    # clear anything a previous process left behind before adding to it
    # Startup keeps held-book legs too: whatever left them resting -- a restart,
    # a crash, the guard -- they are the completing legs of a book already part
    # sold, and this process re-adopts them by the order ids persisted in
    # state (our_ids). Everything else goes, as before.
    print(f'startup cancel-all: '
          f'{km.cancel_all("startup", keep_held=True)}')
    st = load_state()
    backfill_opened_size(st)      # freeze pre-existing legs before anything
                                   # below reads SIZE against them
    # Seed the offered set from the persisted ratchet floors. A leg with a
    # latched floor was already put on the exchange by an earlier session and
    # already spent its budget there; without this, every restart resumes ~50
    # legs and re-spends the whole allowance on them, so a bound of 72 becomes
    # 72 PER RESTART. Restarts are frequent while this is being developed, so
    # that difference is the whole value of having a bound.
    _OFFERED.update(t for t, v in st.items()
                    if isinstance(v, dict) and v.get('floor_px') is not None)
    # --max-posts is a TOTAL, not a per-session allowance. Seeding the counter
    # as well as the set is what makes it one: resumed legs are still legs this
    # experiment has offered, so they count against the bound even though they
    # do not re-post at a new price. Counting only this session's new legs made
    # "60" mean "60 more each time I restart", which is not a cap at all -- and
    # with several restarts in a morning it silently ran past every number that
    # had been agreed.
    global _POSTS_MADE
    _POSTS_MADE = len(_OFFERED)
    if _OFFERED:
        print(f'resuming {len(_OFFERED)} legs already offered'
              + (f' -- {max(0, _POST_BUDGET - _POSTS_MADE)} of '
                 f'{_POST_BUDGET} remaining' if _POST_BUDGET else ''))
    try:
        while not _stop:
            if deadline and time.time() >= deadline:
                print(f'{dt.datetime.now():%H:%M:%S} deadline reached '
                      f'({minutes} min) -- stopping')
                break
            try:
                if not cycle(st, live):
                    break
            except Exception as e:
                print(f'  [cycle error] {type(e).__name__}: {e}')
            if once:
                break
            # never sleep past the deadline: a 20s poll must not hold a live
            # book open for 19 extra seconds after the session was meant to end
            nap = POLL_SECS
            if deadline:
                nap = max(0, min(POLL_SECS, deadline - time.time()))
            time.sleep(nap)
    finally:
        print(f'shutdown cancel-all: '
              f'{km.cancel_all("shutdown", keep_held=_RESTART_MODE)}')
        km.set_live(False)
        lock_release()
        save_state(st)
        # get_open_orders lags a cancel by a second or two, so an immediate read
        # reports orders that are already dead. The first armed run printed
        # "resting orders remaining: 2" when the true answer was 0. Wait, then
        # re-read, and if anything really is left say so loudly -- a false alarm
        # every run trains you to ignore the one that matters.
        for attempt in range(3):
            time.sleep(3)
            try:
                left = (kt.get_open_orders() or {}).get('orders', []) or []
            except Exception as e:
                print(f'  could not verify resting orders: {type(e).__name__}: {e}')
                break
            if _RESTART_MODE:
                _h = km.held_events() or set()
                _kept = [o for o in left
                         if str(o.get('ticker', '')).rsplit('-', 1)[0] in _h]
                left = [o for o in left if o not in _kept]
                if _kept and attempt == 0:
                    print(f'kept {len(_kept)} held-book leg(s) resting for the '
                          f'restart: {[o.get("ticker") for o in _kept]}')
            if not left:
                print('resting orders remaining: 0 (verified)')
                break
            if attempt == 2:
                print(f'!! {len(left)} ORDERS STILL RESTING after cancel + '
                      f'9s: {[o.get("ticker") for o in left]}')
                print(f'!! they expire by themselves within '
                      f'{km.MAKER_TTL_S}s of posting')
    return 0


if __name__ == '__main__':
    sys.exit(main())
