"""PAPER market maker for POLYMARKET US. Places NOTHING. Ever.

Parallel to mm_quoter.py (Kalshi) so the two venues can be compared on identical
rules, identical fair values, and the same 0-5c price ladder. Everything that
differs is a property of the venue, not of the strategy.

WHY POLY-US IS WORTH TRACKING SEPARATELY. On the numbers, it should be a better
venue for this strategy on every axis we can measure:

  MAKER REBATE, not a fee. Poly-US credits the maker 0.0125*C*p*(1-p) at fill
    (docs.polymarket.us/fees, effective 2026-07-01) where Kalshi pays exactly
    zero. On a typical three-leg book (54/27/21) that is +0.76c/contract.
  CHEAPER TAKER for the completions we cannot avoid: 0.06*p*(1-p) vs Kalshi 0.07.
  Together these drop the break-even gross from +2.38c (Kalshi) to +1.42c. That
    matters because the 1c tick quantises gross into 0c/1c/2c/3c buckets -- on
    Kalshi only 3c books clear (2 of our first 9); on Poly-US ordinary 2c books
    do, which roughly triples the eligible population.
  SHALLOW BOOKS: depth of 7-20 contracts vs Kalshi's 1,749-44,427. Our central
    unsolved problem on Kalshi is queue position -- with 44k ahead of us we are
    never reached. Here we would rest near the front.

SAFETY. This module imports poly_us_prices (read-only market data) and never
poly_us_trader. There is no code path in this file that can place an order.

FILL MODEL, and its known bias. Poly-US has no public per-market trade feed on
the retail API (candlesticks live on the institutional host, which our key is
not authorised for -- verified 2026-07-29). So a fill is inferred from `currentPx`,
the last trade price returned by the BBO endpoint: if at any poll currentPx has
reached our ask, someone traded there and a resting order would have been hit.
This is COARSER than the Kalshi model in one direction and more conservative in
another: we only see the LAST trade, so a print above our ask that reverts inside
a 20-second poll is invisible, which UNDER-counts fills. Kalshi's model, by
contrast, sees every print and so over-counts. Do not compare raw fill rates
across the two venues without accounting for that.

Usage:  python main\\mm_quoter_polyus.py [--once]
"""
import csv
import datetime as dt
import hashlib
import json
import math
import sys
import time
from pathlib import Path

sys.path.insert(0, r'E:\soccer\main')
import poly_us_prices as prices          # READ-ONLY market data
import live_monitor as LM                # watchlist + Pinnacle odds
import maps

OUT = Path(r'E:\soccer\research\mm_polyus_quotes.csv')
STATE = Path(r'E:\soccer\research\mm_polyus_state.json')

WINDOW_H = 12.0
POLL_SECS = 20
SIZE = 10
FAIR_TOLERANCE_C = 2.0
COMPLETE_BEFORE_KO_H = 1.0

# GROSS-MARGIN GATE: mirror of mm_quoter.GROSS_GATE_C. Minimum cents of
# overround (sum of market asks - 100c) required before quoting any leg.
GROSS_GATE_C = 2.5

# LIQUIDITY CHECK: mirror of mm_quoter.MAX_SPREAD_C. Stops the gross gate being
# fooled by an unquoted/postponed book's fake overround. Deliberately generous
# -- the target is the 200c+ pathology, not wide-but-real books.
MAX_SPREAD_C = 15.0
BANKROLL = 500.00
REGIME = 'pus-v1'

# Poly-US fee schedule (docs.polymarket.us/fees, effective 2026-07-01)
TAKER_THETA = 0.06
MAKER_THETA = 0.0125          # CREDITED to the maker


def taker_fee(p):
    return TAKER_THETA * p * (1 - p)


def maker_rebate(p):
    return MAKER_THETA * p * (1 - p)


# Same ladder as Kalshi so the venues are comparable. Per LEG, md5-stable.
AB_ENABLED = True

# ── PRICE DECAY: sell as high as possible, concede only as time runs out ──
# Replaces a FIXED per-leg arm. Previously a leg drawn at +5c sat at +5c for its
# whole life and then jumped straight to the market ask at T-60 -- it never tried
# +4, +3, +2, +1. That is holding one rung, not walking a ladder, and it left
# most legs unfilled while forgoing the prices in between.
#
# Now the premium DECAYS with time to kickoff, so we ask the most we plausibly
# can while there is still time to climb down, and concede progressively.
#
# Why fine-grained steps are cheap: queue_ahead is 0 at any price ABOVE the
# touch (nobody else is quoting there), so a cancel-replace from +5c to +4c
# forfeits no queue position. Only the last step, joining the touch, puts us
# behind resting size -- so exactly one expensive reprice, at the end.
#
# Schedule is anchored on WHEN FILLS ARRIVE (measured 07-28/29, n=32 filled
# legs): 44% land >9h out, 66% by 6h, 75% by 3h, 91% by 1h. So the high rungs
# get the window where most buying happens, and the touch is reached with ~1.5h
# still to run.
DECAY = ((9.0, 5.0), (6.0, 4.0), (4.0, 3.0), (2.5, 2.0), (1.5, 1.0))


def arm_for(event, side='', hrs=99.0):
    """Premium in cents over the market ask, decaying toward the touch.

    A per-leg offset of -1/0/+1 (md5-stable) is kept so that at any given time
    different legs sit at different rungs. Without it, price and time-to-kickoff
    would be perfectly confounded and the fill curve could never be separated
    from a time-of-day effect.
    """
    base = 0.0
    for cut, val in DECAY:
        if hrs >= cut:
            base = val
            break
    off = (int(hashlib.md5(f'{event}|{side}'.encode()).hexdigest(), 16) % 3) - 1
    return max(0.0, min(5.0, base + off))




# ── Polymarket.COM reference prices (added 2026-07-29) ───────────────────
# Logged for CROSS-VENUE COMPARISON only; we do not and cannot quote there
# (geo-restricted -- see the Kalshi-only execution research). Capturing all
# three venues at the SAME timestamp is the only way to compare their maker
# economics honestly: overround, spread and depth all move, so prices pulled
# minutes apart are not comparable. Cached because the quoters poll every 20s
# and this would otherwise add an API call per match per cycle.
_PC_CACHE = {}
_PC_TTL = 60


def poly_com_prices(div, home, away, game_date):
    """{'home_bid':..,'home_ask':..,...} from Polymarket.com, or {} if absent."""
    key = (div, home, away, str(game_date))
    hit = _PC_CACHE.get(key)
    if hit and time.time() - hit[0] < _PC_TTL:
        return hit[1]
    try:
        import polymarket_live as _pl
        r = _pl.get_all_prices(str(game_date), home, away, league=str(div).upper()) or {}
    except Exception:
        r = {}
    _PC_CACHE[key] = (time.time(), r)
    return r


def margin_for(hrs):
    if hrs >= 5.0:  return 6.0
    if hrs >= 2.5:  return 5.0
    if hrs >= 1.5:  return 3.0
    if hrs >= 0.5:  return 2.0
    return 1.0


FIELDS = ['ts', 'div', 'home', 'away', 'kickoff', 'event_slug', 'market_slug',
          'side', 'pin_fair', 'p_bid', 'p_ask', 'last_px', 'over_ask',
          'our_ask', 'competitive', 'size', 'bid_depth', 'ask_depth',
          'rebate_c', 'reason', 'arm', 'regime', 'pc_bid', 'pc_ask']


def _pt(s):
    try:
        d = dt.datetime.fromisoformat(str(s).replace('Z', '+00:00'))
    except (ValueError, TypeError):
        return None
    return d if d.tzinfo else d.replace(tzinfo=dt.timezone.utc)


def devig(o):
    r = [1.0 / x for x in o]
    s = sum(r)
    return [x / s for x in r]


def load_state():
    if STATE.exists():
        try:
            return json.loads(STATE.read_text(encoding='utf-8'))
        except Exception:
            return {}
    return {}


def save_state(st):
    STATE.write_text(json.dumps(st, indent=1), encoding='utf-8')


def polyus_playable(div, home, away):
    """Poly-US's own view of whether this match is on.

    Mirrors live_monitor.NOT_PLAYED against the event's `period` field, which
    carries API-Football-style short codes ('NS', '1H', 'FT', 'PST', ...).
    Unknown/absent -> True, so a missing field degrades to "quote it" and the
    API-Football backstop still has the last word, rather than silently
    stopping us on every event whose period Poly-US has not populated.
    """
    e = prices.find_event(div, home, away)
    if not e:
        return False
    if e.get('ended') or e.get('closed') or e.get('archived'):
        return False
    period = (e.get('period') or '').upper()
    return period not in LM.NOT_PLAYED


def resolve_legs(div, home, away):
    """{'home': slug, 'draw': slug, 'away': slug} or None.

    find_market() only knows home/away, so the draw is taken as the remaining
    market once both teams are matched -- and we require EXACTLY three markets
    and both teams identified before accepting, so a partial match can never
    silently assign the wrong slug to the draw.
    """
    e = prices.find_event(div, home, away)
    if not e:
        return None
    # Keep ONLY the match-winner markets. Poly-US events also carry total-goals
    # lines (`tsc-...-2pt5` etc.), so a bare len(markets)==3 check silently threw
    # away every event that offers totals -- which cost us all of USL on
    # 2026-07-29 (Pittsburgh and Louisville each list 3 `atc-` + 4 `tsc-`).
    mk = [m for m in (e.get('markets') or [])
          if str(m.get('slug', '')).startswith('atc-')]
    if len(mk) != 3:
        return None
    out, used = {}, set()
    for side, team in (('home', home), ('away', away)):
        m = prices.find_market(div, home, away, side)
        if not m:
            return None
        out[side] = m['slug']
        used.add(m['slug'])
    rest = [m['slug'] for m in mk if m['slug'] not in used]
    if len(rest) != 1:
        return None
    out['draw'] = rest[0]
    return out, e.get('slug', '')


def release_expired(st, now):
    """Drop legs whose kickoff is >4h past -- the match has finished and the
    paper position has settled.

    ADDED 2026-07-30. The Kalshi quoter did this from the start; this one did
    not, so state accumulated every book forever -- 12 stale books from the
    previous day were still being carried, some up to 23h past kickoff. Nothing
    is lost by dropping them: mm_polyus_quotes.csv logs every leg every cycle and
    is the durable record. State is only the set of CURRENTLY resting orders.
    """
    for k in list(st):
        ko = _pt(st[k].get('kickoff'))
        if ko is None or (now - ko).total_seconds() > 4 * 3600:
            st.pop(k, None)
    return st


# ── Liquidity + quota gates (added 2026-08-09) ────────────────────────────
# The Kalshi study showed partial fills, not thin margins, are what lose money:
# 3-of-3 books ran +1.38c while 2-of-3 ran -7.62c, and completion tracks
# liquidity monotonically (min leg volume >=500 -> 60% completion, +3.01c/book).
#
# That threshold does NOT transfer here. Poly-US exposes no traded-volume field
# at all -- only askDepth/bidDepth from the BBO, which is RESTING SIZE, a
# different quantity in different units. Calibrating a real threshold needs
# Poly-US fill data we do not yet have, so this gate is deliberately weak: it
# rejects only books with essentially no resting size (the unquoted-market
# signature), and the depths are logged so a later sweep can set a real number.
# Claiming 500 works here would be borrowing a result from another venue.
MIN_LEG_DEPTH = 1.0

AF_QUOTA_FLOOR = 400
_quota_checked_at = 0.0
_quota_ok = True


def quota_ok():
    """False when API-Football is nearly spent. Cached 5 min; fails OPEN."""
    global _quota_checked_at, _quota_ok
    import time as _t
    if _t.time() - _quota_checked_at < 300:
        return _quota_ok
    try:
        remaining = LM.get_api_remaining()
        _quota_ok = remaining >= AF_QUOTA_FLOOR
        if not _quota_ok:
            print(f'  [quota] API-Football down to {remaining} -- pausing quoting')
    except Exception:
        _quota_ok = True
    _quota_checked_at = _t.time()
    return _quota_ok


def cycle(writer, fh):
    if not quota_ok():
        return 0, 0
    _, matches = LM.load_watchlist_file()
    now = dt.datetime.now(dt.timezone.utc)
    st = release_expired(load_state(), now)
    quoted = completed = 0

    for m in matches:
        div = (m.get('lc') or '').upper()
        if div not in prices.LEAGUE_SLUG:
            continue
        gd = m.get('game_date')
        if isinstance(gd, str):
            gd = dt.date.fromisoformat(gd)
        try:
            hh, mi = str(m['kickoff']).split(':')[:2]
            ko = dt.datetime(gd.year, gd.month, gd.day, int(hh), int(mi),
                             tzinfo=dt.timezone(dt.timedelta(hours=-4)))
        except Exception:
            continue
        hrs = (ko - now).total_seconds() / 3600.0
        if not (0 < hrs <= WINDOW_H):
            continue

        # Exchange-first, same as mm_quoter (2026-08-07). Poly-US events carry
        # their own `period` (API-Football-style short codes), `ended`, `live`
        # and `closed` -- so the venue we are about to quote on already tells us
        # whether the match is on, for free, in a call we make anyway.
        got = resolve_legs(div, m['home'], m['away'])
        if not got:
            continue
        legs, event_slug = got
        if not polyus_playable(div, m['home'], m['away']):
            continue
        # API-Football as a long-TTL backstop only -- see live_monitor.
        if not LM.fixture_playable(m.get('fid'), max_age=LM.PLAYABLE_BACKSTOP_TTL):
            continue          # postponed/cancelled -- see live_monitor.NOT_PLAYED

        book = {}
        for side, slug in legs.items():
            b = prices.get_bbo(slug)
            if not b or not b.get('bestBid') or not b.get('bestAsk'):
                book = {}
                break
            if not (0 < b['bestBid'] < b['bestAsk'] < 1):
                book = {}
                break
            # thinnest-leg gate: a book completes only if ALL three fill, so one
            # empty leg is what strands us at 2/3 -- see MIN_LEG_DEPTH
            if min(float(b.get('bidDepth') or 0),
                   float(b.get('askDepth') or 0)) < MIN_LEG_DEPTH:
                book = {}
                break
            book[side] = b
        if not book:
            continue

        # COHERENCE CHECK, then GROSS-MARGIN GATE (both added 2026-07-31).
        # Mirror of mm_quoter.py -- see the long notes there. An empty book
        # (nobody quoting yet, or a stale postponed listing) has bids near 0
        # and asks near 1, which fakes a huge "overround" through pure
        # illiquidity -- caught live via a Kalshi example ("Botafogo",
        # gross@post +143c) that would have sailed through the gross gate
        # alone. Checked before the Pinnacle fetch so a book that can never be
        # worth posting does not cost an API call either.
        over_ask = sum(book[s]['bestAsk'] for s in book)
        over_bid = sum(book[s]['bestBid'] for s in book)
        if (over_ask - over_bid) * 100 > MAX_SPREAD_C:
            continue
        if (over_ask - 1.0) * 100 < GROSS_GATE_C:
            continue

        b365, pin = LM.fetch_prematch_odds(m.get('fid'))
        if not pin or not all(pin.get(k) for k in ('h', 'd', 'a')):
            continue
        fair = devig([pin['h'], pin['d'], pin['a']])
        fmap = dict(home=fair[0], draw=fair[1], away=fair[2])

        pc = poly_com_prices(div, m['home'], m['away'], gd)
        for side, slug in legs.items():
            bb = book[side]
            arm = arm_for(event_slug, side, hrs)
            fair_c = fmap[side] * 100
            target_c = fair_c + margin_for(hrs)
            ceiling_c = round(bb['bestAsk'] * 100, 6) + (arm if AB_ENABLED else 0.0)
            our_c = round(min(target_c, ceiling_c), 6)
            skip = our_c < fair_c - FAIR_TOLERANCE_C
            import math
            our = math.ceil(our_c) / 100.0 if not skip else None
            comp = (not skip) and our is not None and our <= bb['bestAsk'] + 1e-9
            reason = ('' if not skip else
                      f"market ask {bb['bestAsk']:.2f} below our fair "
                      f"{fmap[side]:.3f} by > {FAIR_TOLERANCE_C:.1f}c -- skipped")

            key = slug
            # Walk the price DOWN, and concede to the market ask inside the last
            # hour. See the long note in mm_quoter.py: posting once and never
            # repricing left 11 of 15 books naked on 2026-07-29, while conceding
            # near kickoff completed 12 of 15. Only ever reprice down.
            if key in st and not st[key].get('filled') and our is not None:
                cur = st[key]['our_ask']
                tgt = bb['bestAsk'] if hrs <= COMPLETE_BEFORE_KO_H else our
                tgt = math.ceil(round(tgt * 100, 6)) / 100.0
                if tgt < cur - 1e-9:
                    st[key]['our_ask'] = tgt
                    st[key]['repriced_at'] = now.isoformat()
                    st[key]['reprice_count'] = 1 + st[key].get('reprice_count', 0)
            if not skip and key not in st:
                st[key] = {'event': event_slug, 'side': side, 'our_ask': our,
                           'pin_fair': round(fmap[side], 4), 'kickoff': ko.isoformat(),
                           'filled': False, 'completed': False, 'arm': arm,
                           'regime': REGIME, 'div': div, 'home': m['home'],
                           'away': m['away'], 'posted_at': now.isoformat()}

            writer.writerow({
                'ts': now.isoformat(), 'div': div, 'home': m['home'], 'away': m['away'],
                'kickoff': ko.isoformat(), 'event_slug': event_slug, 'market_slug': slug,
                'side': side, 'pin_fair': round(fmap[side], 4),
                'p_bid': bb['bestBid'], 'p_ask': bb['bestAsk'],
                'last_px': bb.get('currentPx'), 'over_ask': round(over_ask, 4),
                'our_ask': (round(our, 2) if our else ''), 'competitive': int(comp),
                'size': SIZE, 'bid_depth': bb.get('bidDepth'), 'ask_depth': bb.get('askDepth'),
                'rebate_c': round(maker_rebate(our) * 100, 3) if our else '',
                'reason': reason, 'arm': arm, 'regime': REGIME,
                'pc_bid': pc.get(f'{side}_bid', ''),
                'pc_ask': pc.get(f'{side}_ask', '')})
            quoted += 1 if comp else 0

        # ── fills: last trade price reached our resting ask ──
        for side, slug in legs.items():
            s = st.get(slug)
            if not s or s.get('filled') or s.get('book_closed'):
                continue
            last = book[side].get('currentPx')
            if last is not None and last >= s['our_ask'] - 1e-9:
                s['filled'] = True
                s['fill_px'] = s['our_ask']
                s['filled_at'] = now.isoformat()
                p = s['our_ask']
                s['rebate'] = round(maker_rebate(p), 4)
                s['fill_kind'] = 'natural'

        completed += complete_book(st, legs, book, writer, now, ko, hrs, m, div, event_slug)
        completed += flatten_stalled(st, legs, book, writer, now, ko, hrs, m, div, event_slug)
        fh.flush()

    save_state(st)
    return quoted, completed



def flatten_stalled(st, legs, book, writer, now, ko, hrs, m, div, event_slug):
    """Book stuck at 1-of-3 near kickoff: buy back the one sold leg, go flat.

    Mirror of mm_quoter.flatten_stalled -- see the long note there for why this
    exists separately from complete_book (which only fires at exactly 2-of-3) and
    why a slightly negative-EV fee is worth paying to stop naked legs burying the
    signal. Poly-US differs only in the fee (0.06 vs 0.07) and in the book being
    keyed by side rather than by ticker.
    """
    mine = [s_ for s_ in legs.values() if s_ in st]
    if len(mine) != 3:
        return 0
    filled = [s_ for s_ in mine if st[s_].get('filled')]
    if len(filled) != 1 or hrs > COMPLETE_BEFORE_KO_H:
        return 0
    tgt = filled[0]
    if st[tgt].get('flattened') or st[tgt].get('completed'):
        return 0
    side = st[tgt]['side']
    ask = book[side]['bestAsk']
    if not (0 < ask < 1):
        return 0
    sold = st[tgt]['our_ask']
    cost_c = (ask - sold) * 100 + taker_fee(ask) * 100
    # See mm_quoter.flatten_stalled: mark the whole book closed so complete_book
    # cannot later treat the bought-back leg as a position we still hold.
    for _t in mine:
        st[_t]['book_closed'] = True
    st[tgt].update({'flattened': True, 'flatten_px': round(ask, 2),
                    'flatten_cost_c': round(cost_c, 2),
                    'flattened_at': now.isoformat()})
    writer.writerow({
        'ts': now.isoformat(), 'div': div, 'home': m['home'], 'away': m['away'],
        'kickoff': ko.isoformat(), 'event_slug': event_slug, 'market_slug': tgt,
        'side': side, 'our_ask': round(ask, 2), 'competitive': 1, 'size': SIZE,
        'arm': st[tgt].get('arm'), 'regime': REGIME,
        'reason': f'FLATTEN_1OF3: bought back {side} sold @{sold:.2f} at ask '
                  f'{ask:.2f}, fee {taker_fee(ask)*100:.2f}c => cost '
                  f'{cost_c:+.1f}c/contract'})
    print(f'    [FLATTEN] {div} {m["home"]} vs {m["away"]}: bought back {side} '
          f'@{ask:.2f} (sold {sold:.2f}) -> {cost_c:+.1f}c/contract')
    return 1


def complete_book(st, legs, book, writer, now, ko, hrs, m, div, event_slug):
    """Cross the last unsold leg into the bid, but only near kickoff.

    Same rule as Kalshi (see mm_quoter.complete_book): firing the instant a book
    hits 2-of-3 cost ~2c per book for nothing, because the third leg still had
    most of its window to sell for free.
    """
    mine = [s for s in legs.values() if s in st]
    if len(mine) != 3:
        return 0
    if any(st[s].get('book_closed') for s in mine):
        return 0                      # already exited via flatten_stalled
    unfilled = [s for s in mine if not st[s].get('filled')]
    if len(unfilled) != 1 or hrs > COMPLETE_BEFORE_KO_H:
        return 0
    tgt = unfilled[0]
    side = st[tgt]['side']
    bid = book[side]['bestBid']
    if not (0 < bid < 1):
        return 0
    collected = sum(st[s]['our_ask'] for s in mine if st[s].get('filled')) + bid
    rebates = sum(st[s].get('rebate', 0.0) for s in mine if st[s].get('filled'))
    net_c = (collected - 1.0 + rebates) * 100 - taker_fee(bid) * 100
    st[tgt].update({'completed': True, 'filled': True, 'fill_px': bid,
                    'filled_at': now.isoformat(), 'fill_kind': 'completed'})
    writer.writerow({
        'ts': now.isoformat(), 'div': div, 'home': m['home'], 'away': m['away'],
        'kickoff': ko.isoformat(), 'event_slug': event_slug, 'market_slug': tgt,
        'side': side, 'our_ask': round(bid, 2), 'competitive': 1, 'size': SIZE,
        'reason': f'COMPLETE_AT_BID: collected {collected*100:.0f}c, rebates '
                  f'{rebates*100:.2f}c, fee {taker_fee(bid)*100:.2f}c => '
                  f'{net_c:+.1f}c/contract locked',
        'arm': st[tgt].get('arm'), 'regime': REGIME})
    print(f'    [COMPLETE] {div} {m["home"]} vs {m["away"]}: sold {side} at bid '
          f'{bid:.2f} -> {net_c:+.1f}c/contract locked')
    return 1


def main():
    once = '--once' in sys.argv
    new = not OUT.exists()
    fh = open(OUT, 'a', newline='', encoding='utf-8')
    w = csv.DictWriter(fh, fieldnames=FIELDS)
    if new:
        w.writeheader()
    print(f'PAPER Poly-US quoter running (window {WINDOW_H}h, ladder 0-5c, '
          f'size {SIZE}, maker REBATE {MAKER_THETA}). Places nothing.')
    while True:
        try:
            n, c = cycle(w, fh)
            print(f'{dt.datetime.now():%H:%M:%S}  logged quotes on {n} legs'
                  + (f', completed {c} book(s)' if c else ''))
        except Exception as e:
            print(f'{dt.datetime.now():%H:%M:%S}  cycle error: {type(e).__name__}: {e}')
        if once:
            break
        time.sleep(POLL_SECS)


if __name__ == '__main__':
    main()
