mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
Initial commit: prediction-market arb scanner + GHA alerts
- Monthly + hourly Kalshi/Polymarket arb scanner (stdlib-only Python). - Live positions tab w/ realized P&L history. - GitHub Actions cron workflow texts SMS via Apps Script webhook on newly-detected arbs. State persisted in alerts_state.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
"""Background arb poller that texts on newly-detected ARBs.
|
||||
|
||||
Polls `scan.run_scan` on an interval, tracks the set of currently-ARB pair
|
||||
ids in memory, and sends an SMS via the configured Apps Script webhook only
|
||||
when a pair id newly appears. On first cycle we "silently prime" — record
|
||||
what's currently ARB without firing — so restarting the server does NOT
|
||||
produce a text storm. A pair that disappears (e.g. price moved) leaves the
|
||||
set and will re-fire if it returns later.
|
||||
|
||||
Config (all required, otherwise the poller no-ops):
|
||||
data/secrets.json -> sms_webhook, sms_phone, sms_carrier
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from . import positions, scan
|
||||
|
||||
POLL_INTERVAL = 60 # seconds between scans
|
||||
START_DELAY = 5 # let the HTTP server warm up before the first scan
|
||||
SMS_GAP = 0.5 # space out multiple texts in a single cycle
|
||||
|
||||
_state = {"primed": False, "active": set()}
|
||||
|
||||
|
||||
def _log(msg):
|
||||
sys.stderr.write("[alerts] %s\n" % msg)
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _send(message, webhook, phone, carrier):
|
||||
body = json.dumps({"message": message, "to": phone,
|
||||
"carrier": carrier}).encode()
|
||||
req = urllib.request.Request(
|
||||
webhook, data=body,
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
ok = r.status == 200
|
||||
if not ok:
|
||||
_log("SMS webhook returned %s" % r.status)
|
||||
return ok
|
||||
except Exception as e:
|
||||
_log("SMS send failed: %s: %s" % (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
|
||||
def _format(row):
|
||||
"""SMS-friendly, ASCII, ~80 chars (one segment)."""
|
||||
asset = row.get("asset") or "?"
|
||||
ks = row.get("kalshi_strike")
|
||||
side = (row.get("best_side") or "").replace("YES", "Y").replace("NO", "N")
|
||||
net = (row.get("net_return") or 0) * 100
|
||||
ann = (row.get("annualized") or 0) * 100
|
||||
ct = int(row.get("max_contracts") or 0)
|
||||
gain = row.get("total_gain") or 0
|
||||
return "ARB %s %s %s net %+.2f%% ann %+.0f%% %dct $%.2f" % (
|
||||
asset, ks, side, net, ann, ct, gain)
|
||||
|
||||
|
||||
def _pair_id(row):
|
||||
return "%s|%s" % (row.get("kalshi_ticker") or "",
|
||||
row.get("poly_slug") or "")
|
||||
|
||||
|
||||
def _cycle():
|
||||
secrets = positions.load_secrets()
|
||||
webhook = secrets.get("sms_webhook") or ""
|
||||
phone = secrets.get("sms_phone") or ""
|
||||
carrier = secrets.get("sms_carrier") or ""
|
||||
if not (webhook and phone and carrier):
|
||||
return # SMS not configured -> no-op
|
||||
|
||||
try:
|
||||
payload = scan.run_scan(force=False)
|
||||
except Exception as e:
|
||||
_log("scan failed: %s" % e)
|
||||
return
|
||||
|
||||
settings = payload.get("settings", {})
|
||||
min_ret = settings.get("min_net_return", 0)
|
||||
|
||||
current = {}
|
||||
for r in payload.get("rows", []):
|
||||
if r.get("status") == "ARB" and (r.get("net_return") or 0) >= min_ret:
|
||||
current[_pair_id(r)] = r
|
||||
|
||||
if not _state["primed"]:
|
||||
_state["active"] = set(current)
|
||||
_state["primed"] = True
|
||||
_log("primed: %d currently-active arb(s), watching for new ones"
|
||||
% len(current))
|
||||
return
|
||||
|
||||
new = set(current) - _state["active"]
|
||||
for pid in new:
|
||||
msg = _format(current[pid])
|
||||
if _send(msg, webhook, phone, carrier):
|
||||
_log("sent: %s" % msg)
|
||||
time.sleep(SMS_GAP)
|
||||
_state["active"] = set(current)
|
||||
|
||||
|
||||
def _loop():
|
||||
time.sleep(START_DELAY)
|
||||
while True:
|
||||
try:
|
||||
_cycle()
|
||||
except Exception as e:
|
||||
_log("cycle crashed: %s: %s" % (type(e).__name__, e))
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
def start_poller():
|
||||
"""Spawn the daemon thread. Safe to call once at server startup."""
|
||||
t = threading.Thread(target=_loop, daemon=True, name="arb-sms-poller")
|
||||
t.start()
|
||||
_log("poller started (interval=%ds)" % POLL_INTERVAL)
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"""Arb math, ported from the workbook formulas.
|
||||
|
||||
Authoritative source: the *Arb Positions* sheet (it holds real Excel formulas;
|
||||
the *Arb Scanner* sheet only holds script-computed values).
|
||||
|
||||
Direction C = IF(SEARCH("MINMON",ticker), "Below", "Above")
|
||||
Basis % AC = IF("Above",(kStrike-pStrike)/kStrike,(pStrike-kStrike)/pStrike)
|
||||
Kalshi fee AI = (rate*size*price)*(1-price) -> per-contract: rate*p*(1-p)
|
||||
Poly fee AJ = size*rate*price*(1-price) -> per-contract: rate*p*(1-p)
|
||||
Per leg win -> (1 - price) - fee (L-AI in the sheet)
|
||||
lose -> (- price) - fee (-K-AI in the sheet)
|
||||
Min Gain AA = MIN(W+X, Y+Z) <- the true guaranteed P&L
|
||||
In Between AD = X+Y <- P&L in the strike-gap region
|
||||
% Return AG = pnl / outlay
|
||||
Annualized AH = AG * (365 / (close - open))
|
||||
|
||||
A clean arb pays $1 from exactly one leg only when BOTH contracts ask the same
|
||||
question (same strike + direction). When strikes differ there is a price band
|
||||
between the two strikes where the position can double-WIN (free money) or
|
||||
double-LOSE (basis risk). The sheet surfaces this via AA and AD; we evaluate
|
||||
all resolution regions and key off the guaranteed worst case, NOT (1 - cost).
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
|
||||
def direction(ticker):
|
||||
return "Below" if "MINMON" in (ticker or "").upper() else "Above"
|
||||
|
||||
|
||||
def basis_pct(kalshi_strike, poly_strike, direc):
|
||||
if not kalshi_strike or not poly_strike:
|
||||
return None
|
||||
if direc == "Above":
|
||||
return (kalshi_strike - poly_strike) / kalshi_strike
|
||||
return (poly_strike - kalshi_strike) / poly_strike
|
||||
|
||||
|
||||
def _days_to(expiry_iso, today=None):
|
||||
if not expiry_iso:
|
||||
return None
|
||||
try:
|
||||
y, m, d = (int(x) for x in expiry_iso.split("-")[:3])
|
||||
delta = (date(y, m, d) - (today or date.today())).days
|
||||
return delta if delta > 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _stmt_true(price, strike, is_above):
|
||||
return price >= strike if is_above else price <= strike
|
||||
|
||||
|
||||
def _leg(price, fee, won):
|
||||
"""Per-contract P&L for one leg. Mirrors L-AI / -K-AI in Arb Positions."""
|
||||
return ((1.0 - price) - fee) if won else ((-price) - fee)
|
||||
|
||||
|
||||
def _scenarios(ks, ps, is_above):
|
||||
"""Representative prices covering every resolution region.
|
||||
|
||||
Returns list of (kalshi_stmt_true, poly_stmt_true). The mid region only
|
||||
exists when strikes differ; that region is the strike-gap / basis zone.
|
||||
"""
|
||||
lo = min(ks, ps) * 0.5
|
||||
hi = max(ks, ps) * 1.5 + 1.0
|
||||
pts = [lo, hi]
|
||||
if ks != ps:
|
||||
pts.append((ks + ps) / 2.0)
|
||||
return [(_stmt_true(p, ks, is_above), _stmt_true(p, ps, is_above))
|
||||
for p in pts]
|
||||
|
||||
|
||||
def evaluate(pair, kq, pq, settings, today=None):
|
||||
"""Build one scanner row from a pair + its Kalshi/Poly quotes."""
|
||||
tkr = pair["kalshi_ticker"]
|
||||
direc = direction(tkr)
|
||||
ks = pair.get("kalshi_strike")
|
||||
ps = pair.get("poly_strike")
|
||||
bpct = basis_pct(ks, ps, direc)
|
||||
|
||||
row = {
|
||||
"asset": pair.get("asset"),
|
||||
"kalshi_ticker": tkr,
|
||||
"kalshi_strike": ks,
|
||||
"poly_slug": pair.get("poly_slug"),
|
||||
"poly_strike": ps,
|
||||
"direction": direc,
|
||||
"basis_pct": bpct,
|
||||
"basis_favorable": None,
|
||||
"best_side": None,
|
||||
"kalshi_price": None, "kalshi_size": None,
|
||||
"poly_price": None, "poly_size": None,
|
||||
"combined_cost": None, "kalshi_fee": None, "poly_fee": None,
|
||||
"total_fee": None,
|
||||
"worst_pnl": None, # guaranteed P&L / contract (sheet "Min Gain")
|
||||
"best_pnl": None, # best-case P&L / contract (sheet "Max Gain")
|
||||
"mid_pnl": None, # strike-gap P&L (sheet "In Between")
|
||||
"net_return": None, # worst_pnl / combined_cost
|
||||
"annualized": None,
|
||||
"max_contracts": None, "total_gain": None,
|
||||
"poly_volume": (pq or {}).get("volume"),
|
||||
"days_to_expiry": None,
|
||||
"status": None,
|
||||
# Display metadata straight from the venues' APIs.
|
||||
"kalshi_title": (kq or {}).get("title"),
|
||||
"kalshi_rules": (kq or {}).get("rules"),
|
||||
"kalshi_yes_label": (kq or {}).get("yes_label"),
|
||||
"kalshi_no_label": (kq or {}).get("no_label"),
|
||||
"poly_question": (pq or {}).get("question"),
|
||||
"poly_description": (pq or {}).get("description"),
|
||||
"image": (pq or {}).get("image") or (pq or {}).get("icon"),
|
||||
}
|
||||
|
||||
if not pair.get("poly_slug"):
|
||||
row["status"] = "NO PAIR"
|
||||
return row
|
||||
if not kq or not pq or not ks or not ps:
|
||||
row["status"] = "NO DATA"
|
||||
return row
|
||||
|
||||
kfee_rate = settings["kalshi_fee_rate"]
|
||||
pfee_rate = settings["poly_fee_rate"]
|
||||
is_above = (direc == "Above")
|
||||
scen = _scenarios(ks, ps, is_above)
|
||||
|
||||
# Kalshi top-of-book size is only fetched for candidates; until then fall
|
||||
# back to open interest as a liquidity proxy for the size gate.
|
||||
k_oi = kq.get("open_interest")
|
||||
k_ysz = kq.get("yes_ask_size") if kq.get("yes_ask_size") is not None else k_oi
|
||||
k_nsz = kq.get("no_ask_size") if kq.get("no_ask_size") is not None else k_oi
|
||||
|
||||
# Candidate hedged pairings: hold opposite sides across the two venues.
|
||||
cands = []
|
||||
if kq.get("yes_ask") and pq.get("no_ask"):
|
||||
cands.append(("YES+NO", "YES", kq["yes_ask"], k_ysz,
|
||||
"NO", pq["no_ask"], pq.get("no_ask_size")))
|
||||
if kq.get("no_ask") and pq.get("yes_ask"):
|
||||
cands.append(("NO+YES", "NO", kq["no_ask"], k_nsz,
|
||||
"YES", pq["yes_ask"], pq.get("yes_ask_size")))
|
||||
if not cands:
|
||||
row["status"] = "NO DATA"
|
||||
return row
|
||||
|
||||
best = None # (worst_pnl, ...)
|
||||
for label, kside, kp, ksz, pside, pp, psz in cands:
|
||||
kfee = kfee_rate * kp * (1 - kp)
|
||||
pfee = pfee_rate * pp * (1 - pp)
|
||||
k_yes = (kside == "YES")
|
||||
p_yes = (pside == "YES")
|
||||
pnls = []
|
||||
for kt, pt in scen:
|
||||
kp_l = _leg(kp, kfee, kt == k_yes)
|
||||
pp_l = _leg(pp, pfee, pt == p_yes)
|
||||
pnls.append(kp_l + pp_l)
|
||||
worst = min(pnls)
|
||||
bestc = max(pnls)
|
||||
mid = pnls[2] if len(pnls) > 2 else None
|
||||
cand = (worst, bestc, mid, label, kside, kp, ksz, pside, pp, psz,
|
||||
kfee, pfee)
|
||||
if best is None or worst > best[0]:
|
||||
best = cand
|
||||
|
||||
(worst, bestc, mid, label, kside, kp, ksz, pside, pp, psz,
|
||||
kfee, pfee) = best
|
||||
cost = kp + pp
|
||||
total_fee = kfee + pfee
|
||||
net_return = worst / cost if cost else None
|
||||
|
||||
expiry = kq.get("expiry") or pq.get("end_date")
|
||||
days = _days_to(expiry, today)
|
||||
annualized = (net_return * (365.0 / days)
|
||||
if (net_return is not None and days) else None)
|
||||
|
||||
sizes = [s for s in (ksz, psz) if s is not None]
|
||||
max_contracts = min(sizes) if sizes else None
|
||||
total_gain = worst * max_contracts if max_contracts is not None else None
|
||||
|
||||
# Favorable basis == strikes match, or the gap region is not a double-loss.
|
||||
fav = (ks == ps) or (mid is not None and mid >= -1e-9)
|
||||
|
||||
row.update({
|
||||
"basis_favorable": fav,
|
||||
"best_side": label,
|
||||
"kalshi_price": kp, "kalshi_size": ksz,
|
||||
"poly_price": pp, "poly_size": psz,
|
||||
"combined_cost": cost, "kalshi_fee": kfee, "poly_fee": pfee,
|
||||
"total_fee": total_fee,
|
||||
"worst_pnl": worst, "best_pnl": bestc, "mid_pnl": mid,
|
||||
"net_return": net_return, "annualized": annualized,
|
||||
"max_contracts": max_contracts, "total_gain": total_gain,
|
||||
"days_to_expiry": days,
|
||||
})
|
||||
|
||||
# Status precedence mirrors the workbook: DATA > BASIS > RETURN > SIZE.
|
||||
min_ret = settings["min_net_return"]
|
||||
min_vol = settings["min_poly_volume"]
|
||||
min_ct = settings["min_contracts"]
|
||||
has_edge = net_return is not None and net_return >= min_ret
|
||||
size_ok = (max_contracts is None or max_contracts >= min_ct) \
|
||||
and (row["poly_volume"] or 0) >= min_vol
|
||||
|
||||
if not fav and not has_edge:
|
||||
row["status"] = "BAD BASIS" # strike-gap double-loss kills it
|
||||
elif not has_edge:
|
||||
row["status"] = "NO ARB" # no guaranteed edge
|
||||
elif not size_ok:
|
||||
row["status"] = "LOW SIZE" # real edge, but untradeable size/volume
|
||||
else:
|
||||
row["status"] = "ARB"
|
||||
return row
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
"""Hourly scanner: Kalshi hourly strike ladder vs Polymarket "Up or Down".
|
||||
|
||||
Pairing model
|
||||
-------------
|
||||
Polymarket "{ASSET} Up or Down - {date} {H}{am/pm} ET" resolves Up if the
|
||||
Binance 1h candle [H:00 -> H+1:00] closes >= it opens. So its *implied strike*
|
||||
is the Binance candle OPEN at H:00, and it settles at H+1:00 ET.
|
||||
|
||||
Kalshi `KX{SYM}D-{YYMMMDD}{HH}-T{strike}` resolves Yes if the asset is >=
|
||||
strike at HH:00 ET (CF Benchmarks). We pick the Kalshi market that settles at
|
||||
the Polymarket window's CLOSE hour, with the strike nearest the Binance open.
|
||||
Then Kalshi-Yes ~= Polymarket-Up, and the existing worst-case math handles the
|
||||
(discrete strike) vs (exact open) gap as basis.
|
||||
|
||||
This is NOT a locked arb: the two venues settle on different price feeds
|
||||
(CF Benchmarks vs Binance). `refs` surfaces that divergence live per asset.
|
||||
"""
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from .net import get_json, FetchError
|
||||
from . import poly, refs
|
||||
from .calc import evaluate
|
||||
|
||||
KALSHI = "https://api.elections.kalshi.com/trade-api/v2"
|
||||
|
||||
# asset -> (kalshi hourly series, polymarket slug name)
|
||||
MARKETS = {
|
||||
"BTC": ("KXBTCD", "bitcoin"),
|
||||
"ETH": ("KXETHD", "ethereum"),
|
||||
"SOL": ("KXSOLD", "solana"),
|
||||
"XRP": ("KXXRPD", "xrp"),
|
||||
"DOGE": ("KXDOGED", "dogecoin"),
|
||||
"BNB": ("KXBNBD", "bnb"),
|
||||
}
|
||||
_MON = ["january", "february", "march", "april", "may", "june", "july",
|
||||
"august", "september", "october", "november", "december"]
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
_ET = ZoneInfo("America/New_York")
|
||||
except Exception: # no tzdata -> EDT (valid Mar-Nov)
|
||||
_ET = timezone(timedelta(hours=-4))
|
||||
|
||||
|
||||
def _et(ms):
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).astimezone(_ET)
|
||||
|
||||
|
||||
def _poly_slug(name, dt_et):
|
||||
h = dt_et.hour
|
||||
ampm = "am" if h < 12 else "pm"
|
||||
h12 = h % 12 or 12
|
||||
return "%s-up-or-down-%s-%d-%d-%d%s-et" % (
|
||||
name, _MON[dt_et.month - 1], dt_et.day, dt_et.year, h12, ampm)
|
||||
|
||||
|
||||
def _strike_from_ticker(t):
|
||||
"""KX..-T89799.99 -> 89799.99 ; range/below buckets -> None."""
|
||||
i = t.rfind("-T")
|
||||
if i == -1:
|
||||
return None
|
||||
try:
|
||||
return float(t[i + 2:])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _kalshi_ladder(series):
|
||||
d = get_json("%s/markets?series_ticker=%s&status=open&limit=1000"
|
||||
% (KALSHI, series))
|
||||
return d.get("markets", [])
|
||||
|
||||
|
||||
def _pick(markets, close_iso, target):
|
||||
"""Among markets settling at close_iso, the '... or above' market whose
|
||||
strike is nearest `target`."""
|
||||
best = None
|
||||
for m in markets:
|
||||
if (m.get("close_time") or "")[:16] != close_iso[:16]:
|
||||
continue
|
||||
if "or above" not in (m.get("yes_sub_title") or "").lower():
|
||||
continue
|
||||
k = _strike_from_ticker(m.get("ticker") or "")
|
||||
if k is None:
|
||||
continue
|
||||
d = abs(k - target)
|
||||
if best is None or d < best[0]:
|
||||
best = (d, k, m)
|
||||
return best # (dist, strike, market) | None
|
||||
|
||||
|
||||
def _kq(m, strike):
|
||||
def f(v):
|
||||
try:
|
||||
x = float(v)
|
||||
return x if x > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"ticker": m.get("ticker"),
|
||||
"yes_ask": f(m.get("yes_ask_dollars")),
|
||||
"no_ask": f(m.get("no_ask_dollars")),
|
||||
"yes_bid": f(m.get("yes_bid_dollars")),
|
||||
"no_bid": f(m.get("no_bid_dollars")),
|
||||
"yes_ask_size": None, "no_ask_size": None,
|
||||
"open_interest": f(m.get("open_interest_fp")) or 0.0,
|
||||
"status": m.get("status"),
|
||||
"expiry": None, # intraday; handled via minutes field
|
||||
"title": m.get("title"),
|
||||
"yes_label": m.get("yes_sub_title"),
|
||||
"no_label": m.get("no_sub_title"),
|
||||
"rules": (m.get("rules_primary") or "").strip()[:360],
|
||||
}
|
||||
|
||||
|
||||
def run(settings, assets=None):
|
||||
assets = assets or list(MARKETS)
|
||||
rf = refs.fetch_refs(assets)
|
||||
|
||||
# Build the current-hour Polymarket slug per asset, fetch them batched.
|
||||
slug_of, want = {}, []
|
||||
for a in assets:
|
||||
r = rf.get(a)
|
||||
if not r:
|
||||
continue
|
||||
start_et = _et(r["hour_open_ms"])
|
||||
slug = _poly_slug(MARKETS[a][1], start_et)
|
||||
slug_of[a] = (slug, r, start_et)
|
||||
want.append(slug)
|
||||
pq_all = poly.fetch_quotes(want) if want else {}
|
||||
|
||||
now_ms = time.time() * 1000
|
||||
rows = []
|
||||
for a in assets:
|
||||
meta = slug_of.get(a)
|
||||
r = rf.get(a)
|
||||
base = {
|
||||
"asset": a,
|
||||
"implied_strike": r["hour_open"] if r else None,
|
||||
"binance_spot": r["binance_spot"] if r else None,
|
||||
"cf_spot": r["cf_spot"] if r else None,
|
||||
"divergence": r["divergence"] if r else None,
|
||||
}
|
||||
if not meta:
|
||||
rows.append({**_empty_row(a), **base, "status": "NO DATA"})
|
||||
continue
|
||||
slug, ref, start_et = meta
|
||||
close_dt = start_et + timedelta(hours=1)
|
||||
close_iso = (start_et.astimezone(timezone.utc) +
|
||||
timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
series = MARKETS[a][0]
|
||||
try:
|
||||
ladder = _kalshi_ladder(series)
|
||||
except FetchError:
|
||||
ladder = []
|
||||
pick = _pick(ladder, close_iso, ref["hour_open"])
|
||||
pq = pq_all.get(slug)
|
||||
|
||||
if not pick or not pq:
|
||||
rows.append({
|
||||
**_empty_row(a), **base,
|
||||
"poly_slug": slug, "poly_question": (pq or {}).get("question"),
|
||||
"window_start": start_et.strftime("%H:%M ET"),
|
||||
"window_close": close_dt.strftime("%H:%M ET"),
|
||||
"minutes_to_resolve": max(0, round(
|
||||
(ref["hour_open_ms"] + 3600_000 - now_ms) / 60000)),
|
||||
"status": "NO DATA" if not pq else "NO KALSHI",
|
||||
})
|
||||
continue
|
||||
|
||||
_, kstrike, kmkt = pick
|
||||
pair = {
|
||||
"asset": a, "kalshi_ticker": kmkt.get("ticker"),
|
||||
"kalshi_strike": kstrike, "poly_slug": slug,
|
||||
"poly_strike": ref["hour_open"], "active": True,
|
||||
}
|
||||
row = evaluate(pair, _kq(kmkt, kstrike), pq, settings)
|
||||
row.update(base)
|
||||
row["window_start"] = start_et.strftime("%H:%M ET")
|
||||
row["window_close"] = close_dt.strftime("%H:%M ET")
|
||||
row["minutes_to_resolve"] = max(0, round(
|
||||
(ref["hour_open_ms"] + 3600_000 - now_ms) / 60000))
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _empty_row(a):
|
||||
return {
|
||||
"asset": a, "kalshi_ticker": None, "kalshi_strike": None,
|
||||
"poly_slug": None, "poly_strike": None, "direction": "Above",
|
||||
"basis_pct": None, "basis_favorable": None, "best_side": None,
|
||||
"kalshi_price": None, "kalshi_size": None, "poly_price": None,
|
||||
"poly_size": None, "combined_cost": None, "kalshi_fee": None,
|
||||
"poly_fee": None, "total_fee": None, "worst_pnl": None,
|
||||
"best_pnl": None, "mid_pnl": None, "net_return": None,
|
||||
"annualized": None, "max_contracts": None, "total_gain": None,
|
||||
"poly_volume": None, "days_to_expiry": None,
|
||||
"kalshi_title": None, "kalshi_rules": None, "kalshi_yes_label": None,
|
||||
"kalshi_no_label": None, "poly_question": None,
|
||||
"poly_description": None, "image": None,
|
||||
"window_start": None, "window_close": None,
|
||||
"minutes_to_resolve": None,
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
"""Kalshi public trade API client (no auth required for market data).
|
||||
|
||||
Kalshi rate-limits aggressively, so we do NOT fetch per ticker. Instead:
|
||||
|
||||
fetch_quotes() -> ONE batched GET /markets?tickers=a,b,c... (chunked +
|
||||
cursor-paged) for prices/status/expiry of every ticker.
|
||||
fetch_sizes() -> /markets/{t}/orderbook, called only for the small set of
|
||||
basis-favorable candidates, to get true executable
|
||||
top-of-book size.
|
||||
|
||||
Kalshi binary markets: buying YES is matched against resting NO bids, so the
|
||||
size available at the YES ask == size of the best NO bid (and vice versa).
|
||||
"""
|
||||
import re
|
||||
from .net import get_json, parallel, FetchError
|
||||
|
||||
BASE = "https://api.elections.kalshi.com/trade-api/v2"
|
||||
_CHUNK = 80
|
||||
|
||||
_MONTHS = {
|
||||
"JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6,
|
||||
"JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12,
|
||||
}
|
||||
|
||||
|
||||
def _f(v):
|
||||
try:
|
||||
x = float(v)
|
||||
return x if x > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_expiry(ticker):
|
||||
"""KX...-26MAY31-7000 -> '2026-05-31', else None."""
|
||||
m = re.search(r"-(\d{2})([A-Z]{3})(\d{2})-", ticker)
|
||||
if not m or m.group(2) not in _MONTHS:
|
||||
return None
|
||||
return "20%s-%02d-%02d" % (m.group(1), _MONTHS[m.group(2)], int(m.group(3)))
|
||||
|
||||
|
||||
def _clip(s, n):
|
||||
s = (s or "").strip()
|
||||
return s if len(s) <= n else s[: n - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _quote(mkt):
|
||||
t = mkt.get("ticker")
|
||||
return {
|
||||
"ticker": t,
|
||||
"yes_bid": _f(mkt.get("yes_bid_dollars")),
|
||||
"yes_ask": _f(mkt.get("yes_ask_dollars")),
|
||||
"no_bid": _f(mkt.get("no_bid_dollars")),
|
||||
"no_ask": _f(mkt.get("no_ask_dollars")),
|
||||
"yes_ask_size": None,
|
||||
"no_ask_size": None,
|
||||
"open_interest": _f(mkt.get("open_interest_fp")) or 0.0,
|
||||
"status": mkt.get("status"),
|
||||
"expiry": (mkt.get("close_time") or "")[:10] or parse_expiry(t or ""),
|
||||
"title": mkt.get("title"),
|
||||
"yes_label": mkt.get("yes_sub_title"),
|
||||
"no_label": mkt.get("no_sub_title"),
|
||||
"rules": _clip(mkt.get("rules_primary"), 360),
|
||||
}
|
||||
|
||||
|
||||
def fetch_quotes(tickers):
|
||||
"""tickers: iterable. Returns {ticker: quote|None} via batched calls."""
|
||||
uniq = sorted({t for t in tickers if t})
|
||||
out = {t: None for t in uniq}
|
||||
for i in range(0, len(uniq), _CHUNK):
|
||||
chunk = uniq[i:i + _CHUNK]
|
||||
cursor = ""
|
||||
for _ in range(10): # cursor-page guard
|
||||
url = "%s/markets?limit=1000&tickers=%s" % (BASE, ",".join(chunk))
|
||||
if cursor:
|
||||
url += "&cursor=" + cursor
|
||||
try:
|
||||
data = get_json(url)
|
||||
except FetchError:
|
||||
break
|
||||
for m in data.get("markets", []):
|
||||
if m.get("ticker") in out:
|
||||
out[m["ticker"]] = _quote(m)
|
||||
cursor = data.get("cursor") or ""
|
||||
if not cursor:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _best_bid_size(ladder):
|
||||
"""ladder = [[price, size], ...] resting bids -> size at the top bid."""
|
||||
best = None
|
||||
for row in ladder or []:
|
||||
try:
|
||||
p, s = float(row[0]), float(row[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if best is None or p > best[0]:
|
||||
best = (p, s)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def _one_ob(ticker):
|
||||
ob = get_json("%s/markets/%s/orderbook" % (BASE, ticker)).get(
|
||||
"orderbook_fp", {})
|
||||
return {
|
||||
"yes_ask_size": _best_bid_size(ob.get("no_dollars")),
|
||||
"no_ask_size": _best_bid_size(ob.get("yes_dollars")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_sizes(tickers):
|
||||
"""Top-of-book executable size for a SMALL candidate set. {ticker: {..}}."""
|
||||
uniq = sorted({t for t in tickers if t})
|
||||
res = parallel(_one_ob, uniq, workers=6)
|
||||
return {t: (None if isinstance(v, FetchError) else v)
|
||||
for t, v in res.items()}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
"""Tiny stdlib HTTP JSON helpers + a bounded thread pool for fan-out fetches."""
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (prediction-market-arb)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_json(url, timeout=20, retries=2):
|
||||
last = None
|
||||
for attempt in range(retries + 1):
|
||||
req = urllib.request.Request(url, headers=_HEADERS)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
last = FetchError("HTTP %s for %s" % (e.code, url))
|
||||
if e.code not in (429, 500, 502, 503, 504):
|
||||
raise last
|
||||
except Exception as e: # URLError, timeout, JSON, etc.
|
||||
last = FetchError("%s for %s" % (type(e).__name__, url))
|
||||
if attempt < retries:
|
||||
time.sleep(0.4 * (attempt + 1)) # linear backoff
|
||||
raise last
|
||||
|
||||
|
||||
def post_json(url, payload, timeout=25):
|
||||
data = json.dumps(payload).encode()
|
||||
headers = dict(_HEADERS)
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise FetchError("HTTP %s for %s" % (e.code, url))
|
||||
except Exception as e:
|
||||
raise FetchError("%s for %s" % (type(e).__name__, url))
|
||||
|
||||
|
||||
def parallel(fn, items, workers=16):
|
||||
"""Map fn over items concurrently; returns {item: result_or_FetchError}."""
|
||||
out = {}
|
||||
if not items:
|
||||
return out
|
||||
with ThreadPoolExecutor(max_workers=min(workers, len(items))) as ex:
|
||||
futs = {ex.submit(fn, it): it for it in items}
|
||||
for fut in futs:
|
||||
it = futs[fut]
|
||||
try:
|
||||
out[it] = fut.result()
|
||||
except FetchError as e:
|
||||
out[it] = e
|
||||
except Exception as e: # never let one bad fetch kill the scan
|
||||
out[it] = FetchError("%s: %s" % (type(e).__name__, e))
|
||||
return out
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
"""Polymarket client.
|
||||
|
||||
- Gamma /markets?slug=a&slug=b... (BATCHED, ~25 slugs/call) -> token ids,
|
||||
volume, end date, open/closed flags, image, description.
|
||||
- CLOB POST /books (1 batched call for ALL tokens) ->
|
||||
executable best ask price + size for the YES and NO books separately.
|
||||
"""
|
||||
import json
|
||||
from .net import get_json, post_json, FetchError
|
||||
|
||||
_SLUG_CHUNK = 25
|
||||
|
||||
GAMMA = "https://gamma-api.polymarket.com/markets"
|
||||
CLOB_BOOKS = "https://clob.polymarket.com/books"
|
||||
|
||||
|
||||
def _loads(s, default):
|
||||
if isinstance(s, (list, dict)):
|
||||
return s
|
||||
try:
|
||||
return json.loads(s)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _parse_meta(m):
|
||||
slug = m.get("slug")
|
||||
outcomes = [str(o).strip().lower() for o in _loads(m.get("outcomes"), [])]
|
||||
tokens = _loads(m.get("clobTokenIds"), [])
|
||||
yes_id = no_id = None
|
||||
for i, oc in enumerate(outcomes):
|
||||
if i >= len(tokens):
|
||||
break
|
||||
if oc == "yes":
|
||||
yes_id = str(tokens[i])
|
||||
elif oc == "no":
|
||||
no_id = str(tokens[i])
|
||||
if yes_id is None and len(tokens) >= 1:
|
||||
yes_id = str(tokens[0])
|
||||
if no_id is None and len(tokens) >= 2:
|
||||
no_id = str(tokens[1])
|
||||
desc = (m.get("description") or "").strip()
|
||||
if len(desc) > 420:
|
||||
desc = desc[:419].rstrip() + "…"
|
||||
return {
|
||||
"slug": slug,
|
||||
"question": m.get("question"),
|
||||
"description": desc,
|
||||
"image": m.get("image") or m.get("icon"),
|
||||
"icon": m.get("icon") or m.get("image"),
|
||||
"yes_id": yes_id,
|
||||
"no_id": no_id,
|
||||
"volume": float(m.get("volumeNum") or 0) or 0.0,
|
||||
"end_date": (m.get("endDate") or "")[:10] or None,
|
||||
"closed": bool(m.get("closed")),
|
||||
"active": bool(m.get("active")),
|
||||
}
|
||||
|
||||
|
||||
def _best_ask(book):
|
||||
"""Lowest-price ask level -> (price, size). Order-agnostic."""
|
||||
best = None
|
||||
for lvl in (book or {}).get("asks") or []:
|
||||
try:
|
||||
p = float(lvl["price"])
|
||||
s = float(lvl["size"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
continue
|
||||
if best is None or p < best[0]:
|
||||
best = (p, s)
|
||||
return best
|
||||
|
||||
|
||||
def _best_bid(book):
|
||||
"""Highest-price bid level -> price (what you could sell into now)."""
|
||||
best = None
|
||||
for lvl in (book or {}).get("bids") or []:
|
||||
try:
|
||||
p = float(lvl["price"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
continue
|
||||
if best is None or p > best:
|
||||
best = p
|
||||
return best
|
||||
|
||||
|
||||
def fetch_token_bids(token_ids):
|
||||
"""Batched CLOB books -> {token_id: best_bid_price}. For valuing held
|
||||
positions at the executable exit price (not last/mid)."""
|
||||
ids = [str(t) for t in dict.fromkeys(token_ids) if t]
|
||||
out = {}
|
||||
for i in range(0, len(ids), 100):
|
||||
chunk = ids[i:i + 100]
|
||||
try:
|
||||
resp = post_json(CLOB_BOOKS, [{"token_id": t} for t in chunk])
|
||||
except FetchError:
|
||||
continue
|
||||
for b in resp or []:
|
||||
out[str(b.get("asset_id"))] = _best_bid(b)
|
||||
return out
|
||||
|
||||
|
||||
def _fetch_metas(uniq):
|
||||
"""Batched Gamma fetch. Returns {slug: parsed_meta} (missing slugs absent)."""
|
||||
metas = {}
|
||||
for i in range(0, len(uniq), _SLUG_CHUNK):
|
||||
chunk = uniq[i:i + _SLUG_CHUNK]
|
||||
url = "%s?limit=%d&%s" % (GAMMA, len(chunk) + 5,
|
||||
"&".join("slug=%s" % s for s in chunk))
|
||||
try:
|
||||
for m in get_json(url) or []:
|
||||
if m.get("slug"):
|
||||
metas[m["slug"]] = _parse_meta(m)
|
||||
except FetchError:
|
||||
continue
|
||||
return metas
|
||||
|
||||
|
||||
def fetch_quotes(slugs):
|
||||
"""slugs: iterable. Returns {slug: quote|None} with executable YES/NO asks."""
|
||||
uniq = sorted({s for s in slugs if s})
|
||||
metas = _fetch_metas(uniq)
|
||||
|
||||
token_ids = []
|
||||
for v in metas.values():
|
||||
for tid in (v["yes_id"], v["no_id"]):
|
||||
if tid:
|
||||
token_ids.append(tid)
|
||||
|
||||
books = {}
|
||||
if token_ids:
|
||||
try:
|
||||
resp = post_json(CLOB_BOOKS, [{"token_id": t} for t in token_ids])
|
||||
for b in resp or []:
|
||||
books[str(b.get("asset_id"))] = b
|
||||
except FetchError:
|
||||
books = {}
|
||||
|
||||
out = {}
|
||||
for slug in uniq:
|
||||
v = metas.get(slug)
|
||||
if v is None:
|
||||
out[slug] = None
|
||||
continue
|
||||
ya = _best_ask(books.get(v["yes_id"] or ""))
|
||||
na = _best_ask(books.get(v["no_id"] or ""))
|
||||
out[slug] = {
|
||||
"slug": slug,
|
||||
"question": v["question"],
|
||||
"description": v["description"],
|
||||
"image": v["image"],
|
||||
"icon": v["icon"],
|
||||
"yes_ask": ya[0] if ya else None,
|
||||
"yes_ask_size": ya[1] if ya else None,
|
||||
"no_ask": na[0] if na else None,
|
||||
"no_ask_size": na[1] if na else None,
|
||||
"volume": v["volume"],
|
||||
"end_date": v["end_date"],
|
||||
"closed": v["closed"],
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Live positions from Polymarket (public, by wallet) and Kalshi (signed).
|
||||
|
||||
Polymarket is on-chain: the public Data API returns holdings + P&L given
|
||||
just the proxy-wallet address — no secret.
|
||||
|
||||
Kalshi requires an authed call. We sign exactly per Kalshi's spec
|
||||
(RSA-PSS / SHA-256 over "{ts}{METHOD}{path}") but shell out to the system
|
||||
`openssl` so no crypto dependency and the private key never leaves disk.
|
||||
Read-only — no order endpoints are ever called.
|
||||
|
||||
Credentials come from data/secrets.json (git-ignored), supplied by the user.
|
||||
Missing/!configured venues degrade gracefully with setup guidance.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
from .net import get_json, parallel, FetchError
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SECRETS = os.path.join(ROOT, "data", "secrets.json")
|
||||
PAIRS = os.path.join(ROOT, "data", "pairs.json")
|
||||
|
||||
POLY_DATA = "https://data-api.polymarket.com"
|
||||
CLOB_MKT = "https://clob.polymarket.com/markets/"
|
||||
KALSHI = "https://api.elections.kalshi.com"
|
||||
KALSHI_POS_PATH = "/trade-api/v2/portfolio/positions"
|
||||
|
||||
_ASSETS = ("BTC", "ETH", "SOL", "XRP", "DOGE", "BNB", "HYPE", "ZEC", "LTC",
|
||||
"ADA", "AVAX", "LINK", "SHIB", "XLM", "SUI", "TRX")
|
||||
|
||||
|
||||
def _asset_from_ticker(t):
|
||||
"""KXXRPMAXMON-XRP-26MAY31-180 -> XRP ; KXHYPED-26MAY0117-T31 -> HYPE."""
|
||||
if not t:
|
||||
return None
|
||||
parts = t.split("-")
|
||||
if len(parts) > 1 and parts[1].isalpha() and 2 <= len(parts[1]) <= 5:
|
||||
return parts[1].upper()
|
||||
head = parts[0].upper()
|
||||
for a in _ASSETS:
|
||||
if a in head:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def load_secrets():
|
||||
try:
|
||||
with open(SECRETS) as f:
|
||||
s = json.load(f)
|
||||
return s if isinstance(s, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Polymarket
|
||||
def _poly(wallet):
|
||||
base = "%s/positions?user=%s&sizeThreshold=0.1&limit=500&sortBy=CURRENT&sortDirection=DESC" % (
|
||||
POLY_DATA, wallet)
|
||||
rows = get_json(base)
|
||||
raw = [p for p in (rows or []) if abs(float(p.get("size") or 0)) >= 1e-9]
|
||||
|
||||
# Value each holding at the current CLOB best BID (executable exit price),
|
||||
# not the data-API curPrice (last/mid). Fall back to the API fields only
|
||||
# if a token has no live bid (illiquid / resolved).
|
||||
from . import poly
|
||||
bids = {}
|
||||
try:
|
||||
bids = poly.fetch_token_bids([p.get("asset") for p in raw])
|
||||
except Exception:
|
||||
bids = {}
|
||||
|
||||
out = []
|
||||
for p in raw:
|
||||
size = float(p.get("size") or 0)
|
||||
cost = p.get("initialValue")
|
||||
bid = bids.get(str(p.get("asset")))
|
||||
if bid is not None:
|
||||
cur_price = bid
|
||||
value = size * bid
|
||||
pnl = (value - cost) if cost is not None else None
|
||||
pnl_pct = (pnl / cost) if (pnl is not None and cost) else None
|
||||
else: # no live bid -> API fallback
|
||||
cur_price = p.get("curPrice")
|
||||
value = p.get("currentValue")
|
||||
pnl = p.get("cashPnl")
|
||||
pnl_pct = (p.get("percentPnl") / 100.0
|
||||
if p.get("percentPnl") is not None else None)
|
||||
out.append({
|
||||
"venue": "Polymarket",
|
||||
"market": p.get("title"),
|
||||
"ref": p.get("slug"),
|
||||
"side": p.get("outcome"),
|
||||
"size": size,
|
||||
"avg_price": p.get("avgPrice"),
|
||||
"cur_price": cur_price,
|
||||
"cost": cost,
|
||||
"value": value,
|
||||
"pnl": pnl,
|
||||
"pnl_pct": pnl_pct,
|
||||
"realized_pnl": p.get("realizedPnl"),
|
||||
"icon": p.get("icon"),
|
||||
"end_date": (p.get("endDate") or "")[:10] or None,
|
||||
"redeemable": bool(p.get("redeemable")),
|
||||
"asset_id": p.get("asset"),
|
||||
"condition_id": p.get("conditionId"),
|
||||
})
|
||||
# Portfolio value coherent with bid-based marks; API /value as fallback.
|
||||
total = sum((p["value"] or 0) for p in out) if out else None
|
||||
if total is None:
|
||||
try:
|
||||
v = get_json("%s/value?user=%s" % (POLY_DATA, wallet))
|
||||
if isinstance(v, list) and v:
|
||||
total = v[0].get("value")
|
||||
except FetchError:
|
||||
pass
|
||||
return {"configured": True, "positions": out, "total_value": total}
|
||||
|
||||
|
||||
def _clob_resolution(cid):
|
||||
"""CLOB market by conditionId -> (closed, winning_outcome|None)."""
|
||||
m = get_json(CLOB_MKT + cid)
|
||||
if not isinstance(m, dict) or not m.get("closed"):
|
||||
return (False, None)
|
||||
win = None
|
||||
for tk in m.get("tokens") or []:
|
||||
if tk.get("winner"):
|
||||
win = tk.get("outcome")
|
||||
return (True, win)
|
||||
|
||||
|
||||
def _poly_history(wallet, open_cids):
|
||||
"""Reconstruct realized P&L for resolved markets from the public activity
|
||||
ledger. Trade cash is exact; the redemption payout is derived as
|
||||
(net contracts held on CLOB's winning outcome) x $1."""
|
||||
acts = []
|
||||
for off in range(0, 4000, 500): # paginate, bounded
|
||||
try:
|
||||
page = get_json("%s/activity?user=%s&limit=500&offset=%d"
|
||||
% (POLY_DATA, wallet, off))
|
||||
except FetchError:
|
||||
break
|
||||
if not page:
|
||||
break
|
||||
acts.extend(page)
|
||||
if len(page) < 500:
|
||||
break
|
||||
|
||||
mk = {}
|
||||
for a in acts:
|
||||
if a.get("type") != "TRADE":
|
||||
continue
|
||||
cid = a.get("conditionId")
|
||||
if not cid or cid in open_cids:
|
||||
continue
|
||||
g = mk.setdefault(cid, {
|
||||
"title": a.get("title"), "slug": a.get("slug"),
|
||||
"icon": a.get("icon"), "net": {}, "buy": 0.0, "sell": 0.0,
|
||||
"ts": a.get("timestamp")})
|
||||
oc = a.get("outcome")
|
||||
sz = float(a.get("size") or 0)
|
||||
usd = float(a.get("usdcSize") or 0)
|
||||
if a.get("side") == "BUY":
|
||||
g["net"][oc] = g["net"].get(oc, 0.0) + sz
|
||||
g["buy"] += usd
|
||||
else:
|
||||
g["net"][oc] = g["net"].get(oc, 0.0) - sz
|
||||
g["sell"] += usd
|
||||
g["ts"] = max(g["ts"] or 0, a.get("timestamp") or 0)
|
||||
|
||||
res = parallel(_clob_resolution, list(mk), workers=12)
|
||||
rows = []
|
||||
for cid, g in mk.items():
|
||||
r = res.get(cid)
|
||||
if isinstance(r, FetchError) or not r or not r[0]:
|
||||
continue # unresolved -> not history
|
||||
winner = r[1]
|
||||
held = max(0.0, g["net"].get(winner, 0.0)) if winner else 0.0
|
||||
payout = held # winning shares pay $1
|
||||
trade_cash = g["sell"] - g["buy"]
|
||||
realized = trade_cash + payout
|
||||
side = max(g["net"].items(), key=lambda kv: abs(kv[1]))[0] \
|
||||
if g["net"] else None
|
||||
import datetime
|
||||
sd = (datetime.datetime.utcfromtimestamp(g["ts"]).date().isoformat()
|
||||
if g.get("ts") else None)
|
||||
rows.append({
|
||||
"venue": "Polymarket",
|
||||
"market": g["title"],
|
||||
"ref": g["slug"],
|
||||
"result": (winner or "—"),
|
||||
"side": side,
|
||||
"size": abs(g["net"].get(side, 0.0)) if side else 0.0,
|
||||
"cost": g["buy"],
|
||||
"payout": payout + max(0.0, g["sell"]),
|
||||
"realized": realized,
|
||||
"settled_date": sd,
|
||||
"icon": g["icon"],
|
||||
"derived": True,
|
||||
})
|
||||
rows.sort(key=lambda x: x["settled_date"] or "", reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- Kalshi
|
||||
def _sign(message, key_path):
|
||||
"""RSA-PSS / SHA-256, salt = digest length — Kalshi's scheme, via openssl."""
|
||||
p = subprocess.run(
|
||||
["openssl", "dgst", "-sha256", "-sign", key_path,
|
||||
"-sigopt", "rsa_padding_mode:pss",
|
||||
"-sigopt", "rsa_pss_saltlen:-1"],
|
||||
input=message.encode(), capture_output=True)
|
||||
if p.returncode != 0:
|
||||
raise FetchError("openssl sign failed: %s"
|
||||
% p.stderr.decode()[:160].strip())
|
||||
return base64.b64encode(p.stdout).decode()
|
||||
|
||||
|
||||
def _kalshi(key_id, key_path):
|
||||
if not key_id or not key_path:
|
||||
return {"configured": False, "reason": "no_credentials"}
|
||||
if not os.path.isfile(os.path.expanduser(key_path)):
|
||||
return {"configured": False, "reason": "key_file_not_found"}
|
||||
key_path = os.path.expanduser(key_path)
|
||||
|
||||
def signed_get(path):
|
||||
ts = str(int(time.time() * 1000))
|
||||
sig = _sign(ts + "GET" + path.split("?")[0], key_path)
|
||||
req = urllib.request.Request(KALSHI + path, headers={
|
||||
"KALSHI-ACCESS-KEY": key_id,
|
||||
"KALSHI-ACCESS-TIMESTAMP": ts,
|
||||
"KALSHI-ACCESS-SIGNATURE": sig,
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "prediction-market-arb",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
try:
|
||||
data = signed_get(KALSHI_POS_PATH + "?limit=500&count_filter=position")
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"configured": True, "error": "Kalshi HTTP %s — check key ID / "
|
||||
"private key / permissions." % e.code, "positions": []}
|
||||
except Exception as e:
|
||||
return {"configured": True,
|
||||
"error": "%s" % type(e).__name__, "positions": []}
|
||||
|
||||
mp = data.get("market_positions") or []
|
||||
tickers = [m.get("ticker") for m in mp if m.get("ticker")]
|
||||
marks = {}
|
||||
if tickers:
|
||||
try:
|
||||
from . import kalshi
|
||||
marks = kalshi.fetch_quotes(tickers)
|
||||
except Exception:
|
||||
marks = {}
|
||||
|
||||
def num(m, base):
|
||||
"""Kalshi returns "{base}_dollars" as a string; fall back to int cents."""
|
||||
d = m.get(base + "_dollars")
|
||||
if d is not None:
|
||||
try:
|
||||
return float(d)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
v = m.get(base)
|
||||
return (v / 100.0) if isinstance(v, (int, float)) else 0.0
|
||||
|
||||
out = []
|
||||
for m in mp:
|
||||
pos = 0.0
|
||||
for f in ("position_fp", "position"): # fp = fractional position
|
||||
try:
|
||||
pos = float(m.get(f))
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if abs(pos) < 1e-9:
|
||||
continue
|
||||
side = "Yes" if pos > 0 else "No" # negative == net No
|
||||
size = abs(pos)
|
||||
q = marks.get(m.get("ticker")) or {}
|
||||
mark = q.get("yes_bid") if pos > 0 else q.get("no_bid")
|
||||
# Cost basis of the CURRENTLY held position = market_exposure.
|
||||
# total_traded is lifetime traded volume (inflated by any round-trips)
|
||||
# and must NOT be used as cost — it shows phantom losses.
|
||||
cost = num(m, "market_exposure") or num(m, "total_traded")
|
||||
value = (size * mark) if mark is not None else cost
|
||||
out.append({
|
||||
"venue": "Kalshi",
|
||||
"market": q.get("title") or m.get("ticker"),
|
||||
"ref": m.get("ticker"),
|
||||
"side": side,
|
||||
"size": size,
|
||||
"avg_price": (cost / size) if size else None,
|
||||
"cur_price": mark,
|
||||
"cost": cost or None,
|
||||
"value": value,
|
||||
"pnl": (value - cost) if cost else None,
|
||||
"pnl_pct": ((value - cost) / cost) if cost else None,
|
||||
"realized_pnl": num(m, "realized_pnl"),
|
||||
"fees": num(m, "fees_paid"),
|
||||
"icon": None,
|
||||
"asset": _asset_from_ticker(m.get("ticker")),
|
||||
"end_date": q.get("expiry") or None,
|
||||
"redeemable": q.get("status") in ("settled", "finalized"),
|
||||
"asset_id": m.get("ticker"),
|
||||
})
|
||||
|
||||
total_value = None
|
||||
try:
|
||||
pv = signed_get("/trade-api/v2/portfolio/balance").get(
|
||||
"portfolio_value")
|
||||
if isinstance(pv, (int, float)):
|
||||
total_value = pv / 100.0 # cents -> dollars
|
||||
except Exception:
|
||||
pass
|
||||
if total_value is None:
|
||||
total_value = sum(p["value"] or 0 for p in out)
|
||||
|
||||
# Settled (expired) markets — exact realized P&L straight from Kalshi.
|
||||
settled, cursor = [], ""
|
||||
for _ in range(15): # cursor-page guard
|
||||
try:
|
||||
sd = signed_get("/trade-api/v2/portfolio/settlements?limit=200"
|
||||
+ ("&cursor=" + cursor if cursor else ""))
|
||||
except Exception:
|
||||
break
|
||||
for s in sd.get("settlements", []):
|
||||
yc = float(s.get("yes_total_cost_dollars") or 0)
|
||||
nc = float(s.get("no_total_cost_dollars") or 0)
|
||||
fee = float(s.get("fee_cost") or 0)
|
||||
yct = float(s.get("yes_count_fp") or 0)
|
||||
nct = float(s.get("no_count_fp") or 0)
|
||||
result = (s.get("market_result") or "").lower()
|
||||
# Kalshi's `revenue`/`value` are 0 when both sides were held to
|
||||
# expiry — NOT the payout. Settled contracts pay $1 each on the
|
||||
# winning side, so derive payout from market_result + counts.
|
||||
if result == "yes":
|
||||
payout = yct
|
||||
elif result == "no":
|
||||
payout = nct
|
||||
else: # void/refund -> fall back
|
||||
payout = (s.get("revenue") or 0) / 100.0
|
||||
cost = yc + nc + fee
|
||||
side = "Yes" if yct >= nct else "No"
|
||||
settled.append({
|
||||
"venue": "Kalshi",
|
||||
"market": s.get("ticker"),
|
||||
"ref": s.get("ticker"),
|
||||
"result": (result.upper() or "—"),
|
||||
"side": side,
|
||||
"size": max(yct, nct),
|
||||
"cost": cost,
|
||||
"payout": payout,
|
||||
"realized": payout - cost,
|
||||
"settled_date": (s.get("settled_time") or "")[:10] or None,
|
||||
"asset": _asset_from_ticker(s.get("ticker")),
|
||||
"derived": False,
|
||||
})
|
||||
cursor = sd.get("cursor") or ""
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
return {"configured": True, "positions": out,
|
||||
"total_value": total_value, "settlements": settled}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- paired view
|
||||
def _load_pairs():
|
||||
try:
|
||||
with open(PAIRS) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _paired(poly_pos, kalshi_pos):
|
||||
pairs = _load_pairs()
|
||||
by_slug, by_tkr = {}, {}
|
||||
for pr in pairs:
|
||||
if pr.get("poly_slug"):
|
||||
by_slug.setdefault(pr["poly_slug"], pr)
|
||||
if pr.get("kalshi_ticker"):
|
||||
by_tkr[pr["kalshi_ticker"]] = pr
|
||||
|
||||
groups = {}
|
||||
|
||||
def key(pr):
|
||||
return "%s|%s|%s" % (pr.get("asset"), pr.get("kalshi_ticker"),
|
||||
pr.get("poly_slug"))
|
||||
|
||||
for p in poly_pos:
|
||||
pr = by_slug.get(p["ref"])
|
||||
if pr:
|
||||
groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []})
|
||||
groups[key(pr)]["poly"].append(p)
|
||||
for p in kalshi_pos:
|
||||
pr = by_tkr.get(p["ref"])
|
||||
if pr:
|
||||
groups.setdefault(key(pr), {"pair": pr, "poly": [], "kalshi": []})
|
||||
groups[key(pr)]["kalshi"].append(p)
|
||||
|
||||
rows = []
|
||||
for g in groups.values():
|
||||
legs = g["poly"] + g["kalshi"]
|
||||
cost = sum((x["cost"] or 0) for x in legs)
|
||||
value = sum((x["value"] or 0) for x in legs)
|
||||
pnl = sum((x["pnl"] or 0) for x in legs if x["pnl"] is not None)
|
||||
rows.append({
|
||||
"asset": g["pair"].get("asset"),
|
||||
"kalshi_ticker": g["pair"].get("kalshi_ticker"),
|
||||
"poly_slug": g["pair"].get("poly_slug"),
|
||||
"kalshi_strike": g["pair"].get("kalshi_strike"),
|
||||
"poly_strike": g["pair"].get("poly_strike"),
|
||||
"legs": legs,
|
||||
"cost": cost,
|
||||
"value": value,
|
||||
"pnl": pnl,
|
||||
"pnl_pct": (pnl / cost if cost else None),
|
||||
"complete": bool(g["poly"] and g["kalshi"]),
|
||||
})
|
||||
rows.sort(key=lambda r: r["pnl"], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def run_positions():
|
||||
s = load_secrets()
|
||||
wallet = (s.get("polymarket_wallet") or "").strip()
|
||||
poly = ({"configured": False, "reason": "no_wallet"}
|
||||
if not wallet or wallet.startswith("0xYOUR")
|
||||
else _safe(_poly, wallet))
|
||||
kalshi = _safe(_kalshi, s.get("kalshi_key_id"),
|
||||
s.get("kalshi_private_key_path"))
|
||||
|
||||
pp = poly.get("positions", []) if poly.get("configured") else []
|
||||
kp = kalshi.get("positions", []) if kalshi.get("configured") else []
|
||||
paired = _paired(pp, kp)
|
||||
|
||||
# History (expired/settled): Kalshi exact; Polymarket reconstructed.
|
||||
k_hist = kalshi.get("settlements", []) if kalshi.get("configured") else []
|
||||
p_hist = []
|
||||
if poly.get("configured"):
|
||||
open_cids = {p.get("condition_id") for p in pp if p.get("condition_id")}
|
||||
p_hist = _safe(_poly_history, wallet, open_cids)
|
||||
if isinstance(p_hist, dict): # _safe wrapped an error
|
||||
p_hist = []
|
||||
|
||||
def total_pnl(lst):
|
||||
return sum((p.get("pnl") or 0) for p in lst)
|
||||
|
||||
def total_cost(lst):
|
||||
return sum((p.get("cost") or 0) for p in lst)
|
||||
|
||||
def total_realized(lst):
|
||||
return sum((p.get("realized") or 0) for p in lst)
|
||||
|
||||
poly_pnl = total_pnl(pp) if poly.get("configured") else None
|
||||
kalshi_pnl = total_pnl(kp) if kalshi.get("configured") else None
|
||||
grand_pnl = (None if poly_pnl is None and kalshi_pnl is None
|
||||
else (poly_pnl or 0) + (kalshi_pnl or 0))
|
||||
all_cost = total_cost(pp) + total_cost(kp)
|
||||
total_return = ((grand_pnl / all_cost)
|
||||
if (grand_pnl is not None and all_cost) else None)
|
||||
|
||||
kalshi_realized = total_realized(k_hist) if kalshi.get("configured") else None
|
||||
poly_realized = total_realized(p_hist) if poly.get("configured") else None
|
||||
grand_realized = (None if kalshi_realized is None and poly_realized is None
|
||||
else (kalshi_realized or 0) + (poly_realized or 0))
|
||||
|
||||
return {
|
||||
"polymarket": poly,
|
||||
"kalshi": kalshi,
|
||||
"paired": paired,
|
||||
"history": {"kalshi": k_hist, "polymarket": p_hist},
|
||||
"totals": {
|
||||
"poly_value": poly.get("total_value"),
|
||||
"kalshi_value": kalshi.get("total_value"),
|
||||
"poly_pnl": poly_pnl,
|
||||
"kalshi_pnl": kalshi_pnl,
|
||||
"total_pnl": grand_pnl,
|
||||
"total_return": total_return,
|
||||
"kalshi_realized": kalshi_realized,
|
||||
"poly_realized": poly_realized,
|
||||
"total_realized": grand_realized,
|
||||
"open_positions": len(pp) + len(kp),
|
||||
"settled_count": len(k_hist) + len(p_hist),
|
||||
"paired_count": sum(1 for r in paired if r["complete"]),
|
||||
},
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
}
|
||||
|
||||
|
||||
def _safe(fn, *a):
|
||||
try:
|
||||
return fn(*a)
|
||||
except FetchError as e:
|
||||
return {"configured": True, "error": str(e), "positions": []}
|
||||
except Exception as e:
|
||||
return {"configured": True,
|
||||
"error": "%s: %s" % (type(e).__name__, e), "positions": []}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
"""Reference-price client for the hourly scanner.
|
||||
|
||||
Polymarket "Up or Down" resolves on the Binance {SYM}/USDT 1-hour candle
|
||||
(Up if close >= open). So the *implied strike* for the hour == the Binance
|
||||
candle OPEN. Kalshi hourly settles on CF Benchmarks (BRTI etc.), whose index
|
||||
is built from Coinbase/Kraken/Bitstamp/LMAX. The Binance-vs-Coinbase spot gap
|
||||
is therefore a live proxy for the unhedgeable settlement-feed basis risk.
|
||||
|
||||
data-api.binance.vision -> Binance public mirror (api.binance.com is 451
|
||||
geo-blocked from here). Gives 1h-candle open +
|
||||
spot for the implied strike & Binance side.
|
||||
api.coinbase.com -> CF-Benchmarks-side spot proxy.
|
||||
"""
|
||||
from .net import get_json, parallel, FetchError
|
||||
|
||||
BINANCE = "https://data-api.binance.vision/api/v3"
|
||||
COINBASE = "https://api.coinbase.com/v2/prices/%s-USD/spot"
|
||||
|
||||
# asset -> (binance symbol, coinbase code or None if not listed there)
|
||||
ASSETS = {
|
||||
"BTC": ("BTCUSDT", "BTC"),
|
||||
"ETH": ("ETHUSDT", "ETH"),
|
||||
"SOL": ("SOLUSDT", "SOL"),
|
||||
"XRP": ("XRPUSDT", "XRP"),
|
||||
"DOGE": ("DOGEUSDT", "DOGE"),
|
||||
"BNB": ("BNBUSDT", None), # not on Coinbase -> no CF-side proxy
|
||||
}
|
||||
|
||||
|
||||
def _one(asset):
|
||||
bsym, cb = ASSETS[asset]
|
||||
k = get_json("%s/klines?symbol=%s&interval=1h&limit=1" % (BINANCE, bsym))
|
||||
row = k[0]
|
||||
open_px = float(row[1])
|
||||
open_ms = int(row[0])
|
||||
binance_spot = float(get_json(
|
||||
"%s/ticker/price?symbol=%s" % (BINANCE, bsym))["price"])
|
||||
cf_spot = None
|
||||
if cb:
|
||||
try:
|
||||
cf_spot = float(get_json(COINBASE % cb)["data"]["amount"])
|
||||
except (FetchError, KeyError, ValueError, TypeError):
|
||||
cf_spot = None
|
||||
div = None
|
||||
if cf_spot:
|
||||
div = (binance_spot - cf_spot) / cf_spot
|
||||
return {
|
||||
"asset": asset,
|
||||
"hour_open": open_px, # implied Polymarket strike for the hour
|
||||
"hour_open_ms": open_ms, # UTC ms of the candle open (window start)
|
||||
"binance_spot": binance_spot,
|
||||
"cf_spot": cf_spot,
|
||||
"divergence": div, # (binance - coinbase) / coinbase
|
||||
}
|
||||
|
||||
|
||||
def fetch_refs(assets):
|
||||
"""assets: iterable of symbols. Returns {asset: ref|None}."""
|
||||
want = [a for a in assets if a in ASSETS]
|
||||
res = parallel(_one, want, workers=8)
|
||||
return {a: (None if isinstance(v, FetchError) else v)
|
||||
for a, v in res.items()}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
"""Load seed data, fan out the live API calls, evaluate every active pair."""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from . import kalshi, poly
|
||||
from .calc import evaluate
|
||||
|
||||
_DATA = os.path.join(os.path.dirname(__file__), "..", "data")
|
||||
_LOCK = threading.Lock()
|
||||
_CACHE = {"ts": 0.0, "payload": None}
|
||||
_HCACHE = {"ts": 0.0, "payload": None}
|
||||
_HLOCK = threading.Lock()
|
||||
_PCACHE = {"ts": 0.0, "payload": None}
|
||||
_PLOCK = threading.Lock()
|
||||
_POS_TTL = 12
|
||||
_CACHE_TTL = 4 # seconds; just enough to dedupe rapid refreshes
|
||||
|
||||
|
||||
def _path(name):
|
||||
return os.path.abspath(os.path.join(_DATA, name))
|
||||
|
||||
|
||||
def load_settings():
|
||||
with open(_path("settings.json")) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_pairs():
|
||||
with open(_path("pairs.json")) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def run_scan(force=False):
|
||||
"""Returns {rows, summary, generated_at, settings}. Cached for _CACHE_TTL."""
|
||||
with _LOCK:
|
||||
now = time.time()
|
||||
if (not force) and _CACHE["payload"] and (now - _CACHE["ts"] < _CACHE_TTL):
|
||||
return _CACHE["payload"]
|
||||
|
||||
settings = load_settings()
|
||||
pairs = load_pairs()
|
||||
active = [p for p in pairs if p.get("active")]
|
||||
|
||||
tickers = [p["kalshi_ticker"] for p in active if p.get("kalshi_ticker")]
|
||||
slugs = [p["poly_slug"] for p in active if p.get("poly_slug")]
|
||||
|
||||
t0 = time.time()
|
||||
# Hit both venues at the same time instead of one after the other.
|
||||
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||
fk = ex.submit(kalshi.fetch_quotes, tickers)
|
||||
fp = ex.submit(poly.fetch_quotes, slugs)
|
||||
kquotes = fk.result()
|
||||
pquotes = fp.result()
|
||||
|
||||
def _eval():
|
||||
return [
|
||||
evaluate(p, kquotes.get(p["kalshi_ticker"]),
|
||||
pquotes.get(p.get("poly_slug")), settings)
|
||||
for p in active
|
||||
]
|
||||
|
||||
rows = _eval()
|
||||
|
||||
# Phase 2: for basis-favorable, near/above-breakeven rows, replace the
|
||||
# open-interest proxy with true Kalshi top-of-book size, then re-eval.
|
||||
cand_tickers = [
|
||||
r["kalshi_ticker"] for r in rows
|
||||
if r.get("basis_favorable")
|
||||
and r.get("worst_pnl") is not None
|
||||
and r.get("combined_cost")
|
||||
and r["worst_pnl"] > -0.05 * r["combined_cost"]
|
||||
]
|
||||
if cand_tickers:
|
||||
for tk, sz in kalshi.fetch_sizes(cand_tickers).items():
|
||||
if sz and kquotes.get(tk):
|
||||
kquotes[tk]["yes_ask_size"] = sz["yes_ask_size"]
|
||||
kquotes[tk]["no_ask_size"] = sz["no_ask_size"]
|
||||
rows = _eval()
|
||||
|
||||
fetch_ms = int((time.time() - t0) * 1000)
|
||||
|
||||
summary = {"total": len(rows), "fetch_ms": fetch_ms}
|
||||
for st in ("ARB", "NO ARB", "BAD BASIS", "LOW SIZE",
|
||||
"NO DATA", "NO PAIR"):
|
||||
summary[st] = sum(1 for r in rows if r["status"] == st)
|
||||
|
||||
payload = {
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
"settings": settings,
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime()),
|
||||
}
|
||||
_CACHE["ts"] = now
|
||||
_CACHE["payload"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
def run_hourly(force=False):
|
||||
"""Kalshi hourly ladder vs Polymarket Up/Down. Cached for _CACHE_TTL."""
|
||||
from . import hourly
|
||||
with _HLOCK:
|
||||
now = time.time()
|
||||
if (not force) and _HCACHE["payload"] and \
|
||||
(now - _HCACHE["ts"] < _CACHE_TTL):
|
||||
return _HCACHE["payload"]
|
||||
|
||||
settings = load_settings()
|
||||
t0 = time.time()
|
||||
rows = hourly.run(settings)
|
||||
fetch_ms = int((time.time() - t0) * 1000)
|
||||
|
||||
summary = {"total": len(rows), "fetch_ms": fetch_ms}
|
||||
for st in ("ARB", "NO ARB", "BAD BASIS", "LOW SIZE",
|
||||
"NO DATA", "NO KALSHI"):
|
||||
summary[st] = sum(1 for r in rows if r["status"] == st)
|
||||
live = [r for r in rows if r.get("divergence") is not None]
|
||||
summary["max_divergence"] = (
|
||||
max(abs(r["divergence"]) for r in live) if live else None)
|
||||
|
||||
payload = {
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
"settings": settings,
|
||||
"mode": "hourly",
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S",
|
||||
time.localtime()),
|
||||
}
|
||||
_HCACHE["ts"] = now
|
||||
_HCACHE["payload"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
def run_positions(force=False):
|
||||
"""Live Polymarket + Kalshi positions. Cached for _POS_TTL."""
|
||||
from . import positions
|
||||
with _PLOCK:
|
||||
now = time.time()
|
||||
if (not force) and _PCACHE["payload"] and \
|
||||
(now - _PCACHE["ts"] < _POS_TTL):
|
||||
return _PCACHE["payload"]
|
||||
payload = positions.run_positions()
|
||||
_PCACHE["ts"] = now
|
||||
_PCACHE["payload"] = payload
|
||||
return payload
|
||||
Reference in New Issue
Block a user