diff --git a/arb/prepare.py b/arb/prepare.py new file mode 100644 index 000000000..209f650ba --- /dev/null +++ b/arb/prepare.py @@ -0,0 +1,122 @@ +"""Build a read-only 'what would be sent' trade ticket for ONE arb pair. + + *** THIS MODULE PLACES NO ORDERS. *** + +It only reads live quotes (the same public / read-only calls the scanner +already makes) and computes the exact pair of orders a cross-venue hedge would +require — side, size, limit price, capital, and guaranteed worst-case — so the +user can preview a trade before any execution path is ever wired up. + +No Kalshi/Polymarket *order* endpoints are touched anywhere in this file. +""" +import time + +from . import calc, kalshi, poly, scan + + +def _int(x): + return int(x) if x is not None else None + + +def run_prepare(ticker, slug, max_size=None, buffer_cents=1.0): + """Return a preview ticket dict for the pair (ticker, slug). Read-only.""" + if not ticker or not slug: + return {"error": "ticker and slug are required"} + + pair = next((p for p in scan.load_pairs() + if p.get("kalshi_ticker") == ticker + and p.get("poly_slug") == slug), None) + if not pair: + return {"error": "pair not found in pairs.json"} + settings = scan.load_settings() + + # Fresh, read-only quotes for just this one pair. + kq = kalshi.fetch_quotes([ticker]).get(ticker) + pq = poly.fetch_quotes([slug]).get(slug) + # Real Kalshi top-of-book ask size (the scan uses open interest as a proxy + # off-candidate; for a trade ticket we want the executable size). + szs = (kalshi.fetch_sizes([ticker]) or {}).get(ticker) + if szs and kq: + if szs.get("yes_ask_size") is not None: + kq["yes_ask_size"] = szs["yes_ask_size"] + if szs.get("no_ask_size") is not None: + kq["no_ask_size"] = szs["no_ask_size"] + + row = calc.evaluate(pair, kq, pq, settings) + if not row.get("best_side"): + return {"error": "no executable hedge right now", + "status": row.get("status"), "asset": row.get("asset")} + + buf = (buffer_cents or 0.0) / 100.0 + + # best_side "YES+NO" = Kalshi YES + Poly NO ; "NO+YES" = Kalshi NO + Poly YES + k_side, p_side = (("yes", "no") if row["best_side"] == "YES+NO" + else ("no", "yes")) + k_ask, p_ask = row.get("kalshi_price"), row.get("poly_price") + # Marketable limit = ask + buffer, capped below $1. Kalshi ticks in cents, + # Polymarket finer — round each to its grid. + k_limit = min(0.99, round(k_ask + buf, 2)) if k_ask is not None else None + p_limit = min(0.999, round(p_ask + buf, 3)) if p_ask is not None else None + + avail = row.get("max_contracts") + size, capped = avail, None + if max_size and avail is not None and max_size < avail: + size, capped = max_size, "your max" + size = _int(size) + + ksz, psz = row.get("kalshi_size"), row.get("poly_size") + thin = None + if ksz is not None and psz is not None: + thin = "Kalshi" if ksz <= psz else "Polymarket" + + k_cost = size * k_ask if (size and k_ask is not None) else None + p_cost = size * p_ask if (size and p_ask is not None) else None + capital = (k_cost or 0) + (p_cost or 0) if size else None + + w_ct = row.get("worst_pnl") + w_total = w_ct * size if (w_ct is not None and size) else None + fee_total = (row.get("total_fee") or 0) * size if size else None + + warnings = [] + if not size: + warnings.append("No executable size on at least one leg right now.") + elif capped: + warnings.append("Size capped to %s (%d contracts)." % (capped, size)) + elif thin: + warnings.append( + "Size capped to the thinner %s book (%d contracts)." % (thin, size)) + if w_ct is not None and w_ct < 0: + warnings.append( + "Worst case is NEGATIVE — this is not a guaranteed-positive arb.") + if row.get("status") != "ARB": + warnings.append("Scanner status is %s, not ARB." % row.get("status")) + + return { + "preview_only": True, # never an executable instruction + "asset": row.get("asset"), + "status": row.get("status"), + "as_of": time.strftime("%H:%M:%S"), + "size": size, + "available": _int(avail), + "thin_leg": thin, + "guaranteed_positive": (w_ct is not None and w_ct >= 0), + "buffer_cents": buffer_cents, + "kalshi": { + "venue": "Kalshi", "ticker": ticker, "action": "buy", + "side": k_side, "ask": k_ask, "limit": k_limit, + "size": size, "cost": k_cost, "title": row.get("kalshi_title"), + }, + "poly": { + "venue": "Polymarket", "slug": slug, "action": "buy", + "side": p_side, "ask": p_ask, "limit": p_limit, + "size": size, "cost": p_cost, "question": row.get("poly_question"), + }, + "combined_cost_ct": row.get("combined_cost"), + "capital_required": capital, + "worst_pnl_ct": w_ct, + "worst_pnl_total": w_total, + "best_pnl_ct": row.get("best_pnl"), + "fees_total": fee_total, + "net_return": row.get("net_return"), + "warnings": warnings, + } diff --git a/server.py b/server.py index 70fa64fc3..1595c9753 100644 --- a/server.py +++ b/server.py @@ -15,7 +15,7 @@ import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs -from arb import scan +from arb import scan, prepare ROOT = os.path.dirname(os.path.abspath(__file__)) WEB = os.path.join(ROOT, "web") @@ -72,6 +72,26 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: return self._send(500, {"error": "%s: %s" % (type(e).__name__, e)}) + if u.path == "/api/prepare": + # Read-only: build a preview ticket for one pair. Places NO orders. + qs = parse_qs(u.query) + ticker = qs.get("ticker", [""])[0] + slug = qs.get("slug", [""])[0] + raw_max = qs.get("max", [""])[0] + try: + max_size = int(float(raw_max)) if raw_max else None + except (TypeError, ValueError): + max_size = None + try: + buffer_cents = float(qs.get("buffer", ["1"])[0]) + except (TypeError, ValueError): + buffer_cents = 1.0 + try: + return self._send(200, prepare.run_prepare( + ticker, slug, max_size, buffer_cents)) + except Exception as e: + return self._send(500, {"error": "%s: %s" + % (type(e).__name__, e)}) if u.path == "/api/settings": return self._send(200, scan.load_settings()) self._send(404, {"error": "not found"}) diff --git a/web/app.js b/web/app.js index c81977d95..37a70b4ca 100644 --- a/web/app.js +++ b/web/app.js @@ -253,9 +253,13 @@ function drawer(r) { ${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' ✓ favorable' : ' ✗ risk'))} ${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")} ${hRows} - `; + ${MODE === "monthly" && r.best_side && r.kalshi_ticker && r.poly_slug + ? `