"""Live three-leg maker for Poly-US. Minimal first run.

SEPARATE PROCESS FROM mm_maker_live.py ON PURPOSE. That one holds real Kalshi
positions and is completing at 76.7%; nothing here may be able to break it.

WHAT IS PROVEN AND WHAT IS NOT (2026-08-22):
  PROVEN live, at $0.00 cost -- post-only refuses crosses; a 90s GTD expires the
  order without us; cancel works and verifies; three legs of one book rest at
  once; the reserved $100 is tradeable; BUY_SHORT prices YES-side.
  NOT PROVEN -- nothing has ever FILLED here. Fill detection, the maker rebate
  landing, and partial-fill handling are all untested. This run exists to
  exercise exactly those. Treat its output as an experiment, not a P&L.

DELIBERATELY SMALLER THAN THE KALSHI MAKER. Poly-US books are 7-20 deep against
Kalshi's ~3,400, so size and gates do not transfer. Caps live in poly_us_maker
(5 contracts/leg, $15 resting, 15 orders) and are not raised here.

A 200 IS NOT A RESTING ORDER. A post-only kill returns HTTP 200 with an order
id. Every post is confirmed against open_orders() before it is treated as live;
that mistake already cost an entire debugging cycle today.

Usage:
    python main\\mm_maker_live_polyus.py --minutes 60            dry
    python main\\mm_maker_live_polyus.py --live --minutes 60     ARMED
    create E:\\soccer\\STOP_PUS_MAKER to stop it
"""
import csv
import datetime as dt
import json
import re
import signal
import sys
import time
from pathlib import Path

sys.path.insert(0, r'E:\soccer\main')
import poly_us_prices as prices
import poly_us_maker as pm
import poly_us_trader as put
import poly_us_auth as pa
import live_monitor as LM

ROOT = Path(r'E:\soccer')
KILL_FILE = ROOT / 'STOP_PUS_MAKER'
STATE_F = ROOT / 'research' / 'pus_live_state.json'
LEG_LOG = ROOT / 'research' / 'pus_live_legs.csv'

WINDOW_H = 24.0       # 6 -> 72 on 2026-08-23, 72 -> 24 on 2026-08-28 (user).
                      # 6h was too tight: this venue's overround COMPRESSES
                      # into kickoff (+0.0 to +1.0c inside 1h, +2.0 to +3.0c at
                      # 3-4h), so a 6h window was mostly books too tight to
                      # quote at all -- it sat idle all evening on 08-24.
                      # 72h fixed the coverage but posts legs that mostly sit:
                      # measured fill rate is 25.3% at 0-0.5h against 6.8% at
                      # 24h+, and every far leg still spends the MAX_PROBES
                      # budget on an endpoint that 429s.
                      # 24h is the compromise -- wide enough to find books with
                      # real overround, short enough that the probe budget is
                      # not spent on legs that will never fill.
                      #
                      # NO hrs_to_ko IN THE POLY-US LEG LOG, so this cannot yet
                      # be checked here the way it can on Kalshi -- where the
                      # corrected data shows completion of risked books is FLAT
                      # by time to kickoff (80.0/81.5/82.1/83.0% across
                      # <1h/1-6h/6-24h/24h+). If that holds here, a shorter
                      # window costs throughput and buys nothing; if the 429s
                      # were the real constraint, it buys back probe budget.
                      # Log hrs_to_ko before re-tuning this again.
# 25 -> 45. The TTL is 90s, so a 45s cycle still re-posts every leg comfortably
# before it expires, at half the request rate. Poly-US 429s well before Kalshi
# does and the failure mode is a silent, fail-closed maker.
POLL_SECS = 45
SIZE = 1.0
FINISH_ONLY = False       # --finish-only: work held books, open nothing new
MAX_BOOKS = 3         # first live run on a venue that has never filled
# GROSS GATE, and why it is NOT the Kalshi number.
# 2.5c was mirrored from mm_quoter_polyus, which inherited it from the Kalshi
# quoter. But the venues have different break-evens: Kalshi CHARGES the maker
# 0.0175*p(1-p) while Poly-US CREDITS 0.0125*p(1-p), which mm_quoter_polyus's
# own header puts at break-even gross +2.38c (Kalshi) vs +1.42c (Poly-US).
# A 2.5c gate on a venue that breaks even at 1.42c throws away most of the
# eligible population -- all three books quotable right now sit at 1-2c.
# 2.0c keeps a real margin over 1.42c without gating the venue out of existence.
GROSS_GATE_C = 2.0
TARGET_MARGIN = 0.005
MAX_ABOVE_TOUCH = 0.03

LEG_FIELDS = ['ts', 'event', 'slug', 'side', 'yes_price', 'touch_ask',
              'touch_bid', 'above_touch_c', 'gross_c', 'collected', 'need',
              'rested', 'live']

_stop = False


def _sig(_s, _f):
    global _stop
    _stop = True
    print('\n[signal] stopping -- cancelling everything')


signal.signal(signal.SIGINT, _sig)


def log_leg(row):
    try:
        LEG_LOG.parent.mkdir(parents=True, exist_ok=True)
        new = not LEG_LOG.exists()
        with LEG_LOG.open('a', newline='', encoding='utf-8') as fh:
            w = csv.DictWriter(fh, fieldnames=LEG_FIELDS)
            if new:
                w.writeheader()
            w.writerow({k: row.get(k, '') for k in LEG_FIELDS})
    except OSError:
        pass


STATE_KEEP_DAYS = 3
_DATE_RE = re.compile(r'(20\d\d)-(\d\d)-(\d\d)')


def prune_state(st, today=None):
    """Drop legs whose match is more than STATE_KEEP_DAYS old.

    Nothing ever removed entries, so state grew without bound and -- worse --
    `held_events` kept reporting every book ever part-filled as still
    committed. Those events fall outside the window so nothing was quoted, but
    the set grew forever and every settled book stayed "committed" in a file
    that is read and rewritten every cycle.

    The match date is in the slug (atc-bra-int-cam-2026-08-22-int), so no
    lookup is needed. Entries with no parseable date are KEPT -- deleting
    something we cannot date is the more dangerous mistake, since a wrongly
    dropped fill makes the maker re-quote a leg it already holds.
    """
    today = today or dt.date.today()
    cutoff = today - dt.timedelta(days=STATE_KEEP_DAYS)
    drop = []
    for slug in st:
        m = _DATE_RE.search(slug)
        if not m:
            continue
        try:
            d = dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
        except ValueError:
            continue
        if d < cutoff:
            drop.append(slug)
    for slug in drop:
        st.pop(slug, None)
    return len(drop)


def load_state():
    try:
        return json.loads(STATE_F.read_text(encoding='utf-8'))
    except (OSError, ValueError):
        return {}


def save_state(st):
    try:
        STATE_F.parent.mkdir(parents=True, exist_ok=True)
        tmp = STATE_F.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(st, indent=1), encoding='utf-8')
        tmp.replace(STATE_F)
    except OSError:
        pass


def _ko_utc(m):
    """Kickoff as an aware UTC datetime, or None.

    The watchlist stores `game_date` (a date) and `kickoff` ('HH:MM', ET) as
    separate fields; there is no shared helper for this. An earlier version of
    this file called mm_quoter_polyus._ko_of / live_monitor._ko_of, NEITHER OF
    WHICH EXISTS -- so every match silently resolved to None, no book ever
    entered the window, and the dry run reported a tidy "posted 0 gated 0" that
    looked like a quiet market rather than a broken function.

    live_monitor.clock_elapsed_min treats kickoff as naive LOCAL time because
    this box runs on ET. Made explicit here instead: America/New_York -> UTC, so
    it stays correct if the machine's timezone ever changes.
    """
    gd, ks = m.get('game_date'), m.get('kickoff')
    if not gd or not ks:
        return None
    try:
        t = dt.datetime.strptime(str(ks).strip(), '%H:%M').time()
    except ValueError:
        return None
    naive = dt.datetime.combine(gd, t)
    try:
        from zoneinfo import ZoneInfo
        return naive.replace(tzinfo=ZoneInfo('America/New_York')).astimezone(dt.timezone.utc)
    except Exception:
        # no tzdata: fall back to the machine's own local zone, which is what
        # live_monitor effectively assumes anyway
        return naive.astimezone(dt.timezone.utc)


MAX_PROBES = 14        # fixtures whose book we will price per cycle
IN_PLAY_H = 3.0        # keep working a book we HOLD this long after kickoff


def held_events(st):
    """Event prefixes where at least one leg has filled -- books we are committed to."""
    out = set()
    for slug, v in st.items():
        if isinstance(v, dict) and v.get('fill_px'):
            out.add(slug.rsplit('-', 1)[0])
    return out


def _filled_to_size(st, slug):
    """Is this leg filled all the way to SIZE?"""
    sv = (st or {}).get(slug) or {}
    if not sv.get('fill_px'):
        return False
    return float(sv.get('fill_qty') or 0) >= SIZE - 1e-9


def books_in_window(now, held=frozenset(), st_ref=None, committed_only=False):
    """Up to MAX_BOOKS books that CLEAR THE GATE, nearest kickoff first.

    TWO THINGS THIS GETS RIGHT THAT THE FIRST VERSION DID NOT.

    1. GATE BEFORE TRUNCATING. It used to price every fixture in the window,
       sort nearest-kickoff-first, take 3, and only then apply the gross gate --
       inherited from the Kalshi maker, where fill rate rises steeply toward
       kickoff so nearest-first is right. On Poly-US the overround COMPRESSES
       into kickoff (measured 2026-08-22: +0.0 to +1.0c inside 1h, +2.0 to
       +3.0c at 3-4h), so nearest-first reliably picked the three tightest
       books, gated all three, and quoted nothing while seven qualifying books
       sat further out untouched.

    2. STOP PROBING ONCE FULL. Pricing every fixture in a 6h window is ~55
       fixtures x 3 legs = 165 requests per cycle, which is what got this
       account rate limited by Cloudflare. We probe in kickoff order and stop
       as soon as MAX_BOOKS qualify, with a hard MAX_PROBES ceiling.
    """
    _, matches = LM.load_watchlist_file()
    cand = []
    for m in matches:
        div = (m.get('lc') or '').upper()
        if div not in prices.LEAGUE_SLUG:
            continue
        ko = _ko_utc(m)
        if ko is None:
            continue
        hrs = (ko - now).total_seconds() / 3600.0
        # IN PLAY, BUT ONLY TO FINISH WHAT WE STARTED.
        # `0 < hrs` alone dropped a book the moment it kicked off, so any book
        # still short of three legs was abandoned mid-flight -- seen live on
        # 2026-08-22 with Espanyol/Real Madrid, two legs filled and the draw
        # simply never quoted again. Opening a NEW book in play stays forbidden
        # (needs ~89% completion to survive the goal risk, see
        # memory/project_mm_inplay_verdict), but FINISHING one is the opposite
        # case: a completed book pays the same whoever scores, so a fill that
        # completes it cannot lose. `held` is what separates the two.
        pre = 0 < hrs <= WINDOW_H
        live_finish = -IN_PLAY_H <= hrs <= 0
        if not (pre or live_finish):
            continue
        cand.append((hrs, div, m, live_finish))
    cand.sort(key=lambda t: t[0])          # nearest kickoff first

    out, probes = [], 0
    for hrs, div, m, live_finish in cand:
        if len(out) >= MAX_BOOKS or probes >= MAX_PROBES:
            break
        # RESOLVE MARKETS FIRST, PRICE SECOND.
        # find_event is cached (poly_us_prices._CACHE_TTL) so resolving slugs is
        # nearly free; get_bbo is a live HTTP call PER LEG on the endpoint that
        # 429s. This used to price all three legs and only then discard the book
        # for being in play and not ours -- three rate-limited requests spent to
        # learn something already known from the kickoff time. With a 6h window
        # that was tolerable; at 72h the probe budget is the binding constraint
        # and every wasted request is a pre-kickoff book that never gets looked
        # at. Measured 2026-08-23: 5 of 9 candidates in window were in-play and
        # unheld, i.e. 15 requests a cycle buying nothing.
        slugs = {}
        for side in ('home', 'draw', 'away'):
            mk = prices.find_market(div, m.get('home'), m.get('away'), side)
            if not mk:
                break
            slugs[side] = mk['slug']
        if len(slugs) != 3 or len(set(slugs.values())) != 3:
            continue
        ev_prefix = next(iter(slugs.values())).rsplit('-', 1)[0]
        committed = ev_prefix in held
        if live_finish and not committed:
            continue                       # in play: finish ours, start nothing
        # DEGRADED MODE (portfolio endpoints down): finish what we hold, open
        # nothing. Fills are invisible while positions 503, so a new book would
        # be priced against a budget we cannot verify.
        if committed_only and not committed:
            continue
        probes += 1
        legs = {}
        for side in ('home', 'draw', 'away'):
            b = prices.get_bbo(slugs[side])
            if not b or b.get('bestAsk') is None or b.get('bestBid') is None:
                break
            legs[side] = (slugs[side], float(b['bestBid']), float(b['bestAsk']))
        if len(legs) != 3:
            continue
        # A COMPLETE BOOK IS DONE -- do not let it hold a slot.
        # Nothing is left to quote once every leg is filled, but the book still
        # counted against MAX_BOOKS and was re-priced every cycle until
        # kickoff+3h, blocking new books and spending requests on an endpoint
        # that rate limits. `done` is checked against state, not orders.
        if committed and all(_filled_to_size(st_ref, v[0]) for v in legs.values()):
            continue
        gross_c = (sum(v[2] for v in legs.values()) - 1.0) * 100.0
        # The gross gate decides whether to OPEN a book. It must not decide
        # whether to finish one: money is already in a committed book, and
        # refusing to quote the remainder just guarantees it stays stranded --
        # the worst state in the strategy.
        if gross_c < GROSS_GATE_C and not committed:
            continue
        out.append((f"{m.get('home')} vs {m.get('away')}", div, legs, hrs))
    return out


def detect_fills(st, live):
    """Mark legs we now HOLD as filled, so the book stops re-quoting them.

    WITHOUT THIS the maker forgets its own fills: `collected` stays 0, the leg
    is no longer resting so it looks unquoted, and the next cycle posts it
    AGAIN -- stacking short position on one outcome instead of working the two
    that would complete the book. poly_us_maker's MAX_CONTRACTS would cap the
    damage at 5 contracts, not prevent it.

    A post-only order can only ever fill AT OUR OWN LIMIT, so the price we
    posted IS the fill price -- no separate trade lookup is needed, which
    matters because this venue publishes no per-market trade tape.
    """
    if not live:
        return 0
    try:
        pos = (pa.get_positions() or {}).get('positions') or {}
    except Exception as e:
        print(f'  [warn] positions unreadable, not marking fills: {e}')
        return 0
    n = 0
    for slug, v in pos.items():
        try:
            held = abs(float(v.get('netPositionDecimal') or 0))
        except (TypeError, ValueError):
            continue
        if held <= 0:
            continue
        sv = st.get(slug) or {}
        if sv.get('fill_px') and (sv.get('fill_qty') or 0) >= held - 1e-9:
            continue                      # already known, at this size

        # PRICE THE FILL FROM THE POSITION, NOT FROM OUR STATE.
        # It used to read `floor_px` and `continue` when absent -- but floor_px
        # is only written when the post-verify read SUCCEEDS, and that read
        # 429s on this venue. An order whose confirmation was throttled still
        # rests and still fills, so the fill was silently dropped, `collected`
        # stayed 0, and the next cycle re-quoted the same leg and stacked
        # short position. Position cost is authoritative and always present:
        # a NO holding cost (1 - yes_price) * qty, so yes_price falls out.
        px = None
        try:
            cost = float((v.get('cost') or {}).get('value'))
            if held > 0 and cost >= 0:
                px = round(1.0 - (cost / held), 2)
        except (TypeError, ValueError, ZeroDivisionError):
            px = None
        if px is None or not (0.0 < px < 1.0):
            px = float(sv.get('floor_px') or 0) or None
        if px is None:
            # holding something we cannot price at all -- say so loudly rather
            # than leave a silent unhedged leg
            print(f'  [WARN] position in {slug} ({held}) but no price could be '
                  f'derived; NOT marking filled')
            continue

        sv['fill_px'] = float(px)
        sv['fill_qty'] = held
        sv['filled_at'] = dt.datetime.now(dt.timezone.utc).isoformat()
        if not sv.get('event'):
            sv['event'] = '(recovered)'
        st[slug] = sv
        n += 1
        print(f'  [FILL] {slug} {held} @ {float(px):.2f}')
    return n


def cycle(now, st, live):
    detect_fills(st, live)
    resting_slugs = set()
    try:
        # via poly_us_maker so this shares its cache -- a direct
        # put.open_orders() here was part of what got us rate limited
        for o in pm._orders():
            if o.get('marketSlug'):
                resting_slugs.add(o['marketSlug'])
        degraded = False
    except Exception as e:
        # DEGRADED, NOT DEAD (2026-08-25). Poly-US returned 503 on
        # /v1/portfolio/positions and /v1/orders/open for hours while PUBLIC
        # market data stayed perfectly healthy -- so the maker skipped every
        # cycle and abandoned books it was mid-way through, including one in
        # play that it was supposed to be finishing. An outage on the venue's
        # portfolio endpoints is not a reason to stop working books we are
        # already committed to.
        #
        # Fall back to our OWN posts as the resting view: we are the only
        # writer, and orders self-expire after MAKER_TTL_S, so anything posted
        # within the TTL is still on the book. That errs toward believing a leg
        # IS resting, which is the safe direction -- the failure it prevents is
        # double-posting.
        #
        # NEW BOOKS STAY SHUT. Fills cannot be seen while positions are 503, so
        # opening a book means quoting against a budget that may already be
        # wrong. Committed books only.
        degraded = True
        _ttl = getattr(pm, 'MAKER_TTL_S', 90)
        _now_ts = time.time()
        resting_slugs = {sl for sl, v in st.items()
                         if isinstance(v, dict) and v.get('posted_ts')
                         and _now_ts - float(v['posted_ts']) < _ttl}
        print(f'  [warn] open orders unreadable ({str(e)[:80]}) -- DEGRADED: '
              f'{len(resting_slugs)} legs assumed resting from our own posts, '
              f'finishing committed books only')

    # OUR OWN RECENT POSTS COUNT AS RESTING, IN BOTH PATHS. Fixed 2026-08-30.
    #
    # The degraded branch above already reasons this out -- "errs toward
    # believing a leg IS resting, which is the safe direction -- the failure it
    # prevents is double-posting" -- and then only applied it when the venue was
    # down. On the happy path resting_slugs came from pm._orders() alone, so a
    # leg posted moments ago but not yet reflected in that view looked unquoted
    # and got posted a SECOND time. Both orders rest, both fill.
    #
    # INCHEON v JEONBUK on 2026-08-30 is the case: SIZE is 1.0 and the draw came
    # back holding 1.79 against 1.00 on the other two legs. A single order cannot
    # over-fill, so two were live at once. The book reads 'complete' on the
    # dashboard while being unhedged on 0.79 of the draw.
    #
    # Orders self-expire after MAKER_TTL_S, so anything posted inside the TTL is
    # still on the book and must not be re-posted. The cost of being wrong is a
    # leg that waits up to 90s to be re-quoted; the cost of the old behaviour is
    # an unhedged position bought with real money.
    _ttl_all = getattr(pm, 'MAKER_TTL_S', 90)
    _now_all = time.time()
    resting_slugs |= {sl for sl, v in st.items()
                      if isinstance(v, dict) and v.get('posted_ts')
                      and _now_all - float(v['posted_ts']) < _ttl_all}

    posted = gated = 0
    held = held_events(st)
    for label, div, legs, hrs in books_in_window(now, held, st,
                                                 committed_only=degraded or FINISH_ONLY):
        asks = {s: v[2] for s, v in legs.items()}
        gross_c = (sum(asks.values()) - 1.0) * 100.0

        # WHAT THE BOOK HAS BANKED -- WEIGHTED BY QUANTITY.
        # This used to add fill_px whole regardless of how much actually
        # filled. Poly-US fills fractionally, so a 0.5 fill was counted as a
        # full one: `collected` came out too high, `need` too low, and the
        # remaining legs were then quoted CHEAP enough to complete the book
        # below budget -- precisely the loss the budget floor exists to stop.
        collected = 0.0
        for s, (slug, _b, _a) in legs.items():
            sv = st.get(slug) or {}
            f = sv.get('fill_px')
            if f:
                q = float(sv.get('fill_qty') or 0)
                collected += float(f) * (min(q, SIZE) / SIZE if SIZE else 1.0)

        # THE GATE DECIDES WHETHER TO OPEN, NEVER WHETHER TO FINISH.
        # This check also lives in books_in_window; having it in BOTH places
        # is what made the first fix useless -- a committed book survived
        # selection and was then gated one line later, so int-cam sat at
        # 2-of-3 with its completing leg never quoted while the log showed a
        # tidy "gated 1" every cycle. `collected` is computed above this now,
        # precisely so the exception can be applied here too.
        if gross_c < GROSS_GATE_C and collected <= 0:
            gated += 1
            continue
        target = 1.0 + TARGET_MARGIN
        # A leg is OPEN until it is filled to SIZE. Treating any fill as closed
        # left the remainder of a partial permanently unquoted, so the book
        # could never actually reach size on that outcome.
        def _open(slug):
            sv = st.get(slug) or {}
            if not sv.get('fill_px'):
                return True
            return float(sv.get('fill_qty') or 0) < SIZE - 1e-9
        open_legs = [s for s in legs if _open(legs[s][0])]
        need = target - collected
        open_ask_sum = sum(asks[s] for s in open_legs) or 1.0
        scale = need / open_ask_sum

        for s in open_legs:
            slug, bid, ask = legs[s]
            if slug in resting_slugs:
                continue                       # already working
            floor = ask * scale if scale > 1.0 else ask
            # RATCHET: never offer below what this leg was first offered at.
            # THE LATCH APPLIES ONLY WHILE NOTHING HAS FILLED. Same fix as
            # mm_maker_live on 2026-08-29, same day, same defect.
            #
            # PARDUBICE v ARTIS BRNO is the case. PAR latched at 0.60 when the
            # book opened. ART then filled at 0.21, leaving 0.79 wanted from PAR
            # plus the draw -- and PAR 0.54 + draw 0.26 = 0.80 already cleared it
            # at the touch. The stale latch held PAR 6c above a market that would
            # have completed the book, the touch later spiked to 0.81 so the
            # ratchet followed it UP, and PAR spent the rest of the match 10-11c
            # above the bid. It never filled and the book settled at 2-of-3.
            #
            # Once anything is banked, `need` and the floor derived from it
            # already stop the book being sold below what it owes, so the latch
            # adds nothing but staleness. Its real job -- stopping an UNTOUCHED
            # book being walked down cent by cent -- is a collected == 0 case.
            latched = (st.get(slug) or {}).get('floor_px')
            if latched and collected <= 0:
                floor = max(floor, float(latched))
            px = round(floor, 2)
            above_c = (px - ask) * 100.0
            # MAX_ABOVE_TOUCH GATES OPENING, NOT FINISHING.
            # Once a book is part-filled the leg is priced at the BUDGET, and
            # any fill there completes the book at a profit no matter how far
            # from the touch it looks. Refusing to quote only guarantees the
            # stranded 1-of-3 state. Seen live 2026-08-22: Internacional filled
            # at 0.48, the budget scaled CAM to 0.24 while its touch drifted to
            # 0.20, and the leg was dropped for being 4c over -- abandoning a
            # book we were already committed to. mm_maker_live.py made the same
            # mistake and its fix is the exception below.
            if above_c > MAX_ABOVE_TOUCH * 100 and collected <= 0:
                continue
            if not (0.02 <= px <= 0.97):
                continue

            rested = False
            if live:
                # PLACE and CONFIRM are separate failures now. They used to
                # share one try, so a 503 on the CONFIRM read discarded a post
                # that had already gone through -- and since the confirm uses
                # the same endpoint that goes down, nothing could post at all
                # during an outage.
                # POST THE REMAINDER, NOT THE FULL SIZE. Fixed 2026-08-30.
                #
                # _open() above already understands a leg can be part-filled and
                # keeps quoting it -- but this posted SIZE every cycle regardless
                # of what was already banked, so a leg that took a partial fill
                # got topped up by another WHOLE order instead of the remainder.
                #
                # INCHEON v JEONBUK, 2026-08-30: the draw filled 0.79 of 1.0
                # (collected 0.24 = 0.31 x 0.79), the next cycle posted a fresh
                # 1.0 rather than the 0.21 outstanding, and it filled. The book
                # settled holding 1.79 of the draw against 1.00 of each team --
                # 'complete' on the dashboard and unhedged on 0.79 of it.
                #
                # Kalshi has always done this correctly (book_budget computes
                # rem = size - qty and posts that); only this venue did not.
                _done = float((st.get(slug) or {}).get('fill_qty') or 0.0)
                want = round(SIZE - _done, 4)
                if want <= 1e-9:
                    continue          # nothing outstanding; _open() disagreed
                try:
                    pm.place_maker_ask(slug, want, px)
                except Exception as e:
                    print(f'  [skip] {slug}: {type(e).__name__}: {e}')
                    continue
                time.sleep(1.5)
                try:
                    # A 200 IS NOT A RESTING ORDER -- confirm on the book.
                    # max_age=0: this is the one read that must be fresh.
                    rested = any(o.get('marketSlug') == slug
                                 for o in pm._orders(max_age=0))
                except Exception as e:
                    # Cannot confirm while the venue is 503ing. The place
                    # returned 200, so assume it rested: that stops us
                    # re-posting the same leg every cycle and stacking short.
                    # The cost of being wrong is a missed fill; the cost of the
                    # opposite assumption is double-selling a capped leg.
                    rested = True
                    print(f'  [warn] {slug}: posted but cannot confirm '
                          f'({str(e)[:60]}) -- assuming rested')
                time.sleep(0.6)      # spread requests; the venue 429s under load
            else:
                rested = None

            if rested:
                posted += 1
                resting_slugs.add(slug)
                sv = st.get(slug) or {}
                sv['floor_px'] = max(float(sv.get('floor_px') or 0), px)
                sv['event'] = label
                # WHEN we put it on the book. The only record of what is
                # resting used to be the exchange itself, so a 503 on
                # /v1/orders/open left the maker with no view at all and it
                # skipped the cycle outright. Orders self-expire after
                # pm.MAKER_TTL_S, so a post timestamp is enough to reconstruct
                # a conservative resting set without the venue's help.
                sv['posted_ts'] = time.time()
                st[slug] = sv
            log_leg({'ts': now.isoformat(), 'event': label, 'slug': slug,
                     'side': s, 'yes_price': f'{px:.2f}',
                     'touch_ask': f'{ask:.2f}', 'touch_bid': f'{bid:.2f}',
                     'above_touch_c': f'{above_c:.1f}',
                     'gross_c': f'{gross_c:.1f}',
                     'collected': f'{collected:.2f}', 'need': f'{need:.2f}',
                     'rested': rested, 'live': live})
    return posted, gated


def main():
    live = '--live' in sys.argv
    # WIND DOWN WITHOUT STRANDING (2026-08-30, user: stop the Poly-US maker).
    # Killing it outright abandons whatever is part-filled -- two books were
    # 1-of-3 and 2-of-3 when this was asked for, and a stranded book costs about
    # 22c against 3.5c for a completed one, so walking away from two of them is
    # the most expensive way to stop. --finish-only reuses the committed_only
    # path the 503 handler already uses: no new books, keep working what is held
    # until it completes or the window closes.
    global FINISH_ONLY
    FINISH_ONLY = '--finish-only' in sys.argv
    mins = 60
    if '--minutes' in sys.argv:
        i = sys.argv.index('--minutes')
        if i + 1 < len(sys.argv):
            mins = int(sys.argv[i + 1])

    if KILL_FILE.exists():
        print(f'refusing to start: {KILL_FILE} exists')
        return 1

    # --minutes 0 (or negative) = run until the kill file appears. The TTL
    # still bounds what can be left resting if this dies, so an unbounded run
    # is no more dangerous than a bounded one -- it just needs the kill file
    # or the guard to stop it.
    forever = mins <= 0
    if FINISH_ONLY:
        print('  FINISH-ONLY: no new books; working held ones until they close')
    print(f'poly-us maker starting  LIVE={live}  size={SIZE}  '
          f'books<={MAX_BOOKS}  window={WINDOW_H}h  ttl={pm.MAKER_TTL_S}s  '
          f'runtime={"UNBOUNDED" if forever else str(mins) + "m"}')
    if not live:
        print('DRY RUN -- no orders will be sent. Pass --live to arm.')

    pm.set_live(live)
    if live:
        print(f'startup cancel-all: {pm.cancel_all("startup")}')

    st = load_state()
    n_pruned = prune_state(st)
    if n_pruned:
        print(f'pruned {n_pruned} state entries older than {STATE_KEEP_DAYS}d')
    deadline = time.time() + mins * 60
    last_prune = dt.date.today()
    try:
        while not _stop and (forever or time.time() < deadline):
            if KILL_FILE.exists():
                print(f'[KILL] {KILL_FILE} present -- stopping')
                break
            # prune once a day, not every cycle: an unbounded run is exactly
            # the case the old never-prune behaviour would have hurt
            if dt.date.today() != last_prune:
                last_prune = dt.date.today()
                n = prune_state(st)
                if n:
                    print(f'pruned {n} state entries older than {STATE_KEEP_DAYS}d')
            now = dt.datetime.now(dt.timezone.utc)
            try:
                posted, gated = cycle(now, st, live)
            except Exception as e:
                print(f'  [warn] cycle failed: {type(e).__name__}: {e}')
                posted = gated = 0
            save_state(st)
            try:
                rows = pm._orders()          # cached; no extra request
                n_rest = len(rows)
                notional = sum(pm._order_notional(o) for o in rows)
            except Exception:
                n_rest, notional = -1, float('nan')
            print(f'{now.strftime("%H:%M:%S")} posted {posted}  gated {gated}  '
                  f'resting {n_rest}  notional ${notional:.2f}', flush=True)
            time.sleep(POLL_SECS)
    finally:
        if live:
            print(f'shutdown cancel-all: {pm.cancel_all("shutdown")}')
        pm.set_live(False)
        save_state(st)
    print('done')
    return 0


if __name__ == '__main__':
    sys.exit(main())
