diff --git a/benchmarks/pm-resolution-delay.yml b/benchmarks/pm-resolution-delay.yml index ebb599a6..d9b9d7e0 100644 --- a/benchmarks/pm-resolution-delay.yml +++ b/benchmarks/pm-resolution-delay.yml @@ -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 @@ -31,17 +31,19 @@ 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." @@ -49,11 +51,11 @@ methodology: 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?" diff --git a/harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go b/harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go index bede415a..a7098b53 100644 --- a/harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go +++ b/harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go @@ -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" @@ -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 ) @@ -29,9 +35,9 @@ 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 { @@ -39,18 +45,77 @@ type kalshiMarketsResponse struct { 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), } } @@ -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", "") @@ -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) @@ -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 { @@ -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) } } @@ -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()