"""Poly-US order layer for the live three-leg maker. Mirror of kalshi_maker.py.

SEPARATE MODULE ON PURPOSE. mm_maker_live.py is holding real Kalshi positions and
completing at 76.7% right now; a second venue must not be able to break it. Same
reasoning mm_quoter_polyus.py already applies to the paper side.

WHAT A "MAKER ASK" IS HERE. On Kalshi the maker posts `side: 'ask'` -- sell YES.
Poly-US has no naked sell-YES; the equivalent is BUYING NO, which is the same
economics (buy NO at q == sell YES at 1-q) and opens a position without holding
anything first. So `place_maker_ask(slug, qty, yes_price)` sends
ORDER_INTENT_BUY_SHORT at (1 - yes_price).

  *** THE SIDE MECHANICS ARE NOT YET VERIFIED ON A REAL FILL. ***
  Everything else here was proven live on 2026-08-22 at $0.00 cost: post-only
  refuses crosses, GTD self-expiry fires at 90s, cancel works and verifies, and
  three legs of one book rest simultaneously. The BUY_SHORT price convention is
  read from docs.polymarket.us and has NOT been confirmed against a fill. Run
  verify_side_convention() before arming. Docs and this venue have disagreed
  before -- cancel_order once called an endpoint that did not exist and 404'd
  silently for the entire period the account was auto-trading.

TWO THINGS DIFFER FROM KALSHI AND BOTH BITE:

  buyingPower DOES NOT DECREMENT for resting orders. Measured: $3.00 of orders
    resting, buyingPower unchanged at $101.1668. So the venue's own number is
    useless as a collateral check and resting_notional() computes it here.

  A POST-ONLY KILL LOOKS LIKE SUCCESS. A crossing post-only order returns
    HTTP 200 with {"id": ..., "executions": []} and is then silently discarded.
    "Received" and "resting" are indistinguishable in the response, so nothing
    may treat a 200 as a resting order -- verify against open_orders().

LIVE IS OFF BY DEFAULT. set_live(True) must be called explicitly.
"""
import csv
import datetime as dt
import json
import math
import sys
import time
from pathlib import Path
from typing import Optional

sys.path.insert(0, r'E:\soccer\main')
import poly_us_trader as put
import poly_us_auth as pa

# ── caps ────────────────────────────────────────────────────────────────────
MAKER_TTL_S = 90          # matches Kalshi. The dead-man's switch: a crash or a
                          # power cut cannot leave anything resting longer than
                          # this. Not hypothetical -- this box was off for 2.5
                          # days unnoticed on 2026-08-10.
MAX_CONTRACTS = 5.0       # per leg. Poly-US books are ~7-20 deep, so size 1 on
                          # Kalshi does not translate; still deliberately small.
MAX_PRICE = 0.99
MIN_PRICE = 0.01
MIN_COUNT = 0.01
# Poly-US charges nothing to rest, but a filled leg costs (1 - yes_price) per
# contract exactly as on Kalshi. Start far below the $101 balance: this venue
# has never been run live, and the first number to trust is a small one.
MAX_RESTING_NOTIONAL = 15.00
MAX_CONCURRENT_ORDERS = 15        # 5 books x 3 legs

ORDER_LOG = Path(r'E:\soccer\research\pus_maker_orders.csv')
LOG_FIELDS = ['ts', 'action', 'slug', 'yes_price', 'no_price', 'count',
              'expires_at', 'live', 'order_id', 'status', 'detail']

_LIVE = False
_PLACED = {}          # order_id -> market_slug, for cancel_all


def set_live(flag: bool) -> None:
    global _LIVE
    _LIVE = bool(flag)


def is_live() -> bool:
    return _LIVE


def _log(action, slug, yes_price, no_price, count, expires_at,
         order_id, status, detail):
    try:
        ORDER_LOG.parent.mkdir(parents=True, exist_ok=True)
        new = not ORDER_LOG.exists()
        with ORDER_LOG.open('a', newline='', encoding='utf-8') as fh:
            w = csv.DictWriter(fh, fieldnames=LOG_FIELDS)
            if new:
                w.writeheader()
            w.writerow({'ts': dt.datetime.now(dt.timezone.utc).isoformat(),
                        'action': action, 'slug': slug, 'yes_price': yes_price,
                        'no_price': no_price, 'count': count,
                        'expires_at': expires_at, 'live': _LIVE,
                        'order_id': order_id or '', 'status': status,
                        'detail': detail})
    except OSError:
        pass          # logging must never take down the maker


def _validate(slug, count, yes_price):
    if not slug or not isinstance(slug, str):
        raise ValueError(f'bad slug {slug!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 (MIN_PRICE <= yes_price <= MAX_PRICE):
        raise ValueError(f'yes_price {yes_price} outside {MIN_PRICE}..{MAX_PRICE}')


_ORDERS_CACHE = {'ts': 0.0, 'rows': None}
ORDERS_CACHE_S = 3.0


def _orders(max_age: float = ORDERS_CACHE_S):
    """Resting orders, from the EXCHANGE, cached for a few seconds.

    RATE LIMITED WITHOUT THIS. Poly-US sits behind Cloudflare and returns
    429 / error 1015 under load. One place_maker_ask call reads this list
    TWICE (order count, then notional), the driver reads it again to see what
    is already working, and again to confirm each post -- so a nine-leg cycle
    made ~40 requests every 25s and the endpoint started refusing. The caps
    then fail closed, so the maker went silent and posted nothing: safe, but
    indistinguishable from a dead market.

    A 3-second cache collapses that to roughly one request per cycle. Pass
    max_age=0 where freshness genuinely matters -- confirming a specific order
    actually rested is the one case that does.

    Raises on failure; callers fail closed on the exception.
    """
    now = time.time()
    if (max_age > 0 and _ORDERS_CACHE['rows'] is not None
            and now - _ORDERS_CACHE['ts'] <= max_age):
        return _ORDERS_CACHE['rows']
    # RETRY 429 SPECIFICALLY. The limit is bursty, not a sustained ban --
    # measured: blocked mid-cycle, then clear again seconds later. Without a
    # retry a single throttled read makes every cap fail closed for that whole
    # cycle, so the maker quietly stops quoting and looks like a dead venue.
    last = None
    for attempt, pause in enumerate((0, 2.0, 5.0)):
        if pause:
            time.sleep(pause)
        try:
            rows = put.open_orders() or []
            _ORDERS_CACHE['rows'] = rows
            _ORDERS_CACHE['ts'] = time.time()
            return rows
        except Exception as e:
            last = e
            if '429' not in str(e):
                raise
    raise last


def invalidate_orders_cache():
    """Drop the cache -- call after placing or cancelling."""
    _ORDERS_CACHE['rows'] = None
    _ORDERS_CACHE['ts'] = 0.0


def _order_notional(o):
    """Cash a resting order can consume. CONSERVATIVE BY CONSTRUCTION.

    The book quotes every order YES-side, so the same price means different
    commitments depending on the intent behind it:
        BUY_LONG  (bid YES at P)                  commits  P * qty
        BUY_SHORT (offer YES at P = buy NO at 1-P) commits (1-P) * qty
    The listing does not reliably say which, and this account can carry both
    (the xlow trader places BUY_LONG). Taking the larger of the two can only
    over-state exposure, which makes the cap bind early rather than late --
    the right direction for a venue that has never been run live.
    """
    px = o.get('price')
    try:
        v = float(px.get('value')) if isinstance(px, dict) else float(px or 0)
    except (TypeError, ValueError):
        return 0.0
    try:
        q = float(o.get('quantity') or 0)
    except (TypeError, ValueError):
        return 0.0
    return max(v, 1.0 - v) * q


def resting_notional() -> float:
    """Dollars committed to resting orders.

    Computed here rather than read from the venue: buyingPower does NOT move
    when orders rest (verified 2026-08-22, $3.00 resting and buyingPower
    unchanged), so it cannot serve as the cap this is protecting.

    Fails CLOSED -- if the book cannot be read, report the cap as reached.
    """
    try:
        return sum(_order_notional(o) for o in _orders())
    except Exception:
        return float('inf')


def open_order_count() -> int:
    try:
        return len(_orders())
    except Exception:
        return MAX_CONCURRENT_ORDERS      # fail closed


def current_position(slug: str) -> float:
    """Net contracts held in this market. NaN when positions are unreadable,
    which callers must treat as 'refuse to post' rather than as zero."""
    try:
        p = pa.get_positions() or {}
        pos = p.get('positions') or {}
        v = pos.get(slug)
        if v is None:
            return 0.0
        return float(v.get('netPositionDecimal') or 0)
    except Exception:
        return float('nan')


def place_maker_ask(slug: str, count: float, yes_price: float,
                    ttl_s: int = MAKER_TTL_S) -> dict:
    """Rest a maker order equivalent to selling YES at `yes_price`.

    Implemented as BUY NO at (1 - yes_price): Poly-US has no naked sell-YES, and
    buying NO opens the same economic position without holding anything first.

    Post-only, so it can never cross and can never be the taker -- the entire
    case for this venue is earning the 0.0125*p(1-p) maker rebate instead of
    paying the 0.06*p(1-p) taker fee, and one crossed order inverts that.

    Returns the API response, or {'dry_run': True} when not armed. Every cap is
    checked against the EXCHANGE before any network write.
    """
    _validate(slug, count, yes_price)
    # PRICE IS ALWAYS QUOTED YES-SIDE, EVEN FOR BUY_SHORT. Verified by A/B on a
    # live market 2026-08-22: BUY_SHORT at 0.79 RESTED at 0.79; BUY_SHORT at
    # 0.21 (the NO price for the same offer) was post-only killed, because the
    # venue read it as buy-NO-at-0.79, far through the ~0.47 NO ask.
    # The first version of this file sent the inverted price and would have had
    # every single order killed -- silently, since a post-only kill returns
    # HTTP 200. `no_price` below is bookkeeping only; it is never sent.
    no_price = round(1.0 - yes_price, 2)
    exp_iso = (dt.datetime.now(dt.timezone.utc)
               + dt.timedelta(seconds=ttl_s)).isoformat()

    n_open = open_order_count()
    if n_open >= MAX_CONCURRENT_ORDERS:
        _log('reject', slug, yes_price, no_price, count, exp_iso, None,
             'cap', f'{n_open} orders already resting')
        raise RuntimeError(f'{n_open} resting orders >= cap {MAX_CONCURRENT_ORDERS}')

    held = current_position(slug)
    if held != held:                       # NaN
        _log('reject', slug, yes_price, no_price, count, exp_iso, None,
             'cap', 'positions unreadable')
        raise RuntimeError('cannot read positions -- refusing to post')
    if abs(held) + count > MAX_CONTRACTS + 1e-9:
        _log('reject', slug, yes_price, no_price, count, exp_iso, None,
             'cap', f'held {held} + {count} exceeds {MAX_CONTRACTS}')
        raise RuntimeError(f'position {held} plus {count} would exceed '
                           f'MAX_CONTRACTS={MAX_CONTRACTS} on {slug}')

    notional = resting_notional() + count * no_price
    if notional > MAX_RESTING_NOTIONAL:
        _log('reject', slug, yes_price, no_price, count, exp_iso, None,
             'cap', f'notional ${notional:.2f}')
        raise RuntimeError(f'resting notional ${notional:.2f} exceeds cap '
                           f'${MAX_RESTING_NOTIONAL:.2f}')

    if not _LIVE:
        _log('dry_run', slug, yes_price, no_price, count, exp_iso, None, 'ok',
             json.dumps({'intent': 'ORDER_INTENT_BUY_SHORT',
                         'price': no_price, 'quantity': count,
                         'post_only': True, 'ttl_s': ttl_s}))
        return {'dry_run': True, 'slug': slug, 'yes_price': yes_price,
                'no_price': no_price, 'count': count}

    try:
        r = put.place_limit_order(slug, yes_price, count,
                                  intent='ORDER_INTENT_BUY_SHORT',
                                  post_only=True, ttl_secs=ttl_s)
    except Exception as e:
        _log('error', slug, yes_price, no_price, count, exp_iso, None,
             'error', f'{type(e).__name__}: {e}')
        raise

    invalidate_orders_cache()      # the book just changed
    oid = (r or {}).get('id') or (r or {}).get('orderId')
    # A 200 IS NOT A RESTING ORDER HERE. A post-only that would have crossed
    # comes back exactly like a success. Record it, but the caller must confirm
    # against open_orders() before believing the leg is working.
    execs = (r or {}).get('executions') or []
    if oid:
        _PLACED[oid] = slug
    _log('placed', slug, yes_price, no_price, count, exp_iso, oid,
         'ok', f'executions={len(execs)}')
    return r


def cancel_all(reason: str = '') -> int:
    """Cancel everything we placed AND everything the venue lists resting.

    Union, for the same reason as Kalshi: our own ids miss anything a previous
    process left behind, and the listing misses anything accepted but not yet
    published. Cancelling a dead order is harmless.
    """
    targets = dict(_PLACED)
    try:
        for o in _orders():
            oid = o.get('id') or o.get('orderId')
            if oid:
                targets[oid] = o.get('marketSlug') or ''
    except Exception as e:
        _log('cancel_all', '', '', '', '', '', None, 'error',
             f'listing failed, using {len(targets)} known ids: '
             f'{type(e).__name__}: {e}')
    n = 0
    for oid, slug in list(targets.items()):
        if not _LIVE:
            _log('dry_run_cancel', slug, '', '', '', '', oid, 'ok', reason)
            n += 1
            continue
        try:
            put.cancel_order(oid, slug)
            _log('cancelled', slug, '', '', '', '', oid, 'ok', reason)
            _PLACED.pop(oid, None)
            n += 1
        except Exception as e:
            _PLACED.pop(oid, None)
            _log('cancel_fail', slug, '', '', '', '', oid, 'error',
                 f'{type(e).__name__}: {e}')
    return n


def verify_side_convention(slug: str, yes_price: float) -> dict:
    """Prove BUY_SHORT rests where we think it does. RUN BEFORE ARMING.

    Places one deep, unfillable post-only order and reports what actually
    appears on the book, so the price convention is confirmed against the venue
    rather than against the docs. Cancels itself.
    """
    was = _LIVE
    set_live(True)
    out = {'slug': slug, 'sent_yes_price': yes_price,
           'sent_no_price': round(1.0 - yes_price, 2)}
    try:
        r = place_maker_ask(slug, MIN_COUNT * 100, yes_price)
        out['response'] = r
        time.sleep(4)
        mine = [o for o in _orders() if o.get('marketSlug') == slug]
        rows = []
        for o in mine:
            px = o.get('price')
            v = px.get('value') if isinstance(px, dict) else px
            rows.append({'price_on_book': v, 'qty': o.get('quantity'),
                         'id': o.get('id') or o.get('orderId')})
        out['resting'] = rows
        out['rested'] = bool(mine)
    except Exception as e:
        out['error'] = f'{type(e).__name__}: {e}'
    finally:
        try:
            cancel_all('verify_side_convention')
        finally:
            set_live(was)
    return out


def _selftest():
    """Guards only -- no network, no orders."""
    print(f'LIVE = {_LIVE}  (must be False here)')
    assert _LIVE is False, 'module must not start armed'
    for bad in ((None, 1, 0.5), ('s', 0, 0.5), ('s', 999, 0.5),
                ('s', 1, 0.0), ('s', 1, 1.5)):
        try:
            _validate(*bad)
        except (ValueError, TypeError):
            pass
        else:
            raise AssertionError(f'_validate accepted {bad!r}')
    r = place_maker_ask('fake-slug', 1.0, 0.30)
    assert r.get('dry_run') is True, 'unarmed place must be a dry run'
    # the ORDER carries the YES price; no_price is bookkeeping only
    assert r['yes_price'] == 0.30, f'expected yes_price 0.30, got {r["yes_price"]}'
    assert abs(_order_notional({'price': {'value': '0.79'}, 'quantity': 1}) - 0.79) < 1e-9
    assert abs(_order_notional({'price': {'value': '0.21'}, 'quantity': 1}) - 0.79) < 1e-9
    print('caps, YES-side pricing and conservative notional OK; dry run is offline')
    print(f'MAX_CONTRACTS={MAX_CONTRACTS}  '
          f'MAX_RESTING_NOTIONAL=${MAX_RESTING_NOTIONAL}  '
          f'MAX_CONCURRENT_ORDERS={MAX_CONCURRENT_ORDERS}  TTL={MAKER_TTL_S}s')


if __name__ == '__main__':
    _selftest()
