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
241 changes: 241 additions & 0 deletions benchmarks/pm-resolution-delay.yml

Large diffs are not rendered by default.

190 changes: 0 additions & 190 deletions benchmarks/polymarket-resolution-delay.yml

This file was deleted.

6 changes: 3 additions & 3 deletions harnesses/pm-resolution-delay/cmd/script/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ func (e *engine) markDisputed(qid string, ts int64, via string) {
title := q.title
e.mu.Unlock()
cat := e.category(qid, title)
disputesTotal.WithLabelValues(cat).Inc()
disputesTotal.WithLabelValues("polymarket", cat).Inc()
log.Printf("[dispute] qid=%s via=%s category=%s title=%q at=%s", qid, via, cat, title, time.Unix(ts, 0).UTC().Format(time.RFC3339))
}

Expand All @@ -306,8 +306,8 @@ func (e *engine) resolve(qid string, ts int64) {
disputed = "true"
extra = fmt.Sprintf(" dispute_extra=%ds", ts-q.firstDisputedAt)
}
resolutionDelay.WithLabelValues(cat, disputed).Observe(float64(delay))
resolutionsTotal.WithLabelValues(cat, disputed).Inc()
resolutionDelay.WithLabelValues("polymarket", cat, disputed).Observe(float64(delay))
resolutionsTotal.WithLabelValues("polymarket", cat, disputed).Inc()
log.Printf("[resolve] qid=%s category=%s disputed=%s delay=%ds proposed=%s resolved=%s%s title=%q",
qid, cat, disputed, delay,
time.Unix(q.firstProposedAt, 0).UTC().Format(time.RFC3339),
Expand Down
193 changes: 193 additions & 0 deletions harnesses/pm-resolution-delay/cmd/script/kalshi_resolution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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:
// observed_resolution_time - market.close_time
//
// where observed_resolution_time = first poll at which result != null.
// Accuracy is bounded by KALSHI_POLL_SECONDS (default 300 = 5 min), which
// is negligible for delays measured in hours.
//
// No auth required — uses the public /trade-api/v2/markets endpoint.

import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
)

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

type kalshiMarketRecord struct {
Ticker string `json:"ticker"`
Title string `json:"title"`
CloseTime string `json:"close_time"` // RFC3339
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"`
}

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
client *http.Client
}

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

func (k *kalshiTracker) fetchPage(status, cursor string) ([]kalshiMarketRecord, string, error) {
req, err := http.NewRequest(http.MethodGet, kalshiMarketsURL, nil)
if err != nil {
return nil, "", err
}
q := req.URL.Query()
q.Set("limit", "200")
q.Set("status", status)
q.Set("order", "close_time")
q.Set("ascending", "false")
if cursor != "" {
q.Set("cursor", cursor)
}
req.URL.RawQuery = q.Encode()
req.Header.Set("User-Agent", userAgent)

resp, err := k.client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", nil
}
var body kalshiMarketsResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, "", err
}
return body.Markets, body.Cursor, nil
}

// normalizeKalshiCategory maps Kalshi's 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":
return "politics"
case "crypto", "cryptocurrency":
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.
func (k *kalshiTracker) pollClosed() {
cutoff := time.Now().Add(-kalshiLookbackDays * 24 * time.Hour)
markets, _, err := k.fetchPage("closed", "")
if err != nil {
log.Printf("[kalshi-res] pollClosed error: %v", err)
return
}
k.mu.Lock()
defer k.mu.Unlock()
for _, m := range markets {
ct, err := time.Parse(time.RFC3339, m.CloseTime)
if err != nil {
continue
}
if ct.Before(cutoff) {
break
}
if _, ok := k.watched[m.Ticker]; !ok {
k.watched[m.Ticker] = ct
}
}
}

// pollFinalized fetches recently finalized markets and emits histogram
// observations for any we haven't recorded yet.
func (k *kalshiTracker) pollFinalized() {
cutoff := time.Now().Add(-kalshiLookbackDays * 24 * time.Hour)
now := time.Now()

markets, _, err := k.fetchPage("finalized", "")
if err != nil {
log.Printf("[kalshi-res] pollFinalized error: %v", err)
return
}

k.mu.Lock()
defer k.mu.Unlock()
for _, m := range markets {
ct, err := time.Parse(time.RFC3339, m.CloseTime)
if err != nil {
continue
}
if ct.Before(cutoff) {
break
}
if _, done := k.recorded[m.Ticker]; done {
continue
}
if m.Result == "" {
continue
}
k.recorded[m.Ticker] = struct{}{}
delete(k.watched, m.Ticker)

delay := now.Sub(ct).Seconds()
if delay < 0 || delay > float64(kalshiLookbackDays*24*3600) {
continue
}
cat := normalizeKalshiCategory(m.Category)
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 result=%s",
m.Ticker, cat, delay, m.CloseTime, m.Result)
}
}

func runKalshiResolutionLoop(ctx context.Context) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()

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

for {
select {
case <-ctx.Done():
return
case <-t.C:
tracker.pollClosed()
tracker.pollFinalized()
}
}
}
1 change: 1 addition & 0 deletions harnesses/pm-resolution-delay/cmd/script/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func main() {

go healthLoop()
go gamma.pendingLoop(ctx)
go runKalshiResolutionLoop(ctx)
go func() {
// Category map first, then the chain engine: backfilled resolutions
// join against Gamma tags instead of falling back to keywords.
Expand Down
14 changes: 8 additions & 6 deletions harnesses/pm-resolution-delay/cmd/script/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,23 @@ var (
// claim the bench fact-checks.
delayBuckets = []float64{300, 900, 1800, 3600, 7200, 14400, 43200, 86400, 172800, 604800, 1209600}

// venue label added 2026-07 for multi-venue support (kalshi + future).
// Polymarket = "polymarket", Kalshi = "kalshi".
resolutionDelay = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "pmres_resolution_delay_seconds",
Help: "QuestionResolved block timestamp minus first OO ProposePrice block timestamp for the same questionID. Includes the UMA challenge window (~2h), so the floor is the market's liveness. disputed=true means at least one OO DisputePrice / adapter QuestionReset occurred before resolution.",
Help: "Time from resolution anchor to settlement. Polymarket: ProposePrice→QuestionResolved (UMA, ~2h floor). Kalshi: close_time→observed settlement (~minutes to hours). disputed=true = at least one UMA DisputePrice before resolution (Polymarket only).",
Buckets: delayBuckets,
}, []string{"category", "disputed"})
}, []string{"venue", "category", "disputed"})

resolutionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "pmres_resolutions_total",
Help: "QuestionResolved events observed and successfully joined to a proposal. Re-counts the 7-day backfill window after a restart (no DB), so prefer rate()/increase() over raw values.",
}, []string{"category", "disputed"})
Help: "Resolved markets recorded. Polymarket re-counts the 7-day backfill on restart; Kalshi accumulates since harness start.",
}, []string{"venue", "category", "disputed"})

disputesTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "pmres_disputes_total",
Help: "Questions that saw their first OO DisputePrice (or adapter QuestionReset) before resolution. Counted once per question, not per dispute round.",
}, []string{"category"})
Help: "Markets disputed before resolution (Polymarket/UMA only). Counted once per question.",
}, []string{"venue", "category"})

pendingMarkets = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "pmres_pending_markets",
Expand Down
5 changes: 0 additions & 5 deletions src/components/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,6 @@ const NAV: NavItem[] = [
label: "Benchmarks",
match: (p) => p === "/benchmarks" || p.startsWith("/benchmarks/"),
},
{
href: "/data-api",
label: "Data APIs",
match: (p) => p === "/data-api" || p.startsWith("/data-api/"),
},
{
href: "/products",
label: "Products",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/pm-venue-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import { logoPath } from "@/lib/logo-manifest";

export const PM_VENUE_META: Record<string, { url: string; chainLabel: string }> = {
polymarket: { url: "https://polymarket.com", chainLabel: "Polygon" },
"polymarket-us": { url: "https://polymarketexchange.com", chainLabel: "Offchain US" },
kalshi: { url: "https://kalshi.com", chainLabel: "Offchain US" },
limitless: { url: "https://limitless.exchange", chainLabel: "Base" },
myriad: { url: "https://myriad.markets", chainLabel: "Abstract L2" },
manifold: { url: "https://manifold.markets", chainLabel: "Offchain" },
};

export const PM_FEED_META: Record<string, { url?: string }> = {
Expand Down Expand Up @@ -109,7 +111,7 @@ export function benchRowsForVenue(
tone: "teal",
},
{
benchSlug: "polymarket-resolution-delay",
benchSlug: "pm-resolution-delay",
label: "Resolution delay",
blurb: "ProposePrice anchor to QuestionResolved block, median.",
rank: rankWithinCohort(cohort.venues, "medianResolutionDelayMin", venue.slug),
Expand Down
Loading