From a65256ca747ced80dca78ec4aed322478e43975e Mon Sep 17 00:00:00 2001 From: Casey Judice Date: Tue, 2 Jun 2026 22:10:57 -0400 Subject: [PATCH] Add Step 1 "Prepare trade" pre-flight ticket (read-only, no execution) New arb/prepare.py + GET /api/prepare: for one monthly arb pair it re-pulls fresh quotes (incl. real Kalshi top-of-book ask size) and builds the exact order pair a cross-venue hedge would need -- sides from best_side, size capped to the thinner book, limit = ask + slippage buffer, capital required, guaranteed worst-case, and warnings. Places NO orders and touches no order endpoints anywhere. UI: a "Prepare trade" button in the monthly drawer's breakdown card opens a PREVIEW-ONLY ticket modal (max-size + slippage-cent inputs that re-price live; a deliberately disabled Confirm button as the Step-2 placeholder). Also reorders the expanded drawer so the breakdown/Prepare card is the wide left column. Co-Authored-By: Claude Opus 4.7 --- arb/prepare.py | 122 +++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 22 ++++++++- web/app.js | 97 ++++++++++++++++++++++++++++++++++++++- web/index.html | 21 +++++++++ web/style.css | 53 ++++++++++++++++++++- 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 arb/prepare.py 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 + ? `
+ + read-only preview · places nothing +
` : ""}`; return ` -
${kCard}${pCard}${bd}
`; +
${bd}${kCard}${pCard}
`; } function renderBody() { @@ -291,6 +295,10 @@ function renderBody() { renderBody(); }; }); + document.querySelectorAll("#body .prep-btn").forEach((btn) => btn.onclick = (e) => { + e.stopPropagation(); + openTicket(btn.dataset.ticker, btn.dataset.slug); + }); } function renderSummary(s) { @@ -680,6 +688,80 @@ async function loadSettings() { s_minct.value = s.min_contracts; } +// ---- Prepare-trade ticket (Step 1: read-only preview, places no orders) ---- +let TICKET = null; + +function openTicket(ticker, slug) { + TICKET = { ticker, slug }; + $("ticketModal").hidden = false; + $("tkMax").value = ""; + $("tkBuf").value = "1"; + $("ticketBody").innerHTML = `
Pulling fresh quotes…
`; + refreshTicket(); +} + +function closeTicket() { + $("ticketModal").hidden = true; + TICKET = null; +} + +async function refreshTicket() { + if (!TICKET) return; + const p = new URLSearchParams({ ticker: TICKET.ticker, slug: TICKET.slug }); + const mx = $("tkMax").value, bf = $("tkBuf").value; + if (mx) p.set("max", mx); + if (bf !== "") p.set("buffer", bf); + try { + const res = await fetch("/api/prepare?" + p.toString()); + const t = await res.json(); + $("ticketBody").innerHTML = + (!res.ok || t.error) ? ticketError(t) : ticketHTML(t); + } catch (e) { + $("ticketBody").innerHTML = `
Failed: ${esc(e.message)}
`; + } +} + +function ticketError(t) { + return `
${esc((t && t.error) || "Could not prepare")} + ${t && t.status ? `(status ${esc(t.status)})` : ""}
`; +} + +function ticketHTML(t) { + const c = (n, d = 2) => n == null ? "—" + : "$" + (+n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d }); + const ct = (n) => n == null ? "—" : Math.round(n).toLocaleString(); + const order = (o) => { + const dp = o.venue === "Kalshi" ? 2 : 3; + return `
+ ${o.venue} + BUY + ${esc((o.side || "—").toUpperCase())} + ${ct(o.size)} ct + @ ${o.limit != null ? (+o.limit).toFixed(dp) : "—"} + (ask ${o.ask != null ? (+o.ask).toFixed(dp) : "—"}) + ${c(o.cost)} +
`; + }; + const wp = t.guaranteed_positive, wct = t.worst_pnl_ct, wt = t.worst_pnl_total; + const stat = (l, v, cls) => + `
${l}${v}
`; + const warn = (t.warnings || []).map((w) => `
  • ${esc(w)}
  • `).join(""); + return `
    + ${esc(t.asset || "?")} + status ${esc(t.status || "—")} · as of ${esc(t.as_of || "")} +
    +
    ${order(t.kalshi)}${order(t.poly)}
    +
    + ${stat("Size", `${ct(t.size)}${t.available != null ? ` / ${ct(t.available)} avail` : ""}`)} + ${stat("Combined cost", t.combined_cost_ct != null ? "$" + (+t.combined_cost_ct).toFixed(4) + ' /ct' : "—")} + ${stat("Capital needed", c(t.capital_required))} + ${stat("Worst-case P&L", `${wt != null ? (wt > 0 ? "+" : "") + c(wt) : "—"}${wct != null ? ` (${wct > 0 ? "+" : ""}$${(+wct).toFixed(4)}/ct)` : ""}`, wp ? "pos" : "neg")} + ${stat("Fees (total)", c(t.fees_total))} + ${stat("Net return", t.net_return != null ? (t.net_return * 100).toFixed(2) + "%" : "—", wp ? "pos" : "")} +
    + ${warn ? `` : ""}`; +} + function wire() { document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => { if (b.classList.contains("on")) return; @@ -724,6 +806,17 @@ function wire() { if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); } else toast("Save failed", "err"); }; + + // prepare-ticket modal controls + $("tkClose").onclick = closeTicket; + $("ticketModal").addEventListener("click", (e) => { + if (e.target === $("ticketModal")) closeTicket(); + }); + $("tkMax").addEventListener("change", refreshTicket); + $("tkBuf").addEventListener("change", refreshTicket); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !$("ticketModal").hidden) closeTicket(); + }); } renderHead(); diff --git a/web/index.html b/web/index.html index 6598bc4c0..55223de7f 100644 --- a/web/index.html +++ b/web/index.html @@ -107,6 +107,27 @@ + + diff --git a/web/style.css b/web/style.css index c78bb60fd..aa7308fc1 100644 --- a/web/style.css +++ b/web/style.css @@ -267,7 +267,7 @@ tbody tr.r.open { background: rgba(108,140,255,.07); } /* ---------- detail drawer ---------- */ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); } -.drawer { display: grid; grid-template-columns: 1fr 1fr 1.1fr; gap: 18px; +.drawer { display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: 18px; padding: 20px 22px; background: linear-gradient(180deg, rgba(108,140,255,.06), transparent); animation: slide .22s ease; } @keyframes slide { from { opacity: 0; transform: translateY(-6px); } } @@ -318,6 +318,57 @@ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); } .modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2); color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; } .modal input:focus { outline: none; border-color: var(--accent); } + +/* ---- Prepare-trade button + preview ticket (read-only) ---- */ +.prep-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; } +.prep-btn { background: linear-gradient(180deg, var(--accent), #4d63d8); + color: #fff; border: none; border-radius: 9px; padding: 8px 14px; + font: 600 13px/1 "Inter", sans-serif; cursor: pointer; letter-spacing: .01em; } +.prep-btn:hover { filter: brightness(1.08); } +.prep-hint { color: var(--faint); font-size: 11.5px; } + +.modal .tk-card { width: 472px; max-width: 94vw; } +.tk-top { display: flex; align-items: center; justify-content: space-between; } +.tk-top h2 { display: flex; align-items: center; gap: 9px; } +.tk-badge { font: 700 10px/1 "Inter", sans-serif; letter-spacing: .08em; + color: #e0a93b; background: #e0a93b22; border: 1px solid #e0a93b55; + padding: 4px 7px; border-radius: 6px; } +.tk-x { padding: 4px 9px; font-size: 14px; } +.tk-banner { background: #e0a93b14; border: 1px solid #e0a93b33; color: var(--dim); + border-radius: 9px; padding: 9px 11px; font-size: 12px; margin: 12px 0 14px; } +.tk-banner b { color: var(--txt); } +.tk-head { display: flex; align-items: center; gap: 10px; font-size: 12px; + margin-bottom: 10px; } +.tk-orders { display: flex; flex-direction: column; gap: 7px; } +.tk-order { display: flex; align-items: center; gap: 9px; padding: 9px 11px; + background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; } +.tk-buy { font-weight: 700; font-size: 11px; letter-spacing: .05em; color: var(--dim); } +.tk-sz { font-family: "JetBrains Mono", monospace; font-size: 12.5px; } +.tk-px { margin-left: auto; font-family: "JetBrains Mono", monospace; font-size: 12px; } +.tk-cost { min-width: 78px; text-align: right; font-family: "JetBrains Mono", monospace; + font-weight: 600; } +.tk-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 18px; + margin: 14px 0 4px; } +.tk-stat { display: flex; align-items: baseline; justify-content: space-between; + gap: 10px; font-size: 12.5px; border-bottom: 1px dashed var(--line); padding: 5px 0; } +.tk-stat > span { color: var(--dim); } +.tk-stat > b { font-family: "JetBrains Mono", monospace; font-weight: 600; } +.tk-warn { margin: 12px 0 2px; padding-left: 18px; color: #e0a93b; + font-size: 12px; line-height: 1.5; } +.tk-controls { display: flex; gap: 16px; margin: 16px 0 6px; } +.tk-controls label { flex: 1; display: flex; flex-direction: column; gap: 5px; + font-size: 12px; color: var(--dim); } +.tk-controls input { width: 100%; background: var(--bg2); border: 1px solid var(--line2); + color: var(--txt); border-radius: 8px; padding: 7px 9px; font-size: 13px; } +.tk-controls input:focus { outline: none; border-color: var(--accent); } +.tk-foot { display: flex; flex-direction: column; gap: 7px; margin-top: 14px; + padding-top: 14px; border-top: 1px solid var(--line); } +.tk-confirm[disabled] { opacity: .5; cursor: not-allowed; + background: var(--bg2); color: var(--dim); border: 1px solid var(--line2); } +.tk-foot-note { color: var(--faint); font-size: 11px; text-align: center; } +.tk-load, .tk-err { padding: 22px 6px; text-align: center; color: var(--dim); + font-size: 13px; } +.tk-err { color: var(--bad); } .modal .row { display: flex; gap: 10px; margin-top: 20px; } .modal .row button { flex: 1; } .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);