mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
Match positions by contract quantity, filling exact strikes before basis
The paired view now recognizes cross-venue hedges economically (by what each leg pays) instead of only matching curated pairs.json rows, and fills by contract count: exact-strike (matched) pairs fill first, so a leg's excess spills into the nearest basis pair only after every matched pairing is exhausted. Partially consumed legs are pro-rated; size/cost/value/pnl conserve exactly. Frontend shows matched / basis+ / basis- badges, strike band, and a plain-English coverage note per row. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+242
-42
@@ -12,6 +12,7 @@ Credentials come from data/secrets.json (git-ignored), supplied by the user.
|
|||||||
Missing/!configured venues degrade gracefully with setup guidance.
|
Missing/!configured venues degrade gracefully with setup guidance.
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -49,6 +50,29 @@ def _asset_from_ticker(t):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Polymarket slugs spell the asset out ("will-bitcoin-…"); map name -> symbol so
|
||||||
|
# poly legs carry an asset for the paired view (Kalshi legs get it from ticker).
|
||||||
|
_POLY_ASSET = [
|
||||||
|
("bitcoin", "BTC"), ("ethereum", "ETH"), ("solana", "SOL"),
|
||||||
|
("ripple", "XRP"), ("dogecoin", "DOGE"), ("hyperliquid", "HYPE"),
|
||||||
|
("binance", "BNB"), ("litecoin", "LTC"), ("cardano", "ADA"),
|
||||||
|
("avalanche", "AVAX"), ("chainlink", "LINK"), ("stellar", "XLM"),
|
||||||
|
("zcash", "ZEC"), ("shiba", "SHIB"),
|
||||||
|
# short forms / tickers that also appear in slugs
|
||||||
|
("btc", "BTC"), ("eth", "ETH"), ("sol", "SOL"), ("xrp", "XRP"),
|
||||||
|
("doge", "DOGE"), ("bnb", "BNB"), ("hype", "HYPE"), ("zec", "ZEC"),
|
||||||
|
("sui", "SUI"), ("trx", "TRX"), ("xlm", "XLM"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_from_slug(slug):
|
||||||
|
s = (slug or "").lower()
|
||||||
|
for name, sym in _POLY_ASSET:
|
||||||
|
if name in s:
|
||||||
|
return sym
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def load_secrets():
|
def load_secrets():
|
||||||
try:
|
try:
|
||||||
with open(SECRETS) as f:
|
with open(SECRETS) as f:
|
||||||
@@ -93,6 +117,7 @@ def _poly(wallet):
|
|||||||
if p.get("percentPnl") is not None else None)
|
if p.get("percentPnl") is not None else None)
|
||||||
out.append({
|
out.append({
|
||||||
"venue": "Polymarket",
|
"venue": "Polymarket",
|
||||||
|
"asset": _asset_from_slug(p.get("slug")) or _asset_from_slug(p.get("title")),
|
||||||
"market": p.get("title"),
|
"market": p.get("title"),
|
||||||
"ref": p.get("slug"),
|
"ref": p.get("slug"),
|
||||||
"side": p.get("outcome"),
|
"side": p.get("outcome"),
|
||||||
@@ -426,53 +451,228 @@ def _load_pairs():
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _paired(poly_pos, kalshi_pos):
|
def _kalshi_dir(tkr):
|
||||||
pairs = _load_pairs()
|
t = (tkr or "").upper()
|
||||||
by_slug, by_tkr = {}, {}
|
if "MAXMON" in t or "MAX" in t:
|
||||||
for pr in pairs:
|
return "above"
|
||||||
if pr.get("poly_slug"):
|
if "MINMON" in t or "MIN" in t:
|
||||||
by_slug.setdefault(pr["poly_slug"], pr)
|
return "below"
|
||||||
if pr.get("kalshi_ticker"):
|
return None
|
||||||
by_tkr[pr["kalshi_ticker"]] = pr
|
|
||||||
|
|
||||||
groups = {}
|
|
||||||
|
|
||||||
def key(pr):
|
def _poly_dir(slug):
|
||||||
return "%s|%s|%s" % (pr.get("asset"), pr.get("kalshi_ticker"),
|
s = (slug or "").lower()
|
||||||
pr.get("poly_slug"))
|
if "reach" in s or "hit" in s or "above" in s:
|
||||||
|
return "above"
|
||||||
|
if "dip" in s or "below" in s:
|
||||||
|
return "below"
|
||||||
|
return None
|
||||||
|
|
||||||
for p in poly_pos:
|
|
||||||
pr = by_slug.get(p["ref"])
|
def _pays_high(direc, side):
|
||||||
if pr:
|
"""A leg 'pays high' if it settles $1 when the asset ends ABOVE its strike.
|
||||||
groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []})
|
above+YES and below+NO pay high; above+NO and below+YES pay low."""
|
||||||
groups[key(pr)]["poly"].append(p)
|
if direc is None:
|
||||||
for p in kalshi_pos:
|
return None
|
||||||
pr = by_tkr.get(p["ref"])
|
s = str(side or "").strip().lower()
|
||||||
if pr:
|
if s in ("yes", "y", "up", "long"):
|
||||||
groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []})
|
yes = True
|
||||||
groups[key(pr)]["kalshi"].append(p)
|
elif s in ("no", "n", "down", "short"):
|
||||||
|
yes = False
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return yes if direc == "above" else (not yes)
|
||||||
|
|
||||||
|
|
||||||
|
def _kalshi_strike(tkr, lookup=None):
|
||||||
|
if lookup and lookup.get(tkr):
|
||||||
|
return lookup[tkr]
|
||||||
|
m = re.search(r"-(\d+)$", tkr or "")
|
||||||
|
return float(m.group(1)) / 100.0 if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _poly_strike(slug, lookup=None):
|
||||||
|
if lookup and lookup.get(slug):
|
||||||
|
return lookup[slug]
|
||||||
|
s = (slug or "").lower()
|
||||||
|
m = re.search(r"(?:reach|hit|dip-to|dip|above|below)-(\d+(?:pt\d+)?)(k?)", s)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
num = float(m.group(1).replace("pt", "."))
|
||||||
|
return num * 1000.0 if m.group(2) == "k" else num
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_strike(v):
|
||||||
|
if v is None:
|
||||||
|
return "?"
|
||||||
|
if v >= 1000:
|
||||||
|
return "%gk" % (v / 1000.0)
|
||||||
|
return "%g" % v
|
||||||
|
|
||||||
|
|
||||||
|
def _exp_key(leg):
|
||||||
|
"""Group key by settlement month. Kalshi/Poly end_date is the day AFTER the
|
||||||
|
month being settled (00:00 UTC of the 1st), so step back a day first."""
|
||||||
|
d = (leg.get("end_date") or "")[:10]
|
||||||
|
try:
|
||||||
|
y, m, dd = (int(x) for x in d.split("-"))
|
||||||
|
dt = datetime.date(y, m, dd) - datetime.timedelta(days=1)
|
||||||
|
return "%04d-%02d" % (dt.year, dt.month)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def _classify(leg, tkr_strike, slug_strike):
|
||||||
|
if leg.get("venue") == "Kalshi":
|
||||||
|
direc = _kalshi_dir(leg.get("ref"))
|
||||||
|
leg["_strike"] = _kalshi_strike(leg.get("ref"), tkr_strike)
|
||||||
|
else:
|
||||||
|
direc = _poly_dir(leg.get("ref"))
|
||||||
|
leg["_strike"] = _poly_strike(leg.get("ref"), slug_strike)
|
||||||
|
leg["_dir"] = direc
|
||||||
|
leg["_pays"] = _pays_high(direc, leg.get("side"))
|
||||||
|
return leg
|
||||||
|
|
||||||
|
|
||||||
|
def _row_from_legs(legs, kind, complete, note, asset, expiry,
|
||||||
|
low_strike=None, high_strike=None):
|
||||||
|
cost = sum((x.get("cost") or 0) for x in legs)
|
||||||
|
value = sum((x.get("value") or 0) for x in legs)
|
||||||
|
pnl = sum((x.get("pnl") or 0) for x in legs if x.get("pnl") is not None)
|
||||||
|
return {
|
||||||
|
"asset": asset, "kind": kind, "complete": complete, "note": note,
|
||||||
|
"expiry": expiry, "low_strike": low_strike, "high_strike": high_strike,
|
||||||
|
"legs": legs, "cost": cost, "value": value, "pnl": pnl,
|
||||||
|
"pnl_pct": (pnl / cost if cost else None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_leg(leg, qty):
|
||||||
|
"""A view of `leg` holding only `qty` contracts, with cost/value/pnl
|
||||||
|
pro-rated. Used when one leg's size is split across several hedge rows."""
|
||||||
|
s = float(leg.get("size") or 0)
|
||||||
|
if s <= 0 or qty is None or abs(qty - s) < 1e-9:
|
||||||
|
return leg # whole leg — no split needed
|
||||||
|
frac = qty / s
|
||||||
|
out = dict(leg)
|
||||||
|
out["size"] = qty
|
||||||
|
for k in ("cost", "value", "pnl"):
|
||||||
|
v = leg.get(k)
|
||||||
|
out[k] = (v * frac) if isinstance(v, (int, float)) else v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _match_bucket(legs, asset, expiry):
|
||||||
|
"""Quantity-aware pairing of pays-low against pays-high legs across venues.
|
||||||
|
A hedge holds one of each: pays-low covers the downside, pays-high the up.
|
||||||
|
|
||||||
|
strikes equal -> matched (clean barrier hedge)
|
||||||
|
low strike > high -> basis+ (overlap band where BOTH legs win)
|
||||||
|
low strike < high -> basis- (gap band where NEITHER wins = basis risk)
|
||||||
|
|
||||||
|
Contracts are filled by size: exact-strike (matched, gap 0) pairs sort and
|
||||||
|
fill first, so a leg's excess only spills into a basis pair once every
|
||||||
|
matched pairing is exhausted. Each fill consumes min(remaining) contracts
|
||||||
|
from both legs; a partially used leg's economics are pro-rated. Cross-venue
|
||||||
|
only; reject absurd gaps (unfavorable > 10% of level, overlap > 25%)."""
|
||||||
|
lows = [l for l in legs if l.get("_pays") is False and l.get("_strike")]
|
||||||
|
highs = [l for l in legs if l.get("_pays") is True and l.get("_strike")]
|
||||||
|
other = [l for l in legs if l.get("_pays") is None or not l.get("_strike")]
|
||||||
|
|
||||||
|
rem_lo = [float(l.get("size") or 0) for l in lows]
|
||||||
|
rem_hi = [float(l.get("size") or 0) for l in highs]
|
||||||
|
|
||||||
|
cands = []
|
||||||
|
for li, lo in enumerate(lows):
|
||||||
|
for hi, hg in enumerate(highs):
|
||||||
|
if lo.get("venue") == hg.get("venue"):
|
||||||
|
continue # a real hedge spans both venues
|
||||||
|
ls, hs = lo["_strike"], hg["_strike"]
|
||||||
|
level = max(ls, hs) or 1.0
|
||||||
|
gap = hs - ls
|
||||||
|
if gap > 0 and gap > 0.10 * level:
|
||||||
|
continue # basis- gap too wide to be a hedge
|
||||||
|
if gap < 0 and (-gap) > 0.25 * level:
|
||||||
|
continue # basis+ overlap implausibly large
|
||||||
|
cands.append((abs(ls - hs), li, hi))
|
||||||
|
cands.sort() # gap 0 (matched) first, then nearest
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for g in groups.values():
|
for _, li, hi in cands:
|
||||||
legs = g["poly"] + g["kalshi"]
|
q = min(rem_lo[li], rem_hi[hi])
|
||||||
cost = sum((x["cost"] or 0) for x in legs)
|
if q <= 1e-9:
|
||||||
value = sum((x["value"] or 0) for x in legs)
|
continue # one side already fully consumed
|
||||||
pnl = sum((x["pnl"] or 0) for x in legs if x["pnl"] is not None)
|
rem_lo[li] -= q
|
||||||
rows.append({
|
rem_hi[hi] -= q
|
||||||
"asset": g["pair"].get("asset"),
|
lo, hg = lows[li], highs[hi]
|
||||||
"kalshi_ticker": g["pair"].get("kalshi_ticker"),
|
ls, hs = lo["_strike"], hg["_strike"]
|
||||||
"poly_slug": g["pair"].get("poly_slug"),
|
if abs(ls - hs) < 1e-9:
|
||||||
"kalshi_strike": g["pair"].get("kalshi_strike"),
|
kind = "matched"
|
||||||
"poly_strike": g["pair"].get("poly_strike"),
|
note = "hedged at %s" % _fmt_strike(ls)
|
||||||
"legs": legs,
|
elif ls > hs:
|
||||||
"cost": cost,
|
kind = "basis+"
|
||||||
"value": value,
|
note = ("overlap %s-%s — both legs win in the gap"
|
||||||
"pnl": pnl,
|
% (_fmt_strike(hs), _fmt_strike(ls)))
|
||||||
"pnl_pct": (pnl / cost if cost else None),
|
else:
|
||||||
"complete": bool(g["poly"] and g["kalshi"]),
|
kind = "basis-"
|
||||||
})
|
note = ("gap %s-%s — neither leg wins between (basis risk)"
|
||||||
rows.sort(key=lambda r: r["pnl"], reverse=True)
|
% (_fmt_strike(ls), _fmt_strike(hs)))
|
||||||
return rows
|
rows.append(_row_from_legs([_slice_leg(hg, q), _slice_leg(lo, q)],
|
||||||
|
kind, True, note, asset, expiry,
|
||||||
|
low_strike=ls, high_strike=hs))
|
||||||
|
|
||||||
|
leftover = [_slice_leg(lo, rem_lo[i]) for i, lo in enumerate(lows)
|
||||||
|
if rem_lo[i] > 1e-9]
|
||||||
|
leftover += [_slice_leg(hg, rem_hi[i]) for i, hg in enumerate(highs)
|
||||||
|
if rem_hi[i] > 1e-9]
|
||||||
|
leftover += other
|
||||||
|
return rows, leftover
|
||||||
|
|
||||||
|
|
||||||
|
def _single_row(leg):
|
||||||
|
pays = leg.get("_pays")
|
||||||
|
strike = leg.get("_strike")
|
||||||
|
if pays is True and strike is not None:
|
||||||
|
note = "uncovered — pays only above %s" % _fmt_strike(strike)
|
||||||
|
elif pays is False and strike is not None:
|
||||||
|
note = "uncovered — pays only below %s" % _fmt_strike(strike)
|
||||||
|
else:
|
||||||
|
note = "unclassified leg"
|
||||||
|
return _row_from_legs([leg], "single", False, note,
|
||||||
|
leg.get("asset"), _exp_key(leg))
|
||||||
|
|
||||||
|
|
||||||
|
def _paired(poly_pos, kalshi_pos):
|
||||||
|
"""Recognize economic hedges from live holdings, not just curated pairs.
|
||||||
|
|
||||||
|
Curated pairs.json (if present) only supplies authoritative strikes; the
|
||||||
|
matching itself keys off each leg's economics so any cross-venue,
|
||||||
|
opposite-direction hedge in the book is surfaced — matched or basis."""
|
||||||
|
pairs = _load_pairs()
|
||||||
|
tkr_strike = {p["kalshi_ticker"]: p.get("kalshi_strike")
|
||||||
|
for p in pairs if p.get("kalshi_ticker")}
|
||||||
|
slug_strike = {p["poly_slug"]: p.get("poly_strike")
|
||||||
|
for p in pairs if p.get("poly_slug")}
|
||||||
|
|
||||||
|
# Copy each leg so _classify's _pays/_strike/_dir scratch keys don't leak
|
||||||
|
# into the dicts returned under "polymarket"/"kalshi".
|
||||||
|
legs = [_classify(dict(p), tkr_strike, slug_strike)
|
||||||
|
for p in list(poly_pos) + list(kalshi_pos)]
|
||||||
|
|
||||||
|
buckets = {}
|
||||||
|
for l in legs:
|
||||||
|
buckets.setdefault((l.get("asset"), _exp_key(l)), []).append(l)
|
||||||
|
|
||||||
|
pair_rows, single_rows = [], []
|
||||||
|
for (asset, expiry), blegs in buckets.items():
|
||||||
|
pr, leftover = _match_bucket(blegs, asset, expiry)
|
||||||
|
pair_rows.extend(pr)
|
||||||
|
single_rows.extend(_single_row(l) for l in leftover)
|
||||||
|
|
||||||
|
pair_rows.sort(key=lambda r: (r["pnl"] if r["pnl"] is not None else 0),
|
||||||
|
reverse=True)
|
||||||
|
single_rows.sort(key=lambda r: (r["asset"] or "z", -(r["pnl"] or 0)))
|
||||||
|
return pair_rows + single_rows
|
||||||
|
|
||||||
|
|
||||||
def run_positions():
|
def run_positions():
|
||||||
|
|||||||
+26
-5
@@ -416,12 +416,32 @@ function venuePanel(name, tag, v) {
|
|||||||
</tr></thead><tbody>${rows}</tbody></table></div>`;
|
</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kindBadge(r) {
|
||||||
|
const m = {
|
||||||
|
matched: ["matched", "#16c784", "#16c78422"],
|
||||||
|
"basis+": ["basis +", "#e0a93b", "#e0a93b22"],
|
||||||
|
"basis-": ["basis −", "#ef5350", "#ef535022"],
|
||||||
|
single: ["one leg", "#8a93a6", "#8a93a622"],
|
||||||
|
};
|
||||||
|
const [label, fg, bg] = m[r.kind] || m.single;
|
||||||
|
return `<span class="kindpill" style="background:${bg};color:${fg}">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function strikeBand(r) {
|
||||||
|
if (r.low_strike == null && r.high_strike == null) return "";
|
||||||
|
const f = (v) => (v == null ? "?" : v >= 1000 ? `${(v / 1000).toLocaleString()}k` : `${v}`);
|
||||||
|
if (r.kind === "matched") return `<span class="dimv mono">@ ${f(r.low_strike)}</span>`;
|
||||||
|
const lo = Math.min(r.low_strike, r.high_strike);
|
||||||
|
const hi = Math.max(r.low_strike, r.high_strike);
|
||||||
|
return `<span class="dimv mono">${f(lo)}–${f(hi)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function pairedView(rows) {
|
function pairedView(rows) {
|
||||||
if (!rows || !rows.length)
|
if (!rows || !rows.length)
|
||||||
return `<div class="vcard"><h4>Paired exposure</h4>
|
return `<div class="vcard"><h4>Paired exposure</h4>
|
||||||
<p class="dimv">No held positions match a known Kalshi↔Polymarket arb
|
<p class="dimv">No held positions yet. Cross-venue hedges are matched
|
||||||
pair (pairs come from the Monthly map). Positions still show under
|
automatically from your live book and shown here; everything also appears
|
||||||
<b>By venue</b>.</p></div>`;
|
under <b>By venue</b>.</p></div>`;
|
||||||
const body = rows.map((r) => {
|
const body = rows.map((r) => {
|
||||||
const legs = r.legs.map((l) => `<div class="leg2">
|
const legs = r.legs.map((l) => `<div class="leg2">
|
||||||
<span class="tag ${l.venue === "Kalshi" ? "k" : "p"}">${l.venue[0]}</span>
|
<span class="tag ${l.venue === "Kalshi" ? "k" : "p"}">${l.venue[0]}</span>
|
||||||
@@ -431,7 +451,8 @@ function pairedView(rows) {
|
|||||||
<span>${pnl(l.pnl)}</span></div>`).join("");
|
<span>${pnl(l.pnl)}</span></div>`).join("");
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="l"><span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
|
<td class="l"><span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
|
||||||
${r.complete ? '<span class="okpill">paired</span>' : '<span class="onepill">one leg</span>'}</td>
|
<div class="pairmeta">${kindBadge(r)} ${strikeBand(r)}</div>
|
||||||
|
${r.note ? `<div class="dimv pairnote">${esc(r.note)}</div>` : ""}</td>
|
||||||
<td class="l">${legs}</td>
|
<td class="l">${legs}</td>
|
||||||
<td class="num">${usd(r.cost)}</td>
|
<td class="num">${usd(r.cost)}</td>
|
||||||
<td class="num">${usd(r.value)}</td>
|
<td class="num">${usd(r.value)}</td>
|
||||||
@@ -440,7 +461,7 @@ function pairedView(rows) {
|
|||||||
}).join("");
|
}).join("");
|
||||||
return `<div class="vcard"><h4>Paired / netted exposure</h4>
|
return `<div class="vcard"><h4>Paired / netted exposure</h4>
|
||||||
<table class="ptbl"><thead><tr>
|
<table class="ptbl"><thead><tr>
|
||||||
<th class="l">Pair</th><th class="l">Legs</th>
|
<th class="l">Hedge</th><th class="l">Legs</th>
|
||||||
<th>Cost</th><th>Value</th><th>Net P&L</th>
|
<th>Cost</th><th>Value</th><th>Net P&L</th>
|
||||||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,11 @@ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
|||||||
.onepill { font-size: 10px; font-weight: 700; color: var(--warn);
|
.onepill { font-size: 10px; font-weight: 700; color: var(--warn);
|
||||||
background: var(--warn-d); padding: 2px 8px; border-radius: 999px;
|
background: var(--warn-d); padding: 2px 8px; border-radius: 999px;
|
||||||
margin-left: 6px; }
|
margin-left: 6px; }
|
||||||
|
.kindpill { font-size: 10px; font-weight: 800; padding: 2px 8px;
|
||||||
|
border-radius: 999px; letter-spacing: .02em; }
|
||||||
|
.pairmeta { display: flex; align-items: center; gap: 7px; margin-top: 4px; }
|
||||||
|
.pairnote { font-size: 10.5px; margin-top: 3px; max-width: 230px;
|
||||||
|
line-height: 1.3; }
|
||||||
.leg2 { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
.leg2 { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||||
font-size: 12px; }
|
font-size: 12px; }
|
||||||
.leg2 .tag { width: 16px; height: 16px; display: grid; place-items: center;
|
.leg2 .tag { width: 16px; height: 16px; display: grid; place-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user