"""Maker-side order client: RESTING SELL orders, with a dead-man's switch.

SEPARATE FROM kalshi_trader.place_limit_order ON PURPOSE. That function buys
YES, immediate-or-cancel, capped at $1, and its comment says why: "never leave a
resting order sitting unmonitored" (project_no_subask_limits). xlow's auto-trade
depends on those properties. Market making needs the opposite -- sell YES, rest
in the queue between polls -- so it gets its own client rather than a flag that
weakens the one already in production.

THE RISK THIS IS BUILT AROUND. On 2026-08-10 this machine powered off and was
not noticed for two and a half days. Resting orders would have filled and
settled with no complete_book, no flatten_stalled, and nobody watching. Cancel
on startup and cancel on shutdown do not help: a power cut runs neither.

So every order carries `expiration_time`. If this process stops refreshing them,
the EXCHANGE cancels them within MAKER_TTL_S. The quoter re-posts every poll, so
the orders are continuously renewed while it is alive and evaporate when it is
not. That is the only safety property here that survives the machine dying.

FOUR MORE GUARDS, all enforced before any HTTP request:
  post_only          -- the exchange REJECTS the order if it would cross, so a
                        maker can never accidentally lift somebody's bid.
  cancel_on_pause    -- exchange-side cancel if the market halts.
  hard caps          -- contracts, price, and total resting collateral.
  LIVE off by default -- set_live(True) must be called explicitly; until then
                        every call is logged and returns without touching the
                        network.

Usage is via the quoter; run this file directly for the guard self-test.
"""
import datetime as dt
import json
import time
from pathlib import Path
from typing import Optional

import kalshi_trader as kt

# ── caps ────────────────────────────────────────────────────────────────
MAKER_TTL_S = 3600        # orders self-cancel this long after posting -- the
                          # dead-man's switch: a dead process leaves nothing
                          # resting for more than this.
                          #
                          # 300 -> 3600 on 2026-09-13 (user), same session as
                          # the 90 -> 300 below. THE TTL IS THE ONLY THING THAT
                          # FORCES A REFRESH, and every refresh surrenders
                          # queue position. The median book is exposed 339
                          # minutes, so at 300s it renewed ~68 times; at an
                          # hour it renews ~6. The intent is "never re-post
                          # unless the PRICE changes", and an hour is the
                          # closest approximation that still leaves orders
                          # dying on their own if this machine dies.
                          #
                          # NOT removed entirely (the alternative considered):
                          # without an expiration_time nothing cleans up after
                          # a power cut, and the guard cannot help because a
                          # dead box runs nothing. On the 2026-08-23 outage the
                          # TTL expired 24 resting orders with nobody home.
                          # Residual exposure is now up to 1h of resting sells,
                          # ~15-21 legs at ~$0.35; a process crash is still
                          # cleaned in ~5min by the guard's startup cancel-all.
                          #
                          # 90 -> 300 on 2026-09-13 (user). The old value was
                          # chosen when the quoter polled every 20s, so an
                          # order was refreshed ~4x before it could lapse. A
                          # cycle now takes ~67s, so it was refreshed ~ONCE --
                          # and every refresh surrenders queue position, since
                          # cancel+repost goes to the back of the price level.
                          # 99.8% of re-posts were at an unchanged price, i.e.
                          # the spot was given up for nothing.
                          #
                          # COST OF THE LONGER TTL: if this box dies, ~15 legs
                          # at ~$0.35 rest unattended for up to 5 minutes
                          # instead of 90s. Bounded by MAX_CONTRACTS and the
                          # collateral cap; the guard also runs every 5 min and
                          # does a startup cancel-all. Raise only with the
                          # refresh threshold in mm_maker_live, which is
                          # derived from this value.
MAX_CONTRACTS = 10        # per leg. 3 -> 10 on 2026-09-22, in step with
                          # mm_maker_live.SIZE going 1 -> 10 for the size-10
                          # test -- see that constant's comment. Kept EQUAL
                          # rather than padded: this file's own MAX_RESTING_
                          # COLLATERAL note argues a cap sized above the
                          # operating point is decoration, not a backstop, and
                          # that applies here too now that SIZE is not 1.
                          #
                          # 1 -> 3 on 2026-08-23 (user): partial scale-up on a
                          # smaller set of books (5, not 10). This ALSO raises
                          # the max short per leg below, which is intended --
                          # a leg is sold to SIZE, so the resulting short must
                          # be allowed to reach it.
                          #
                          # KEEP IN STEP WITH mm_maker_live.SIZE, ALWAYS. If
                          # this is the smaller of the two, every post is
                          # rejected with 'count N exceeds MAX_CONTRACTS' and
                          # the maker runs but quotes NOTHING (seen 14:51 on
                          # 2026-08-23).
MIN_COUNT = 0.01          # smallest remainder worth re-posting after a partial
MAX_PRICE_CENTS = 99
MIN_PRICE_CENTS = 1
# 87 legs at the observed ~0.64 average collateral is ~$56, so 60 left no
# headroom. 75 covers it with room for a few more books; cash is $100 and the
# $20 mark-to-market stop loss is what actually bounds the downside.
# 80 -> 30 on 2026-08-22, following the book cap 40 -> 10. At $0.649/leg
# (measured on live orders) 10 books x 3 legs = ~$19.5, so 30 leaves ~50%
# headroom. Brought DOWN with the book count deliberately: these caps are a
# backstop against a bug -- a duplicate maker, a runaway loop -- and a cap sized
# four times the operating point is not a backstop, it is decoration.
# History: 60 -> 75 -> 80 while the book cap climbed to 40.
# 52 -> 22 on 2026-09-01, following the book cap 9 -> 5 and the $50 moved out
# of the primary into subaccount 1 for xlow. Primary cash is now $26.88, and a
# cap ABOVE available cash is not a cap -- the exchange would reject the order
# first and the backstop would never be what stopped anything.
#
# Sizing: a 3-leg book collects ~1.02 across its legs, so its collateral is
# ~(3 - 1.02) = ~$2 per contract, ~$6 at size 3. Five books is ~$30, which does
# NOT fit in $26.88 -- so this cap binds before the book count does, on purpose.
# It will hold roughly 4 books at a time and let the 5th in as others settle.
# Same discipline as the 08-22 note below: a cap sized well above the operating
# point is decoration, not a backstop.
# REMOVED AS A FIXED NUMBER 2026-09-01 (user: "remove the money cap, the book
# cap should be enough to cover it"). It is right: MAX_BOOKS already bounds
# normal operation at ~$6 a book, and this constant had to be retuned three
# times in one day (52 -> 22 -> 32) chasing a cash balance that moves as
# positions settle. A limit that needs babysitting is not a safety net.
#
# WHAT REPLACES IT is not nothing. mm_maker_live's all-or-nothing [fit] check
# still runs, but now measures a prospective book against ACTUAL free cash read
# from the exchange instead of this constant -- see _cash_headroom() there. That
# is what stops a book being posted half-in when money runs short, which is the
# failure this file cares about (SFEMIL, 1 leg of 3, 2026-09-01).
#
# Kept as infinity rather than deleted so every reference still resolves and the
# cap can be restored by assigning a number here.
MAX_RESTING_COLLATERAL = float('inf')   # disabled; book cap + cash check govern
# 40 -> 52 on 2026-08-30 for the THIRD arm: 9 books measured at $4.43 of
# collateral each is ~$40 typical and ~$50 at the p90 of leg prices. That is
# about half a $99 bankroll committed -- deliberate, and the reason the cap is
# not simply removed.
# 36 -> 40 on 2026-08-29 for the two-arm test: 6 books x 3 legs x ~$0.59 of
# collateral is ~$32 typical and ~$40 at the p90 of leg prices. Deliberately
# NOT doubled -- 5 slots per arm would need ~$60 of a ~$100 bankroll.
# 30 -> 36 on 2026-08-23 (user), to run 5 books at SIZE 3.
# COLLATERAL IS (1 - price) x count, NOT price x count -- a short is
# collateralised by what it could lose. So CHEAP legs lock the MOST money:
# a 14c leg holds $0.86/contract, a 75c leg only $0.25. Sizing this off the
# average PRICE (0.409) instead of the average COLLATERAL (0.591) is how
# 5 books x 3 legs x 3 contracts got signed off as ~$16 when it is really
#   0.591 x 3 x 15 = $26.59 on average, and $30-33 on a normal spread.
# At 30 the cap bound constantly and dropped ONE leg per book -- always the
# cheapest, because that is the one demanding the most collateral -- which
# manufactures 2-of-3 stranded books, the -2.82c state this strategy is
# most exposed to. Better to commit more and quote complete books.
# Headroom check before changing MAX_BOOKS or SIZE again:
#     collateral ~= 0.591 x SIZE x (MAX_BOOKS x 3)
#     size 3: 3 books $15.95 | 4 books $21.27 | 5 books $26.59
# 60 -> 100 on 2026-08-17. At WINDOW_H=72 the maker sees 29 quotable books =
# 87 legs, and a 60-order cap meant Wednesday's MLS card held slots that TODAY's
# games then could not get -- the cap began rejecting KXALLSVENSKANGAME and
# KXEFLCHAMPIONSHIPGAME books kicking off within hours. Books are worked
# nearest-kickoff-first, but that only orders NEW posts; resting far-out legs are
# never evicted to make room.
# 120 -> 45 on 2026-08-22, following the book cap 40 -> 10. 10 books x 3 legs
# = 30, so 45 carries 50% headroom for the overlap while expiring orders are
# replaced. Kept slightly loose on purpose: this must not bind before MAX_BOOKS
# does, or new books get rejected while old ones are still ageing out.
MAX_CONCURRENT_ORDERS = 45        # 10 books x 3 legs, +50%

ORDER_LOG = Path(r'E:\soccer\research\mm_live_orders.csv')
LOG_FIELDS = ['ts', 'action', 'ticker', 'side', 'count', 'price_cents',
              'expires_at', 'live', 'order_id', 'status', 'detail']

_LIVE = False

# Order ids we placed and have not confirmed cancelled. The exchange listing is
# briefly stale after a placement -- in the 2026-08-15 read-back test it came
# back empty ~200ms after a confirmed order -- so a cancel driven only by the
# listing can miss an order that certainly exists. Cancelling by remembered id
# does not depend on the listing having caught up.
# order_id -> (ticker, expires_epoch). The EXPIRY is what makes shutdown fast.
# It used to be order_id -> ticker with no pruning, so a session accumulated
# every order it had ever placed: 4,059 after six hours, and cancel_all then
# made one HTTP call per DEAD id, every one of them raising 'already expired',
# turning a restart into a six-minute wait. A 24h session would have taken half
# an hour. Orders self-expire after MAKER_TTL_S, so anything past its expiry is
# provably gone and does not need cancelling.
_PLACED = {}
# order_id -> cancelled-at. Short-lived: only long enough to cover the listing
# lag, so a genuine duplicate we did NOT cancel still trips the cap.
_CANCELLED = {}
_CANCEL_MEMORY_S = 30


def _prune_placed(now=None):
    """Drop ids whose TTL has passed -- the exchange has already killed them."""
    now = now or time.time()
    dead = [oid for oid, v in _PLACED.items()
            if isinstance(v, tuple) and v[1] <= now]
    for oid in dead:
        _PLACED.pop(oid, None)
    return len(dead)


def cancel_order_v2(order_id: str) -> dict:
    """DELETE /portfolio/events/orders/{id} -- the V2 cancel.

    NOT kalshi_trader.cancel_order, which still calls V1
    (/portfolio/orders/{id}) and has returned 410 deprecated_v1_order_endpoint
    since 2026-07-23. Placement was migrated to V2 then; cancellation was not,
    and nothing caught it because every order this project had ever sent was
    immediate-or-cancel and so never rested long enough to need cancelling. The
    first resting order ever placed (2026-08-15) could not be cancelled at all
    and was only cleaned up by its expiration_time.
    """
    import requests
    path = f'/portfolio/events/orders/{order_id}'
    r = requests.delete(kt.API_BASE + path,
                        headers=kt._auth_headers('DELETE', path), timeout=15)
    if r.status_code not in (200, 204):
        raise RuntimeError(f'Kalshi DELETE v2 order -> {r.status_code}: {r.text}')
    # REMEMBER IT, because the order LISTING lags a cancel by a second or two.
    # The refresh path cancels an order and immediately re-posts the leg; the
    # per-leg cap below re-reads open orders and would still see the dead one,
    # conclude 3 resting + 3 new > MAX_CONTRACTS, and reject the replacement.
    # That is exactly what happened after the cap shipped: 1,037 [skip]s in a
    # day and resting sitting at 11-13 instead of 15, i.e. ~20% of legs
    # silently unquoted. An order we cancelled ourselves is not a duplicate.
    _CANCELLED[order_id] = time.time()
    return r.json() if r.text else {}


def set_live(flag: bool) -> None:
    """Arm or disarm real order placement. Off until explicitly called."""
    global _LIVE
    _LIVE = bool(flag)


def is_live() -> bool:
    return _LIVE


def _log(action, ticker, side, count, price_cents, expires_at, order_id,
         status, detail=''):
    new = not ORDER_LOG.exists()
    ORDER_LOG.parent.mkdir(parents=True, exist_ok=True)
    with ORDER_LOG.open('a', newline='', encoding='utf-8') as fh:
        import csv
        w = csv.DictWriter(fh, fieldnames=LOG_FIELDS)
        if new:
            w.writeheader()
        w.writerow({'ts': dt.datetime.now(dt.timezone.utc).isoformat(),
                    'action': action, 'ticker': ticker, 'side': side,
                    'count': count, 'price_cents': price_cents,
                    'expires_at': expires_at, 'live': int(_LIVE),
                    'order_id': order_id or '', 'status': status,
                    # 600, not 300: a dry-run row carries the whole order body
                    # and this log is the audit trail for what was sent. At 300
                    # the JSON was cut mid-string and would not parse back.
                    'detail': str(detail)[:600]})


def _validate(ticker, count, price_cents):
    if not ticker or not isinstance(ticker, str):
        raise ValueError(f'bad ticker: {ticker!r}')
    # Fractional counts are real: the exchange returned a count_fp of 0.50 on a
    # live fill, so a leg can be left part-sold and the remainder re-posted.
    if not isinstance(count, (int, float)) or isinstance(count, bool):
        raise ValueError(f'count must be a number, got {count!r}')
    if not (count == count) or count < MIN_COUNT:
        raise ValueError(f'count must be >= {MIN_COUNT}, got {count!r}')
    if count > MAX_CONTRACTS:
        raise ValueError(f'count {count} exceeds MAX_CONTRACTS={MAX_CONTRACTS}')
    if not isinstance(price_cents, int):
        raise ValueError(f'price_cents must be int, got {price_cents!r}')
    if not (MIN_PRICE_CENTS <= price_cents <= MAX_PRICE_CENTS):
        raise ValueError(f'price_cents {price_cents} outside '
                         f'{MIN_PRICE_CENTS}..{MAX_PRICE_CENTS}')


def resting_collateral() -> float:
    """Dollars locked by orders currently resting, from the EXCHANGE.

    Read from the exchange rather than from our own state: our state is what we
    believe, and the whole point of a cap is to catch the case where belief and
    reality have diverged.
    """
    try:
        orders = (kt.get_open_orders() or {}).get('orders', []) or []
    except Exception:
        # fail CLOSED -- if the exchange cannot be read, assume the cap is hit
        return float('inf')
    total = 0.0
    for o in orders:
        try:
            # _fp / _dollars FIRST. The bare 'remaining_count' and 'yes_price'
            # keys do not exist on a Kalshi order, so reading them returned 0
            # for every order and this cap -- the one that is supposed to stop
            # the account over-committing -- summed to 0.00 and could never
            # fire. Third instance of the same naming trap in one session.
            c = float(o.get('remaining_count_fp')
                      or o.get('remaining_count')
                      or o.get('count_fp') or o.get('count') or 0)
            p = float(o.get('yes_price_dollars')
                      or (float(o.get('yes_price') or 0) / 100.0))
        except (TypeError, ValueError):
            continue
        # selling YES at p locks (1 - p) per contract
        total += c * max(0.0, 1.0 - p)
    return total


def open_order_count() -> int:
    try:
        return len((kt.get_open_orders() or {}).get('orders', []) or [])
    except Exception:
        return MAX_CONCURRENT_ORDERS      # fail closed


def current_position(ticker: str) -> float:
    """Net contracts held in `ticker`, from the EXCHANGE. Negative = short.

    Guards the case the quoter cannot see: a leg fills, our state misses it,
    and the next poll re-posts -- leaving us short twice what we intended. The
    exchange is the only authority on what we actually hold.
    """
    try:
        pos = (kt.get_positions() or {}).get('market_positions', []) or []
    except Exception:
        return float('nan')          # unknown -> treated as blocking below
    for p in pos:
        if p.get('ticker') == ticker:
            try:
                # position_fp, NOT position. Kalshi suffixes its numeric fields
                # _fp and the bare name does not exist -- .get('position')
                # returns None, which reads as flat, so this guard could never
                # fire. Exactly the trap queue_aware_fill.py documents for
                # count_fp, found again on 2026-08-15 when a real short of -1.00
                # showed as 0 and the maker would happily have re-sold the leg.
                return float(p.get('position_fp') or p.get('position') or 0)
            except (TypeError, ValueError):
                return float('nan')
    return 0.0


def place_maker_ask(ticker: str, count: int, price_cents: int,
                    client_order_id: Optional[str] = None,
                    ttl_s: int = MAKER_TTL_S) -> dict:
    """Rest a SELL-YES order that expires on its own.

    Returns the API response, or {'dry_run': True} when not armed. Raises on any
    cap breach BEFORE touching the network.
    """
    _validate(ticker, count, price_cents)
    expires = int(time.time()) + int(ttl_s)
    exp_iso = dt.datetime.fromtimestamp(expires, dt.timezone.utc).isoformat()

    # caps checked against the EXCHANGE, not our own bookkeeping
    n_open = open_order_count()
    if n_open >= MAX_CONCURRENT_ORDERS:
        _log('reject', ticker, 'ask', count, price_cents, exp_iso, None,
             'cap', f'{n_open} orders already resting')
        raise RuntimeError(f'{n_open} resting orders >= cap {MAX_CONCURRENT_ORDERS}')
    # already short this leg? then a fill we did not see has already happened,
    # or a duplicate is in flight. Either way do not add to it.
    held = current_position(ticker)
    if held != held:                      # NaN -> positions unreadable
        _log('reject', ticker, 'ask', count, price_cents, exp_iso, None,
             'cap', 'positions unreadable')
        raise RuntimeError('cannot read positions -- refusing to post')
    # Never end up short more than MAX_CONTRACTS of a leg. This used to reject
    # ANY existing short, which was safe but made a partial fill terminal: the
    # exchange fills fractionally, and a leg left at 0.50 could never be topped
    # up, so the book could not be completed at size and sat half-hedged.
    # Cap the resulting position instead of forbidding the top-up.
    #
    # RESTING ORDERS COUNT TOO (2026-08-26). The cap above looks only at the
    # POSITION, so a leg with a 3-lot already on the book still reads held=0
    # and happily accepts another 3. Both then rest, and if both fill the
    # account is short 6 against a cap of 3 -- which is exactly what happened
    # on KXDIMAYORGAME-26AUG26CHIFOR-FOR: two orders, two different ids, both
    # filled at 20:26:46, position -6.00.
    #
    # mm_maker_live has a [DUP] sweep that cancels extra resting orders, but it
    # is REACTIVE -- it runs at the top of the next cycle, and these filled in
    # the same second. A cap enforced after the fill is not a cap. The only
    # place that can actually stop it is here, before the order is sent.
    #
    # Fails CLOSED: if the open-order list cannot be read we assume the worst
    # rather than posting blind, consistent with every other cap in this file.
    try:
        _cut = time.time() - _CANCEL_MEMORY_S
        for _oid in [k for k, v in _CANCELLED.items() if v < _cut]:
            _CANCELLED.pop(_oid, None)
        mine = [o for o in (kt.get_open_orders() or {}).get('orders', []) or []
                if o.get('ticker') == ticker
                and o.get('order_id') not in _CANCELLED]
        resting_qty = sum(float(o.get('remaining_count_fp')
                                or o.get('count_fp') or 0) for o in mine)
    except Exception as e:
        _log('reject', ticker, 'ask', count, price_cents, exp_iso, None,
             'cap', f'open orders unreadable: {type(e).__name__}')
        raise RuntimeError(f'cannot read open orders -- refusing to post '
                           f'on {ticker}')
    if -held + resting_qty + count > MAX_CONTRACTS + 1e-9:
        _log('reject', ticker, 'ask', count, price_cents, exp_iso, None,
             'cap', f'short {held} + resting {resting_qty} + {count} '
                    f'exceeds {MAX_CONTRACTS}')
        raise RuntimeError(f'short {held} plus {resting_qty} resting plus '
                           f'{count} would exceed MAX_CONTRACTS='
                           f'{MAX_CONTRACTS} on {ticker}')

    coll = resting_collateral() + count * (1.0 - price_cents / 100.0)
    if coll > MAX_RESTING_COLLATERAL:
        _log('reject', ticker, 'ask', count, price_cents, exp_iso, None,
             'cap', f'collateral ${coll:.2f}')
        raise RuntimeError(f'collateral ${coll:.2f} exceeds cap '
                           f'${MAX_RESTING_COLLATERAL:.2f}')

    body = {
        'ticker': ticker,
        'side': 'ask',                     # SELL yes -- the maker side
        'count': f'{count:.2f}',
        'price': f'{price_cents / 100.0:.4f}',
        'time_in_force': 'good_till_canceled',
        'expiration_time': expires,        # the dead-man's switch
        'post_only': True,                 # never cross; exchange rejects if it would
        'cancel_order_on_pause': True,
        'self_trade_prevention_type': 'taker_at_cross',
    }
    if client_order_id:
        body['client_order_id'] = client_order_id

    if not _LIVE:
        _log('dry_run', ticker, 'ask', count, price_cents, exp_iso, None,
             'ok', json.dumps(body))
        return {'dry_run': True, 'body': body}
    try:
        r = kt._post('/portfolio/events/orders', body)
    except Exception as e:
        _log('error', ticker, 'ask', count, price_cents, exp_iso, None,
             'error', f'{type(e).__name__}: {e}')
        raise
    oid = ((r or {}).get('order') or {}).get('order_id') or (r or {}).get('order_id')
    if oid:
        # +5s of slack so a fill/cancel racing the expiry is still cancellable
        _PLACED[oid] = (ticker, expires + 5)
    _prune_placed()
    _log('placed', ticker, 'ask', count, price_cents, exp_iso, oid, 'ok', '')
    return r


def held_events():
    """Events where the maker's subaccount holds a SHORT -- a book with at least
    one leg already sold. None if the positions cannot be read.

    From the EXCHANGE, not our state, for the same reason resting_collateral()
    is: this decides what survives a restart, and belief must not override the
    ledger.
    """
    try:
        ps = (kt.get_positions() or {}).get('market_positions', []) or []
    except Exception:
        return None
    return {str(p.get('ticker', '')).rsplit('-', 1)[0] for p in ps
            if float(p.get('position_fp') or 0) < -1e-9}


def cancel_all(reason: str = '', keep_held: bool = False) -> int:
    """Cancel everything we placed AND everything the exchange lists resting.

    The union matters. Listing alone misses orders the exchange has accepted but
    not yet published (see _PLACED); our own ids alone would miss anything left
    over from a previous process. Cancelling an already-dead order is harmless,
    so erring toward cancelling twice is the right direction.

    keep_held=True SPARES orders on any event we already hold a leg in -- the
    completing legs of a part-filled book (2026-09-29). Used on RESTART paths
    only, never on a stop. Those legs carry a long TTL precisely so they rest
    through an outage ("leaving it resting through an outage is the outcome we
    want", mm_maker_live's HELD_TTL note), but every restart cancelled them
    anyway: on 2026-09-28 the guard's restart at 00:06 pulled TURITA's TUR and
    TIE, the relaunch then failed on DNS, and the book sat one-legged for 8h.
    A completing leg is priced at the book's budget, so a fill completes it at
    a profit whoever wins -- keeping it is the safe side. If positions cannot
    be read, this FAILS CLOSED and cancels everything, as before.
    """
    keep = held_events() if keep_held else set()
    if keep is None:
        _log('cancel_all', '', '', '', '', '', None, 'error',
             'keep_held requested but positions unreadable -- cancelling all')
        keep = set()
    _prune_placed()
    targets = {oid: (v[0] if isinstance(v, tuple) else v)
               for oid, v in _PLACED.items()}
    try:
        for o in (kt.get_open_orders() or {}).get('orders', []) or []:
            if o.get('order_id'):
                targets[o['order_id']] = o.get('ticker', '')
    except Exception as e:
        _log('cancel_all', '', '', '', '', '', None, 'error',
             f'listing failed, falling back to {len(targets)} known ids: '
             f'{type(e).__name__}: {e}')
    n = 0
    for oid, ticker in targets.items():
        if keep and ticker and str(ticker).rsplit('-', 1)[0] in keep:
            _log('kept', ticker, '', '', '', '', oid, 'ok',
                 f'{reason}: completing leg of a held book')
            continue
        if not _LIVE:
            _log('dry_run_cancel', ticker, '', '', '', '', oid, 'ok', reason)
            n += 1
            continue
        try:
            cancel_order_v2(oid)
            _log('cancelled', ticker, '', '', '', '', oid, 'ok', reason)
            _PLACED.pop(oid, None)
            n += 1
        except Exception as e:
            # already filled, already expired, or already cancelled all raise
            # here; drop it from tracking either way so it cannot leak forever
            _PLACED.pop(oid, None)
            _log('cancel_fail', ticker, '', '', '', '', oid, 'error',
                 f'{type(e).__name__}: {e}')
    return n


def _selftest():
    """Guards only -- no network, no orders. Run before trusting this file."""
    print(f'LIVE = {_LIVE}  (must be False here)')
    assert _LIVE is False, 'module must not start armed'
    ok = 0

    def expect_raise(fn, label):
        nonlocal ok
        try:
            fn()
        except (ValueError, RuntimeError) as e:
            print(f'  [pass] {label}: {type(e).__name__}')
            ok += 1
            return
        raise AssertionError(f'{label} did NOT raise')

    expect_raise(lambda: _validate('T', 0, 30), 'count 0 rejected')
    expect_raise(lambda: _validate('T', MAX_CONTRACTS + 1, 30), 'count above MAX_CONTRACTS')
    expect_raise(lambda: _validate('T', 1, 0), 'price 0 rejected')
    expect_raise(lambda: _validate('T', 1, 100), 'price 100 rejected')
    # Fractional counts are ACCEPTED now -- the exchange fills fractionally and
    # a part-sold leg has to be re-postable for its remainder. What must still
    # be rejected is a count outside the bounds, or one that is not a number.
    expect_raise(lambda: _validate('T', MAX_CONTRACTS + 0.01, 30), 'fraction above cap rejected')
    expect_raise(lambda: _validate('T', 0.0, 30), 'count below MIN_COUNT rejected')
    expect_raise(lambda: _validate('T', True, 30), 'bool count rejected')
    expect_raise(lambda: _validate('T', '1', 30), 'string count rejected')
    expect_raise(lambda: _validate('', 1, 30), 'empty ticker rejected')
    _validate('T', 0.5, 30)          # must NOT raise
    print('  [pass] fractional count 0.5 accepted')
    ok += 1

    body = place_maker_ask('KXTEST-1', 1, 30)['body']
    assert body['side'] == 'ask', body
    assert body['post_only'] is True, body
    assert body['time_in_force'] == 'good_till_canceled', body
    assert body['expiration_time'] > time.time(), body
    assert body['expiration_time'] <= time.time() + MAKER_TTL_S + 2, body
    print(f'  [pass] dry-run body is a post-only expiring ASK '
          f'(ttl {MAKER_TTL_S}s)')
    ok += 1
    print(f'\n{ok} guard checks passed; nothing was sent.')


if __name__ == '__main__':
    _selftest()
