mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37: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:
@@ -0,0 +1,48 @@
|
||||
name: arb-alerts
|
||||
|
||||
# Runs the monthly-arb scanner every ~5 min on GitHub's runners and texts
|
||||
# you (via your Apps Script SMS webhook) when a NEW arb opportunity appears.
|
||||
# State (which arbs were already-alerted) lives in alerts_state.json and is
|
||||
# committed back by the workflow itself so cron runs remember each other.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *" # 5-min cadence (GH min; expect ~5-15m lag)
|
||||
workflow_dispatch: {} # manual "Run workflow" button in the UI
|
||||
|
||||
permissions:
|
||||
contents: write # needed to commit alerts_state.json back
|
||||
|
||||
concurrency:
|
||||
group: arb-alerts
|
||||
cancel-in-progress: false # let an in-flight run finish before the next
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run alert poller
|
||||
env:
|
||||
SMS_WEBHOOK: ${{ secrets.SMS_WEBHOOK }}
|
||||
SMS_PHONE: ${{ secrets.SMS_PHONE }}
|
||||
SMS_CARRIER: ${{ secrets.SMS_CARRIER }}
|
||||
run: python3 scripts/gha_alerts.py
|
||||
|
||||
- name: Persist state if changed
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain alerts_state.json)" ]; then
|
||||
echo "no state change"
|
||||
exit 0
|
||||
fi
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
git config user.name "arb-alerts"
|
||||
git add alerts_state.json
|
||||
git commit -m "alerts: update state [skip ci]"
|
||||
git push
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Secrets — never commit. Provide your own data/secrets.json locally.
|
||||
data/secrets.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local preview staging
|
||||
.arb-preview/
|
||||
|
||||
# Local Claude Code config (launch.json, settings.local.json, worktrees, etc.)
|
||||
.claude/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
+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
|
||||
+946
@@ -0,0 +1,946 @@
|
||||
[
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-6250",
|
||||
"kalshi_strike": 62.5,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-6000",
|
||||
"kalshi_strike": 60.0,
|
||||
"poly_slug": "will-hyperliquid-reach-52-in-may",
|
||||
"poly_strike": 52.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5750",
|
||||
"kalshi_strike": 57.5,
|
||||
"poly_slug": "will-hyperliquid-reach-52-in-may",
|
||||
"poly_strike": 52.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5500",
|
||||
"kalshi_strike": 55.0,
|
||||
"poly_slug": "will-hyperliquid-reach-52-in-may",
|
||||
"poly_strike": 52.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5250",
|
||||
"kalshi_strike": 52.5,
|
||||
"poly_slug": "will-hyperliquid-reach-52-in-may",
|
||||
"poly_strike": 52.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-5000",
|
||||
"kalshi_strike": 50.0,
|
||||
"poly_slug": "will-hyperliquid-reach-52-in-may",
|
||||
"poly_strike": 52.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-4750",
|
||||
"kalshi_strike": 47.5,
|
||||
"poly_slug": "will-hyperliquid-reach-48-in-may",
|
||||
"poly_strike": 48.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMAXMON-HYPE-26MAY31-4500",
|
||||
"kalshi_strike": 45.0,
|
||||
"poly_slug": "will-hyperliquid-reach-44-in-may",
|
||||
"poly_strike": 44.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-4000",
|
||||
"kalshi_strike": 40.0,
|
||||
"poly_slug": "will-hyperliquid-dip-to-38-in-may",
|
||||
"poly_strike": 38.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3750",
|
||||
"kalshi_strike": 37.5,
|
||||
"poly_slug": "will-hyperliquid-dip-to-38-in-may",
|
||||
"poly_strike": 38.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3500",
|
||||
"kalshi_strike": 35.0,
|
||||
"poly_slug": "will-hyperliquid-dip-to-38-in-may",
|
||||
"poly_strike": 38.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3250",
|
||||
"kalshi_strike": 32.5,
|
||||
"poly_slug": "will-hyperliquid-dip-to-32-in-may",
|
||||
"poly_strike": 32.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-3000",
|
||||
"kalshi_strike": 30.0,
|
||||
"poly_slug": "will-hyperliquid-dip-to-32-in-may",
|
||||
"poly_strike": 32.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2750",
|
||||
"kalshi_strike": 27.5,
|
||||
"poly_slug": "will-hyperliquid-dip-to-28-in-may",
|
||||
"poly_strike": 28.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2500",
|
||||
"kalshi_strike": 25.0,
|
||||
"poly_slug": "will-hyperliquid-dip-to-24-in-may",
|
||||
"poly_strike": 24.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "HYPE",
|
||||
"kalshi_ticker": "KXHYPEMINMON-HYPE-26MAY31-2250",
|
||||
"kalshi_strike": 22.5,
|
||||
"poly_slug": "will-hyperliquid-dip-to-24-in-may",
|
||||
"poly_strike": 24.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9750000",
|
||||
"kalshi_strike": 97500.0,
|
||||
"poly_slug": "will-bitcoin-reach-95k-in-may-2026",
|
||||
"poly_strike": 95000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9500000",
|
||||
"kalshi_strike": 95000.0,
|
||||
"poly_slug": "will-bitcoin-reach-95k-in-may-2026",
|
||||
"poly_strike": 95000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9250000",
|
||||
"kalshi_strike": 92500.0,
|
||||
"poly_slug": "will-bitcoin-reach-95k-in-may-2026",
|
||||
"poly_strike": 95000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-9000000",
|
||||
"kalshi_strike": 90000.0,
|
||||
"poly_slug": "will-bitcoin-reach-90k-in-may-2026",
|
||||
"poly_strike": 90000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8750000",
|
||||
"kalshi_strike": 87500.0,
|
||||
"poly_slug": "will-bitcoin-reach-85k-in-may-2026",
|
||||
"poly_strike": 85000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8500000",
|
||||
"kalshi_strike": 85000.0,
|
||||
"poly_slug": "will-bitcoin-reach-85k-in-may-2026",
|
||||
"poly_strike": 85000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMAXMON-BTC-26MAY31-8250000",
|
||||
"kalshi_strike": 82500.0,
|
||||
"poly_slug": "will-bitcoin-reach-85k-in-may-2026",
|
||||
"poly_strike": 85000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7500000",
|
||||
"kalshi_strike": 75000.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-75k-in-may-2026",
|
||||
"poly_strike": 75000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7250000",
|
||||
"kalshi_strike": 72500.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-70k-in-may-2026",
|
||||
"poly_strike": 70000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-7000000",
|
||||
"kalshi_strike": 70000.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-70k-in-may-2026",
|
||||
"poly_strike": 70000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6750000",
|
||||
"kalshi_strike": 67500.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-65k-in-may-2026",
|
||||
"poly_strike": 65000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6500000",
|
||||
"kalshi_strike": 65000.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-65k-in-may-2026",
|
||||
"poly_strike": 65000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6250000",
|
||||
"kalshi_strike": 62500.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-60k-in-may-2026",
|
||||
"poly_strike": 60000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-6000000",
|
||||
"kalshi_strike": 60000.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-60k-in-may-2026",
|
||||
"poly_strike": 60000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BTC",
|
||||
"kalshi_ticker": "KXBTCMINMON-BTC-26MAY31-5750000",
|
||||
"kalshi_strike": 57500.0,
|
||||
"poly_slug": "will-bitcoin-dip-to-55k-in-may-2026",
|
||||
"poly_strike": 55000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-425000",
|
||||
"kalshi_strike": 4250.0,
|
||||
"poly_slug": "will-ethereum-reach-4000-in-may-2026",
|
||||
"poly_strike": 4000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-400000",
|
||||
"kalshi_strike": 4000.0,
|
||||
"poly_slug": "will-ethereum-reach-4000-in-may-2026",
|
||||
"poly_strike": 4000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-375000",
|
||||
"kalshi_strike": 3750.0,
|
||||
"poly_slug": "will-ethereum-reach-3800-in-may-2026",
|
||||
"poly_strike": 3800.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-350000",
|
||||
"kalshi_strike": 3500.0,
|
||||
"poly_slug": "will-ethereum-reach-3400-in-may-2026",
|
||||
"poly_strike": 3400.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-325000",
|
||||
"kalshi_strike": 3250.0,
|
||||
"poly_slug": "will-ethereum-reach-3200-in-may-2026",
|
||||
"poly_strike": 3200.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-300000",
|
||||
"kalshi_strike": 3000.0,
|
||||
"poly_slug": "will-ethereum-reach-3000-in-may-2026",
|
||||
"poly_strike": 3000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-275000",
|
||||
"kalshi_strike": 2750.0,
|
||||
"poly_slug": "will-ethereum-reach-2800-in-may-2026",
|
||||
"poly_strike": 2800.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMAXMON-ETH-26MAY31-250000",
|
||||
"kalshi_strike": 2500.0,
|
||||
"poly_slug": "will-ethereum-reach-2600-in-may-2026",
|
||||
"poly_strike": 2600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-75000",
|
||||
"kalshi_strike": 750.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-50000",
|
||||
"kalshi_strike": 500.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-25000",
|
||||
"kalshi_strike": 250.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-200000",
|
||||
"kalshi_strike": 2000.0,
|
||||
"poly_slug": "will-ethereum-dip-to-2000-in-may-2026",
|
||||
"poly_strike": 2000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-175000",
|
||||
"kalshi_strike": 1750.0,
|
||||
"poly_slug": "will-ethereum-dip-to-2000-in-may-2026",
|
||||
"poly_strike": 2000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-150000",
|
||||
"kalshi_strike": 1500.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-125000",
|
||||
"kalshi_strike": 1250.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ETH",
|
||||
"kalshi_ticker": "KXETHMINMON-ETH-26MAY31-100000",
|
||||
"kalshi_strike": 1000.0,
|
||||
"poly_slug": "will-ethereum-dip-to-1000-in-may-2026",
|
||||
"poly_strike": 1000.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-9500",
|
||||
"kalshi_strike": 95.0,
|
||||
"poly_slug": "will-solana-reach-100-in-may-2026",
|
||||
"poly_strike": 100.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-9000",
|
||||
"kalshi_strike": 90.0,
|
||||
"poly_slug": "will-solana-reach-90-in-may-2026",
|
||||
"poly_strike": 90.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-12500",
|
||||
"kalshi_strike": 125.0,
|
||||
"poly_slug": "will-solana-reach-130-in-may-2026",
|
||||
"poly_strike": 130.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-12000",
|
||||
"kalshi_strike": 120.0,
|
||||
"poly_slug": "will-solana-reach-120-in-may-2026",
|
||||
"poly_strike": 120.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-11500",
|
||||
"kalshi_strike": 115.0,
|
||||
"poly_slug": "will-solana-reach-120-in-may-2026",
|
||||
"poly_strike": 120.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-11000",
|
||||
"kalshi_strike": 110.0,
|
||||
"poly_slug": "will-solana-reach-110-in-may-2026",
|
||||
"poly_strike": 110.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-10500",
|
||||
"kalshi_strike": 105.0,
|
||||
"poly_slug": "will-solana-reach-100-in-may-2026",
|
||||
"poly_strike": 100.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMAXMON-SOL-26MAY31-10000",
|
||||
"kalshi_strike": 100.0,
|
||||
"poly_slug": "will-solana-reach-100-in-may-2026",
|
||||
"poly_strike": 100.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-8000",
|
||||
"kalshi_strike": 80.0,
|
||||
"poly_slug": "will-solana-dip-to-70-in-may-2026",
|
||||
"poly_strike": 70.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-7500",
|
||||
"kalshi_strike": 75.0,
|
||||
"poly_slug": "will-solana-dip-to-70-in-may-2026",
|
||||
"poly_strike": 70.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-7000",
|
||||
"kalshi_strike": 70.0,
|
||||
"poly_slug": "will-solana-dip-to-70-in-may-2026",
|
||||
"poly_strike": 70.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-6500",
|
||||
"kalshi_strike": 65.0,
|
||||
"poly_slug": "will-solana-dip-to-60-in-may-2026",
|
||||
"poly_strike": 60.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-6000",
|
||||
"kalshi_strike": 60.0,
|
||||
"poly_slug": "will-solana-dip-to-60-in-may-2026",
|
||||
"poly_strike": 60.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-5500",
|
||||
"kalshi_strike": 55.0,
|
||||
"poly_slug": "will-solana-dip-to-50-in-may-2026",
|
||||
"poly_strike": 50.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-5000",
|
||||
"kalshi_strike": 50.0,
|
||||
"poly_slug": "will-solana-dip-to-50-in-may-2026",
|
||||
"poly_strike": 50.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "SOL",
|
||||
"kalshi_ticker": "KXSOLMINMON-SOL-26MAY31-4500",
|
||||
"kalshi_strike": 45.0,
|
||||
"poly_slug": "will-solana-dip-to-50-in-may-2026",
|
||||
"poly_strike": 50.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-210",
|
||||
"kalshi_strike": 2.1,
|
||||
"poly_slug": "will-xrp-reach-2-in-may-2026",
|
||||
"poly_strike": 2.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-200",
|
||||
"kalshi_strike": 2.0,
|
||||
"poly_slug": "will-xrp-reach-2-in-may-2026",
|
||||
"poly_strike": 2.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-190",
|
||||
"kalshi_strike": 1.9,
|
||||
"poly_slug": "will-xrp-reach-1pt8-in-may-2026",
|
||||
"poly_strike": 1.8
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-180",
|
||||
"kalshi_strike": 1.8,
|
||||
"poly_slug": "will-xrp-reach-1pt8-in-may-2026",
|
||||
"poly_strike": 1.8
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-170",
|
||||
"kalshi_strike": 1.7,
|
||||
"poly_slug": "will-xrp-reach-1pt6-in-may-2026",
|
||||
"poly_strike": 1.6
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-160",
|
||||
"kalshi_strike": 1.6,
|
||||
"poly_slug": "will-xrp-reach-1pt6-in-may-2026",
|
||||
"poly_strike": 1.6
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMAXMON-XRP-26MAY31-150",
|
||||
"kalshi_strike": 1.5,
|
||||
"poly_slug": "will-xrp-reach-1pt6-in-may-2026",
|
||||
"poly_strike": 1.6
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-130",
|
||||
"kalshi_strike": 1.3,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-120",
|
||||
"kalshi_strike": 1.2,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-110",
|
||||
"kalshi_strike": 1.1,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-100",
|
||||
"kalshi_strike": 1.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-090",
|
||||
"kalshi_strike": 0.9,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-080",
|
||||
"kalshi_strike": 0.8,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-070",
|
||||
"kalshi_strike": 0.7,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "XRP",
|
||||
"kalshi_ticker": "KXXRPMINMON-XRP-26MAY31-060",
|
||||
"kalshi_strike": 0.6,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMAXMON-ZEC-26MAY31-43000",
|
||||
"kalshi_strike": 430.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-35000",
|
||||
"kalshi_strike": 350.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-34000",
|
||||
"kalshi_strike": 340.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-33000",
|
||||
"kalshi_strike": 330.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-32000",
|
||||
"kalshi_strike": 320.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-31000",
|
||||
"kalshi_strike": 310.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-30000",
|
||||
"kalshi_strike": 300.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-29000",
|
||||
"kalshi_strike": 290.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "ZEC",
|
||||
"kalshi_ticker": "KXZECMINMON-ZEC-26MAY31-28000",
|
||||
"kalshi_strike": 280.0,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-018",
|
||||
"kalshi_strike": 0.18,
|
||||
"poly_slug": "will-dogecoin-reach-0pt2-in-may-2026",
|
||||
"poly_strike": 0.2
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-017",
|
||||
"kalshi_strike": 0.17,
|
||||
"poly_slug": "will-dogecoin-reach-0pt15-in-may-2026",
|
||||
"poly_strike": 0.15
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-016",
|
||||
"kalshi_strike": 0.16,
|
||||
"poly_slug": "will-dogecoin-reach-0pt15-in-may-2026",
|
||||
"poly_strike": 0.15
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-015",
|
||||
"kalshi_strike": 0.15,
|
||||
"poly_slug": "will-dogecoin-reach-0pt15-in-may-2026",
|
||||
"poly_strike": 0.15
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-014",
|
||||
"kalshi_strike": 0.14,
|
||||
"poly_slug": "will-dogecoin-reach-0pt15-in-may-2026",
|
||||
"poly_strike": 0.15
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-013",
|
||||
"kalshi_strike": 0.13,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMAXMON-DOGE-26MAY31-012",
|
||||
"kalshi_strike": 0.12,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-010",
|
||||
"kalshi_strike": 0.1,
|
||||
"poly_slug": "will-dogecoin-dip-to-0pt1-in-may-2026",
|
||||
"poly_strike": 0.1
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-009",
|
||||
"kalshi_strike": 0.09,
|
||||
"poly_slug": "will-dogecoin-dip-to-0pt1-in-may-2026",
|
||||
"poly_strike": 0.1
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-008",
|
||||
"kalshi_strike": 0.08,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-007",
|
||||
"kalshi_strike": 0.07,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-006",
|
||||
"kalshi_strike": 0.06,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-005",
|
||||
"kalshi_strike": 0.05,
|
||||
"poly_slug": "will-dogecoin-dip-to-0pt05-in-may-2026",
|
||||
"poly_strike": 0.05
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-004",
|
||||
"kalshi_strike": 0.04,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "DOGE",
|
||||
"kalshi_ticker": "KXDOGEMINMON-DOGE-26MAY31-003",
|
||||
"kalshi_strike": 0.03,
|
||||
"poly_slug": null,
|
||||
"poly_strike": null
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-72000",
|
||||
"kalshi_strike": 720.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-71000",
|
||||
"kalshi_strike": 710.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-70000",
|
||||
"kalshi_strike": 700.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-69000",
|
||||
"kalshi_strike": 690.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-68000",
|
||||
"kalshi_strike": 680.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-67000",
|
||||
"kalshi_strike": 670.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-66000",
|
||||
"kalshi_strike": 660.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMAXMON-BNB-26MAY31-65000",
|
||||
"kalshi_strike": 650.0,
|
||||
"poly_slug": "will-bnb-reach-700-in-may",
|
||||
"poly_strike": 700.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-60000",
|
||||
"kalshi_strike": 600.0,
|
||||
"poly_slug": "will-bnb-dip-to-600-in-may",
|
||||
"poly_strike": 600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-59000",
|
||||
"kalshi_strike": 590.0,
|
||||
"poly_slug": "will-bnb-dip-to-600-in-may",
|
||||
"poly_strike": 600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-58000",
|
||||
"kalshi_strike": 580.0,
|
||||
"poly_slug": "will-bnb-dip-to-600-in-may",
|
||||
"poly_strike": 600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-57000",
|
||||
"kalshi_strike": 570.0,
|
||||
"poly_slug": "will-bnb-dip-to-600-in-may",
|
||||
"poly_strike": 600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-56000",
|
||||
"kalshi_strike": 560.0,
|
||||
"poly_slug": "will-bnb-dip-to-600-in-may",
|
||||
"poly_strike": 600.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-55000",
|
||||
"kalshi_strike": 550.0,
|
||||
"poly_slug": "will-bnb-dip-to-500-in-may",
|
||||
"poly_strike": 500.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-54000",
|
||||
"kalshi_strike": 540.0,
|
||||
"poly_slug": "will-bnb-dip-to-500-in-may",
|
||||
"poly_strike": 500.0
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"asset": "BNB",
|
||||
"kalshi_ticker": "KXBNBMINMON-BNB-26MAY31-53000",
|
||||
"kalshi_strike": 530.0,
|
||||
"poly_slug": "will-bnb-dip-to-500-in-may",
|
||||
"poly_strike": 500.0
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"_comment": "Copy this file to data/secrets.json and fill in your own values. data/secrets.json is git-ignored. Never share these values.",
|
||||
|
||||
"polymarket_wallet": "0xYOUR_POLYMARKET_PROXY_WALLET_ADDRESS",
|
||||
|
||||
"_kalshi_comment": "Kalshi -> Settings -> API Keys. Create a key, save the private key file locally, put its absolute path + the Key ID here. Read-only is fine.",
|
||||
"kalshi_key_id": "",
|
||||
"kalshi_private_key_path": "",
|
||||
|
||||
"_sms_comment": "Optional SMS alerts. Deploy a tiny Apps Script web app whose doPost reads {message,to,carrier} and calls MailApp.sendEmail(to+'@'+carrier, '', message). Paste its /exec URL below, then your phone (10 digits) and the carrier gateway domain (e.g. vtext.com, tmomail.net, txt.att.net).",
|
||||
"sms_webhook": "",
|
||||
"sms_phone": "",
|
||||
"sms_carrier": ""
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"kalshi_fee_rate": 0.07,
|
||||
"poly_fee_rate": 0.072,
|
||||
"min_net_return": 0.002,
|
||||
"min_poly_volume": 100.0,
|
||||
"min_contracts": 1.0
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One alert cycle, designed for GitHub Actions cron.
|
||||
|
||||
- Runs `scan.run_scan` (public Kalshi + Polymarket APIs; no account creds
|
||||
needed for monthly arb discovery).
|
||||
- Reads/writes `alerts_state.json` at the repo root so successive cron runs
|
||||
remember which arbs were already alerted on. The workflow commits this
|
||||
file back to the repo (concurrency-guarded).
|
||||
- On the very first run (no state file yet) we *silently prime* — record
|
||||
the currently-active arb set without firing — so you don't get a text
|
||||
storm the moment you enable the workflow.
|
||||
- SMS goes via your existing Apps Script webhook (`SMS_WEBHOOK`), which
|
||||
email-relays to your carrier gateway (`SMS_PHONE@SMS_CARRIER`).
|
||||
- No secrets live on disk in the repo; all SMS values come from GitHub
|
||||
Actions Secrets via env vars.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from arb import scan # noqa: E402 (after sys.path)
|
||||
|
||||
STATE_FILE = ROOT / "alerts_state.json"
|
||||
|
||||
|
||||
def load_state():
|
||||
try:
|
||||
return set(json.loads(STATE_FILE.read_text()).get("active", []))
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
|
||||
|
||||
def save_state(active):
|
||||
STATE_FILE.write_text(json.dumps({
|
||||
"active": sorted(active),
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}, indent=2) + "\n")
|
||||
|
||||
|
||||
def send_sms(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=20) as r:
|
||||
return r.status == 200
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
|
||||
print("send failed: %s: %s" % (type(e).__name__, e))
|
||||
return False
|
||||
|
||||
|
||||
def fmt(r):
|
||||
asset = r.get("asset") or "?"
|
||||
ks = r.get("kalshi_strike")
|
||||
side = (r.get("best_side") or "").replace("YES", "Y").replace("NO", "N")
|
||||
net = (r.get("net_return") or 0) * 100
|
||||
ann = (r.get("annualized") or 0) * 100
|
||||
ct = int(r.get("max_contracts") or 0)
|
||||
gain = r.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(r):
|
||||
return "%s|%s" % (r.get("kalshi_ticker") or "",
|
||||
r.get("poly_slug") or "")
|
||||
|
||||
|
||||
def main():
|
||||
webhook = os.environ.get("SMS_WEBHOOK", "").strip()
|
||||
phone = os.environ.get("SMS_PHONE", "").strip()
|
||||
carrier = os.environ.get("SMS_CARRIER", "").strip()
|
||||
if not (webhook and phone and carrier):
|
||||
print("SMS env vars missing — exiting (set SMS_WEBHOOK/SMS_PHONE/SMS_CARRIER)")
|
||||
return
|
||||
|
||||
payload = scan.run_scan(force=True)
|
||||
settings = payload.get("settings", {})
|
||||
min_ret = settings.get("min_net_return", 0)
|
||||
|
||||
current = {pair_id(r): r for r in payload.get("rows", [])
|
||||
if r.get("status") == "ARB"
|
||||
and (r.get("net_return") or 0) >= min_ret}
|
||||
|
||||
if not STATE_FILE.exists():
|
||||
save_state(set(current))
|
||||
print("primed (first run): %d active arb(s) recorded, no texts sent"
|
||||
% len(current))
|
||||
return
|
||||
|
||||
prev = load_state()
|
||||
new = sorted(set(current) - prev)
|
||||
for pid in new:
|
||||
msg = fmt(current[pid])
|
||||
if send_sms(msg, webhook, phone, carrier):
|
||||
print("sent: %s" % msg)
|
||||
time.sleep(0.5)
|
||||
save_state(set(current))
|
||||
print("cycle: %d active, %d new (sent), %d unchanged"
|
||||
% (len(current), len(new), len(current) - len(new)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prediction-market arb scanner — pure-stdlib HTTP server.
|
||||
|
||||
python3 server.py [port] (default 8787)
|
||||
|
||||
Routes:
|
||||
GET / served web UI
|
||||
GET /api/scan live Kalshi + Polymarket scan (JSON)
|
||||
GET /api/settings current thresholds
|
||||
POST /api/settings update thresholds (writes data/settings.json)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from arb import scan
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
WEB = os.path.join(ROOT, "web")
|
||||
|
||||
_STATIC = {
|
||||
"/": ("index.html", "text/html; charset=utf-8"),
|
||||
"/index.html": ("index.html", "text/html; charset=utf-8"),
|
||||
"/app.js": ("app.js", "application/javascript; charset=utf-8"),
|
||||
"/style.css": ("style.css", "text/css; charset=utf-8"),
|
||||
}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _send(self, code, body, ctype="application/json; charset=utf-8"):
|
||||
if isinstance(body, (dict, list)):
|
||||
body = json.dumps(body).encode()
|
||||
elif isinstance(body, str):
|
||||
body = body.encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _static(self, path):
|
||||
name, ctype = _STATIC[path]
|
||||
try:
|
||||
with open(os.path.join(WEB, name), "rb") as f:
|
||||
self._send(200, f.read(), ctype)
|
||||
except OSError:
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_GET(self):
|
||||
u = urlparse(self.path)
|
||||
if u.path in _STATIC:
|
||||
return self._static(u.path)
|
||||
if u.path in ("/api/scan", "/api/scan/hourly"):
|
||||
force = parse_qs(u.query).get("force", ["0"])[0] == "1"
|
||||
runner = (scan.run_hourly if u.path.endswith("hourly")
|
||||
else scan.run_scan)
|
||||
try:
|
||||
return self._send(200, runner(force=force))
|
||||
except Exception as e:
|
||||
return self._send(500, {"error": "%s: %s"
|
||||
% (type(e).__name__, e)})
|
||||
if u.path == "/api/positions":
|
||||
force = parse_qs(u.query).get("force", ["0"])[0] == "1"
|
||||
try:
|
||||
return self._send(200, scan.run_positions(force=force))
|
||||
except Exception as e:
|
||||
return self._send(500, {"error": "%s: %s"
|
||||
% (type(e).__name__, e)})
|
||||
if u.path == "/api/settings":
|
||||
return self._send(200, scan.load_settings())
|
||||
self._send(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
if urlparse(self.path).path != "/api/settings":
|
||||
return self._send(404, {"error": "not found"})
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
incoming = json.loads(self.rfile.read(n) or b"{}")
|
||||
cur = scan.load_settings()
|
||||
allowed = ("kalshi_fee_rate", "poly_fee_rate", "min_net_return",
|
||||
"min_poly_volume", "min_contracts")
|
||||
for k in allowed:
|
||||
if k in incoming:
|
||||
cur[k] = float(incoming[k])
|
||||
with open(os.path.join(ROOT, "data", "settings.json"), "w") as f:
|
||||
json.dump(cur, f, indent=2)
|
||||
scan._CACHE["payload"] = None # force fresh recompute next scan
|
||||
self._send(200, cur)
|
||||
except Exception as e:
|
||||
self._send(400, {"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||
|
||||
|
||||
def main():
|
||||
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
|
||||
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print("Arb scanner running -> http://127.0.0.1:%d" % port)
|
||||
print("Press Ctrl+C to stop.")
|
||||
from arb import alerts
|
||||
alerts.start_poller()
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping.")
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+633
@@ -0,0 +1,633 @@
|
||||
"use strict";
|
||||
|
||||
const ASSET = {
|
||||
BTC: "#f7931a", ETH: "#7b87ff", SOL: "#19fb9b", XRP: "#3fb6e8",
|
||||
BNB: "#f3ba2f", DOGE: "#c2a633", HYPE: "#22d3a6", ZEC: "#f4b728",
|
||||
};
|
||||
const ac = (a) => ASSET[a] || "#7c8cff";
|
||||
|
||||
const COLS_MONTHLY = [
|
||||
{ k: "_mkt", t: "Market", align: "l", sort: "kalshi_ticker" },
|
||||
{ k: "best_side", t: "Side", align: "l" },
|
||||
{ k: "_px", t: "K / P px", align: "l", sort: "combined_cost" },
|
||||
{ k: "combined_cost", t: "Comb $", f: "px" },
|
||||
{ k: "worst_pnl", t: "Min $/ct", f: "s4" },
|
||||
{ k: "net_return", t: "Net Ret", f: "pctBig" },
|
||||
{ k: "annualized", t: "Annual", f: "pct" },
|
||||
{ k: "max_contracts", t: "Max Ct", f: "sz" },
|
||||
{ k: "total_gain", t: "Tot $", f: "s2" },
|
||||
{ k: "poly_volume", t: "P Vol", f: "money" },
|
||||
{ k: "days_to_expiry",t: "Days", f: "int" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
const COLS_HOURLY = [
|
||||
{ k: "_mkt", t: "Market", align: "l", sort: "asset" },
|
||||
{ k: "_window", t: "Window", align: "l", sort: "minutes_to_resolve" },
|
||||
{ k: "kalshi_strike", t: "K Strike", f: "strike" },
|
||||
{ k: "implied_strike",t: "Binance open", f: "strike" },
|
||||
{ k: "best_side", t: "Side", align: "l" },
|
||||
{ k: "_px", t: "K / P px", align: "l", sort: "combined_cost" },
|
||||
{ k: "combined_cost", t: "Comb $", f: "px" },
|
||||
{ k: "worst_pnl", t: "Min $/ct", f: "s4" },
|
||||
{ k: "net_return", t: "Net Ret", f: "pctBig" },
|
||||
{ k: "divergence", t: "Feed Δ", f: "div" },
|
||||
{ k: "poly_volume", t: "P Vol", f: "money" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
|
||||
let MODE = "monthly", POS_VIEW = "venue";
|
||||
const COLS = () => (MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY);
|
||||
const endpoint = () => MODE === "positions" ? "/api/positions"
|
||||
: MODE === "hourly" ? "/api/scan/hourly" : "/api/scan";
|
||||
|
||||
let RAW = [], sortKey = "net_return", sortAsc = false, openKey = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
const rid = (r) => (r.asset || "") + "|" + (r.kalshi_ticker || "") +
|
||||
"|" + (r.poly_slug || "");
|
||||
|
||||
function fmt(v, kind) {
|
||||
if (v === null || v === undefined || v === "")
|
||||
return '<span class="dimv">—</span>';
|
||||
const sign = (n, body, big) => {
|
||||
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
|
||||
return `<span class="num ${c}${big ? " big" : ""}">${body}</span>`;
|
||||
};
|
||||
switch (kind) {
|
||||
case "int": return String(Math.round(v));
|
||||
case "sz": return (+v).toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
case "px": return `<span class="mono">${(+v).toFixed(3)}</span>`;
|
||||
case "money": return "$" + (+v).toLocaleString(undefined, { notation: v >= 1e6 ? "compact" : "standard", maximumFractionDigits: 1 });
|
||||
case "pct": return sign(v, (v * 100).toFixed(1) + "%");
|
||||
case "pctBig":return sign(v, (v > 0 ? "+" : "") + (v * 100).toFixed(2) + "%", true);
|
||||
case "s4": return sign(v, (v > 0 ? "+" : "") + (+v).toFixed(4));
|
||||
case "s2": return sign(v, (v > 0 ? "+" : "") + "$" + (+v).toFixed(2));
|
||||
case "strike":return `<span class="mono">${(+v).toLocaleString(undefined, { maximumFractionDigits: v < 10 ? 4 : 0 })}</span>`;
|
||||
case "div": { const p = (v * 100), hot = Math.abs(p) >= 0.3;
|
||||
return `<span class="mono ${hot ? "neg" : "dimv"}">${p >= 0 ? "+" : ""}${p.toFixed(3)}%</span>`; }
|
||||
case "status":{ const s = String(v).replace(/\s+/g, "");
|
||||
return `<span class="pill ${s}">${v}</span>`; }
|
||||
default: return esc(v);
|
||||
}
|
||||
}
|
||||
|
||||
function thumb(r, sz) {
|
||||
const s = sz || 34;
|
||||
if (r.image)
|
||||
return `<img class="thumb" style="width:${s}px;height:${s}px"
|
||||
src="${esc(r.image)}" loading="lazy"
|
||||
onerror="this.replaceWith(Object.assign(document.createElement('div'),
|
||||
{className:'badge',style:'width:${s}px;height:${s}px;background:${ac(r.asset)}',
|
||||
textContent:'${esc(r.asset || "?").slice(0,4)}'}))">`;
|
||||
return `<div class="badge" style="width:${s}px;height:${s}px;background:${ac(r.asset)}">${esc((r.asset || "?").slice(0, 4))}</div>`;
|
||||
}
|
||||
|
||||
function cellMarket(r) {
|
||||
const title = r.kalshi_title || r.poly_question ||
|
||||
`${r.asset} ${r.direction} ${r.kalshi_strike ?? ""}`;
|
||||
const strikes = `K $${r.kalshi_strike ?? "—"} · P $${r.poly_strike ?? "—"}`;
|
||||
return `<div class="mkt">${thumb(r)}
|
||||
<div class="info">
|
||||
<div class="qa">
|
||||
<span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
|
||||
<span class="q" title="${esc(title)}">${esc(title)}</span>
|
||||
</div>
|
||||
<div class="sub"><span class="dir">${esc(r.direction || "")}</span> ${esc(strikes)}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function cellPx(r) {
|
||||
const k = r.kalshi_price, p = r.poly_price, c = r.combined_cost;
|
||||
if (k == null || p == null) return '<span class="dimv">—</span>';
|
||||
const pct = Math.min(100, (c / 1) * 100);
|
||||
const col = c < 1 ? "var(--good)" : "var(--bad)";
|
||||
return `<div class="pricebar">
|
||||
<div class="lbl"><span>K ${k.toFixed(3)}</span><span>P ${p.toFixed(3)}</span></div>
|
||||
<div class="track"><div class="fill" style="width:${pct}%;background:${col}"></div></div>
|
||||
<div class="lbl"><span class="dimv">cost vs $1</span><span class="${c < 1 ? "pos" : "neg"}">${c.toFixed(3)}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function cellWindow(r) {
|
||||
if (!r.window_start) return '<span class="dimv">—</span>';
|
||||
const m = r.minutes_to_resolve;
|
||||
const mc = m != null && m <= 10 ? "neg" : m != null && m <= 25 ? "pos" : "dimv";
|
||||
return `<div class="info">
|
||||
<div class="q mono">${esc(r.window_start)} → ${esc(r.window_close)}</div>
|
||||
<div class="sub ${mc}">${m != null ? "resolves in " + m + "m" : ""}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
$("head").innerHTML = COLS().map((c) => {
|
||||
const sk = c.sort || c.k;
|
||||
let cls = c.align === "l" ? "l" : "";
|
||||
if (sk === sortKey) cls += sortAsc ? " asc" : " sorted";
|
||||
return `<th class="${cls}" data-sk="${sk}">${c.t}</th>`;
|
||||
}).join("");
|
||||
document.querySelectorAll("#head th").forEach((th) => {
|
||||
th.onclick = () => {
|
||||
const k = th.dataset.sk;
|
||||
if (k === sortKey) sortAsc = !sortAsc; else { sortKey = k; sortAsc = false; }
|
||||
renderHead(); renderBody();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function activeStatus() {
|
||||
const b = document.querySelector("#statusSeg button.on");
|
||||
return b ? b.dataset.s : "";
|
||||
}
|
||||
function activeAssets() {
|
||||
return [...document.querySelectorAll("#assetChips .ac.on")].map((e) => e.dataset.a);
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const s = activeStatus(), as = activeAssets(), fav = $("favOnly").checked;
|
||||
return RAW.filter((r) => {
|
||||
if (s && r.status !== s) return false;
|
||||
if (as.length && !as.includes(r.asset)) return false;
|
||||
if (fav && !r.basis_favorable) return false;
|
||||
if (q) {
|
||||
const h = `${r.asset} ${r.kalshi_ticker} ${r.poly_slug || ""} ${r.kalshi_title || ""} ${r.poly_question || ""}`.toLowerCase();
|
||||
if (!h.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(rows) {
|
||||
return rows.sort((x, y) => {
|
||||
let a = x[sortKey], b = y[sortKey];
|
||||
a = a == null ? -Infinity : a; b = b == null ? -Infinity : b;
|
||||
if (typeof a === "string" || typeof b === "string")
|
||||
return sortAsc ? String(a).localeCompare(b) : String(b).localeCompare(a);
|
||||
return sortAsc ? a - b : b - a;
|
||||
});
|
||||
}
|
||||
|
||||
function drawer(r) {
|
||||
const kv = (k, v) => `<div class="k">${k}</div><div class="v">${v}</div>`;
|
||||
const money = (n, d = 4) => n == null ? "—"
|
||||
: `<span class="${n > 0 ? "pos" : n < 0 ? "neg" : ""}">${n > 0 ? "+" : ""}${(+n).toFixed(d)}</span>`;
|
||||
const kCard = `<div class="vcard">
|
||||
<h4><span class="tag k">Kalshi</span> ${esc(r.kalshi_ticker)}</h4>
|
||||
<div class="title">${esc(r.kalshi_title || "—")}</div>
|
||||
<div class="legs">
|
||||
<div class="leg"><div class="k">Yes ask</div><div class="v">${r.kalshi_price != null && r.best_side === "YES+NO" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">No ask</div><div class="v">${r.kalshi_price != null && r.best_side === "NO+YES" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">Open int</div><div class="v">${r.kalshi_size != null ? Math.round(r.kalshi_size).toLocaleString() : "—"}</div></div>
|
||||
</div>
|
||||
<div class="desc">${esc(r.kalshi_rules || "Resolution rules unavailable.")}</div>
|
||||
</div>`;
|
||||
const pImg = r.image ? `<img class="hero-img" src="${esc(r.image)}" onerror="this.remove()">` : "";
|
||||
const pSideRaw = (r.best_side || "—").split("+")[1] || "—";
|
||||
const pSide = MODE === "hourly"
|
||||
? (pSideRaw === "NO" ? "Down" : pSideRaw === "YES" ? "Up" : pSideRaw)
|
||||
: pSideRaw;
|
||||
const pCard = `<div class="vcard">
|
||||
<h4><span class="tag p">Polymarket</span> ${esc(r.poly_slug || "no paired market")}</h4>
|
||||
${pImg}
|
||||
<div class="title">${esc(r.poly_question || "—")}</div>
|
||||
<div class="legs">
|
||||
<div class="leg"><div class="k">Side held</div><div class="v">${esc(pSide)}</div></div>
|
||||
<div class="leg"><div class="k">Poly px</div><div class="v">${r.poly_price != null ? r.poly_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">Volume</div><div class="v">$${(r.poly_volume || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div></div>
|
||||
</div>
|
||||
<div class="desc">${esc(r.poly_description || "Market description unavailable.")}</div>
|
||||
</div>`;
|
||||
const hourly = MODE === "hourly";
|
||||
const scen = `<div class="scen">
|
||||
<div class="b"><div class="t">${hourly ? "Worst case" : "Min / guaranteed"}</div><div class="x">${money(r.worst_pnl)}</div></div>
|
||||
<div class="b"><div class="t">Strike gap</div><div class="x">${r.mid_pnl == null ? '<span class="dimv">none</span>' : money(r.mid_pnl)}</div></div>
|
||||
<div class="b"><div class="t">Best case</div><div class="x">${money(r.best_pnl)}</div></div>
|
||||
</div>`;
|
||||
const hRows = hourly ? `
|
||||
${kv("Window", esc((r.window_start || "—") + " → " + (r.window_close || "—")))}
|
||||
${kv("Resolves in", r.minutes_to_resolve != null ? r.minutes_to_resolve + " min" : "—")}
|
||||
${kv("Implied strike (Binance open)", r.implied_strike != null ? r.implied_strike.toLocaleString() : "—")}
|
||||
${kv("Binance spot / CF spot", `${r.binance_spot != null ? r.binance_spot.toLocaleString() : "—"} / ${r.cf_spot != null ? r.cf_spot.toLocaleString() : "n/a"}`)}
|
||||
${kv("Feed divergence", fmt(r.divergence, "div"))}` : `
|
||||
${kv("Annualized", fmt(r.annualized, "pct"))}
|
||||
${kv("Total guaranteed $", money(r.total_gain, 2))}
|
||||
${kv("Days to expiry", r.days_to_expiry ?? "—")}`;
|
||||
const bd = `<div class="vcard">
|
||||
<h4>${hourly ? "Speculative breakdown" : "Arb breakdown"}</h4>
|
||||
${scen}
|
||||
${hourly ? '<div class="warn-line">Different settlement feeds — legs are not a locked hedge.</div>' : ""}
|
||||
<div class="kv">
|
||||
${kv("Best side", esc(r.best_side || "—"))}
|
||||
${kv("Combined cost", r.combined_cost != null ? "$" + r.combined_cost.toFixed(4) : "—")}
|
||||
${kv("Total fee / ct", r.total_fee != null ? "$" + r.total_fee.toFixed(4) : "—")}
|
||||
${kv("Net return", fmt(r.net_return, "pctBig"))}
|
||||
${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() : "—")}
|
||||
${hRows}
|
||||
</div></div>`;
|
||||
return `<tr class="detail"><td colspan="${COLS().length}">
|
||||
<div class="drawer">${kCard}${pCard}${bd}</div></td></tr>`;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const rows = sortRows(filtered());
|
||||
$("rowCount").textContent = `${rows.length} of ${RAW.length} markets`;
|
||||
$("empty").hidden = rows.length > 0;
|
||||
const html = [];
|
||||
for (const r of rows) {
|
||||
const id = rid(r), isOpen = id === openKey;
|
||||
html.push(`<tr class="r${isOpen ? " open" : ""}" data-id="${esc(id)}">` +
|
||||
COLS().map((c) => {
|
||||
const cls = c.align === "l" ? "l" : "";
|
||||
if (c.k === "_mkt") return `<td class="${cls}">${cellMarket(r)}</td>`;
|
||||
if (c.k === "_px") return `<td class="${cls}">${cellPx(r)}</td>`;
|
||||
if (c.k === "_window") return `<td class="${cls}">${cellWindow(r)}</td>`;
|
||||
if (c.k === "best_side") {
|
||||
let bs = r.best_side || "—";
|
||||
if (MODE === "hourly" && bs !== "—")
|
||||
bs = bs === "YES+NO" ? "K-Yes · P-Down" : "K-No · P-Up";
|
||||
return `<td class="${cls}"><span class="mono">${esc(bs)}</span></td>`;
|
||||
}
|
||||
return `<td class="${cls}">${fmt(r[c.k], c.f)}</td>`;
|
||||
}).join("") + "</tr>");
|
||||
if (isOpen) html.push(drawer(r));
|
||||
}
|
||||
$("body").innerHTML = html.join("");
|
||||
document.querySelectorAll("#body tr.r").forEach((tr) => {
|
||||
tr.onclick = () => {
|
||||
openKey = openKey === tr.dataset.id ? null : tr.dataset.id;
|
||||
renderBody();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const cards = MODE === "hourly" ? [
|
||||
["ARB", "Edges", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["NO DATA", "No data", ""],
|
||||
["total", "Assets", ""],
|
||||
] : [
|
||||
["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["LOW SIZE", "Low size", "s-warn"],
|
||||
["NO DATA", "No data", ""], ["total", "Scanned", ""],
|
||||
];
|
||||
$("summary").innerHTML = cards.map(([k, l, cls]) =>
|
||||
`<div class="stat ${cls}"><div class="n">${s[k] ?? 0}</div>
|
||||
<div class="l">${l}</div></div>`).join("") +
|
||||
`<div class="stat"><div class="n">${s.fetch_ms ?? "—"}<span style="font-size:13px;color:var(--faint)">ms</span></div>
|
||||
<div class="l">Fetch time</div></div>`;
|
||||
}
|
||||
|
||||
function renderHero(rows) {
|
||||
const hourly = MODE === "hourly";
|
||||
const top = rows.filter((r) => r.status === "ARB" && r.net_return > 0)
|
||||
.sort((a, b) => b.net_return - a.net_return).slice(0, 4);
|
||||
const h = $("hero");
|
||||
h.querySelector("h2").textContent =
|
||||
hourly ? "Top dislocations" : "Top opportunities";
|
||||
h.querySelector(".muted").textContent = hourly
|
||||
? "combined cost < $1 — speculative, basis risk applies"
|
||||
: "positive guaranteed return, ranked";
|
||||
if (!top.length) { h.hidden = true; return; }
|
||||
h.hidden = false;
|
||||
$("heroCards").innerHTML = top.map((r) => `
|
||||
<div class="hcard${hourly ? " spec" : ""}" data-id="${esc(rid(r))}">
|
||||
<div class="glow"></div>
|
||||
<div class="top">${thumb(r, 38)}
|
||||
<div class="q">${esc(r.kalshi_title || r.poly_question || r.asset)}</div></div>
|
||||
<div class="ret">+${(r.net_return * 100).toFixed(2)}%</div>
|
||||
<div class="meta">
|
||||
${hourly
|
||||
? `<span><b>${r.minutes_to_resolve ?? "—"}m</b> left</span>
|
||||
<span><b>${r.divergence != null ? (r.divergence * 100).toFixed(3) + "%" : "—"}</b> feed Δ</span>`
|
||||
: `<span><b>${r.annualized ? (r.annualized * 100).toFixed(0) + "%" : "—"}</b> annual</span>
|
||||
<span><b>$${(r.total_gain || 0).toFixed(2)}</b> locked</span>`}
|
||||
<span><b>${Math.round(r.max_contracts || 0).toLocaleString()}</b> ct</span>
|
||||
<span class="mono">${esc(r.best_side)}</span>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
document.querySelectorAll(".hcard").forEach((c) => c.onclick = () => {
|
||||
openKey = c.dataset.id;
|
||||
document.querySelector("#statusSeg button.on")?.classList.remove("on");
|
||||
document.querySelector('#statusSeg button[data-s=""]').classList.add("on");
|
||||
renderBody();
|
||||
document.querySelector(`#body tr.r[data-id="${CSS.escape(c.dataset.id)}"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}
|
||||
|
||||
let POS_DATA = null;
|
||||
|
||||
const usd = (n, d = 2) => n == null ? "—"
|
||||
: "$" + (+n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
function pnl(n, pct) {
|
||||
if (n == null) return '<span class="dimv">—</span>';
|
||||
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
|
||||
const p = pct != null ? ` <span class="pp">(${(pct * 100).toFixed(1)}%)</span>` : "";
|
||||
return `<span class="num ${c}">${n > 0 ? "+" : ""}${usd(n)}${p}</span>`;
|
||||
}
|
||||
|
||||
function posThumb(p) {
|
||||
if (p.icon) return `<img class="thumb" style="width:30px;height:30px"
|
||||
src="${esc(p.icon)}" loading="lazy" onerror="this.remove()">`;
|
||||
const a = p.asset ? p.asset.slice(0, 4) : "?";
|
||||
return `<div class="badge" style="width:30px;height:30px;background:${ac(p.asset)}">${esc(a)}</div>`;
|
||||
}
|
||||
|
||||
function venuePanel(name, tag, v) {
|
||||
const setup = (body) => `<div class="vcard setup">
|
||||
<h4><span class="tag ${tag}">${name}</span> not connected</h4>${body}</div>`;
|
||||
if (!v || v.configured === false) {
|
||||
if (name === "Polymarket")
|
||||
return setup(`<p>Add your wallet to <code>data/secrets.json</code>:</p>
|
||||
<pre>{ "polymarket_wallet": "0xYourProxyWallet" }</pre>
|
||||
<p class="dimv">Public address only — no private key. Copy
|
||||
<code>data/secrets.example.json</code> to start.</p>`);
|
||||
return setup(`<p>Create a read-only API key in Kalshi → Settings → API Keys,
|
||||
save the private-key file locally, then add to
|
||||
<code>data/secrets.json</code>:</p>
|
||||
<pre>{ "kalshi_key_id": "…",
|
||||
"kalshi_private_key_path": "/abs/path/key.pem" }</pre>
|
||||
<p class="dimv">Signed locally via openssl — the key never leaves your
|
||||
machine. ${v && v.reason === "key_file_not_found"
|
||||
? '<span class="neg">Key file not found at that path.</span>' : ""}</p>`);
|
||||
}
|
||||
if (v.error)
|
||||
return `<div class="vcard"><h4><span class="tag ${tag}">${name}</span></h4>
|
||||
<p class="neg">${esc(v.error)}</p></div>`;
|
||||
const ps = v.positions || [];
|
||||
const head = `<div class="vc-head">
|
||||
<h4><span class="tag ${tag}">${name}</span> ${ps.length} open</h4>
|
||||
<span class="vc-tot">${usd(v.total_value)}</span></div>`;
|
||||
if (!ps.length)
|
||||
return `<div class="vcard">${head}<p class="dimv">No open positions.</p></div>`;
|
||||
const rows = ps.sort((a, b) => (b.value || 0) - (a.value || 0)).map((p) => `
|
||||
<tr>
|
||||
<td class="l"><div class="mkt">${posThumb(p)}<div class="info">
|
||||
<div class="q" title="${esc(p.market)}">${esc(p.market)}</div>
|
||||
<div class="sub mono">${esc(p.ref || "")}</div></div></div></td>
|
||||
<td><span class="sidetag ${String(p.side).toLowerCase()}">${esc(p.side)}</span></td>
|
||||
<td class="num">${(p.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
||||
<td class="num mono">${p.avg_price != null ? (+p.avg_price).toFixed(3) : "—"} → ${p.cur_price != null ? (+p.cur_price).toFixed(3) : "—"}</td>
|
||||
<td class="num">${usd(p.cost)}</td>
|
||||
<td class="num">${usd(p.value)}</td>
|
||||
<td class="num">${pnl(p.pnl, p.pnl_pct)}</td>
|
||||
</tr>`).join("");
|
||||
return `<div class="vcard">${head}
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Market</th><th>Side</th><th>Size</th>
|
||||
<th>Avg → Cur</th><th>Cost</th><th>Value</th><th>P&L</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function pairedView(rows) {
|
||||
if (!rows || !rows.length)
|
||||
return `<div class="vcard"><h4>Paired exposure</h4>
|
||||
<p class="dimv">No held positions match a known Kalshi↔Polymarket arb
|
||||
pair (pairs come from the Monthly map). Positions still show under
|
||||
<b>By venue</b>.</p></div>`;
|
||||
const body = rows.map((r) => {
|
||||
const legs = r.legs.map((l) => `<div class="leg2">
|
||||
<span class="tag ${l.venue === "Kalshi" ? "k" : "p"}">${l.venue[0]}</span>
|
||||
<span class="sidetag ${String(l.side).toLowerCase()}">${esc(l.side)}</span>
|
||||
<span class="dimv mono">${esc(l.ref || "")}</span>
|
||||
<span class="num">${(l.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
||||
<span>${pnl(l.pnl)}</span></div>`).join("");
|
||||
return `<tr>
|
||||
<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>
|
||||
<td class="l">${legs}</td>
|
||||
<td class="num">${usd(r.cost)}</td>
|
||||
<td class="num">${usd(r.value)}</td>
|
||||
<td class="num">${pnl(r.pnl, r.pnl_pct)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
return `<div class="vcard"><h4>Paired / netted exposure</h4>
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Pair</th><th class="l">Legs</th>
|
||||
<th>Cost</th><th>Value</th><th>Net P&L</th>
|
||||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function historyCard(name, tag, rows, note) {
|
||||
const head = `<div class="vc-head">
|
||||
<h4><span class="tag ${tag}">${name}</span> ${rows.length} settled${note ? ` <span class="dimv">· ${note}</span>` : ""}</h4>
|
||||
<span class="vc-tot">${pnl(rows.reduce((s, r) => s + (r.realized || 0), 0))}</span></div>`;
|
||||
if (!rows.length)
|
||||
return `<div class="vcard">${head}<p class="dimv">No settled markets found.</p></div>`;
|
||||
const body = rows.slice().sort((a, b) =>
|
||||
(b.settled_date || "").localeCompare(a.settled_date || "")).map((r) => `
|
||||
<tr>
|
||||
<td class="l"><div class="mkt">
|
||||
${r.icon ? `<img class="thumb" style="width:26px;height:26px" src="${esc(r.icon)}" loading="lazy" onerror="this.remove()">` : ""}
|
||||
<div class="info"><div class="q" title="${esc(r.market)}">${esc(r.market)}</div>
|
||||
<div class="sub mono">${esc(r.settled_date || "")}</div></div></div></td>
|
||||
<td><span class="sidetag ${String(r.result).toLowerCase()}">${esc(r.result)}</span></td>
|
||||
<td><span class="sidetag ${String(r.side).toLowerCase()}">${esc(r.side || "—")}</span></td>
|
||||
<td class="num">${(r.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
||||
<td class="num">${usd(r.cost)}</td>
|
||||
<td class="num">${usd(r.payout)}</td>
|
||||
<td class="num">${pnl(r.realized)}</td>
|
||||
</tr>`).join("");
|
||||
return `<div class="vcard">${head}
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Market</th><th>Result</th><th>Side</th><th>Size</th>
|
||||
<th>Cost</th><th>Payout</th><th>Realized</th>
|
||||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function historyView(h) {
|
||||
h = h || {};
|
||||
return `<div class="vstack">
|
||||
${historyCard("Kalshi", "k", h.kalshi || [], "exact (settlements)")}
|
||||
${historyCard("Polymarket", "p", h.polymarket || [], "reconstructed · payout derived")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPositions(d) {
|
||||
if (d) POS_DATA = d;
|
||||
d = POS_DATA;
|
||||
const t = d.totals || {};
|
||||
const stat = (n, l, cls) =>
|
||||
`<div class="stat ${cls || ""}"><div class="n">${n}</div><div class="l">${l}</div></div>`;
|
||||
const pstat = (n, l, big) => {
|
||||
if (n == null)
|
||||
return `<div class="stat"><div class="n">—</div><div class="l">${l}</div></div>`;
|
||||
const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : "";
|
||||
const v = (n > 0 ? "+" : n < 0 ? "-" : "") +
|
||||
"$" + Math.abs(n).toLocaleString(undefined,
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return `<div class="stat ${cls}${big ? " stat-lg" : ""}">
|
||||
<div class="n">${v}</div><div class="l">${l}</div></div>`;
|
||||
};
|
||||
const strip = `<div class="summary">
|
||||
${stat(usd(t.poly_value), "Polymarket value")}
|
||||
${stat(t.kalshi_value == null ? "—" : usd(t.kalshi_value), "Kalshi value")}
|
||||
${pstat(t.kalshi_pnl, "Kalshi P&L")}
|
||||
${pstat(t.poly_pnl, "Polymarket P&L")}
|
||||
${(() => {
|
||||
const n = t.total_pnl, r = t.total_return;
|
||||
if (n == null)
|
||||
return `<div class="stat stat-lg"><div class="n">—</div><div class="l">Total P&L</div></div>`;
|
||||
const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : "";
|
||||
const v = (n > 0 ? "+" : n < 0 ? "-" : "") + "$" +
|
||||
Math.abs(n).toLocaleString(undefined,
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const sub = r == null ? "" :
|
||||
`<div class="stat-sub ${r > 0 ? "pos" : r < 0 ? "neg" : ""}">${r > 0 ? "+" : ""}${(r * 100).toFixed(2)}% return</div>`;
|
||||
return `<div class="stat stat-lg ${cls}"><div class="n">${v}</div>${sub}<div class="l">Total P&L</div></div>`;
|
||||
})()}
|
||||
${pstat(t.total_realized, "Realized P&L")}
|
||||
${stat(t.open_positions ?? 0, "Open positions")}
|
||||
${stat(t.settled_count ?? 0, "Settled")}
|
||||
${stat(t.paired_count ?? 0, "Complete pairs", "s-arb")}</div>`;
|
||||
const tab = (v, l) =>
|
||||
`<button data-v="${v}" class="${POS_VIEW === v ? "on" : ""}">${l}</button>`;
|
||||
const seg = `<div class="seg posseg">
|
||||
${tab("venue", "By venue")}${tab("paired", "Paired")}${tab("history", "History")}
|
||||
</div>`;
|
||||
const content = POS_VIEW === "paired" ? pairedView(d.paired)
|
||||
: POS_VIEW === "history" ? historyView(d.history)
|
||||
: `<div class="vstack">
|
||||
${venuePanel("Polymarket", "p", d.polymarket)}
|
||||
${venuePanel("Kalshi", "k", d.kalshi)}</div>`;
|
||||
$("positions").innerHTML = strip +
|
||||
`<div class="postoolbar">${seg}
|
||||
<span class="dimv pos-note">Read-only · credentials stay in local
|
||||
data/secrets.json</span></div>` + content;
|
||||
$("positions").querySelectorAll(".posseg button").forEach((b) =>
|
||||
b.onclick = () => {
|
||||
POS_VIEW = b.dataset.v; renderPositions();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, kind) {
|
||||
const t = $("toast");
|
||||
t.textContent = msg; t.className = "toast" + (kind ? " " + kind : "");
|
||||
t.hidden = false; clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => (t.hidden = true), kind === "err" ? 7000 : 2800);
|
||||
}
|
||||
|
||||
function applyChrome() {
|
||||
const scanner = MODE !== "positions";
|
||||
$("summary").hidden = !scanner;
|
||||
document.querySelector(".toolbar").hidden = !scanner;
|
||||
document.querySelector("main").hidden = !scanner;
|
||||
$("positions").hidden = scanner;
|
||||
if (!scanner) { $("hero").hidden = true; $("hbanner").hidden = true; }
|
||||
}
|
||||
|
||||
async function load(force) {
|
||||
const b = $("refresh");
|
||||
b.disabled = true; b.classList.add("loading");
|
||||
b.querySelector(".blabel").textContent =
|
||||
MODE === "positions" ? "Loading…" : "Scanning…";
|
||||
applyChrome();
|
||||
try {
|
||||
const res = await fetch(endpoint() + (force ? "?force=1" : ""));
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error || res.status);
|
||||
if (MODE === "positions") {
|
||||
renderPositions(d);
|
||||
$("meta").textContent =
|
||||
`${d.generated_at} · ${d.totals.open_positions} open`;
|
||||
} else {
|
||||
RAW = d.rows;
|
||||
renderSummary(d.summary);
|
||||
buildAssetChips();
|
||||
renderHero(RAW);
|
||||
renderBody();
|
||||
updateBanner(d.summary);
|
||||
const unit = MODE === "hourly" ? "assets" : "pairs";
|
||||
$("meta").textContent = `${d.generated_at} · ${d.summary.total} ${unit}`;
|
||||
}
|
||||
} catch (e) {
|
||||
toast((MODE === "positions" ? "Load" : "Scan") + " failed: " + e.message, "err");
|
||||
$("meta").textContent = "load failed";
|
||||
} finally {
|
||||
b.disabled = false; b.classList.remove("loading");
|
||||
b.querySelector(".blabel").textContent = "Refresh";
|
||||
}
|
||||
}
|
||||
|
||||
function updateBanner(summary) {
|
||||
const b = $("hbanner");
|
||||
if (MODE !== "hourly") { b.hidden = true; return; }
|
||||
b.hidden = false;
|
||||
const d = summary && summary.max_divergence;
|
||||
const el = $("hbDiv");
|
||||
if (d == null) { el.textContent = "n/a"; el.classList.remove("hot"); return; }
|
||||
const pct = (d * 100);
|
||||
el.textContent = "max " + pct.toFixed(3) + "%";
|
||||
el.classList.toggle("hot", pct >= 0.3);
|
||||
}
|
||||
|
||||
function buildAssetChips() {
|
||||
const el = $("assetChips");
|
||||
if (el.dataset.built) return;
|
||||
const assets = [...new Set(RAW.map((r) => r.asset))].filter(Boolean).sort();
|
||||
el.innerHTML = assets.map((a) =>
|
||||
`<span class="ac" data-a="${a}"><span class="blob" style="background:${ac(a)}"></span>${a}</span>`).join("");
|
||||
el.dataset.built = "1";
|
||||
el.querySelectorAll(".ac").forEach((c) => c.onclick = () => {
|
||||
c.classList.toggle("on"); renderBody();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await (await fetch("/api/settings")).json();
|
||||
s_kfee.value = s.kalshi_fee_rate; s_pfee.value = s.poly_fee_rate;
|
||||
s_minret.value = s.min_net_return; s_minvol.value = s.min_poly_volume;
|
||||
s_minct.value = s.min_contracts;
|
||||
}
|
||||
|
||||
function wire() {
|
||||
document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => {
|
||||
if (b.classList.contains("on")) return;
|
||||
document.querySelector("#tabs button.on")?.classList.remove("on");
|
||||
b.classList.add("on");
|
||||
MODE = b.dataset.m;
|
||||
openKey = null;
|
||||
sortKey = "net_return"; sortAsc = false;
|
||||
const ac = $("assetChips"); ac.dataset.built = ""; ac.innerHTML = "";
|
||||
document.querySelector("#statusSeg button.on")?.classList.remove("on");
|
||||
document.querySelector('#statusSeg button[data-s=""]').classList.add("on");
|
||||
$("search").value = "";
|
||||
renderHead();
|
||||
load(true);
|
||||
});
|
||||
$("refresh").onclick = () => load(true);
|
||||
$("search").addEventListener("input", renderBody);
|
||||
$("favOnly").addEventListener("change", renderBody);
|
||||
document.querySelectorAll("#statusSeg button").forEach((b) => b.onclick = () => {
|
||||
document.querySelector("#statusSeg button.on")?.classList.remove("on");
|
||||
b.classList.add("on"); renderBody();
|
||||
});
|
||||
let timer = null;
|
||||
$("auto").onchange = (e) => {
|
||||
clearInterval(timer);
|
||||
if (e.target.checked) { load(true); timer = setInterval(() => load(true), 8000); }
|
||||
};
|
||||
$("settingsBtn").onclick = async () => {
|
||||
await loadSettings(); $("settingsModal").hidden = false;
|
||||
};
|
||||
$("closeSettings").onclick = () => ($("settingsModal").hidden = true);
|
||||
$("saveSettings").onclick = async () => {
|
||||
const body = {
|
||||
kalshi_fee_rate: +s_kfee.value, poly_fee_rate: +s_pfee.value,
|
||||
min_net_return: +s_minret.value, min_poly_volume: +s_minvol.value,
|
||||
min_contracts: +s_minct.value,
|
||||
};
|
||||
const r = await fetch("/api/settings", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); }
|
||||
else toast("Save failed", "err");
|
||||
};
|
||||
}
|
||||
|
||||
renderHead();
|
||||
wire();
|
||||
load(false);
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arb Scanner · Kalshi × Polymarket</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="aurora"></div>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">◆</div>
|
||||
<div>
|
||||
<h1>Arb Scanner</h1>
|
||||
<span class="sub">Kalshi × Polymarket · crypto</span>
|
||||
</div>
|
||||
<div class="tabs" id="tabs">
|
||||
<button data-m="monthly" class="on">Monthly arb</button>
|
||||
<button data-m="hourly">Hourly <span class="spec">spec</span></button>
|
||||
<button data-m="positions">Positions</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="live"><span class="dot"></span><span id="meta">connecting…</span></div>
|
||||
<label class="auto"><input type="checkbox" id="auto"><span>Auto 8s</span></label>
|
||||
<button id="settingsBtn" class="ghost">Settings</button>
|
||||
<button id="refresh" class="primary"><span class="bspin"></span><span class="blabel">Refresh</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="hbanner" class="hbanner" hidden>
|
||||
<div class="hb-ico">⚠</div>
|
||||
<div class="hb-txt">
|
||||
<b>Speculative — not a locked arb.</b> Kalshi settles on CF Benchmarks,
|
||||
Polymarket on Binance. Different feeds & the discrete-strike gap mean
|
||||
legs can both lose. Live Binance↔CF divergence:
|
||||
<span id="hbDiv" class="hb-div">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="summary" class="summary"></section>
|
||||
|
||||
<section id="hero" class="hero" hidden>
|
||||
<div class="hero-head"><h2>Top opportunities</h2><span class="muted">positive guaranteed return, ranked</span></div>
|
||||
<div id="heroCards" class="hero-cards"></div>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15"><path d="M21 21l-4.3-4.3M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<input type="search" id="search" placeholder="Search asset, ticker, market…">
|
||||
</div>
|
||||
<div id="assetChips" class="chips"></div>
|
||||
<div class="seg" id="statusSeg">
|
||||
<button data-s="" class="on">All</button>
|
||||
<button data-s="ARB">Arb</button>
|
||||
<button data-s="NO ARB">No arb</button>
|
||||
<button data-s="BAD BASIS">Bad basis</button>
|
||||
<button data-s="LOW SIZE">Low size</button>
|
||||
</div>
|
||||
<label class="chk"><input type="checkbox" id="favOnly"><span>Favorable basis</span></label>
|
||||
<span class="count" id="rowCount"></span>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<table id="grid">
|
||||
<thead><tr id="head"></tr></thead>
|
||||
<tbody id="body"></tbody>
|
||||
</table>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<div class="empty-art">◇</div>
|
||||
<p>No markets match the current filters.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section id="positions" class="positions" hidden></section>
|
||||
|
||||
<div id="settingsModal" class="modal" hidden>
|
||||
<div class="card">
|
||||
<h2>Scanner settings</h2>
|
||||
<p class="hint">Mirrors the workbook <em>Settings</em> sheet · persisted server-side.</p>
|
||||
<label>Kalshi fee rate <input type="number" step="0.001" id="s_kfee"></label>
|
||||
<label>Polymarket fee rate <input type="number" step="0.001" id="s_pfee"></label>
|
||||
<label>Min net return <input type="number" step="0.0001" id="s_minret"></label>
|
||||
<label>Min Poly volume ($) <input type="number" step="100" id="s_minvol"></label>
|
||||
<label>Min contracts <input type="number" step="1" id="s_minct"></label>
|
||||
<div class="row">
|
||||
<button id="saveSettings" class="primary">Save & rescan</button>
|
||||
<button id="closeSettings" class="ghost">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
:root {
|
||||
--bg: #0a0c14; --bg2: #0e1120; --panel: rgba(22,26,42,.72);
|
||||
--panel-solid: #161a2a; --line: rgba(255,255,255,.07);
|
||||
--line2: rgba(255,255,255,.12);
|
||||
--txt: #eef1f8; --dim: #9aa3bd; --faint: #6b7290;
|
||||
--accent: #6c8cff; --accent2: #b06cff;
|
||||
--good: #2fe39b; --good-d: #0c2c22; --warn: #ffc24b; --warn-d: #2e2410;
|
||||
--bad: #ff647c; --bad-d: #2c1420; --info: #4cc9f0;
|
||||
--r: 14px; --shadow: 0 14px 40px -18px rgba(0,0,0,.7);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
/* The HTML `hidden` attribute must always win, even over display:grid/flex. */
|
||||
[hidden] { display: none !important; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg); color: var(--txt); min-height: 100vh;
|
||||
font: 14px/1.5 "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; padding-bottom: 80px;
|
||||
}
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; }
|
||||
|
||||
/* ambient gradient wash */
|
||||
.aurora {
|
||||
position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60vw 50vh at 12% -5%, rgba(108,140,255,.20), transparent 60%),
|
||||
radial-gradient(55vw 45vh at 95% 0%, rgba(176,108,255,.16), transparent 55%),
|
||||
radial-gradient(50vw 50vh at 60% 110%, rgba(47,227,155,.10), transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 18px 26px; position: sticky; top: 0; z-index: 20;
|
||||
background: linear-gradient(180deg, rgba(10,12,20,.92), rgba(10,12,20,.6));
|
||||
backdrop-filter: blur(14px); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.logo {
|
||||
width: 42px; height: 42px; display: grid; place-items: center;
|
||||
font-size: 20px; border-radius: 12px; color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 8px 24px -8px var(--accent);
|
||||
}
|
||||
h1 { font-size: 19px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.brand .sub { color: var(--dim); font-size: 12px; }
|
||||
.tabs { display: flex; gap: 4px; margin-left: 18px; padding: 4px;
|
||||
background: var(--panel); border: 1px solid var(--line2); border-radius: 12px; }
|
||||
.tabs button { background: transparent; color: var(--dim); font-size: 13px;
|
||||
padding: 7px 16px; border-radius: 9px; display: flex; align-items: center; gap: 6px; }
|
||||
.tabs button.on { color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 6px 18px -8px var(--accent); }
|
||||
.tabs .spec { font-size: 9px; font-weight: 800; letter-spacing: .05em;
|
||||
padding: 1px 5px; border-radius: 5px; background: var(--warn-d);
|
||||
color: var(--warn); text-transform: uppercase; }
|
||||
.tabs button.on .spec { background: rgba(255,255,255,.2); color: #fff; }
|
||||
|
||||
.hbanner { display: flex; gap: 14px; align-items: center; margin: 18px 26px 0;
|
||||
padding: 14px 18px; border-radius: var(--r);
|
||||
background: linear-gradient(135deg, rgba(255,194,75,.12), var(--panel));
|
||||
border: 1px solid rgba(255,194,75,.3); }
|
||||
.hb-ico { font-size: 22px; color: var(--warn); }
|
||||
.hb-txt { font-size: 13px; color: var(--dim); line-height: 1.5; }
|
||||
.hb-txt b { color: var(--warn); }
|
||||
.hb-div { font-weight: 700; font-family: "JetBrains Mono", monospace;
|
||||
color: var(--txt); padding: 1px 8px; border-radius: 6px;
|
||||
background: rgba(255,255,255,.06); }
|
||||
.hb-div.hot { color: var(--bad); background: var(--bad-d); }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.live {
|
||||
display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; padding: 7px 12px; border: 1px solid var(--line);
|
||||
border-radius: 999px; background: var(--panel);
|
||||
}
|
||||
.live .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--good);
|
||||
box-shadow: 0 0 0 0 rgba(47,227,155,.6); animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(47,227,155,.55); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(47,227,155,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47,227,155,0); }
|
||||
}
|
||||
.auto { display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; user-select: none; cursor: pointer; }
|
||||
.auto input { accent-color: var(--accent); }
|
||||
button {
|
||||
font: inherit; font-weight: 600; cursor: pointer; border: 0;
|
||||
border-radius: 10px; padding: 9px 16px; transition: .16s;
|
||||
}
|
||||
.primary {
|
||||
color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
box-shadow: 0 8px 22px -10px var(--accent);
|
||||
}
|
||||
.primary:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
.ghost { color: var(--txt); background: var(--panel); border: 1px solid var(--line2); }
|
||||
.ghost:hover { border-color: var(--accent); color: #fff; }
|
||||
button:disabled { opacity: .55; cursor: default; transform: none; }
|
||||
.bspin { width: 13px; height: 13px; border-radius: 50%; display: none;
|
||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
||||
animation: spin .7s linear infinite; }
|
||||
.loading .bspin { display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---------- summary ---------- */
|
||||
.summary {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 14px; padding: 22px 26px 6px;
|
||||
}
|
||||
.stat {
|
||||
position: relative; overflow: hidden; border-radius: var(--r);
|
||||
border: 1px solid var(--line); background: var(--panel);
|
||||
backdrop-filter: blur(8px); padding: 16px 18px;
|
||||
}
|
||||
.stat::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.stat.s-arb::before { background: var(--good); }
|
||||
.stat.s-bad::before { background: var(--bad); }
|
||||
.stat.s-warn::before { background: var(--warn); }
|
||||
.stat .n { font-size: 28px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.stat.s-arb .n { color: var(--good); }
|
||||
.stat.s-bad .n { color: var(--bad); }
|
||||
.stat .l { color: var(--dim); font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .07em; margin-top: 2px; }
|
||||
.stat .spark { color: var(--faint); font-size: 11px; margin-top: 6px; }
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero { padding: 18px 26px 0; }
|
||||
.hero-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||
.hero-head h2 { font-size: 15px; font-weight: 700; }
|
||||
.muted { color: var(--faint); font-size: 12px; }
|
||||
.hero-cards { display: grid; gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.hcard {
|
||||
position: relative; overflow: hidden; border-radius: var(--r);
|
||||
padding: 16px; border: 1px solid var(--line2);
|
||||
background: linear-gradient(150deg, rgba(47,227,155,.10), var(--panel) 55%);
|
||||
box-shadow: var(--shadow); transition: .18s; cursor: pointer;
|
||||
}
|
||||
.hcard:hover { transform: translateY(-3px); border-color: var(--good); }
|
||||
.hcard .glow {
|
||||
position: absolute; right: -40px; top: -40px; width: 130px; height: 130px;
|
||||
background: radial-gradient(circle, rgba(47,227,155,.4), transparent 70%);
|
||||
filter: blur(6px);
|
||||
}
|
||||
.hcard .top { display: flex; align-items: center; gap: 11px; margin-bottom: 14px; }
|
||||
.hcard .thumb { width: 38px; height: 38px; border-radius: 10px; }
|
||||
.hcard .q { font-weight: 600; font-size: 13px; line-height: 1.35; }
|
||||
.hcard .ret { font-size: 30px; font-weight: 800; color: var(--good);
|
||||
letter-spacing: -.02em; }
|
||||
.hcard .meta { display: flex; gap: 16px; margin-top: 8px; color: var(--dim);
|
||||
font-size: 12px; }
|
||||
.hcard .meta b { color: var(--txt); font-weight: 600; }
|
||||
.hcard.spec { background: linear-gradient(150deg, rgba(255,194,75,.12),
|
||||
var(--panel) 55%); }
|
||||
.hcard.spec:hover { border-color: var(--warn); }
|
||||
.hcard.spec .glow { background: radial-gradient(circle,
|
||||
rgba(255,194,75,.34), transparent 70%); }
|
||||
.hcard.spec .ret { color: var(--warn); }
|
||||
.warn-line { font-size: 11.5px; color: var(--warn); background: var(--warn-d);
|
||||
border-radius: 7px; padding: 7px 10px; margin: 10px 0; }
|
||||
|
||||
/* ---------- toolbar ---------- */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 20px 26px 14px;
|
||||
}
|
||||
.search { position: relative; display: flex; align-items: center; }
|
||||
.search svg { position: absolute; left: 12px; color: var(--faint); }
|
||||
.search input {
|
||||
background: var(--panel); border: 1px solid var(--line2); color: var(--txt);
|
||||
border-radius: 999px; padding: 9px 16px 9px 34px; min-width: 250px;
|
||||
font: inherit; outline: none;
|
||||
}
|
||||
.search input:focus { border-color: var(--accent); }
|
||||
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.chips .ac {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: 999px; border: 1px solid var(--line2); background: var(--panel);
|
||||
color: var(--dim); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: .14s;
|
||||
}
|
||||
.chips .ac .blob { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.chips .ac.on { color: #fff; border-color: transparent;
|
||||
background: rgba(255,255,255,.10); }
|
||||
.seg { display: flex; background: var(--panel); border: 1px solid var(--line2);
|
||||
border-radius: 999px; padding: 3px; }
|
||||
.seg button { background: transparent; color: var(--dim); padding: 6px 13px;
|
||||
border-radius: 999px; font-size: 12px; }
|
||||
.seg button.on { color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2)); }
|
||||
.chk { display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; cursor: pointer; user-select: none; }
|
||||
.chk input { accent-color: var(--accent); }
|
||||
.toolbar .count { margin-left: auto; color: var(--faint); font-size: 12px; }
|
||||
|
||||
/* ---------- table ---------- */
|
||||
main { padding: 0 26px; }
|
||||
table { border-collapse: separate; border-spacing: 0; width: 100%;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
thead th {
|
||||
position: sticky; top: 79px; z-index: 5; background: var(--bg2);
|
||||
color: var(--faint); font-size: 10.5px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .06em; text-align: right;
|
||||
padding: 12px 12px; cursor: pointer; user-select: none; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--line2);
|
||||
}
|
||||
thead th:first-child, thead th.l { text-align: left; }
|
||||
thead th:hover { color: var(--txt); }
|
||||
thead th.sorted, thead th.asc { color: var(--accent); }
|
||||
thead th.sorted::after { content: " ↓"; }
|
||||
thead th.asc::after { content: " ↑"; }
|
||||
tbody td {
|
||||
padding: 11px 12px; text-align: right; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
tbody td.l { text-align: left; }
|
||||
tbody tr.r { cursor: pointer; transition: background .12s; }
|
||||
tbody tr.r:hover { background: rgba(255,255,255,.035); }
|
||||
tbody tr.r.open { background: rgba(108,140,255,.07); }
|
||||
|
||||
.mkt { display: flex; align-items: center; gap: 11px; max-width: 360px; }
|
||||
.thumb, .badge {
|
||||
width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
|
||||
object-fit: cover; background: var(--panel-solid);
|
||||
}
|
||||
.badge { display: grid; place-items: center; font-weight: 800; font-size: 12px;
|
||||
color: #fff; letter-spacing: -.02em; }
|
||||
.mkt .info { min-width: 0; }
|
||||
.mkt .qa { display: flex; align-items: center; gap: 7px; }
|
||||
.mkt .q { font-weight: 600; font-size: 13px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; max-width: 270px; }
|
||||
.mkt .sub { color: var(--faint); font-size: 11px; margin-top: 1px; }
|
||||
.achip { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 6px;
|
||||
letter-spacing: .02em; flex-shrink: 0; }
|
||||
.dir { font-size: 10px; font-weight: 700; color: var(--dim);
|
||||
border: 1px solid var(--line2); padding: 1px 6px; border-radius: 5px; }
|
||||
|
||||
.pricebar { display: flex; flex-direction: column; gap: 3px; min-width: 110px; }
|
||||
.pricebar .track { height: 6px; border-radius: 3px; background: rgba(255,255,255,.08);
|
||||
position: relative; overflow: hidden; }
|
||||
.pricebar .fill { position: absolute; inset: 0 auto 0 0; border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2)); }
|
||||
.pricebar .lbl { display: flex; justify-content: space-between;
|
||||
font-size: 10px; color: var(--faint); }
|
||||
|
||||
.num { font-weight: 600; }
|
||||
.pos { color: var(--good); } .neg { color: var(--bad); }
|
||||
.big { font-size: 15px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.dimv { color: var(--faint); }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px;
|
||||
border-radius: 999px; font-size: 11px; font-weight: 700; }
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: currentColor; }
|
||||
.pill.ARB { color: var(--good); background: var(--good-d);
|
||||
box-shadow: 0 0 14px -3px rgba(47,227,155,.5); }
|
||||
.pill.NOARB { color: var(--warn); background: var(--warn-d); }
|
||||
.pill.BADBASIS { color: var(--bad); background: var(--bad-d); }
|
||||
.pill.LOWSIZE { color: var(--info); background: rgba(76,201,240,.12); }
|
||||
.pill.NODATA, .pill.NOPAIR { color: var(--faint); background: rgba(255,255,255,.05); }
|
||||
|
||||
/* ---------- detail drawer ---------- */
|
||||
tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
||||
.drawer { display: grid; grid-template-columns: 1fr 1fr 1.1fr; gap: 18px;
|
||||
padding: 20px 22px; background: linear-gradient(180deg,
|
||||
rgba(108,140,255,.06), transparent); animation: slide .22s ease; }
|
||||
@keyframes slide { from { opacity: 0; transform: translateY(-6px); } }
|
||||
.vcard { border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
||||
background: var(--panel); }
|
||||
.vcard h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--dim); margin-bottom: 10px; display: flex; align-items: center;
|
||||
gap: 8px; }
|
||||
.vcard h4 .tag { font-size: 10px; padding: 2px 7px; border-radius: 5px;
|
||||
color: #fff; font-weight: 700; }
|
||||
.tag.k { background: #5b6cff; } .tag.p { background: #00c2a8; }
|
||||
.vcard .title { font-weight: 600; font-size: 13px; line-height: 1.4;
|
||||
margin-bottom: 10px; }
|
||||
.vcard img.hero-img { width: 100%; max-height: 120px; object-fit: cover;
|
||||
border-radius: 9px; margin-bottom: 10px; }
|
||||
.vcard .desc { color: var(--dim); font-size: 12px; line-height: 1.55;
|
||||
max-height: 130px; overflow: auto; }
|
||||
.legs { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.leg { flex: 1; background: rgba(255,255,255,.04); border-radius: 9px;
|
||||
padding: 9px 11px; }
|
||||
.leg .k { color: var(--faint); font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.leg .v { font-weight: 700; font-size: 15px; margin-top: 2px; }
|
||||
.kv { display: grid; grid-template-columns: 1fr auto; gap: 7px 14px;
|
||||
font-size: 12px; }
|
||||
.kv .k { color: var(--dim); }
|
||||
.kv .v { font-weight: 600; text-align: right; }
|
||||
.scen { display: flex; gap: 8px; margin: 12px 0 4px; }
|
||||
.scen .b { flex: 1; text-align: center; border-radius: 9px; padding: 10px 6px;
|
||||
background: rgba(255,255,255,.04); border: 1px solid var(--line); }
|
||||
.scen .b .t { font-size: 10px; color: var(--faint); text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.scen .b .x { font-weight: 800; font-size: 15px; margin-top: 3px; }
|
||||
|
||||
.empty { text-align: center; padding: 70px 20px; color: var(--faint); }
|
||||
.empty-art { font-size: 40px; opacity: .4; margin-bottom: 10px; }
|
||||
|
||||
/* ---------- modal / toast ---------- */
|
||||
.modal { position: fixed; inset: 0; z-index: 40; display: grid;
|
||||
place-items: center; background: rgba(4,6,12,.7); backdrop-filter: blur(4px); }
|
||||
.modal .card { width: 360px; background: var(--panel-solid);
|
||||
border: 1px solid var(--line2); border-radius: 16px; padding: 24px;
|
||||
box-shadow: var(--shadow); }
|
||||
.modal h2 { font-size: 17px; }
|
||||
.modal .hint { color: var(--dim); font-size: 12px; margin: 4px 0 16px; }
|
||||
.modal label { display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 12px; margin: 10px 0; color: var(--dim); font-size: 13px; }
|
||||
.modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2);
|
||||
color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||
.modal .row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.modal .row button { flex: 1; }
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 50; background: var(--panel-solid); border: 1px solid var(--line2);
|
||||
color: var(--txt); padding: 12px 20px; border-radius: 12px;
|
||||
box-shadow: var(--shadow); font-size: 13px; font-weight: 500;
|
||||
animation: slide .2s ease; }
|
||||
.toast.err { border-color: var(--bad); color: var(--bad); }
|
||||
.toast.ok { border-color: var(--good); }
|
||||
|
||||
/* ---------- positions ---------- */
|
||||
.positions { padding: 22px 26px 60px; }
|
||||
.stat.stat-lg { border-width: 1px 1px 1px 4px;
|
||||
background: linear-gradient(135deg, rgba(108,140,255,.10), var(--panel)); }
|
||||
.stat.stat-lg .n { font-size: 32px; }
|
||||
.stat.stat-lg .l { color: var(--txt); }
|
||||
.stat .stat-sub { font-size: 14px; font-weight: 700; margin-top: 2px; }
|
||||
.postoolbar { display: flex; align-items: center; gap: 14px; margin: 16px 0; }
|
||||
.posseg { display: flex; }
|
||||
.pos-note { font-size: 12px; }
|
||||
.vstack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.vcard .vc-head { display: flex; justify-content: space-between;
|
||||
align-items: center; margin-bottom: 12px; }
|
||||
.vc-tot { font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.vcard.setup { border-style: dashed; }
|
||||
.vcard.setup p { color: var(--dim); font-size: 13px; margin: 8px 0; }
|
||||
.vcard.setup pre { background: var(--bg2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 12px; font: 12px/1.5 "JetBrains Mono", monospace;
|
||||
color: var(--txt); overflow-x: auto; }
|
||||
.vcard code { background: var(--bg2); padding: 1px 6px; border-radius: 5px;
|
||||
font: 12px "JetBrains Mono", monospace; color: var(--accent); }
|
||||
.ptbl { width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
.ptbl th { text-align: right; color: var(--faint); font-size: 10.5px;
|
||||
font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line2); }
|
||||
.ptbl th.l { text-align: left; }
|
||||
.ptbl td { text-align: right; padding: 10px; border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap; }
|
||||
.ptbl td.l { text-align: left; }
|
||||
.ptbl tr:hover td { background: rgba(255,255,255,.03); }
|
||||
.ptbl .mkt .q { max-width: 340px; }
|
||||
.ptbl .pp { color: var(--faint); font-size: 11px; font-weight: 600; }
|
||||
.sidetag { font-size: 10px; font-weight: 800; padding: 2px 8px;
|
||||
border-radius: 6px; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.sidetag.yes, .sidetag.up { color: var(--good); background: var(--good-d); }
|
||||
.sidetag.no, .sidetag.down { color: var(--bad); background: var(--bad-d); }
|
||||
.okpill { font-size: 10px; font-weight: 700; color: var(--good);
|
||||
background: var(--good-d); padding: 2px 8px; border-radius: 999px;
|
||||
margin-left: 6px; }
|
||||
.onepill { font-size: 10px; font-weight: 700; color: var(--warn);
|
||||
background: var(--warn-d); padding: 2px 8px; border-radius: 999px;
|
||||
margin-left: 6px; }
|
||||
.leg2 { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
font-size: 12px; }
|
||||
.leg2 .tag { width: 16px; height: 16px; display: grid; place-items: center;
|
||||
font-size: 9px; padding: 0; }
|
||||
|
||||
@media (max-width: 1100px) { .drawer { grid-template-columns: 1fr; } }
|
||||
Reference in New Issue
Block a user