#!/usr/bin/env python3
"""
Polymarket Edge Scanner — read-only opportunity finder.

Scans all active Polymarket events via the public Gamma API and flags:
  1. NegRisk arbitrage (long):  sum of YES asks across a mutually-exclusive
     event < $1  ->  buying every outcome guarantees profit.
  2. NegRisk arbitrage (short): sum of YES bids > $1  ->  buying every NO
     costs less than (n-1), guaranteeing profit.
  3. Settlement bonds: near-certain outcomes resolving soon, with annualized
     yield above threshold. NOT risk-free — requires human verification.
  4. Deadline-decay candidates: "will X happen by <date>" markets near their
     deadline whose price may not reflect the little time remaining.
  5. Big 24h movers: informational, possible news over/under-reaction.

Arbitrage finalists are re-verified against the live CLOB order book
(top-of-book price AND size) before being reported.

Usage:
    python3 scanner.py [--bankroll 100] [--kelly 0.25] [--min-liquidity 500]
                       [--fast] [--out report.md]
"""

import argparse
import datetime as dt
import json
import re
import sys
import time
import warnings

warnings.filterwarnings("ignore")  # LibreSSL noise on macOS system Python

import requests

GAMMA = "https://gamma-api.polymarket.com"
CLOB = "https://clob.polymarket.com"

# --- thresholds -------------------------------------------------------------
ARB_MIN_EDGE = 0.005          # require >= 0.5c guaranteed margin after fees
BOND_MIN_PRICE = 0.95         # "near certain" side costs at least this
BOND_MAX_DAYS = 45            # bond must resolve within this many days
BOND_MIN_ANNUAL_YIELD = 0.20  # 20%/yr annualized or don't bother
DECAY_MAX_DAYS = 21           # deadline markets ending within N days
DECAY_PRICE_LO = 0.05
DECAY_PRICE_HI = 0.60
MOVER_MIN_CHANGE = 0.15       # 15c move in 24h
ARB_BANKROLL_CAP = 0.30       # max fraction of bankroll into one arb
BOND_BANKROLL_CAP = 0.10      # max fraction of bankroll into one bond

session = requests.Session()
session.headers["User-Agent"] = "edge-scanner/1.0 (read-only research)"


def jget(url, params=None, retries=3):
    for i in range(retries):
        try:
            r = session.get(url, params=params, timeout=20)
            if r.status_code == 200:
                return r.json()
        except requests.RequestException:
            pass
        time.sleep(1.0 + i)
    return None


def jloads(s, default=None):
    try:
        return json.loads(s) if isinstance(s, str) else (s or default)
    except (ValueError, TypeError):
        return default


def fnum(x, default=None):
    try:
        return float(x)
    except (TypeError, ValueError):
        return default


def fetch_all_events(max_pages=80):
    """Paginate through every active, order-book-enabled event."""
    events, offset = [], 0
    for _ in range(max_pages):
        page = jget(GAMMA + "/events", {
            "active": "true", "closed": "false", "archived": "false",
            "limit": 100, "offset": offset,
        })
        if not page:
            break
        events.extend(page)
        if len(page) < 100:
            break
        offset += 100
        time.sleep(0.12)
    return events


def parse_market(m):
    """Normalize a Gamma market dict; return None if not tradeable."""
    if not (m.get("active") and not m.get("closed")
            and m.get("enableOrderBook") and m.get("acceptingOrders")):
        return None
    bid, ask = fnum(m.get("bestBid")), fnum(m.get("bestAsk"))
    if bid is None or ask is None or not (0 < ask <= 1) or not (0 <= bid < 1):
        return None
    tokens = jloads(m.get("clobTokenIds"), [])
    end = None
    try:
        end = dt.datetime.fromisoformat(
            (m.get("endDate") or "").replace("Z", "+00:00"))
    except ValueError:
        pass
    fee_bps = fnum(m.get("takerBaseFee"), 0) if m.get("feesEnabled") else 0.0
    return {
        "question": m.get("question", "?"),
        "slug": m.get("slug", ""),
        "outcome": m.get("groupItemTitle") or m.get("question", "?"),
        "bid": bid, "ask": ask,
        "liquidity": fnum(m.get("liquidityNum"), 0) or 0,
        "vol24": fnum(m.get("volume24hr"), 0) or 0,
        "change24": fnum(m.get("oneDayPriceChange"), 0) or 0,
        "end": end,
        "fee_bps": fee_bps or 0.0,
        "token_yes": tokens[0] if len(tokens) > 0 else None,
        "token_no": tokens[1] if len(tokens) > 1 else None,
        "min_size": fnum(m.get("orderMinSize"), 5) or 5,
    }


def taker_fee(fee_bps, price):
    """Polymarket fee per share: bps/1e4 * min(p, 1-p)."""
    return (fee_bps / 1e4) * min(price, 1.0 - price)


def days_left(end, now):
    if end is None:
        return None
    return max((end - now).total_seconds() / 86400.0, 0.0)


# --- order-book verification -------------------------------------------------

def clob_top(token_id, side):
    """Return (price, size) of best ask ('buy' cost) or best bid."""
    book = jget(CLOB + "/book", {"token_id": token_id})
    if not book:
        return None, 0.0
    levels = book.get("asks" if side == "ask" else "bids") or []
    if not levels:
        return None, 0.0
    # asks ascending? CLOB returns levels unsorted-ish; pick best explicitly
    best = (min if side == "ask" else max)(
        levels, key=lambda l: float(l["price"]))
    return float(best["price"]), float(best["size"])


def verify_negrisk_arb(legs, side):
    """Re-check an event arb against live books. side: 'yes' buys YES of
    every leg, 'no' buys NO of every leg. Returns (cost_per_set, max_sets)."""
    cost, max_sets = 0.0, float("inf")
    for leg in legs:
        token = leg["token_yes"] if side == "yes" else leg["token_no"]
        if not token:
            return None, 0
        price, size = clob_top(token, "ask")
        if price is None:
            return None, 0
        cost += price + taker_fee(leg["fee_bps"], price)
        max_sets = min(max_sets, size)
        leg["live_ask"] = price
        time.sleep(0.08)
    return cost, max_sets


# --- checks -------------------------------------------------------------------

def resolved_no(m):
    """True if a raw Gamma market is closed and effectively resolved to NO,
    so it can be ignored when checking outcome-list exhaustiveness."""
    if not m.get("closed"):
        return False
    prices = jloads(m.get("outcomePrices"), [])
    p0 = fnum(prices[0]) if prices else None
    return p0 is not None and p0 <= 0.005


def check_negrisk(event, markets, exhaustive):
    """Long-all-YES and buy-all-NO arbitrage screen (Gamma prices).

    `exhaustive` must guarantee that exactly one of `markets` resolves YES:
    every market of the event is either in `markets` (open + tradeable) or
    already resolved NO. Without that, a subset of outcomes summing below $1
    is NOT an arb — the true winner may be an unlisted/untradeable outcome.
    """
    out = []
    if not event.get("negRisk") or not exhaustive or len(markets) < 2:
        return out
    n = len(markets)
    sum_ask = sum(m["ask"] + taker_fee(m["fee_bps"], m["ask"]) for m in markets)
    sum_bid = sum(m["bid"] - taker_fee(m["fee_bps"], m["bid"]) for m in markets)
    if sum_ask < 1.0 - ARB_MIN_EDGE:
        out.append({"type": "ARB_LONG", "event": event, "legs": markets,
                    "cost": sum_ask, "payout": 1.0,
                    "roi": (1.0 - sum_ask) / sum_ask})
    # buying all NO costs ~ sum(1 - bid_i); pays n-1
    no_cost = sum((1.0 - m["bid"]) + taker_fee(m["fee_bps"], 1.0 - m["bid"])
                  for m in markets)
    if sum_bid > 1.0 + ARB_MIN_EDGE and no_cost < (n - 1) - ARB_MIN_EDGE:
        out.append({"type": "ARB_SHORT", "event": event, "legs": markets,
                    "cost": no_cost, "payout": float(n - 1),
                    "roi": ((n - 1) - no_cost) / no_cost})
    return out


def check_bond(event, m, now):
    d = days_left(m["end"], now)
    if d is None or d <= 0 or d > BOND_MAX_DAYS:
        return None
    # near-certain YES: buy YES at ask; near-certain NO: buy NO at 1-bid
    for side, price in (("YES", m["ask"]), ("NO", 1.0 - m["bid"])):
        if price < BOND_MIN_PRICE or price >= 1.0:
            continue
        eff = price + taker_fee(m["fee_bps"], price)
        if eff >= 1.0:
            continue
        yld = (1.0 - eff) / eff
        annual = yld * (365.0 / max(d, 0.25))
        if annual >= BOND_MIN_ANNUAL_YIELD and m["liquidity"] >= 250:
            return {"type": "BOND", "event": event, "m": m, "side": side,
                    "price": eff, "days": d, "yield": yld, "annual": annual}
    return None


DEADLINE_RE = re.compile(r"\b(by|before|prior to)\b", re.I)


def check_decay(event, m, now):
    d = days_left(m["end"], now)
    if d is None or d <= 0 or d > DECAY_MAX_DAYS:
        return None
    mid = (m["bid"] + m["ask"]) / 2.0
    if not (DECAY_PRICE_LO <= mid <= DECAY_PRICE_HI):
        return None
    if not DEADLINE_RE.search(m["question"]):
        return None
    if m["liquidity"] < 250:
        return None
    return {"type": "DECAY", "event": event, "m": m, "mid": mid, "days": d}


def check_mover(event, m):
    if abs(m["change24"]) >= MOVER_MIN_CHANGE and m["liquidity"] >= 1000 \
            and m["vol24"] >= 5000:
        return {"type": "MOVER", "event": event, "m": m}
    return None


# --- sizing -------------------------------------------------------------------

def kelly_stake(bankroll, kelly_frac, p, price):
    """Fractional Kelly stake in $ for buying a share at `price` that pays $1
    with probability p. Full Kelly fraction: (p - price) / (1 - price)."""
    if price >= 1 or p <= price:
        return 0.0
    return bankroll * kelly_frac * (p - price) / (1.0 - price)


# --- main ---------------------------------------------------------------------

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--bankroll", type=float, default=100.0)
    ap.add_argument("--kelly", type=float, default=0.25)
    ap.add_argument("--min-liquidity", type=float, default=500.0)
    ap.add_argument("--fast", action="store_true",
                    help="skip live order-book verification of arb finalists")
    ap.add_argument("--out", default=None, help="markdown report path")
    args = ap.parse_args()

    now = dt.datetime.now(dt.timezone.utc)
    print("Fetching active events from Gamma API ...", flush=True)
    events = fetch_all_events()
    print("  %d events fetched" % len(events), flush=True)

    arbs, bonds, decays, movers = [], [], [], []
    n_markets = 0
    for ev in events:
        markets = [pm for pm in
                   (parse_market(m) for m in (ev.get("markets") or []))
                   if pm and pm["liquidity"] >= args.min_liquidity]
        if not markets:
            continue
        n_markets += len(markets)
        # arbitrage needs ALL still-open outcomes of the event, so re-parse
        # without the liquidity filter, and require exhaustiveness: every
        # market is either tradeable here or already resolved NO
        raw = ev.get("markets") or []
        all_open = []
        exhaustive = True
        for rm in raw:
            pm = parse_market(rm)
            if pm:
                all_open.append(pm)
            elif not resolved_no(rm):
                exhaustive = False
        arbs.extend(check_negrisk(ev, all_open, exhaustive))
        for m in markets:
            b = check_bond(ev, m, now)
            if b:
                bonds.append(b)
            dc = check_decay(ev, m, now)
            if dc:
                decays.append(dc)
            mv = check_mover(ev, m)
            if mv:
                movers.append(mv)

    print("Screened %d liquid markets in %d events" % (n_markets, len(events)))
    print("  raw hits: %d arb, %d bond, %d decay, %d mover"
          % (len(arbs), len(bonds), len(decays), len(movers)), flush=True)

    # verify arbs against live books
    verified = []
    if arbs and not args.fast:
        print("Verifying %d arbitrage candidates on live order books ..."
              % len(arbs), flush=True)
        for a in arbs:
            side = "yes" if a["type"] == "ARB_LONG" else "no"
            cost, max_sets = verify_negrisk_arb(a["legs"], side)
            if cost is None:
                continue
            edge = a["payout"] - cost
            if edge >= ARB_MIN_EDGE and max_sets >= 5:
                a["cost"], a["roi"] = cost, edge / cost
                a["max_sets"] = max_sets
                verified.append(a)
    elif args.fast:
        verified = arbs

    bonds.sort(key=lambda b: -b["annual"])
    decays.sort(key=lambda d: d["days"])
    movers.sort(key=lambda m: -abs(m["m"]["change24"]))
    verified.sort(key=lambda a: -a["roi"])

    report = render_report(args, now, len(events), n_markets,
                           verified, bonds[:15], decays[:15], movers[:10])
    out = args.out or "report-%s.md" % now.strftime("%Y%m%d-%H%M")
    with open(out, "w") as f:
        f.write(report)
    print("\n" + report)
    print("Report written to %s" % out)


def fmt_market_line(ev, m):
    url = "https://polymarket.com/event/" + (ev.get("slug") or "")
    return "[%s](%s)" % (m["question"], url)


def render_report(args, now, n_events, n_markets, arbs, bonds, decays, movers):
    L = []
    L.append("# Polymarket Edge Report — %s UTC" % now.strftime("%Y-%m-%d %H:%M"))
    L.append("")
    L.append("Bankroll $%.0f · ¼-Kelly sizing · scanned %d events / %d liquid "
             "markets" % (args.bankroll, n_events, n_markets))
    L.append("")

    L.append("## 1. Arbitrage (mathematically guaranteed if filled)")
    if not arbs:
        L.append("")
        L.append("_None found above the %.1f¢ post-fee margin. Normal — these "
                 "get sniped fast; rerun often, especially during news._"
                 % (ARB_MIN_EDGE * 100))
    for a in arbs:
        cap = args.bankroll * ARB_BANKROLL_CAP
        sets = min(a.get("max_sets", 0), cap / a["cost"]) if a["cost"] else 0
        L.append("")
        L.append("- **%s** — *%s*" % (a["type"], a["event"].get("title", "?")))
        L.append("  - Buy %s of **all %d outcomes**. Cost/set **$%.4f**, "
                 "pays $%.2f → **+%.2f%% risk-free**"
                 % ("YES" if a["type"] == "ARB_LONG" else "NO",
                    len(a["legs"]), a["cost"], a["payout"], a["roi"] * 100))
        L.append("  - Depth allows ~%.0f sets; with your cap (%.0f%% of "
                 "bankroll) stake ≈ **$%.2f**"
                 % (a.get("max_sets", 0), ARB_BANKROLL_CAP * 100,
                    min(sets * a["cost"], cap)))
        L.append("  - https://polymarket.com/event/%s"
                 % a["event"].get("slug", ""))
        L.append("  - ⚠ Verify the outcome list is exhaustive (an unlisted "
                 "'Other' winner breaks the arb).")

    L.append("")
    L.append("## 2. Settlement bonds (near-certain, short-dated — verify first!)")
    L.append("")
    L.append("_Buy the ~certain side, collect the last cents at resolution. "
             "The market prices a small tail risk for a reason — only act "
             "where YOU can verify the outcome is locked. Cap: %.0f%% of "
             "bankroll each, diversify across uncorrelated markets._"
             % (BOND_BANKROLL_CAP * 100))
    if not bonds:
        L.append("")
        L.append("_None above %.0f%% annualized._" % (BOND_MIN_ANNUAL_YIELD * 100))
    for b in bonds:
        stake = min(args.bankroll * BOND_BANKROLL_CAP,
                    kelly_stake(args.bankroll, args.kelly, 0.995, b["price"]))
        L.append("")
        L.append("- %s" % fmt_market_line(b["event"], b["m"]))
        L.append("  - Buy **%s @ %.1f¢** · resolves in **%.1f d** · "
                 "**+%.2f%%** (= %.0f%% annualized) · liq $%.0fk · "
                 "suggested ≤ **$%.2f**"
                 % (b["side"], b["price"] * 100, b["days"],
                    b["yield"] * 100, b["annual"] * 100,
                    b["m"]["liquidity"] / 1000, max(stake, 0)))

    L.append("")
    L.append("## 3. Deadline-decay candidates (judgment required)")
    L.append("")
    L.append("_\"Happen by <date>\" markets close to deadline, still priced "
             "%.0f–%.0f¢. If nothing has changed, price should be decaying "
             "toward 0 — check the news, then consider buying NO. Not "
             "automatic edges._" % (DECAY_PRICE_LO * 100, DECAY_PRICE_HI * 100))
    if not decays:
        L.append("")
        L.append("_None matched._")
    for d in decays:
        L.append("")
        L.append("- %s" % fmt_market_line(d["event"], d["m"]))
        L.append("  - mid **%.0f¢**, **%.1f d** to deadline, liq $%.0fk. "
                 "If your p(yes) is half the price, ¼-Kelly NO stake ≈ $%.2f"
                 % (d["mid"] * 100, d["days"], d["m"]["liquidity"] / 1000,
                    kelly_stake(args.bankroll, args.kelly,
                                1 - d["mid"] / 2, 1 - d["m"]["bid"])))

    L.append("")
    L.append("## 4. Big 24h movers (news check)")
    L.append("")
    if not movers:
        L.append("_None._")
    for mv in movers:
        m = mv["m"]
        L.append("- %s — %+0.0f¢ → now %.0f¢ bid (24h vol $%.0fk)"
                 % (fmt_market_line(mv["event"], m), m["change24"] * 100,
                    m["bid"] * 100, m["vol24"] / 1000))

    L.append("")
    L.append("---")
    L.append("_Read-only research tool. Prices move; re-check the book before "
             "placing any order. Nothing here is financial advice; prediction "
             "markets can lose your entire stake._")
    L.append("")
    return "\n".join(L)


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