Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions benchmarks/pm-resolution-delay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ slug: pm-resolution-delay
number: "039"
title: Resolution delay, Polymarket vs Kalshi
seo_title: "Prediction market resolution delay live 2026"
seo_description: "Polymarket and Kalshi resolution time measured live: median delay from close to payout, broken down by category. Polymarket anchored on UMA ProposePrice, Kalshi on API-observed finalization."
subtitle: How long from market close to final settlement — Polymarket measured onchain via UMA ProposePrice→QuestionResolved, Kalshi measured via REST API polling (close_time to first observed finalized status).
seo_description: "Polymarket and Kalshi resolution time measured live: median delay from close to payout, broken down by category. Polymarket anchored on UMA ProposePrice, Kalshi on settlement_ts from the public API."
subtitle: How long from market close to final settlement — Polymarket measured onchain via UMA ProposePrice→QuestionResolved, Kalshi via settlement_ts minus close_time from the public REST API. Auto-generated multivariate parlays excluded from Kalshi data.

category: Trading
status: live
Expand All @@ -31,29 +31,31 @@ abstract: |
Oracle they call (ProposePrice, DisputePrice). Delay = first ProposePrice
block to QuestionResolved block, joined to Gamma for category tags.

Kalshi: a poller hits the public /trade-api/v2/markets endpoint every 5
minutes. Delay = market close_time to the first poll at which result != null.
Accuracy is ±5 min, negligible for delays measured in minutes to hours. No
auth required.
Kalshi: a poller hits the public /trade-api/v2/markets?status=settled endpoint
every 5 minutes. Delay = settlement_ts - close_time (both from API, exact,
no polling error). Categories resolved via /events/{event_ticker}. Auto-generated
multivariate parlay markets (Kalshi "Exotics" category) are excluded: they
settle algorithmically in seconds and are not prediction market questions. No auth required.

methodology:
- "Polymarket anchor: the first OO ProposePrice block timestamp, not Gamma fields. Verified live: Gamma closedTime equals the QuestionResolved block timestamp (circular by construction) and endDate is a scheduled buffer most markets beat."
- "Polymarket sources: Polygon logs of the post-migration UMA CTF adapters (binary 0x65070BE9..., neg-risk 0x69c47De9...) and their Optimistic Oracle (0x2c0367a9...). Gamma supplies the market catalogue and category tags."
- "Polymarket join: questionID = keccak256 of the proposal ancillary data, verified against QuestionInitialized events."
- "Kalshi anchor: close_time from the public markets API. Delay = observed finalization time (first poll at which result != null) minus close_time. Accuracy bounded by 5-minute poll interval."
- "Kalshi source: public REST API /trade-api/v2/markets, no auth. 30-day lookback window. Categories mapped: Sports→sports, Politics→politics, Crypto/Cryptocurrency→crypto, everything else→other."
- "Kalshi anchor: settlement_ts from the public markets API (exact timestamp, no polling error). Delay = settlement_ts - close_time. close_time is the scheduled market close, not the real-world event end."
- "Kalshi scope: /trade-api/v2/markets?status=settled, 30-day lookback. Exotics excluded (auto-generated multivariate parlays that settle algorithmically in seconds — not prediction market questions). Categories from /events/{event_ticker}: Sports→sports, Politics/Elections→politics, Crypto/Financials→crypto, everything else→other."
- "Anchor asymmetry: Polymarket's 2h UMA challenge window is protocol-enforced — no market can resolve in under 2h on the clean path. Kalshi has no such floor; traditional sports and politics markets settle in minutes to hours after close_time. The difference in distributions reflects both operational speed and architecture."
- "Categories: sports, politics, crypto, other, consistent across venues. Per-category rows are the honest unit of comparison because market types run different resolution windows."
- "Disputes (Polymarket only): a DisputePrice before resolution marks the market disputed. Disputed delays land in the disputed series and are excluded from the main category rows."
- "Pending backlog (Polymarket only): markets past their scheduled endDate with no onchain resolution, polled from Gamma every cycle."
- "Listener: 45s incremental Polygon log polling with a 7-day chunked backfill on restart. Kalshi poller ticks every 5 min. Both coexist in the same process."

findings:
- "Polymarket across all markets: median onchain resolution delay {{p50:polymarket-all}} from first proposal to resolution, p99 at {{p99:polymarket-all}}. The within 2 hours share is in the table above, measured, and does not match the 93 percent figure that circulates without a methodology."
- "Kalshi across all markets: median resolution delay {{p50:kalshi-all}} from close_time to observed finalization. This measures a different thing than Polymarket: Kalshi's resolution process is internal, not onchain, so the anchor is the scheduled market close."
- "Kalshi across all markets (excluding auto-generated parlays): median resolution delay {{p50:kalshi-all}} from scheduled close_time to settlement_ts. Kalshi resolves internally — no onchain oracle, no mandatory challenge window. The difference vs Polymarket's median reflects both operational speed and Polymarket's protocol-enforced 2h minimum."
- "Categories are not comparable on a single median: sports, crypto and politics markets run different resolution processes on each venue."
- "Disputes (Polymarket only) are rare but expensive: disputed markets carry materially longer delays than the clean path."

disclaimer: "Polymarket delay is measured from the first onchain outcome proposal, not from the real world event ending. Kalshi delay is measured from scheduled close_time with ±5 min accuracy. Both are lower bounds on total user wait."
disclaimer: "Polymarket delay is measured from the first onchain outcome proposal to resolution (UMA challenge window, minimum 2h by protocol). Kalshi delay is settlement_ts - close_time (exact from API), where close_time is the scheduled market close, not the real-world event end. Kalshi auto-generated parlay markets (Exotics) are excluded. Both anchors are lower bounds on total user wait from the real-world event ending."

faq:
- q: "How long does Polymarket take to pay out after a market ends?"
Expand Down
158 changes: 128 additions & 30 deletions harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ package main

// Kalshi resolution delay poller.
//
// Unlike Polymarket (UMA on-chain events, 2h floor), Kalshi resolves via its
// own internal process. Delay is measured as:
// settlement_ts - market.close_time
// Kalshi resolves via its own internal process (no onchain oracle).
// Delay = settlement_ts - close_time, both from the public API (exact, no
// polling error). The categories come from the /events/{event_ticker}
// endpoint (the /markets endpoint omits the category field).
//
// settlement_ts is returned directly by the Kalshi API (exact, no polling error).
// No auth required — uses the public /trade-api/v2/markets endpoint.
// "Exotics" markets (KXMV* — auto-generated multivariate parlays) are
// excluded: they settle by algorithm in 0–200 seconds and are not
// comparable to traditional prediction market questions on either venue.
//
// No auth required — uses the public /trade-api/v2/markets + events endpoints.

import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"
Expand All @@ -21,6 +26,7 @@ import (

const (
kalshiMarketsURL = "https://api.elections.kalshi.com/trade-api/v2/markets"
kalshiEventsURL = "https://api.elections.kalshi.com/trade-api/v2/events"
kalshiLookbackDays = 30
)

Expand All @@ -29,28 +35,87 @@ type kalshiMarketRecord struct {
Title string `json:"title"`
CloseTime string `json:"close_time"` // RFC3339
SettlementTs string `json:"settlement_ts"` // RFC3339Nano, present when settled
EventTicker string `json:"event_ticker"` // parent event, used to look up category
Status string `json:"status"` // "open", "closed", "finalized"
Result string `json:"result"` // "yes", "no", "" when unresolved
Category string `json:"category"` // "Sports", "Politics", "Crypto", etc.
}

type kalshiMarketsResponse struct {
Markets []kalshiMarketRecord `json:"markets"`
Cursor string `json:"cursor"`
}

// kalshiCategoryCache lazily fetches and caches event_ticker → category
// from /events/{event_ticker}. The cache is never evicted (events are stable).
type kalshiCategoryCache struct {
mu sync.Mutex
cats map[string]string
client *http.Client
}

func newKalshiCategoryCache(client *http.Client) *kalshiCategoryCache {
return &kalshiCategoryCache{
cats: make(map[string]string),
client: client,
}
}

func (c *kalshiCategoryCache) get(eventTicker string) string {
if eventTicker == "" {
return ""
}
c.mu.Lock()
if cat, ok := c.cats[eventTicker]; ok {
c.mu.Unlock()
return cat
}
c.mu.Unlock()

req, err := http.NewRequest(http.MethodGet, kalshiEventsURL+"/"+eventTicker, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", userAgent)
resp, err := c.client.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
return ""
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var wrapper struct {
Event struct {
Category string `json:"category"`
} `json:"event"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return ""
}
cat := wrapper.Event.Category

c.mu.Lock()
c.cats[eventTicker] = cat
c.mu.Unlock()
return cat
}

type kalshiTracker struct {
mu sync.Mutex
watched map[string]time.Time // ticker → close_time (markets we're watching)
recorded map[string]struct{} // tickers already emitted to histogram
watched map[string]time.Time // ticker → close_time
recorded map[string]struct{} // tickers already emitted
client *http.Client
catCache *kalshiCategoryCache
}

func newKalshiTracker() *kalshiTracker {
client := &http.Client{Timeout: 15 * time.Second}
return &kalshiTracker{
watched: make(map[string]time.Time),
recorded: make(map[string]struct{}),
client: &http.Client{Timeout: 15 * time.Second},
client: client,
catCache: newKalshiCategoryCache(client),
}
}

Expand Down Expand Up @@ -85,25 +150,23 @@ func (k *kalshiTracker) fetchPage(status, cursor string) ([]kalshiMarketRecord,
return body.Markets, body.Cursor, nil
}

// normalizeKalshiCategory maps Kalshi's category strings to lowercase slugs
// comparable to Polymarket's categories.
// normalizeKalshiCategory maps Kalshi's event category strings to lowercase
// slugs comparable to Polymarket's categories.
func normalizeKalshiCategory(raw string) string {
switch strings.ToLower(raw) {
case "sports":
return "sports"
case "politics":
case "politics", "elections":
return "politics"
case "crypto", "cryptocurrency":
case "crypto", "cryptocurrency", "financials":
return "crypto"
case "economics", "finance":
return "other"
default:
return "other"
}
}

// pollClosed fetches recently closed (unresolved) markets and adds them to
// the watch list so we notice when they become finalized.
// the watch list so we notice when they become settled.
func (k *kalshiTracker) pollClosed() {
cutoff := time.Now().Add(-kalshiLookbackDays * 24 * time.Hour)
markets, _, err := k.fetchPage("closed", "")
Expand All @@ -128,9 +191,10 @@ func (k *kalshiTracker) pollClosed() {
}

// pollFinalized fetches recently settled markets and emits histogram
// observations for any we haven't recorded yet. The API filter param is
// "settled"; the status field in the response body is "finalized".
// Delay is settlement_ts - close_time (exact, from API, no polling error).
// observations for traditional prediction market questions. Auto-generated
// multivariate parlays (Kalshi "Exotics" category, KXMV* prefix) are
// excluded because they settle algorithmically in seconds and are not
// comparable to either venue's real prediction market questions.
func (k *kalshiTracker) pollFinalized() {
cutoff := time.Now().Add(-kalshiLookbackDays * 24 * time.Hour)

Expand All @@ -140,8 +204,14 @@ func (k *kalshiTracker) pollFinalized() {
return
}

// Collect candidates outside the lock so we can do HTTP calls for categories.
type candidate struct {
m kalshiMarketRecord
ct time.Time
}
var candidates []candidate

k.mu.Lock()
defer k.mu.Unlock()
for _, m := range markets {
ct, err := time.Parse(time.RFC3339, m.CloseTime)
if err != nil {
Expand All @@ -156,26 +226,54 @@ func (k *kalshiTracker) pollFinalized() {
if m.Result == "" {
continue
}
k.recorded[m.Ticker] = struct{}{}
delete(k.watched, m.Ticker)
candidates = append(candidates, candidate{m: m, ct: ct})
}
k.mu.Unlock()

for _, c := range candidates {
// Fetch event category (cached after first call).
rawCat := k.catCache.get(c.m.EventTicker)

// Prefer settlement_ts (exact); fall back to now (first-poll proxy, ±5 min).
// Skip auto-generated multivariate parlays: they settle by algorithm
// in seconds and are not comparable to prediction market questions.
if strings.EqualFold(rawCat, "exotics") {
k.mu.Lock()
k.recorded[c.m.Ticker] = struct{}{}
k.mu.Unlock()
continue
}

// settlement_ts - close_time (exact from API).
var settleAt time.Time
if m.SettlementTs != "" {
settleAt, _ = time.Parse(time.RFC3339Nano, m.SettlementTs)
if c.m.SettlementTs != "" {
settleAt, _ = time.Parse(time.RFC3339Nano, c.m.SettlementTs)
}
if settleAt.IsZero() {
settleAt = time.Now()
}
delay := settleAt.Sub(ct).Seconds()
delay := settleAt.Sub(c.ct).Seconds()
if delay < 0 || delay > float64(kalshiLookbackDays*24*3600) {
k.mu.Lock()
k.recorded[c.m.Ticker] = struct{}{}
k.mu.Unlock()
continue
}

cat := normalizeKalshiCategory(rawCat)

k.mu.Lock()
if _, done := k.recorded[c.m.Ticker]; done {
k.mu.Unlock()
continue
}
cat := normalizeKalshiCategory(m.Category)
k.recorded[c.m.Ticker] = struct{}{}
delete(k.watched, c.m.Ticker)
k.mu.Unlock()

resolutionDelay.WithLabelValues("kalshi", cat, "false").Observe(delay)
resolutionsTotal.WithLabelValues("kalshi", cat, "false").Inc()
log.Printf("[kalshi-res] resolved ticker=%s category=%s delay=%.0fs close_time=%s settled=%s result=%s",
m.Ticker, cat, delay, m.CloseTime, settleAt.UTC().Format(time.RFC3339), m.Result)
log.Printf("[kalshi-res] resolved ticker=%s category=%s(%s) delay=%.0fs close=%s settled=%s result=%s",
c.m.Ticker, cat, rawCat, delay, c.m.CloseTime, settleAt.UTC().Format(time.RFC3339), c.m.Result)
}
}

Expand All @@ -184,7 +282,7 @@ func runKalshiResolutionLoop(ctx context.Context) {
defer t.Stop()

tracker := newKalshiTracker()
// Prime the watch list immediately on startup.
// Prime the watch list and backfill immediately on startup.
tracker.pollClosed()
tracker.pollFinalized()

Expand Down
Loading