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 <noreply@anthropic.com>
This commit is contained in:
Casey Judice
2026-06-02 22:10:57 -04:00
parent 56f9362713
commit a65256ca74
5 changed files with 311 additions and 4 deletions
+122
View File
@@ -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,
}
+21 -1
View File
@@ -15,7 +15,7 @@ import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from arb import scan from arb import scan, prepare
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
WEB = os.path.join(ROOT, "web") WEB = os.path.join(ROOT, "web")
@@ -72,6 +72,26 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
return self._send(500, {"error": "%s: %s" return self._send(500, {"error": "%s: %s"
% (type(e).__name__, e)}) % (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": if u.path == "/api/settings":
return self._send(200, scan.load_settings()) return self._send(200, scan.load_settings())
self._send(404, {"error": "not found"}) self._send(404, {"error": "not found"})
+95 -2
View File
@@ -253,9 +253,13 @@ function drawer(r) {
${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))} ${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))}
${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")} ${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")}
${hRows} ${hRows}
</div></div>`; </div>${MODE === "monthly" && r.best_side && r.kalshi_ticker && r.poly_slug
? `<div class="prep-row">
<button class="prep-btn" data-ticker="${esc(r.kalshi_ticker)}" data-slug="${esc(r.poly_slug)}">Prepare trade ▸</button>
<span class="prep-hint">read-only preview · places nothing</span>
</div>` : ""}</div>`;
return `<tr class="detail"><td colspan="${COLS().length}"> return `<tr class="detail"><td colspan="${COLS().length}">
<div class="drawer">${kCard}${pCard}${bd}</div></td></tr>`; <div class="drawer">${bd}${kCard}${pCard}</div></td></tr>`;
} }
function renderBody() { function renderBody() {
@@ -291,6 +295,10 @@ function renderBody() {
renderBody(); renderBody();
}; };
}); });
document.querySelectorAll("#body .prep-btn").forEach((btn) => btn.onclick = (e) => {
e.stopPropagation();
openTicket(btn.dataset.ticker, btn.dataset.slug);
});
} }
function renderSummary(s) { function renderSummary(s) {
@@ -680,6 +688,80 @@ async function loadSettings() {
s_minct.value = s.min_contracts; 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 = `<div class="tk-load">Pulling fresh quotes…</div>`;
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 = `<div class="tk-err">Failed: ${esc(e.message)}</div>`;
}
}
function ticketError(t) {
return `<div class="tk-err">${esc((t && t.error) || "Could not prepare")}
${t && t.status ? `<span class="dimv">(status ${esc(t.status)})</span>` : ""}</div>`;
}
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 `<div class="tk-order">
<span class="tag ${o.venue === "Kalshi" ? "k" : "p"}">${o.venue}</span>
<span class="tk-buy">BUY</span>
<span class="sidetag ${esc(o.side)}">${esc((o.side || "—").toUpperCase())}</span>
<span class="tk-sz">${ct(o.size)}<span class="dimv"> ct</span></span>
<span class="tk-px">@ ${o.limit != null ? (+o.limit).toFixed(dp) : "—"}
<span class="dimv">(ask ${o.ask != null ? (+o.ask).toFixed(dp) : "—"})</span></span>
<span class="tk-cost">${c(o.cost)}</span>
</div>`;
};
const wp = t.guaranteed_positive, wct = t.worst_pnl_ct, wt = t.worst_pnl_total;
const stat = (l, v, cls) =>
`<div class="tk-stat"><span>${l}</span><b class="${cls || ""}">${v}</b></div>`;
const warn = (t.warnings || []).map((w) => `<li>${esc(w)}</li>`).join("");
return `<div class="tk-head">
<span class="achip" style="background:${ac(t.asset)}22;color:${ac(t.asset)}">${esc(t.asset || "?")}</span>
<span class="dimv">status ${esc(t.status || "—")} · as of ${esc(t.as_of || "")}</span>
</div>
<div class="tk-orders">${order(t.kalshi)}${order(t.poly)}</div>
<div class="tk-stats">
${stat("Size", `${ct(t.size)}${t.available != null ? ` <span class="dimv">/ ${ct(t.available)} avail</span>` : ""}`)}
${stat("Combined cost", t.combined_cost_ct != null ? "$" + (+t.combined_cost_ct).toFixed(4) + ' <span class="dimv">/ct</span>' : "—")}
${stat("Capital needed", c(t.capital_required))}
${stat("Worst-case P&L", `${wt != null ? (wt > 0 ? "+" : "") + c(wt) : "—"}${wct != null ? ` <span class="dimv">(${wct > 0 ? "+" : ""}$${(+wct).toFixed(4)}/ct)</span>` : ""}`, 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" : "")}
</div>
${warn ? `<ul class="tk-warn">${warn}</ul>` : ""}`;
}
function wire() { function wire() {
document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => { document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => {
if (b.classList.contains("on")) return; if (b.classList.contains("on")) return;
@@ -724,6 +806,17 @@ function wire() {
if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); } if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); }
else toast("Save failed", "err"); 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(); renderHead();
+21
View File
@@ -107,6 +107,27 @@
</div> </div>
</div> </div>
<div id="ticketModal" class="modal" hidden>
<div class="card tk-card">
<div class="tk-top">
<h2>Prepare trade <span class="tk-badge">PREVIEW ONLY</span></h2>
<button id="tkClose" class="ghost tk-x" aria-label="Close"></button>
</div>
<div class="tk-banner">Nothing is sent to Kalshi or Polymarket. This is exactly
what the two orders <b>would</b> be — for review only.</div>
<div id="ticketBody"></div>
<div class="tk-controls">
<label>Max size <input type="number" id="tkMax" min="1" step="1" placeholder="all"></label>
<label>Slippage buffer (¢) <input type="number" id="tkBuf" min="0" step="1" value="1"></label>
</div>
<div class="tk-foot">
<button class="primary tk-confirm" disabled
title="Execution is not wired — this is Step 1 (preview only)">Confirm &amp; Send — not enabled</button>
<span class="tk-foot-note">Step&nbsp;2 would place these with your keys, only on your click.</span>
</div>
</div>
</div>
<div id="toast" class="toast" hidden></div> <div id="toast" class="toast" hidden></div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+52 -1
View File
@@ -267,7 +267,7 @@ tbody tr.r.open { background: rgba(108,140,255,.07); }
/* ---------- detail drawer ---------- */ /* ---------- detail drawer ---------- */
tr.detail td { padding: 0; border-bottom: 1px solid var(--line); } 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, padding: 20px 22px; background: linear-gradient(180deg,
rgba(108,140,255,.06), transparent); animation: slide .22s ease; } rgba(108,140,255,.06), transparent); animation: slide .22s ease; }
@keyframes slide { from { opacity: 0; transform: translateY(-6px); } } @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); .modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2);
color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; } color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; }
.modal input:focus { outline: none; border-color: var(--accent); } .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 { display: flex; gap: 10px; margin-top: 20px; }
.modal .row button { flex: 1; } .modal .row button { flex: 1; }
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);